v0.3.98 ff10c69

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		if provName == "amazon-bedrock" {
600			// The default AWS CLI profile is resolved by the Bedrock client,
601			// not the general credential resolver. Never pass the local-model
602			// placeholder as a bearer token.
603			cred = "<aws>"
604		}
605		credErr = nil
606		requireCred = false
607	}
608
609	if credErr != nil && requireCred {
610		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
611			credErr, envVarName(provName))
612	}
613
614	sandbox := tools.NewSandbox(args.CWD)
615	if cfg.JailByDefault != nil && *cfg.JailByDefault {
616		sandbox.Lock()
617	}
618	if args.PermissionSet != nil {
619		sandbox.SetPermissions(args.PermissionSet)
620	}
621	reg := buildConfiguredToolRegistry(args, args.CWD, sandbox, cfg, runtime.GOOS)
622	if !args.NoTools && provName == "openrouter" && !isOpenRouterPresetModel(model) && OpenRouterServerToolsEnabled() {
623		reg = withOpenRouterServerTools(reg)
624	}
625
626	docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
627
628	// Skill discovery: scan project + global locations + built-in
629	// skills shipped with the binary. If any are found, register
630	// the on-demand `skill` loader tool plus a system-prompt
631	// manifest so the model knows what's available.
632	//
633	// --no-skill bypasses the entire mechanism: no manifest in the
634	// system prompt, no `skill` tool in the registry. Useful for a
635	// clean-room run with zero extra context biasing the model.
636	var (
637		discovered       []*skills.Skill
638		skillTool        *skills.Tool
639		skillAddendum    string
640		skillDiagnostics []string
641	)
642	if !args.NoSkill {
643		homeDir, _ := os.UserHomeDir()
644		var sources []skills.Source
645		if args.WithSkills {
646			for _, loc := range skills.SearchSources(ZotHome(), args.CWD, homeDir) {
647				sources = append(sources, loc)
648			}
649		}
650		if !args.NoExt || len(args.Exts) > 0 {
651			extSources, errs := extensions.PlanSkillSources(ZotHome(), args.CWD, args.Exts, !args.NoExt)
652			sources = append(extSources, sources...)
653			for _, err := range errs {
654				skillDiagnostics = append(skillDiagnostics, err.Error())
655			}
656		}
657		var errs []error
658		discovered, errs = skills.DiscoverSources(sources, true)
659		for _, err := range errs {
660			skillDiagnostics = append(skillDiagnostics, err.Error())
661		}
662		if len(discovered) > 0 {
663			skillTool = skills.NewTool(discovered)
664			reg[skillTool.Name()] = skillTool
665			skillAddendum = skills.SystemPromptAddendum(discovered)
666		}
667	}
668	_ = skillTool
669
670	summaries := toolSummaries(reg, args)
671
672	var contextFiles []ContextFile
673	if !args.NoContextFiles {
674		contextFiles = loadAgentsContext(args.CWD, ZotHome())
675	}
676	append_ := append([]string(nil), args.AppendSystemPrompt...)
677	if agentsAddendum := formatAgentsContext(contextFiles); agentsAddendum != "" {
678		append_ = append(append_, agentsAddendum)
679	}
680	if skillAddendum != "" {
681		append_ = append(append_, skillAddendum)
682	}
683	if AutoSwarmEnabled() {
684		append_ = append(append_, AutoSwarmSystemAddendum)
685	}
686
687	// Custom system prompt resolution order:
688	//   1. --system-prompt flag (highest priority; ad-hoc per run)
689	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
690	//   3. built-in default (defaultIdentity + defaultGuidelines)
691	custom := args.SystemPrompt
692	customSet := args.SystemPromptSet || custom != ""
693	if !customSet {
694		custom = readUserSystemPrompt(ZotHome())
695		customSet = custom != ""
696	}
697
698	sys := BuildSystemPrompt(SystemPromptOpts{
699		CWD:        args.CWD,
700		Tools:      summaries,
701		Custom:     custom,
702		CustomSet:  customSet,
703		Append:     append_,
704		ZotDocsDir: docsDir,
705	})
706
707	reasoning := provider.NormalizeReasoning(firstNonEmpty(args.Reasoning, cfg.Reasoning))
708	temperature := args.Temperature
709	if temperature == nil {
710		temperature = cfg.Temperature
711	}
712
713	max := args.MaxSteps // 0 = unlimited
714
715	return Resolved{
716		Provider:         provName,
717		Model:            model,
718		Credential:       cred,
719		AuthMethod:       method,
720		AccountID:        accountID,
721		BaseURL:          args.BaseURL,
722		InsecureTLS:      insecureTLS,
723		CWD:              args.CWD,
724		Reasoning:        reasoning,
725		Temperature:      temperature,
726		ToolRegistry:     reg,
727		ToolSummary:      summaries,
728		SystemPrompt:     sys,
729		MaxSteps:         max,
730		MaxOutput:        resolvedModel.MaxOutput,
731		Sandbox:          sandbox,
732		SkillTool:        skillTool,
733		SkillDiagnostics: skillDiagnostics,
734		ContextFiles:     contextFiles,
735		systemAppend:     append_,
736		systemCustom:     custom,
737		systemCustomSet:  customSet,
738		toolDescriptions: descMapFromSummaries(summaries),
739	}, nil
740}
741
742// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
743// trimmed contents, or "" when the file is missing / unreadable /
744// empty. Errors are intentionally swallowed: the file is optional,
745// and any failure to read it should fall back to the built-in
746// default system prompt rather than crash the run.
747func readUserSystemPrompt(zotHome string) string {
748	if zotHome == "" {
749		return ""
750	}
751	path := filepath.Join(zotHome, "SYSTEM.md")
752	raw, err := os.ReadFile(path)
753	if err != nil {
754		return ""
755	}
756	return strings.TrimSpace(string(raw))
757}
758
759// loadAgentsContext loads optional AGENTS.md instruction files. No
760// default file is created or required: zot only includes files that
761// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
762// followed by project instructions from the filesystem root down to cwd.
763func loadAgentsContext(cwd, zotHome string) []ContextFile {
764	var files []ContextFile
765	seen := map[string]bool{}
766	add := func(path string) {
767		if path == "" {
768			return
769		}
770		abs, err := filepath.Abs(path)
771		if err == nil {
772			path = abs
773		}
774		if seen[path] {
775			return
776		}
777		raw, err := os.ReadFile(path)
778		if err != nil {
779			return
780		}
781		content := strings.TrimSpace(string(raw))
782		if content == "" {
783			return
784		}
785		seen[path] = true
786		files = append(files, ContextFile{Path: path, Content: content})
787	}
788	addFirstFromDir := func(dir string) {
789		if dir == "" {
790			return
791		}
792		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
793			path := filepath.Join(dir, name)
794			if _, err := os.Stat(path); err == nil {
795				add(path)
796				return
797			}
798		}
799	}
800
801	addFirstFromDir(zotHome)
802
803	if cwd != "" {
804		abs, err := filepath.Abs(cwd)
805		if err == nil {
806			cwd = abs
807		}
808		var dirs []string
809		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
810			dirs = append(dirs, dir)
811			parent := filepath.Dir(dir)
812			if parent == dir {
813				break
814			}
815		}
816		for i := len(dirs) - 1; i >= 0; i-- {
817			addFirstFromDir(dirs[i])
818		}
819	}
820
821	return files
822}
823
824func formatAgentsContext(files []ContextFile) string {
825	if len(files) == 0 {
826		return ""
827	}
828	var sb strings.Builder
829	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")
830	for _, f := range files {
831		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.Path, f.Content)
832	}
833	return strings.TrimSpace(sb.String())
834}
835
836// descMapFromSummaries indexes the human-readable descriptions for
837// the renderToolsSection rebuild path.
838func descMapFromSummaries(summaries []ToolSummary) map[string]string {
839	out := make(map[string]string, len(summaries))
840	for _, s := range summaries {
841		out[s.Name] = s.Description
842	}
843	return out
844}
845
846// NewClient returns a provider.Client for r, choosing the auth mode
847// based on r.AuthMethod. Panics if no credential is present; callers
848// must check HasCredential() first.
849func (r Resolved) NewClient() provider.Client {
850	if !r.HasCredential() {
851		panic("NewClient called without credential; check HasCredential first")
852	}
853	wrap := r.withHTTPClient
854	switch r.Provider {
855	case "ollama":
856		return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
857	case provider.LlamaCPPProviderID, provider.LMStudioProviderID:
858		credential := r.Credential
859		if credential == "local" {
860			credential = ""
861		}
862		return wrap(provider.NewOpenAI(credential, r.BaseURL))
863	case "kimi":
864		// kimi-coding speaks anthropic-messages on api.kimi.com/coding.
865		// Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
866		inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
867		if r.AuthMethod == "oauth" {
868			return r.wrapWithRefresh(inner)
869		}
870		return inner
871	case "moonshotai":
872		return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
873	case "moonshotai-cn":
874		return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
875	case "deepseek":
876		return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
877	case "openai":
878		return provider.NewModelRouter("openai",
879			wrap(provider.NewOpenAI(r.Credential, r.BaseURL)),
880			map[string]provider.Client{
881				provider.APIResponses: wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai")),
882			})
883	case "openai-codex":
884		inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
885		return r.wrapWithRefresh(inner)
886	case "openai-responses":
887		// Public OpenAI Responses API (api.openai.com/v1/responses) via
888		// API key. Separate provider from `openai` (Chat Completions) and
889		// from `openai-codex` (ChatGPT subscription OAuth).
890		return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
891	case "google":
892		return wrap(provider.NewGemini(r.Credential, r.BaseURL))
893	case "cerebras":
894		return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
895	case "groq":
896		return wrap(provider.NewGroq(r.Credential, r.BaseURL))
897	case "xai":
898		inner := r.newXAIClient(r.Credential)
899		if r.AuthMethod == "oauth" {
900			return r.wrapWithRefresh(inner)
901		}
902		return inner
903	case "together":
904		return wrap(provider.NewTogether(r.Credential, r.BaseURL))
905	case "huggingface":
906		return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
907	case "openrouter":
908		return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
909	case "gondola":
910		return wrap(provider.NewGondola(r.Credential, r.BaseURL))
911	case "yolo-auto":
912		return wrap(provider.NewYoloAuto(r.Credential, r.BaseURL))
913	case "zai":
914		return wrap(provider.NewZAI(r.Credential, r.BaseURL))
915	case "xiaomi":
916		return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
917	case "xiaomi-token-plan-ams":
918		return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
919	case "xiaomi-token-plan-cn":
920		return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
921	case "xiaomi-token-plan-sgp":
922		return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
923	case "opencode":
924		return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
925	case "opencode-go":
926		return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
927	case "minimax":
928		return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
929	case "minimax-cn":
930		return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
931	case "fireworks":
932		return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
933	case "vercel-ai-gateway":
934		return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
935	case "mistral":
936		return wrap(provider.NewMistral(r.Credential, r.BaseURL))
937	case "amazon-bedrock":
938		return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
939	case "google-vertex":
940		return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
941	case "azure-openai-responses":
942		return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
943	case "github-copilot":
944		return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
945	case "cloudflare-workers-ai":
946		return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
947	case "cloudflare-ai-gateway":
948		return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
949	default:
950		// Custom providers: choose wire format from the models.json api field.
951		if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
952			switch cfg.API {
953			case provider.APIResponses:
954				return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, r.Provider))
955			case "anthropic":
956				return wrap(provider.NewAnthropicCompat(r.Provider, r.Credential, r.BaseURL))
957			default: // "openai"
958				return wrap(provider.NewOpenAICompat(r.Provider, r.Credential, r.BaseURL, ""))
959			}
960		}
961		if r.AuthMethod == "oauth" {
962			inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
963			return r.wrapWithRefresh(inner)
964		}
965		return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
966	}
967}
968
969func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
970	if !r.InsecureTLS {
971		return c
972	}
973	return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
974}
975
976func (r Resolved) newXAIClient(token string) provider.Client {
977	return provider.NewModelRouter("xai",
978		r.withHTTPClient(provider.NewXAI(token, r.BaseURL)),
979		map[string]provider.Client{
980			provider.APIResponses: r.withHTTPClient(provider.NewOpenAIResponsesNamed(token, r.BaseURL, "xai")),
981		})
982}
983
984// wrapWithRefresh wraps an OAuth client so the access token is
985// refreshed automatically before each API call. Without this, long
986// sessions (hours) silently fail when the 1-hour token expires.
987func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
988	provName := r.Provider
989	tokenProvider := provName
990	if provName == "openai-codex" {
991		tokenProvider = "openai"
992	}
993	baseURL := r.BaseURL
994	accountID := r.AccountID
995
996	refreshFn := func(ctx context.Context) (string, error) {
997		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
998		if err != nil {
999			return "", err
1000		}
1001		return tok.AccessToken, nil
1002	}
1003
1004	factory := func(token string) provider.Client {
1005		switch provName {
1006		case "openai-codex":
1007			return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
1008		case "kimi":
1009			// anthropic-messages on api.kimi.com/coding.
1010			return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
1011		case "xai":
1012			return r.newXAIClient(token)
1013		default:
1014			return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
1015		}
1016	}
1017
1018	return provider.NewRefreshingClient(inner, refreshFn, factory)
1019}
1020
1021// UseSandbox replaces the sandbox pointer that every tool in r's
1022// registry references. Used to keep the /jail state stable across
1023// agent rebuilds (e.g. /login, /model switching providers).
1024func (r *Resolved) UseSandbox(s *tools.Sandbox) {
1025	if s == nil || r == nil {
1026		return
1027	}
1028	if r.Sandbox != nil && r.Sandbox.Permissions != nil {
1029		s.Permissions = r.Sandbox.Permissions
1030	}
1031	r.Sandbox = s
1032	for name, t := range r.ToolRegistry {
1033		switch v := t.(type) {
1034		case *tools.ReadTool:
1035			v.Sandbox = s
1036		case *tools.WriteTool:
1037			v.Sandbox = s
1038		case *tools.EditTool:
1039			v.Sandbox = s
1040		case *tools.BashTool:
1041			v.Sandbox = s
1042		case *tools.PowerShellTool:
1043			v.Sandbox = s
1044		case *tools.GlobTool:
1045			v.Sandbox = s
1046		}
1047		_ = name
1048	}
1049}
1050
1051// NewAgent constructs a core.Agent from r. Requires a credential.
1052func (r Resolved) NewAgent() *core.Agent {
1053	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
1054	a.MaxSteps = r.MaxSteps
1055	a.MaxTokens = r.MaxOutput
1056	a.Reasoning = r.Reasoning
1057	a.Temperature = r.Temperature
1058	if hasOpenRouterServerTools(r.ToolRegistry) {
1059		a.MaxToolCalls = openRouterServerToolCallLimit()
1060	}
1061	return a
1062}
1063
1064// openRouterServerToolCallLimit returns the configured call limit from
1065// config.json, falling back to the built-in default (4).
1066func openRouterServerToolCallLimit() int {
1067	cfg, err := LoadConfig()
1068	if err != nil {
1069		return tools.OpenRouterServerToolCallLimit
1070	}
1071	if cfg.OpenRouterServerToolCallLimit != nil && *cfg.OpenRouterServerToolCallLimit > 0 {
1072		return *cfg.OpenRouterServerToolCallLimit
1073	}
1074	return tools.OpenRouterServerToolCallLimit
1075}
1076
1077// OpenRouterServerToolsEnabled reports the persisted preference.
1078// nil/missing and unreadable configuration fail closed to disabled.
1079func OpenRouterServerToolsEnabled() bool {
1080	cfg, err := LoadConfig()
1081	if err != nil {
1082		return false
1083	}
1084	return cfg.OpenRouterServerToolsEnabled != nil && *cfg.OpenRouterServerToolsEnabled
1085}
1086
1087func isOpenRouterPresetModel(model string) bool {
1088	return strings.Contains(strings.ToLower(model), "@preset/")
1089}
1090
1091func hasOpenRouterServerTools(reg core.Registry) bool {
1092	for name := range reg {
1093		if tools.IsOpenRouterServerTool(name) {
1094			return true
1095		}
1096	}
1097	return false
1098}
1099
1100func withOpenRouterServerTools(reg core.Registry) core.Registry {
1101	out := core.Registry{}
1102	for name, t := range reg {
1103		out[name] = t
1104	}
1105	for _, t := range tools.OpenRouterServerTools() {
1106		out[t.Name()] = t
1107	}
1108	return out
1109}
1110
1111func buildConfiguredToolRegistry(args Args, cwd string, sandbox *tools.Sandbox, cfg Config, goos string) core.Registry {
1112	reg := buildToolRegistry(args, cwd, sandbox)
1113	if goos == "windows" && !args.NoTools && len(args.Tools) == 0 && cfg.PowerShellEnabled != nil && *cfg.PowerShellEnabled {
1114		reg["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1115	}
1116	return reg
1117}
1118
1119func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
1120	if args.NoTools {
1121		return core.Registry{}
1122	}
1123	all := map[string]core.Tool{
1124		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
1125		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
1126		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
1127		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
1128		"glob":  &tools.GlobTool{CWD: cwd, Sandbox: sandbox},
1129	}
1130	reg := core.Registry{}
1131	if len(args.Tools) == 0 {
1132		for _, t := range all {
1133			reg[t.Name()] = t
1134		}
1135		return reg
1136	}
1137	// PowerShell is explicitly selected, never added to the default tool set.
1138	all["powershell"] = &tools.PowerShellTool{CWD: cwd, Sandbox: sandbox}
1139	for _, name := range args.Tools {
1140		if t, ok := all[name]; ok {
1141			reg[name] = t
1142		}
1143	}
1144	return reg
1145}
1146
1147func toolSummaries(reg core.Registry, args Args) []ToolSummary {
1148	order := []string{"read", "write", "edit", "bash", "powershell", "glob"}
1149	var out []ToolSummary
1150	for _, name := range order {
1151		if t, ok := reg[name]; ok {
1152			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
1153		}
1154	}
1155	return out
1156}
1157
1158func firstNonEmpty(vals ...string) string {
1159	for _, v := range vals {
1160		if v != "" {
1161			return v
1162		}
1163	}
1164	return ""
1165}
1166
1167func kimiCodeHeaders() map[string]string {
1168	host, _ := os.Hostname()
1169	if host == "" {
1170		host = "unknown"
1171	}
1172	deviceID := ""
1173	if home, err := os.UserHomeDir(); err == nil {
1174		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
1175			deviceID = strings.TrimSpace(string(b))
1176		}
1177	}
1178	if deviceID == "" {
1179		deviceID = "zot"
1180	}
1181	return map[string]string{
1182		"User-Agent":         "KimiCLI/1.41.0",
1183		"X-Msh-Platform":     "kimi_cli",
1184		"X-Msh-Version":      "1.41.0",
1185		"X-Msh-Device-Name":  host,
1186		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
1187		"X-Msh-Os-Version":   runtime.GOOS,
1188		"X-Msh-Device-Id":    deviceID,
1189	}
1190}
1191
1192func envVarName(provider string) string {
1193	switch provider {
1194	case "openai", "openai-codex", "openai-responses":
1195		return "OPENAI"
1196	case "kimi":
1197		return "KIMI"
1198	case "deepseek":
1199		return "DEEPSEEK"
1200	case "google":
1201		return "GEMINI"
1202	case "ollama":
1203		return "OLLAMA"
1204	case "llama.cpp":
1205		return "LLAMA"
1206	case "moonshotai", "moonshotai-cn":
1207		return "MOONSHOT"
1208	case "groq":
1209		return "GROQ"
1210	case "xai":
1211		return "XAI"
1212	case "cerebras":
1213		return "CEREBRAS"
1214	case "together":
1215		return "TOGETHER"
1216	case "huggingface":
1217		return "HF"
1218	case "openrouter":
1219		return "OPENROUTER"
1220	case "gondola":
1221		return "GONDOLA"
1222	case "yolo-auto":
1223		return "YOLO_AUTO"
1224	case "mistral":
1225		return "MISTRAL"
1226	case "zai":
1227		return "ZAI"
1228	case "xiaomi":
1229		return "XIAOMI"
1230	case "xiaomi-token-plan-ams":
1231		return "XIAOMI_TOKEN_PLAN_AMS"
1232	case "xiaomi-token-plan-cn":
1233		return "XIAOMI_TOKEN_PLAN_CN"
1234	case "xiaomi-token-plan-sgp":
1235		return "XIAOMI_TOKEN_PLAN_SGP"
1236	case "minimax":
1237		return "MINIMAX"
1238	case "minimax-cn":
1239		return "MINIMAX_CN"
1240	case "fireworks":
1241		return "FIREWORKS"
1242	case "vercel-ai-gateway":
1243		return "AI_GATEWAY"
1244	case "opencode", "opencode-go":
1245		return "OPENCODE"
1246	case "github-copilot":
1247		return "COPILOT_GITHUB_TOKEN"
1248	case "cloudflare-workers-ai", "cloudflare-ai-gateway":
1249		return "CLOUDFLARE"
1250	case "amazon-bedrock":
1251		return "AWS"
1252	case "google-vertex":
1253		return "GOOGLE_CLOUD"
1254	case "azure-openai-responses":
1255		return "AZURE_OPENAI"
1256	default:
1257		return "ANTHROPIC"
1258	}
1259}

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