v0.1.88 14ffdb6

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/internal/agent/tools"
13	"github.com/patriceckhart/zot/internal/core"
14	"github.com/patriceckhart/zot/internal/provider"
15	"github.com/patriceckhart/zot/internal/skills"
16)
17
18// Resolved is the effective configuration after merging CLI, config, defaults.
19type Resolved struct {
20	Provider   string
21	Model      string
22	Credential string // api key or oauth access token
23	AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
24	AccountID  string // ChatGPT account id (for openai oauth), "" otherwise
25	BaseURL    string
26	CWD        string
27	Reasoning  string
28
29	ToolRegistry core.Registry
30	ToolSummary  []ToolSummary
31	SystemPrompt string
32	MaxSteps     int
33	Sandbox      *tools.Sandbox
34
35	// SkillTool is the on-demand skill loader registered with the
36	// agent's tool registry, or nil if no SKILL.md files were
37	// discovered. Exposed so the tui can list / preview skills.
38	SkillTool *skills.Tool
39
40	// Bookkeeping for MergeExtensionTools. Captured at Resolve time
41	// so the system prompt can be rebuilt later without re-running
42	// resolve.
43	systemAppend     []string
44	systemCustom     string
45	toolDescriptions map[string]string
46}
47
48// HasCredential reports whether a credential was resolved.
49func (r Resolved) HasCredential() bool { return r.Credential != "" }
50
51// MergeExtensionTools folds every tool registered by an extension
52// into r's ToolRegistry and re-renders the system prompt's tool
53// summary so the model sees both built-in and extension tools.
54//
55// Idempotent: calling twice with the same manager state has no
56// effect on the second pass (existing names are preserved). Built-in
57// tools always win on conflict.
58func (r *Resolved) MergeExtensionTools(mgr ExtensionToolSource) {
59	if mgr == nil {
60		return
61	}
62	infos := mgr.Tools()
63	if len(infos) == 0 {
64		return
65	}
66	changed := false
67	for _, info := range infos {
68		if _, exists := r.ToolRegistry[info.Name]; exists {
69			continue
70		}
71		r.ToolRegistry[info.Name] = mgr.NewExtensionTool(info)
72		changed = true
73	}
74	if !changed {
75		return
76	}
77	// Re-render the system prompt with the merged tool list. Skill
78	// addendum is preserved by walking the existing append slice.
79	append_ := r.systemAppend
80	r.SystemPrompt = BuildSystemPrompt(SystemPromptOpts{
81		CWD:        r.CWD,
82		Tools:      toolSummariesFromRegistry(r.ToolRegistry, r.toolDescriptions),
83		Custom:     r.systemCustom,
84		Append:     append_,
85		ZotDocsDir: filepath.Join(ZotHome(), "docs"),
86	})
87}
88
89// ExtensionToolSource is the slice of the extension manager that
90// MergeExtensionTools needs. Lives here as an interface so the
91// build package doesn't import internal/agent/extensions (which
92// imports core, which imports... avoid the cycle).
93type ExtensionToolSource interface {
94	Tools() []ExtensionToolInfo
95	NewExtensionTool(info ExtensionToolInfo) core.Tool
96}
97
98// ExtensionToolInfo mirrors extensions.ToolInfo so we can declare
99// ExtensionToolSource here without importing the extensions
100// package. The cli wires a tiny adapter to bridge them.
101type ExtensionToolInfo struct {
102	Extension   string
103	Name        string
104	Description string
105	Schema      []byte
106}
107
108// toolSummariesFromRegistry rebuilds the system-prompt tool list
109// from a (possibly extended) registry, using cached descriptions for
110// the human-readable summary text.
111func toolSummariesFromRegistry(reg core.Registry, cached map[string]string) []ToolSummary {
112	out := make([]ToolSummary, 0, len(reg))
113	for name, t := range reg {
114		desc := t.Description()
115		if d, ok := cached[name]; ok && d != "" {
116			desc = d
117		}
118		out = append(out, ToolSummary{Name: name, Description: desc})
119	}
120	return out
121}
122
123// defaultModelForProvider returns the model id zot prefers when the
124// caller didn't pick one. Mirrors the per-provider switch used at
125// multiple points in Resolve; centralised so the unknown-model
126// recovery path and the no-model-configured path can't drift.
127//
128// Returns the empty string for "ollama", which has no built-in
129// default — the caller is expected to special-case ollama and
130// error or use whatever the user passed.
131func defaultModelForProvider(prov string) string {
132	switch prov {
133	case "openai":
134		return "gpt-5"
135	case "openai-codex":
136		return "gpt-5.5"
137	case "kimi":
138		return "kimi-for-coding"
139	case "deepseek":
140		return "deepseek-v4-pro"
141	case "google":
142		return "gemini-2.5-pro"
143	case "ollama":
144		return ""
145	default:
146		return provider.DefaultModel.ID
147	}
148}
149
150// Resolve merges args, config, and env into a Resolved set.
151//
152// Unlike the earlier version, Resolve NEVER returns an error for
153// missing credentials: the TUI can start without them and launch a
154// login flow. requireCred controls whether missing credentials are a
155// hard error (used by print/json modes).
156func Resolve(args Args, requireCred bool) (Resolved, error) {
157	cfg, _ := LoadConfig()
158
159	// User-requested provider (explicit > config > default).
160	provName := firstNonEmpty(args.Provider, cfg.Provider, "anthropic")
161	if provName != "anthropic" && provName != "openai" && provName != "openai-codex" && provName != "kimi" && provName != "deepseek" && provName != "google" && provName != "ollama" {
162		// Unknown provider (maybe removed or renamed). Fall back to
163		// the first provider that has credentials, or anthropic.
164		provName = "anthropic"
165		if _, _, _, err := ResolveCredentialFull("openai", ""); err == nil {
166			provName = "openai"
167		}
168		if _, _, _, err := ResolveCredentialFull("openai-codex", ""); err == nil {
169			provName = "openai-codex"
170		}
171		if _, _, _, err := ResolveCredentialFull("kimi", ""); err == nil {
172			provName = "kimi"
173		}
174		if _, _, _, err := ResolveCredentialFull("deepseek", ""); err == nil {
175			provName = "deepseek"
176		}
177		if _, _, _, err := ResolveCredentialFull("anthropic", ""); err == nil {
178			provName = "anthropic"
179		}
180		// Reset the saved config so this doesn't keep happening.
181		cfg.Provider = provName
182		cfg.Model = ""
183		_ = SaveConfig(cfg)
184	}
185
186	var (
187		cred      string
188		method    string
189		accountID string
190		credErr   error
191	)
192	if provName == "ollama" {
193		if args.BaseURL == "" {
194			args.BaseURL = "http://localhost:11434"
195		}
196		cred = firstNonEmpty(args.APIKey, "ollama")
197		method = "apikey"
198	} else {
199		cred, method, accountID, credErr = ResolveCredentialFull(provName, args.APIKey)
200	}
201
202	// If the user did NOT explicitly pick a provider and the default one
203	// has no credentials, auto-fall-back to whichever provider is actually
204	// logged in. That way running plain `zot` after `/login` (any provider)
205	// never shows a "not logged in" banner.
206	userPickedProvider := args.Provider != ""
207	if credErr != nil && !userPickedProvider && provName != "ollama" {
208		for _, other := range []string{"anthropic", "openai", "openai-codex", "kimi", "deepseek", "google"} {
209			if other == provName {
210				continue
211			}
212			if c, m, a, err := ResolveCredentialFull(other, args.APIKey); err == nil {
213				provName = other
214				cred, method, accountID, credErr = c, m, a, err
215				break
216			}
217		}
218	}
219
220	model := firstNonEmpty(args.Model, cfg.Model)
221	if model == "" {
222		switch provName {
223		case "openai":
224			model = "gpt-5"
225		case "openai-codex":
226			model = "gpt-5.5"
227		case "kimi":
228			model = "kimi-for-coding"
229		case "deepseek":
230			model = "deepseek-v4-pro"
231		case "google":
232			model = "gemini-2.5-pro"
233		case "ollama":
234			return Resolved{}, fmt.Errorf("ollama requires --model (e.g. --model llama3)")
235		default:
236			model = provider.DefaultModel.ID
237		}
238	}
239	// If the resolved model belongs to a different provider (e.g. config
240	// says gpt-5 but we fell back to anthropic), pick that provider's default.
241	if provName != "ollama" {
242		if m, err := provider.FindModel("", model); err == nil && m.Provider != provName {
243			switch provName {
244			case "openai":
245				model = "gpt-5"
246			case "openai-codex":
247				model = "gpt-5.5"
248			case "kimi":
249				model = "kimi-for-coding"
250			case "deepseek":
251				model = "deepseek-v4-pro"
252			case "google":
253				model = "gemini-2.5-pro"
254			default:
255				model = provider.DefaultModel.ID
256			}
257		}
258	}
259	resolvedModel, err := provider.FindModel(provName, model)
260	if err != nil && provName == "ollama" {
261		// ollama is intentionally open-catalogue: any model id the
262		// local server understands is valid, even if not in the
263		// baked-in catalog.
264		resolvedModel = provider.Model{
265			Provider:      "ollama",
266			ID:            model,
267			DisplayName:   model,
268			ContextWindow: 32768,
269			MaxOutput:     8192,
270			BaseURL:       args.BaseURL,
271			Source:        "ollama",
272		}
273		err = nil
274	}
275	if err != nil {
276		// The model the user (or persisted config) asked for is no
277		// longer in the active catalogue — they probably removed it
278		// from their models.json or upgraded zot and the id changed.
279		// Refusing to launch is the wrong move: it strands the user
280		// with no way to even open the TUI and pick a new model.
281		// Fall back to the provider's default, warn on stderr, and,
282		// when the stale id came from the persisted config (not an
283		// explicit --model flag), repair the config so the warning
284		// doesn't repeat on every launch.
285		fallback := defaultModelForProvider(provName)
286		fm, ferr := provider.FindModel(provName, fallback)
287		if ferr != nil {
288			// Even the provider default is gone (catastrophic
289			// catalogue trim). Last resort: any model on this
290			// provider, then the global DefaultModel.
291			if candidates := provider.ModelsForProvider(provName); len(candidates) > 0 {
292				fm = candidates[0]
293				ferr = nil
294			} else {
295				fm = provider.DefaultModel
296				ferr = nil
297			}
298		}
299		fmt.Fprintf(os.Stderr,
300			"zot: model %q is not in the active catalogue; using %q instead. Pick a different model with --model or /model.\n",
301			model, fm.ID)
302		if args.Model == "" && cfg.Model == model {
303			cfg.Model = fm.ID
304			_ = SaveConfig(cfg)
305		}
306		resolvedModel = fm
307		model = fm.ID
308	}
309
310	// If the model defines a base URL (e.g. local ollama) and the
311	// user didn't pass --base-url, use the model's URL.
312	if args.BaseURL == "" && resolvedModel.BaseURL != "" {
313		args.BaseURL = resolvedModel.BaseURL
314	}
315
316	// If the model has a base URL, credentials are optional (local
317	// models like ollama don't need real API keys).
318	if resolvedModel.BaseURL != "" && credErr != nil {
319		cred = "ollama"
320		credErr = nil
321		requireCred = false
322	}
323
324	if credErr != nil && requireCred {
325		return Resolved{}, fmt.Errorf("%w; set %s_API_KEY, pass --api-key, or run `zot` and /login",
326			credErr, envVarName(provName))
327	}
328
329	sandbox := tools.NewSandbox(args.CWD)
330	reg := buildToolRegistry(args, args.CWD, sandbox)
331
332	docsDir, _ := zotdocs.EnsureInstalled(ZotHome())
333
334	// Skill discovery: scan project + global locations + built-in
335	// skills shipped with the binary. If any are found, register
336	// the on-demand `skill` loader tool plus a system-prompt
337	// manifest so the model knows what's available.
338	//
339	// --no-skill bypasses the entire mechanism: no manifest in the
340	// system prompt, no `skill` tool in the registry. Useful for a
341	// clean-room run with zero extra context biasing the model.
342	var (
343		discovered    []*skills.Skill
344		skillTool     *skills.Tool
345		skillAddendum string
346	)
347	if !args.NoSkill {
348		homeDir, _ := os.UserHomeDir()
349		discovered, _ = skills.Discover(ZotHome(), args.CWD, homeDir, args.WithSkills)
350		if len(discovered) > 0 {
351			skillTool = skills.NewTool(discovered)
352			reg[skillTool.Name()] = skillTool
353			skillAddendum = skills.SystemPromptAddendum(discovered)
354		}
355	}
356	_ = skillTool
357
358	summaries := toolSummaries(reg, args)
359
360	append_ := append([]string(nil), args.AppendSystemPrompt...)
361	if agentsAddendum := readAgentsContext(args.CWD, ZotHome()); agentsAddendum != "" {
362		append_ = append(append_, agentsAddendum)
363	}
364	if skillAddendum != "" {
365		append_ = append(append_, skillAddendum)
366	}
367
368	// Custom system prompt resolution order:
369	//   1. --system-prompt flag (highest priority; ad-hoc per run)
370	//   2. $ZOT_HOME/SYSTEM.md (persistent user override)
371	//   3. built-in default (defaultIdentity + defaultGuidelines)
372	custom := args.SystemPrompt
373	if custom == "" {
374		custom = readUserSystemPrompt(ZotHome())
375	}
376
377	sys := BuildSystemPrompt(SystemPromptOpts{
378		CWD:        args.CWD,
379		Tools:      summaries,
380		Custom:     custom,
381		Append:     append_,
382		ZotDocsDir: docsDir,
383	})
384
385	reasoning := firstNonEmpty(args.Reasoning, cfg.Reasoning)
386
387	max := args.MaxSteps // 0 = unlimited
388
389	return Resolved{
390		Provider:         provName,
391		Model:            model,
392		Credential:       cred,
393		AuthMethod:       method,
394		AccountID:        accountID,
395		BaseURL:          args.BaseURL,
396		CWD:              args.CWD,
397		Reasoning:        reasoning,
398		ToolRegistry:     reg,
399		ToolSummary:      summaries,
400		SystemPrompt:     sys,
401		MaxSteps:         max,
402		Sandbox:          sandbox,
403		SkillTool:        skillTool,
404		systemAppend:     append_,
405		systemCustom:     custom,
406		toolDescriptions: descMapFromSummaries(summaries),
407	}, nil
408}
409
410// readUserSystemPrompt looks for $ZOT_HOME/SYSTEM.md and returns its
411// trimmed contents, or "" when the file is missing / unreadable /
412// empty. Errors are intentionally swallowed: the file is optional,
413// and any failure to read it should fall back to the built-in
414// default system prompt rather than crash the run.
415func readUserSystemPrompt(zotHome string) string {
416	if zotHome == "" {
417		return ""
418	}
419	path := filepath.Join(zotHome, "SYSTEM.md")
420	raw, err := os.ReadFile(path)
421	if err != nil {
422		return ""
423	}
424	return strings.TrimSpace(string(raw))
425}
426
427// readAgentsContext loads optional AGENTS.md instruction files. No
428// default file is created or required: zot only includes files that
429// already exist. Global instructions ($ZOT_HOME/AGENTS.md) come first,
430// followed by project instructions from the top-most parent down to cwd.
431func readAgentsContext(cwd, zotHome string) string {
432	type contextFile struct {
433		path    string
434		content string
435	}
436	var files []contextFile
437	seen := map[string]bool{}
438	add := func(path string) {
439		if path == "" {
440			return
441		}
442		abs, err := filepath.Abs(path)
443		if err == nil {
444			path = abs
445		}
446		if seen[path] {
447			return
448		}
449		raw, err := os.ReadFile(path)
450		if err != nil {
451			return
452		}
453		content := strings.TrimSpace(string(raw))
454		if content == "" {
455			return
456		}
457		seen[path] = true
458		files = append(files, contextFile{path: path, content: content})
459	}
460	addFirstFromDir := func(dir string) {
461		if dir == "" {
462			return
463		}
464		for _, name := range []string{"AGENTS.md", "AGENTS.MD"} {
465			path := filepath.Join(dir, name)
466			if _, err := os.Stat(path); err == nil {
467				add(path)
468				return
469			}
470		}
471	}
472
473	addFirstFromDir(zotHome)
474
475	if cwd != "" {
476		abs, err := filepath.Abs(cwd)
477		if err == nil {
478			cwd = abs
479		}
480		var dirs []string
481		for dir := filepath.Clean(cwd); ; dir = filepath.Dir(dir) {
482			dirs = append(dirs, dir)
483			parent := filepath.Dir(dir)
484			if parent == dir {
485				break
486			}
487		}
488		for i := len(dirs) - 1; i >= 0; i-- {
489			addFirstFromDir(dirs[i])
490		}
491	}
492
493	if len(files) == 0 {
494		return ""
495	}
496	var sb strings.Builder
497	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")
498	for _, f := range files {
499		fmt.Fprintf(&sb, "\n## %s\n\n%s\n", f.path, f.content)
500	}
501	return strings.TrimSpace(sb.String())
502}
503
504// descMapFromSummaries indexes the human-readable descriptions for
505// the renderToolsSection rebuild path.
506func descMapFromSummaries(summaries []ToolSummary) map[string]string {
507	out := make(map[string]string, len(summaries))
508	for _, s := range summaries {
509		out[s.Name] = s.Description
510	}
511	return out
512}
513
514// NewClient returns a provider.Client for r, choosing the auth mode
515// based on r.AuthMethod. Panics if no credential is present; callers
516// must check HasCredential() first.
517func (r Resolved) NewClient() provider.Client {
518	if !r.HasCredential() {
519		panic("NewClient called without credential; check HasCredential first")
520	}
521	switch r.Provider {
522	case "ollama":
523		return provider.NewOpenAI(r.Credential, r.BaseURL)
524	case "kimi":
525		inner := provider.NewKimiWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
526		if r.AuthMethod == "oauth" {
527			return r.wrapWithRefresh(inner)
528		}
529		return inner
530	case "deepseek":
531		return provider.NewDeepSeek(r.Credential, r.BaseURL)
532	case "openai":
533		return provider.NewOpenAI(r.Credential, r.BaseURL)
534	case "openai-codex":
535		inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
536		return r.wrapWithRefresh(inner)
537	case "google":
538		// API-key only path. Gemini Generative Language API.
539		return provider.NewGemini(r.Credential, r.BaseURL)
540	default:
541		if r.AuthMethod == "oauth" {
542			inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
543			return r.wrapWithRefresh(inner)
544		}
545		return provider.NewAnthropic(r.Credential, r.BaseURL)
546	}
547}
548
549// wrapWithRefresh wraps an OAuth client so the access token is
550// refreshed automatically before each API call. Without this, long
551// sessions (hours) silently fail when the 1-hour token expires.
552func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
553	provName := r.Provider
554	tokenProvider := provName
555	if provName == "openai-codex" {
556		tokenProvider = "openai"
557	}
558	baseURL := r.BaseURL
559	accountID := r.AccountID
560
561	refreshFn := func(ctx context.Context) (string, error) {
562		tok, err := refreshIfExpired(tokenProvider, loadOAuthToken(tokenProvider))
563		if err != nil {
564			return "", err
565		}
566		return tok.AccessToken, nil
567	}
568
569	factory := func(token string) provider.Client {
570		switch provName {
571		case "openai-codex":
572			return provider.NewOpenAICodex(token, accountID, baseURL)
573		case "kimi":
574			return provider.NewKimiWithHeaders(token, baseURL, kimiCodeHeaders())
575		default:
576			return provider.NewAnthropicOAuth(token, baseURL)
577		}
578	}
579
580	return provider.NewRefreshingClient(inner, refreshFn, factory)
581}
582
583// UseSandbox replaces the sandbox pointer that every tool in r's
584// registry references. Used to keep the /jail state stable across
585// agent rebuilds (e.g. /login, /model switching providers).
586func (r *Resolved) UseSandbox(s *tools.Sandbox) {
587	if s == nil || r == nil {
588		return
589	}
590	r.Sandbox = s
591	for name, t := range r.ToolRegistry {
592		switch v := t.(type) {
593		case *tools.ReadTool:
594			v.Sandbox = s
595		case *tools.WriteTool:
596			v.Sandbox = s
597		case *tools.EditTool:
598			v.Sandbox = s
599		case *tools.BashTool:
600			v.Sandbox = s
601		}
602		_ = name
603	}
604}
605
606// NewAgent constructs a core.Agent from r. Requires a credential.
607func (r Resolved) NewAgent() *core.Agent {
608	a := core.NewAgent(r.NewClient(), r.Model, r.SystemPrompt, r.ToolRegistry)
609	a.MaxSteps = r.MaxSteps
610	a.Reasoning = r.Reasoning
611	return a
612}
613
614func buildToolRegistry(args Args, cwd string, sandbox *tools.Sandbox) core.Registry {
615	if args.NoTools {
616		return core.Registry{}
617	}
618	all := map[string]core.Tool{
619		"read":  &tools.ReadTool{CWD: cwd, Sandbox: sandbox},
620		"write": &tools.WriteTool{CWD: cwd, Sandbox: sandbox},
621		"edit":  &tools.EditTool{CWD: cwd, Sandbox: sandbox},
622		"bash":  &tools.BashTool{CWD: cwd, Sandbox: sandbox},
623	}
624	reg := core.Registry{}
625	if len(args.Tools) == 0 {
626		for _, t := range all {
627			reg[t.Name()] = t
628		}
629		return reg
630	}
631	for _, name := range args.Tools {
632		if t, ok := all[name]; ok {
633			reg[name] = t
634		}
635	}
636	return reg
637}
638
639func toolSummaries(reg core.Registry, args Args) []ToolSummary {
640	order := []string{"read", "write", "edit", "bash"}
641	var out []ToolSummary
642	for _, name := range order {
643		if t, ok := reg[name]; ok {
644			out = append(out, ToolSummary{Name: t.Name(), Description: t.Description()})
645		}
646	}
647	return out
648}
649
650func firstNonEmpty(vals ...string) string {
651	for _, v := range vals {
652		if v != "" {
653			return v
654		}
655	}
656	return ""
657}
658
659func kimiCodeHeaders() map[string]string {
660	host, _ := os.Hostname()
661	if host == "" {
662		host = "unknown"
663	}
664	deviceID := ""
665	if home, err := os.UserHomeDir(); err == nil {
666		if b, err := os.ReadFile(filepath.Join(home, ".kimi", "device_id")); err == nil {
667			deviceID = strings.TrimSpace(string(b))
668		}
669	}
670	if deviceID == "" {
671		deviceID = "zot"
672	}
673	return map[string]string{
674		"User-Agent":         "KimiCLI/1.41.0",
675		"X-Msh-Platform":     "kimi_cli",
676		"X-Msh-Version":      "1.41.0",
677		"X-Msh-Device-Name":  host,
678		"X-Msh-Device-Model": runtime.GOOS + "-" + runtime.GOARCH,
679		"X-Msh-Os-Version":   runtime.GOOS,
680		"X-Msh-Device-Id":    deviceID,
681	}
682}
683
684func envVarName(provider string) string {
685	switch provider {
686	case "openai", "openai-codex":
687		return "OPENAI"
688	case "kimi":
689		return "KIMI"
690	case "deepseek":
691		return "DEEPSEEK"
692	case "google":
693		return "GEMINI"
694	case "ollama":
695		return "OLLAMA"
696	default:
697		return "ANTHROPIC"
698	}
699}

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