v0.3.84 97ae0a2

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

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 477

↑↓ moveenter openesc close