detached 4570db2

Merge branch 'main' into feat/custom-llm-providers

commit
4570db2a3f6ce34de5425fea69fe2e4f9aff2c88
author
Patric Eckhart · · 2026-06-16 17:43 UTC
committer
GitHub · · 2026-06-16 17:43 UTC
merge of
dc2d337 + 1cc654e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +268 -38
M README.md +1 -0
@@ -141,6 +141,7 @@ zot --help
141141 | `--model <id>` | Pick the model (see `--list-models`). |
142142 | `--api-key <key>` | Override the API key. |
143143 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
144+| `--insecure` | Skip TLS certificate verification, only for the explicit `--base-url` endpoint (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |
144145 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
145146 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
146147 | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |
@@ -141,6 +141,7 @@ zot --help
141 | `--model <id>` | Pick the model (see `--list-models`). |141 | `--model <id>` | Pick the model (see `--list-models`). |
142 | `--api-key <key>` | Override the API key. |142 | `--api-key <key>` | Override the API key. |
143 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |143 | `--base-url <url>` | Override the provider base URL (tests, self-hosted). |
144+| `--insecure` | Skip TLS certificate verification, only for the explicit `--base-url` endpoint (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |
144 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |145 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
145 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |146 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
146 | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |147 | `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |
M packages/agent/args.go +6 -0
@@ -74,6 +74,9 @@ type Args struct {
7474 // skill discovery, including built-ins.
7575 WithSkills bool
7676
77+ // InsecureTLS skips TLS verification for custom inference endpoints.
78+ InsecureTLS bool
79+
7780 // NoYolo turns on per-tool confirmation. Before each tool
7881 // invocation the TUI prompts the user with the tool name + args
7982 // and waits for an explicit yes/no. The user can also pick
@@ -190,6 +193,8 @@ func ParseArgs(in []string) (Args, error) {
190193 case "--with-skills", "--with-skill":
191194 // Deprecated no-op: user skills are loaded by default.
192195 a.WithSkills = true
196+ case "--insecure":
197+ a.InsecureTLS = true
193198 case "--no-yolo":
194199 a.NoYolo = true
195200 case "--reasoning":
@@ -373,6 +378,7 @@ func PrintHelp(version string) {
373378 row{"--model ID", "model id (see --list-models)"},
374379 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
375380 row{"--base-url URL", "override provider api base url"},
381+ row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},
376382 row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"},
377383 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
378384 )
@@ -74,6 +74,9 @@ type Args struct {
74 // skill discovery, including built-ins.74 // skill discovery, including built-ins.
75 WithSkills bool75 WithSkills bool
76 76
77+ // InsecureTLS skips TLS verification for custom inference endpoints.
78+ InsecureTLS bool
79+
77 // NoYolo turns on per-tool confirmation. Before each tool80 // NoYolo turns on per-tool confirmation. Before each tool
78 // invocation the TUI prompts the user with the tool name + args81 // invocation the TUI prompts the user with the tool name + args
79 // and waits for an explicit yes/no. The user can also pick82 // and waits for an explicit yes/no. The user can also pick
@@ -190,6 +193,8 @@ func ParseArgs(in []string) (Args, error) {
190 case "--with-skills", "--with-skill":193 case "--with-skills", "--with-skill":
191 // Deprecated no-op: user skills are loaded by default.194 // Deprecated no-op: user skills are loaded by default.
192 a.WithSkills = true195 a.WithSkills = true
196+ case "--insecure":
197+ a.InsecureTLS = true
193 case "--no-yolo":198 case "--no-yolo":
194 a.NoYolo = true199 a.NoYolo = true
195 case "--reasoning":200 case "--reasoning":
@@ -373,6 +378,7 @@ func PrintHelp(version string) {
373 row{"--model ID", "model id (see --list-models)"},378 row{"--model ID", "model id (see --list-models)"},
374 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},379 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
375 row{"--base-url URL", "override provider api base url"},380 row{"--base-url URL", "override provider api base url"},
381+ row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},
376 row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"},382 row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"},
377 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},383 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
378 )384 )
M packages/agent/build.go +55 -38
@@ -23,6 +23,7 @@ type Resolved struct {
2323 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
2424 AccountID string // ChatGPT account id (for openai oauth), "" otherwise
2525 BaseURL string
26+ InsecureTLS bool
2627 CWD string
2728 Reasoning string
2829 Temperature *float32
@@ -455,6 +456,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
455456 model = fm.ID
456457 }
457458
459+ explicitBaseURL := args.BaseURL != ""
460+
458461 // If the model defines a base URL (e.g. local ollama) and the
459462 // user didn't pass --base-url, use the model's URL. For ollama,
460463 // keep http://localhost:11434 as a fallback only after the model
@@ -466,6 +469,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
466469 args.BaseURL = "http://localhost:11434"
467470 }
468471
472+ // Insecure TLS is intentionally scoped to explicit custom endpoints.
473+ // Built-in provider base URLs, auth calls, and model discovery keep normal
474+ // certificate verification even when --insecure is present.
475+ insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
476+
469477 // If the model has a base URL, credentials are optional (local
470478 // models like ollama don't need real API keys).
471479 if resolvedModel.BaseURL != "" && credErr != nil {
@@ -553,6 +561,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
553561 AuthMethod: method,
554562 AccountID: accountID,
555563 BaseURL: args.BaseURL,
564+ InsecureTLS: insecureTLS,
556565 CWD: args.CWD,
557566 Reasoning: reasoning,
558567 Temperature: temperature,
@@ -680,83 +689,84 @@ func (r Resolved) NewClient() provider.Client {
680689 if !r.HasCredential() {
681690 panic("NewClient called without credential; check HasCredential first")
682691 }
692+ wrap := r.withHTTPClient
683693 switch r.Provider {
684694 case "ollama":
685- return provider.NewOpenAI(r.Credential, r.BaseURL)
695+ return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
686696 case "kimi":
687697 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
688698 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
689- inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())
699+ inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
690700 if r.AuthMethod == "oauth" {
691701 return r.wrapWithRefresh(inner)
692702 }
693703 return inner
694704 case "moonshotai":
695- return provider.NewMoonshot(r.Credential, r.BaseURL)
705+ return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
696706 case "moonshotai-cn":
697- return provider.NewMoonshotCN(r.Credential, r.BaseURL)
707+ return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
698708 case "deepseek":
699- return provider.NewDeepSeek(r.Credential, r.BaseURL)
709+ return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
700710 case "openai":
701- return provider.NewOpenAI(r.Credential, r.BaseURL)
711+ return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
702712 case "openai-codex":
703- inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)
713+ inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
704714 return r.wrapWithRefresh(inner)
705715 case "openai-responses":
706716 // Public OpenAI Responses API (api.openai.com/v1/responses) via
707717 // API key. Separate provider from `openai` (Chat Completions) and
708718 // from `openai-codex` (ChatGPT subscription OAuth).
709- return provider.NewOpenAIResponses(r.Credential, r.BaseURL)
719+ return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
710720 case "google":
711- return provider.NewGemini(r.Credential, r.BaseURL)
721+ return wrap(provider.NewGemini(r.Credential, r.BaseURL))
712722 case "cerebras":
713- return provider.NewCerebras(r.Credential, r.BaseURL)
723+ return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
714724 case "groq":
715- return provider.NewGroq(r.Credential, r.BaseURL)
725+ return wrap(provider.NewGroq(r.Credential, r.BaseURL))
716726 case "xai":
717- return provider.NewXAI(r.Credential, r.BaseURL)
727+ return wrap(provider.NewXAI(r.Credential, r.BaseURL))
718728 case "together":
719- return provider.NewTogether(r.Credential, r.BaseURL)
729+ return wrap(provider.NewTogether(r.Credential, r.BaseURL))
720730 case "huggingface":
721- return provider.NewHuggingFace(r.Credential, r.BaseURL)
731+ return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
722732 case "openrouter":
723- return provider.NewOpenRouter(r.Credential, r.BaseURL)
733+ return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
724734 case "zai":
725- return provider.NewZAI(r.Credential, r.BaseURL)
735+ return wrap(provider.NewZAI(r.Credential, r.BaseURL))
726736 case "xiaomi":
727- return provider.NewXiaomi(r.Credential, r.BaseURL)
737+ return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
728738 case "xiaomi-token-plan-ams":
729- return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)
739+ return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
730740 case "xiaomi-token-plan-cn":
731- return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)
741+ return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
732742 case "xiaomi-token-plan-sgp":
733- return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)
743+ return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
734744 case "opencode":
735- return provider.NewOpenCode(r.Credential, r.BaseURL)
745+ return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
736746 case "opencode-go":
737- return provider.NewOpenCodeGo(r.Credential, r.BaseURL)
747+ return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
738748 case "minimax":
739- return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)
749+ return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
740750 case "minimax-cn":
741- return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)
751+ return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
742752 case "fireworks":
743- return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)
753+ return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
744754 case "vercel-ai-gateway":
745- return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)
755+ return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
746756 case "mistral":
747- return provider.NewMistral(r.Credential, r.BaseURL)
757+ return wrap(provider.NewMistral(r.Credential, r.BaseURL))
748758 case "amazon-bedrock":
749- return provider.NewBedrock(r.Credential, r.BaseURL)
759+ return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
750760 case "google-vertex":
751- return provider.NewGoogleVertex(r.Credential, r.BaseURL)
761+ return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
752762 case "azure-openai-responses":
753- return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)
763+ return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
754764 case "github-copilot":
755- return provider.NewGithubCopilot(r.Credential, r.BaseURL)
765+ return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
756766 case "cloudflare-workers-ai":
757- return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)
767+ return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
758768 case "cloudflare-ai-gateway":
759- return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)
769+ return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
760770 default:
761771 // Custom providers: choose wire format from the models.json api field.
762772 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
@@ -768,11 +778,18 @@ func (r Resolved) NewClient() provider.Client {
768778 }
769779 }
770780 if r.AuthMethod == "oauth" {
771- inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)
781+ inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
772782 return r.wrapWithRefresh(inner)
773783 }
774- return provider.NewAnthropic(r.Credential, r.BaseURL)
784+ return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
785+ }
786+}
787+
788+func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
789+ if !r.InsecureTLS {
790+ return c
775791 }
792+ return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
776793 }
777794
778795 // wrapWithRefresh wraps an OAuth client so the access token is
@@ -798,12 +815,12 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
798815 factory := func(token string) provider.Client {
799816 switch provName {
800817 case "openai-codex":
801- return provider.NewOpenAICodex(token, accountID, baseURL)
818+ return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
802819 case "kimi":
803820 // anthropic-messages on api.kimi.com/coding.
804- return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())
821+ return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
805822 default:
806- return provider.NewAnthropicOAuth(token, baseURL)
823+ return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
807824 }
808825 }
809826
@@ -23,6 +23,7 @@ type Resolved struct {
23 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)23 AuthMethod string // "apikey" | "oauth" | "" (no credential yet)
24 AccountID string // ChatGPT account id (for openai oauth), "" otherwise24 AccountID string // ChatGPT account id (for openai oauth), "" otherwise
25 BaseURL string25 BaseURL string
26+ InsecureTLS bool
26 CWD string27 CWD string
27 Reasoning string28 Reasoning string
28 Temperature *float3229 Temperature *float32
@@ -455,6 +456,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
455 model = fm.ID456 model = fm.ID
456 }457 }
457 458
459+ explicitBaseURL := args.BaseURL != ""
460+
458 // If the model defines a base URL (e.g. local ollama) and the461 // If the model defines a base URL (e.g. local ollama) and the
459 // user didn't pass --base-url, use the model's URL. For ollama,462 // user didn't pass --base-url, use the model's URL. For ollama,
460 // keep http://localhost:11434 as a fallback only after the model463 // keep http://localhost:11434 as a fallback only after the model
@@ -466,6 +469,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
466 args.BaseURL = "http://localhost:11434"469 args.BaseURL = "http://localhost:11434"
467 }470 }
468 471
472+ // Insecure TLS is intentionally scoped to explicit custom endpoints.
473+ // Built-in provider base URLs, auth calls, and model discovery keep normal
474+ // certificate verification even when --insecure is present.
475+ insecureTLS := (args.InsecureTLS || cfg.Insecure) && explicitBaseURL
476+
469 // If the model has a base URL, credentials are optional (local477 // If the model has a base URL, credentials are optional (local
470 // models like ollama don't need real API keys).478 // models like ollama don't need real API keys).
471 if resolvedModel.BaseURL != "" && credErr != nil {479 if resolvedModel.BaseURL != "" && credErr != nil {
@@ -553,6 +561,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
553 AuthMethod: method,561 AuthMethod: method,
554 AccountID: accountID,562 AccountID: accountID,
555 BaseURL: args.BaseURL,563 BaseURL: args.BaseURL,
564+ InsecureTLS: insecureTLS,
556 CWD: args.CWD,565 CWD: args.CWD,
557 Reasoning: reasoning,566 Reasoning: reasoning,
558 Temperature: temperature,567 Temperature: temperature,
@@ -680,83 +689,84 @@ func (r Resolved) NewClient() provider.Client {
680 if !r.HasCredential() {689 if !r.HasCredential() {
681 panic("NewClient called without credential; check HasCredential first")690 panic("NewClient called without credential; check HasCredential first")
682 }691 }
692+ wrap := r.withHTTPClient
683 switch r.Provider {693 switch r.Provider {
684 case "ollama":694 case "ollama":
685- return provider.NewOpenAI(r.Credential, r.BaseURL)695+ return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
686 case "kimi":696 case "kimi":
687 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.697 // kimi-coding speaks anthropic-messages on api.kimi.com/coding.
688 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.698 // Subscription OAuth (refreshed) wraps the same Anthropic-shaped client.
689- inner := provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders())699+ inner := wrap(provider.NewKimiCodingWithHeaders(r.Credential, r.BaseURL, kimiCodeHeaders()))
690 if r.AuthMethod == "oauth" {700 if r.AuthMethod == "oauth" {
691 return r.wrapWithRefresh(inner)701 return r.wrapWithRefresh(inner)
692 }702 }
693 return inner703 return inner
694 case "moonshotai":704 case "moonshotai":
695- return provider.NewMoonshot(r.Credential, r.BaseURL)705+ return wrap(provider.NewMoonshot(r.Credential, r.BaseURL))
696 case "moonshotai-cn":706 case "moonshotai-cn":
697- return provider.NewMoonshotCN(r.Credential, r.BaseURL)707+ return wrap(provider.NewMoonshotCN(r.Credential, r.BaseURL))
698 case "deepseek":708 case "deepseek":
699- return provider.NewDeepSeek(r.Credential, r.BaseURL)709+ return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
700 case "openai":710 case "openai":
701- return provider.NewOpenAI(r.Credential, r.BaseURL)711+ return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
702 case "openai-codex":712 case "openai-codex":
703- inner := provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL)713+ inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
704 return r.wrapWithRefresh(inner)714 return r.wrapWithRefresh(inner)
705 case "openai-responses":715 case "openai-responses":
706 // Public OpenAI Responses API (api.openai.com/v1/responses) via716 // Public OpenAI Responses API (api.openai.com/v1/responses) via
707 // API key. Separate provider from `openai` (Chat Completions) and717 // API key. Separate provider from `openai` (Chat Completions) and
708 // from `openai-codex` (ChatGPT subscription OAuth).718 // from `openai-codex` (ChatGPT subscription OAuth).
709- return provider.NewOpenAIResponses(r.Credential, r.BaseURL)719+ return wrap(provider.NewOpenAIResponses(r.Credential, r.BaseURL))
710 case "google":720 case "google":
711- return provider.NewGemini(r.Credential, r.BaseURL)721+ return wrap(provider.NewGemini(r.Credential, r.BaseURL))
712 case "cerebras":722 case "cerebras":
713- return provider.NewCerebras(r.Credential, r.BaseURL)723+ return wrap(provider.NewCerebras(r.Credential, r.BaseURL))
714 case "groq":724 case "groq":
715- return provider.NewGroq(r.Credential, r.BaseURL)725+ return wrap(provider.NewGroq(r.Credential, r.BaseURL))
716 case "xai":726 case "xai":
717- return provider.NewXAI(r.Credential, r.BaseURL)727+ return wrap(provider.NewXAI(r.Credential, r.BaseURL))
718 case "together":728 case "together":
719- return provider.NewTogether(r.Credential, r.BaseURL)729+ return wrap(provider.NewTogether(r.Credential, r.BaseURL))
720 case "huggingface":730 case "huggingface":
721- return provider.NewHuggingFace(r.Credential, r.BaseURL)731+ return wrap(provider.NewHuggingFace(r.Credential, r.BaseURL))
722 case "openrouter":732 case "openrouter":
723- return provider.NewOpenRouter(r.Credential, r.BaseURL)733+ return wrap(provider.NewOpenRouter(r.Credential, r.BaseURL))
724 case "zai":734 case "zai":
725- return provider.NewZAI(r.Credential, r.BaseURL)735+ return wrap(provider.NewZAI(r.Credential, r.BaseURL))
726 case "xiaomi":736 case "xiaomi":
727- return provider.NewXiaomi(r.Credential, r.BaseURL)737+ return wrap(provider.NewXiaomi(r.Credential, r.BaseURL))
728 case "xiaomi-token-plan-ams":738 case "xiaomi-token-plan-ams":
729- return provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL)739+ return wrap(provider.NewXiaomiTokenPlan("ams", r.Credential, r.BaseURL))
730 case "xiaomi-token-plan-cn":740 case "xiaomi-token-plan-cn":
731- return provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL)741+ return wrap(provider.NewXiaomiTokenPlan("cn", r.Credential, r.BaseURL))
732 case "xiaomi-token-plan-sgp":742 case "xiaomi-token-plan-sgp":
733- return provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL)743+ return wrap(provider.NewXiaomiTokenPlan("sgp", r.Credential, r.BaseURL))
734 case "opencode":744 case "opencode":
735- return provider.NewOpenCode(r.Credential, r.BaseURL)745+ return wrap(provider.NewOpenCode(r.Credential, r.BaseURL))
736 case "opencode-go":746 case "opencode-go":
737- return provider.NewOpenCodeGo(r.Credential, r.BaseURL)747+ return wrap(provider.NewOpenCodeGo(r.Credential, r.BaseURL))
738 case "minimax":748 case "minimax":
739- return provider.NewMinimaxAnthropic(r.Credential, r.BaseURL)749+ return wrap(provider.NewMinimaxAnthropic(r.Credential, r.BaseURL))
740 case "minimax-cn":750 case "minimax-cn":
741- return provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL)751+ return wrap(provider.NewMinimaxCNAnthropic(r.Credential, r.BaseURL))
742 case "fireworks":752 case "fireworks":
743- return provider.NewFireworksAnthropic(r.Credential, r.BaseURL)753+ return wrap(provider.NewFireworksAnthropic(r.Credential, r.BaseURL))
744 case "vercel-ai-gateway":754 case "vercel-ai-gateway":
745- return provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL)755+ return wrap(provider.NewVercelGatewayAnthropic(r.Credential, r.BaseURL))
746 case "mistral":756 case "mistral":
747- return provider.NewMistral(r.Credential, r.BaseURL)757+ return wrap(provider.NewMistral(r.Credential, r.BaseURL))
748 case "amazon-bedrock":758 case "amazon-bedrock":
749- return provider.NewBedrock(r.Credential, r.BaseURL)759+ return wrap(provider.NewBedrock(r.Credential, r.BaseURL))
750 case "google-vertex":760 case "google-vertex":
751- return provider.NewGoogleVertex(r.Credential, r.BaseURL)761+ return wrap(provider.NewGoogleVertex(r.Credential, r.BaseURL))
752 case "azure-openai-responses":762 case "azure-openai-responses":
753- return provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL)763+ return wrap(provider.NewAzureOpenAIResponses(r.Credential, r.BaseURL))
754 case "github-copilot":764 case "github-copilot":
755- return provider.NewGithubCopilot(r.Credential, r.BaseURL)765+ return wrap(provider.NewGithubCopilot(r.Credential, r.BaseURL))
756 case "cloudflare-workers-ai":766 case "cloudflare-workers-ai":
757- return provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL)767+ return wrap(provider.NewCloudflareWorkersAI(r.Credential, r.BaseURL))
758 case "cloudflare-ai-gateway":768 case "cloudflare-ai-gateway":
759- return provider.NewCloudflareAIGateway(r.Credential, r.BaseURL)769+ return wrap(provider.NewCloudflareAIGateway(r.Credential, r.BaseURL))
760 default:770 default:
761 // Custom providers: choose wire format from the models.json api field.771 // Custom providers: choose wire format from the models.json api field.
762 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {772 if cfg, ok := provider.CustomProviders()[r.Provider]; ok {
@@ -768,11 +778,18 @@ func (r Resolved) NewClient() provider.Client {
768 }778 }
769 }779 }
770 if r.AuthMethod == "oauth" {780 if r.AuthMethod == "oauth" {
771- inner := provider.NewAnthropicOAuth(r.Credential, r.BaseURL)781+ inner := wrap(provider.NewAnthropicOAuth(r.Credential, r.BaseURL))
772 return r.wrapWithRefresh(inner)782 return r.wrapWithRefresh(inner)
773 }783 }
774- return provider.NewAnthropic(r.Credential, r.BaseURL)784+ return wrap(provider.NewAnthropic(r.Credential, r.BaseURL))
785+ }
786+}
787+
788+func (r Resolved) withHTTPClient(c provider.Client) provider.Client {
789+ if !r.InsecureTLS {
790+ return c
775 }791 }
792+ return provider.WithHTTPClient(c, provider.NewHTTPClient(true))
776 }793 }
777 794
778 // wrapWithRefresh wraps an OAuth client so the access token is795 // wrapWithRefresh wraps an OAuth client so the access token is
@@ -798,12 +815,12 @@ func (r Resolved) wrapWithRefresh(inner provider.Client) provider.Client {
798 factory := func(token string) provider.Client {815 factory := func(token string) provider.Client {
799 switch provName {816 switch provName {
800 case "openai-codex":817 case "openai-codex":
801- return provider.NewOpenAICodex(token, accountID, baseURL)818+ return r.withHTTPClient(provider.NewOpenAICodex(token, accountID, baseURL))
802 case "kimi":819 case "kimi":
803 // anthropic-messages on api.kimi.com/coding.820 // anthropic-messages on api.kimi.com/coding.
804- return provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders())821+ return r.withHTTPClient(provider.NewKimiCodingWithHeaders(token, baseURL, kimiCodeHeaders()))
805 default:822 default:
806- return provider.NewAnthropicOAuth(token, baseURL)823+ return r.withHTTPClient(provider.NewAnthropicOAuth(token, baseURL))
807 }824 }
808 }825 }
809 826
M packages/agent/build_test.go +67 -0
@@ -1,6 +1,7 @@
11 package agent
22
33 import (
4+ "net/http"
45 "os"
56 "path/filepath"
67 "strings"
@@ -211,3 +212,69 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
211212 }
212213 }
213214 }
215+
216+func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
217+ orig := http.DefaultTransport
218+ t.Cleanup(func() { http.DefaultTransport = orig })
219+
220+ t.Setenv("ZOT_HOME", t.TempDir())
221+ t.Setenv("OPENAI_API_KEY", "test-key")
222+
223+ resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
224+ if err != nil {
225+ t.Fatalf("Resolve: %v", err)
226+ }
227+ if resolved.InsecureTLS {
228+ t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
229+ }
230+ assertDefaultTransportStillSecure(t)
231+
232+ resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
233+ if err != nil {
234+ t.Fatalf("Resolve: %v", err)
235+ }
236+ if !resolved.InsecureTLS {
237+ t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
238+ }
239+ assertDefaultTransportStillSecure(t)
240+}
241+
242+func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
243+ orig := http.DefaultTransport
244+ t.Cleanup(func() { http.DefaultTransport = orig })
245+
246+ t.Setenv("ZOT_HOME", t.TempDir())
247+ t.Setenv("OPENAI_API_KEY", "test-key")
248+ if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
249+ t.Fatal(err)
250+ }
251+
252+ resolved, err := Resolve(Args{Provider: "openai"}, false)
253+ if err != nil {
254+ t.Fatalf("Resolve: %v", err)
255+ }
256+ if resolved.InsecureTLS {
257+ t.Fatal("InsecureTLS must not be set without a custom base URL")
258+ }
259+ assertDefaultTransportStillSecure(t)
260+
261+ resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
262+ if err != nil {
263+ t.Fatalf("Resolve: %v", err)
264+ }
265+ if !resolved.InsecureTLS {
266+ t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
267+ }
268+ assertDefaultTransportStillSecure(t)
269+}
270+
271+func assertDefaultTransportStillSecure(t *testing.T) {
272+ t.Helper()
273+ tr, ok := http.DefaultTransport.(*http.Transport)
274+ if !ok {
275+ return
276+ }
277+ if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
278+ t.Fatal("http.DefaultTransport must not be made insecure")
279+ }
280+}
@@ -1,6 +1,7 @@
1 package agent1 package agent
2 2
3 import (3 import (
4+ "net/http"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "strings"7 "strings"
@@ -211,3 +212,69 @@ func TestCanonicalProviderAliasesAreKnown(t *testing.T) {
211 }212 }
212 }213 }
213 }214 }
215+
216+func TestResolveInsecureOnlyWithExplicitBaseURL(t *testing.T) {
217+ orig := http.DefaultTransport
218+ t.Cleanup(func() { http.DefaultTransport = orig })
219+
220+ t.Setenv("ZOT_HOME", t.TempDir())
221+ t.Setenv("OPENAI_API_KEY", "test-key")
222+
223+ resolved, err := Resolve(Args{Provider: "moonshotai", InsecureTLS: true}, false)
224+ if err != nil {
225+ t.Fatalf("Resolve: %v", err)
226+ }
227+ if resolved.InsecureTLS {
228+ t.Fatal("InsecureTLS must not be set for built-in provider base URLs")
229+ }
230+ assertDefaultTransportStillSecure(t)
231+
232+ resolved, err = Resolve(Args{Provider: "openai", InsecureTLS: true, BaseURL: "https://my-llm.internal/v1"}, false)
233+ if err != nil {
234+ t.Fatalf("Resolve: %v", err)
235+ }
236+ if !resolved.InsecureTLS {
237+ t.Fatal("InsecureTLS must be set with --insecure and explicit --base-url")
238+ }
239+ assertDefaultTransportStillSecure(t)
240+}
241+
242+func TestResolveInsecureFromConfigRequiresExplicitBaseURL(t *testing.T) {
243+ orig := http.DefaultTransport
244+ t.Cleanup(func() { http.DefaultTransport = orig })
245+
246+ t.Setenv("ZOT_HOME", t.TempDir())
247+ t.Setenv("OPENAI_API_KEY", "test-key")
248+ if err := SaveConfig(Config{Provider: "openai", Insecure: true}); err != nil {
249+ t.Fatal(err)
250+ }
251+
252+ resolved, err := Resolve(Args{Provider: "openai"}, false)
253+ if err != nil {
254+ t.Fatalf("Resolve: %v", err)
255+ }
256+ if resolved.InsecureTLS {
257+ t.Fatal("InsecureTLS must not be set without a custom base URL")
258+ }
259+ assertDefaultTransportStillSecure(t)
260+
261+ resolved, err = Resolve(Args{Provider: "openai", BaseURL: "https://my-llm.internal/v1"}, false)
262+ if err != nil {
263+ t.Fatalf("Resolve: %v", err)
264+ }
265+ if !resolved.InsecureTLS {
266+ t.Fatal("InsecureTLS must be set when config insecure=true and --base-url is provided")
267+ }
268+ assertDefaultTransportStillSecure(t)
269+}
270+
271+func assertDefaultTransportStillSecure(t *testing.T) {
272+ t.Helper()
273+ tr, ok := http.DefaultTransport.(*http.Transport)
274+ if !ok {
275+ return
276+ }
277+ if tr.TLSClientConfig != nil && tr.TLSClientConfig.InsecureSkipVerify {
278+ t.Fatal("http.DefaultTransport must not be made insecure")
279+ }
280+}
M packages/agent/config.go +3 -0
@@ -46,6 +46,9 @@ type Config struct {
4646 // which is on; false shows ignored entries. Toggle from /settings.
4747 RespectGitignore *bool `json:"respect_gitignore,omitempty"`
4848
49+ // Insecure skips TLS verification for custom inference endpoints.
50+ Insecure bool `json:"insecure,omitempty"`
51+
4952 // LastChangelogShown is the version whose release-notes
5053 // dialog the user has already seen. When the running binary's
5154 // version differs, the next interactive run shows the
@@ -46,6 +46,9 @@ type Config struct {
46 // which is on; false shows ignored entries. Toggle from /settings.46 // which is on; false shows ignored entries. Toggle from /settings.
47 RespectGitignore *bool `json:"respect_gitignore,omitempty"`47 RespectGitignore *bool `json:"respect_gitignore,omitempty"`
48 48
49+ // Insecure skips TLS verification for custom inference endpoints.
50+ Insecure bool `json:"insecure,omitempty"`
51+
49 // LastChangelogShown is the version whose release-notes52 // LastChangelogShown is the version whose release-notes
50 // dialog the user has already seen. When the running binary's53 // dialog the user has already seen. When the running binary's
51 // version differs, the next interactive run shows the54 // version differs, the next interactive run shows the
A packages/provider/httpclient.go +50 -0
@@ -0,0 +1,50 @@
1+package provider
2+
3+import (
4+ "crypto/tls"
5+ "net/http"
6+)
7+
8+// NewHTTPClient returns a provider HTTP client. When insecureTLS is true,
9+// only this client skips TLS certificate verification. The process-wide
10+// default transport is left untouched so auth, discovery, and other providers
11+// keep normal certificate validation.
12+func NewHTTPClient(insecureTLS bool) *http.Client {
13+ if !insecureTLS {
14+ return &http.Client{Timeout: 0}
15+ }
16+ tr, ok := http.DefaultTransport.(*http.Transport)
17+ if ok {
18+ tr = tr.Clone()
19+ } else {
20+ tr = &http.Transport{}
21+ }
22+ if tr.TLSClientConfig != nil {
23+ tr.TLSClientConfig = tr.TLSClientConfig.Clone()
24+ } else {
25+ tr.TLSClientConfig = &tls.Config{}
26+ }
27+ tr.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec
28+ return &http.Client{Timeout: 0, Transport: tr}
29+}
30+
31+// WithHTTPClient scopes an HTTP client to a concrete provider client.
32+// Unsupported clients are returned unchanged.
33+func WithHTTPClient(c Client, httpClient *http.Client) Client {
34+ if httpClient == nil {
35+ return c
36+ }
37+ switch v := c.(type) {
38+ case *openaiClient:
39+ v.http = httpClient
40+ case *anthropicClient:
41+ v.http = httpClient
42+ case *geminiClient:
43+ v.http = httpClient
44+ case *bedrockClient:
45+ v.http = httpClient
46+ case *renamedClient:
47+ v.inner = WithHTTPClient(v.inner, httpClient)
48+ }
49+ return c
50+}
@@ -0,0 +1,50 @@
1+package provider
2+
3+import (
4+ "crypto/tls"
5+ "net/http"
6+)
7+
8+// NewHTTPClient returns a provider HTTP client. When insecureTLS is true,
9+// only this client skips TLS certificate verification. The process-wide
10+// default transport is left untouched so auth, discovery, and other providers
11+// keep normal certificate validation.
12+func NewHTTPClient(insecureTLS bool) *http.Client {
13+ if !insecureTLS {
14+ return &http.Client{Timeout: 0}
15+ }
16+ tr, ok := http.DefaultTransport.(*http.Transport)
17+ if ok {
18+ tr = tr.Clone()
19+ } else {
20+ tr = &http.Transport{}
21+ }
22+ if tr.TLSClientConfig != nil {
23+ tr.TLSClientConfig = tr.TLSClientConfig.Clone()
24+ } else {
25+ tr.TLSClientConfig = &tls.Config{}
26+ }
27+ tr.TLSClientConfig.InsecureSkipVerify = true //nolint:gosec
28+ return &http.Client{Timeout: 0, Transport: tr}
29+}
30+
31+// WithHTTPClient scopes an HTTP client to a concrete provider client.
32+// Unsupported clients are returned unchanged.
33+func WithHTTPClient(c Client, httpClient *http.Client) Client {
34+ if httpClient == nil {
35+ return c
36+ }
37+ switch v := c.(type) {
38+ case *openaiClient:
39+ v.http = httpClient
40+ case *anthropicClient:
41+ v.http = httpClient
42+ case *geminiClient:
43+ v.http = httpClient
44+ case *bedrockClient:
45+ v.http = httpClient
46+ case *renamedClient:
47+ v.inner = WithHTTPClient(v.inner, httpClient)
48+ }
49+ return c
50+}
A packages/provider/httpclient_test.go +54 -0
@@ -0,0 +1,54 @@
1+package provider
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+)
8+
9+func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) {
10+ orig := http.DefaultTransport
11+ t.Cleanup(func() { http.DefaultTransport = orig })
12+
13+ client := NewHTTPClient(true)
14+ if http.DefaultTransport != orig {
15+ t.Fatal("NewHTTPClient must not mutate http.DefaultTransport")
16+ }
17+ tr, ok := client.Transport.(*http.Transport)
18+ if !ok {
19+ t.Fatalf("expected *http.Transport, got %T", client.Transport)
20+ }
21+ if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify {
22+ t.Fatal("expected scoped InsecureSkipVerify=true in TLS config")
23+ }
24+}
25+
26+func TestScopedInsecureClientReachesTLSServer(t *testing.T) {
27+ srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
28+ w.WriteHeader(http.StatusOK)
29+ }))
30+ defer srv.Close()
31+
32+ orig := http.DefaultTransport
33+ t.Cleanup(func() { http.DefaultTransport = orig })
34+
35+ secureClient := NewHTTPClient(false)
36+ if _, err := secureClient.Get(srv.URL); err == nil {
37+ t.Fatal("expected TLS error with secure client, got nil")
38+ }
39+
40+ insecureClient := NewHTTPClient(true)
41+ resp, err := insecureClient.Get(srv.URL)
42+ if err != nil {
43+ t.Fatalf("request failed with scoped insecure client: %v", err)
44+ }
45+ resp.Body.Close()
46+ if resp.StatusCode != http.StatusOK {
47+ t.Fatalf("status=%d", resp.StatusCode)
48+ }
49+
50+ defaultClient := &http.Client{}
51+ if _, err := defaultClient.Get(srv.URL); err == nil {
52+ t.Fatal("default client must still reject self-signed TLS")
53+ }
54+}
@@ -0,0 +1,54 @@
1+package provider
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+)
8+
9+func TestNewHTTPClientDoesNotChangeDefaultTransport(t *testing.T) {
10+ orig := http.DefaultTransport
11+ t.Cleanup(func() { http.DefaultTransport = orig })
12+
13+ client := NewHTTPClient(true)
14+ if http.DefaultTransport != orig {
15+ t.Fatal("NewHTTPClient must not mutate http.DefaultTransport")
16+ }
17+ tr, ok := client.Transport.(*http.Transport)
18+ if !ok {
19+ t.Fatalf("expected *http.Transport, got %T", client.Transport)
20+ }
21+ if tr.TLSClientConfig == nil || !tr.TLSClientConfig.InsecureSkipVerify {
22+ t.Fatal("expected scoped InsecureSkipVerify=true in TLS config")
23+ }
24+}
25+
26+func TestScopedInsecureClientReachesTLSServer(t *testing.T) {
27+ srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
28+ w.WriteHeader(http.StatusOK)
29+ }))
30+ defer srv.Close()
31+
32+ orig := http.DefaultTransport
33+ t.Cleanup(func() { http.DefaultTransport = orig })
34+
35+ secureClient := NewHTTPClient(false)
36+ if _, err := secureClient.Get(srv.URL); err == nil {
37+ t.Fatal("expected TLS error with secure client, got nil")
38+ }
39+
40+ insecureClient := NewHTTPClient(true)
41+ resp, err := insecureClient.Get(srv.URL)
42+ if err != nil {
43+ t.Fatalf("request failed with scoped insecure client: %v", err)
44+ }
45+ resp.Body.Close()
46+ if resp.StatusCode != http.StatusOK {
47+ t.Fatalf("status=%d", resp.StatusCode)
48+ }
49+
50+ defaultClient := &http.Client{}
51+ if _, err := defaultClient.Get(srv.URL); err == nil {
52+ t.Fatal("default client must still reject self-signed TLS")
53+ }
54+}
M packages/tui/input.go +13 -0
@@ -342,9 +342,22 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
342342 shift := bits&1 != 0
343343 alt := bits&2 != 0
344344 ctrl := bits&4 != 0
345+ // Kitty keyboard protocol (CSI ... u) reports control keys as their
346+ // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the
347+ // enhanced-mode handling these arrive as raw bytes; with it enabled
348+ // they come through here, so map them back to their dedicated keys.
345349 switch code {
346350 case 13:
347351 return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true
352+ case 27:
353+ return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true
354+ case 9:
355+ if shift {
356+ return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true
357+ }
358+ return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true
359+ case 127, 8:
360+ return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true
348361 }
349362 if ctrl {
350363 switch code {
@@ -342,9 +342,22 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
342 shift := bits&1 != 0342 shift := bits&1 != 0
343 alt := bits&2 != 0343 alt := bits&2 != 0
344 ctrl := bits&4 != 0344 ctrl := bits&4 != 0
345+ // Kitty keyboard protocol (CSI ... u) reports control keys as their
346+ // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the
347+ // enhanced-mode handling these arrive as raw bytes; with it enabled
348+ // they come through here, so map them back to their dedicated keys.
345 switch code {349 switch code {
346 case 13:350 case 13:
347 return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true351 return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true
352+ case 27:
353+ return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true
354+ case 9:
355+ if shift {
356+ return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true
357+ }
358+ return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true
359+ case 127, 8:
360+ return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true
348 }361 }
349 if ctrl {362 if ctrl {
350 switch code {363 switch code {
M packages/tui/input_test.go +19 -0
@@ -30,6 +30,25 @@ func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) {
3030 }
3131 }
3232
33+func TestReaderParsesCSIUEsc(t *testing.T) {
34+ k := readKey(t, "\x1b[27u")
35+ if k.Kind != KeyEsc {
36+ t.Fatalf("Read kind=%v, want esc", k.Kind)
37+ }
38+}
39+
40+func TestReaderParsesCSIUTabAndBackspace(t *testing.T) {
41+ if k := readKey(t, "\x1b[9u"); k.Kind != KeyTab {
42+ t.Fatalf("Read kind=%v, want tab", k.Kind)
43+ }
44+ if k := readKey(t, "\x1b[9;2u"); k.Kind != KeyShiftTab {
45+ t.Fatalf("Read kind=%v, want shift-tab", k.Kind)
46+ }
47+ if k := readKey(t, "\x1b[127u"); k.Kind != KeyBackspace {
48+ t.Fatalf("Read kind=%v, want backspace", k.Kind)
49+ }
50+}
51+
3352 func TestReaderParsesSGRMouseWheel(t *testing.T) {
3453 cases := []struct {
3554 seq string
@@ -30,6 +30,25 @@ func TestReaderParsesModifyOtherKeysCtrlC(t *testing.T) {
30 }30 }
31 }31 }
32 32
33+func TestReaderParsesCSIUEsc(t *testing.T) {
34+ k := readKey(t, "\x1b[27u")
35+ if k.Kind != KeyEsc {
36+ t.Fatalf("Read kind=%v, want esc", k.Kind)
37+ }
38+}
39+
40+func TestReaderParsesCSIUTabAndBackspace(t *testing.T) {
41+ if k := readKey(t, "\x1b[9u"); k.Kind != KeyTab {
42+ t.Fatalf("Read kind=%v, want tab", k.Kind)
43+ }
44+ if k := readKey(t, "\x1b[9;2u"); k.Kind != KeyShiftTab {
45+ t.Fatalf("Read kind=%v, want shift-tab", k.Kind)
46+ }
47+ if k := readKey(t, "\x1b[127u"); k.Kind != KeyBackspace {
48+ t.Fatalf("Read kind=%v, want backspace", k.Kind)
49+ }
50+}
51+
33 func TestReaderParsesSGRMouseWheel(t *testing.T) {52 func TestReaderParsesSGRMouseWheel(t *testing.T) {
34 cases := []struct {53 cases := []struct {
35 seq string54 seq string

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