detached 3bdfea4

Merge branch 'main' into feat/issue-templates

commit
3bdfea48c3207397ec622c24335b1bc61789a94d
author
Patric Eckhart · · 2026-06-08 05:47 UTC
committer
GitHub · · 2026-06-08 05:47 UTC
merge of
4d6cbeb + a7ef8c2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
20 files · +1146 -51
M docs/extensions.md +17 -2
@@ -337,7 +337,22 @@ Closes a previously-open panel.
337337 ```
338338
339339 `level` is one of `info`, `success`, `warn`, `error`. The note shows
340-up below the transcript with the extension's name in brackets.
340+up below the transcript with the extension's name in brackets. Notes
341+are one-shot: they clear automatically when the user sends their next
342+prompt (and on `esc` / `/clear`).
343+
344+#### `clear_notes` (one-way, any time)
345+
346+Removes every note this extension previously pushed via `notify` /
347+`display`. Use it for transient status lines (e.g. an approval prompt)
348+so they do not stack up; notes from other extensions are untouched.
349+
350+```json
351+{"type":"clear_notes"}
352+```
353+
354+In `--mode rpc`, this surfaces to the host as an `ext_clear_notes`
355+event (alongside `ext_notify` / `ext_display`).
341356
342357 #### `shutdown_ack`
343358
@@ -593,7 +608,7 @@ trust or run zot under your platform's sandboxing tool (`bwrap` /
593608 Phase 1 (shipped):
594609 - [x] subprocess lifecycle + hello handshake
595610 - [x] `register_command` + `command_invoked`
596-- [x] `notify`
611+- [x] `notify` + `clear_notes`
597612 - [x] `zot ext` CLI
598613
599614 Phase 2 (shipped):
@@ -337,7 +337,22 @@ Closes a previously-open panel.
337 ```337 ```
338 338
339 `level` is one of `info`, `success`, `warn`, `error`. The note shows339 `level` is one of `info`, `success`, `warn`, `error`. The note shows
340-up below the transcript with the extension's name in brackets.340+up below the transcript with the extension's name in brackets. Notes
341+are one-shot: they clear automatically when the user sends their next
342+prompt (and on `esc` / `/clear`).
343+
344+#### `clear_notes` (one-way, any time)
345+
346+Removes every note this extension previously pushed via `notify` /
347+`display`. Use it for transient status lines (e.g. an approval prompt)
348+so they do not stack up; notes from other extensions are untouched.
349+
350+```json
351+{"type":"clear_notes"}
352+```
353+
354+In `--mode rpc`, this surfaces to the host as an `ext_clear_notes`
355+event (alongside `ext_notify` / `ext_display`).
341 356
342 #### `shutdown_ack`357 #### `shutdown_ack`
343 358
@@ -593,7 +608,7 @@ trust or run zot under your platform's sandboxing tool (`bwrap` /
593 Phase 1 (shipped):608 Phase 1 (shipped):
594 - [x] subprocess lifecycle + hello handshake609 - [x] subprocess lifecycle + hello handshake
595 - [x] `register_command` + `command_invoked`610 - [x] `register_command` + `command_invoked`
596-- [x] `notify`611+- [x] `notify` + `clear_notes`
597 - [x] `zot ext` CLI612 - [x] `zot ext` CLI
598 613
599 Phase 2 (shipped):614 Phase 2 (shipped):
A docs/providers.md +210 -0
@@ -0,0 +1,210 @@
1+# zot providers
2+
3+zot ships with built-in providers and a model catalog. You can select models
4+with `/model`, list them with `zot --list-models`, and add private models in
5+`$ZOT_HOME/models.json`.
6+
7+## Login methods
8+
9+Use `/login` in interactive mode.
10+
11+- `api key`: stores an API key in `$ZOT_HOME/auth.json` when the provider uses a normal key.
12+- `subscription`: stores OAuth credentials for subscription-backed providers.
13+
14+Use `/logout` to remove stored credentials.
15+
16+Some providers need more than a single pasted key. For those providers,
17+`/login` shows setup instructions instead of opening a localhost browser form.
18+This avoids broken browser flows in SSH, containers, and `kubectl exec`
19+sessions.
20+
21+Setup-instruction providers:
22+
23+- Amazon Bedrock
24+- Google Vertex AI
25+- Cloudflare Workers AI
26+- Cloudflare AI Gateway
27+- Azure OpenAI Responses
28+
29+## Subscription providers
30+
31+These providers support subscription login:
32+
33+| Provider | Notes |
34+| --- | --- |
35+| Anthropic | Claude Pro/Max OAuth credentials. |
36+| OpenAI Codex | ChatGPT Plus/Pro Codex subscription route. Separate from the OpenAI API-key provider. |
37+| Kimi | Kimi subscription login. |
38+| GitHub Copilot | GitHub Copilot token flow. |
39+
40+OAuth tokens are stored in `$ZOT_HOME/auth.json` and refreshed when refresh is
41+available.
42+
43+## API-key providers
44+
45+These providers can use environment variables. Simple API-key providers can
46+also be configured through `/login`. Providers that require extra cloud setup
47+show instructions and should be configured with environment variables.
48+
49+| Provider | Environment variable | Stored key |
50+| --- | --- | --- |
51+| Anthropic | `ANTHROPIC_API_KEY` | `anthropic` |
52+| OpenAI | `OPENAI_API_KEY` | `openai` |
53+| OpenAI Responses | `OPENAI_API_KEY` | `openai-responses` |
54+| Kimi | `KIMI_API_KEY` or `MOONSHOT_API_KEY` | `kimi` |
55+| Google Gemini | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `google` |
56+| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |
57+| Moonshot AI | `MOONSHOT_API_KEY` | `moonshotai` |
58+| Moonshot AI China | `MOONSHOT_API_KEY` | `moonshotai-cn` |
59+| Groq | `GROQ_API_KEY` | `groq` |
60+| xAI | `XAI_API_KEY` | `xai` |
61+| Cerebras | `CEREBRAS_API_KEY` | `cerebras` |
62+| Together AI | `TOGETHER_API_KEY` | `together` |
63+| Hugging Face | `HF_TOKEN` | `huggingface` |
64+| OpenRouter | `OPENROUTER_API_KEY` | `openrouter` |
65+| Mistral | `MISTRAL_API_KEY` | `mistral` |
66+| ZAI | `ZAI_API_KEY` | `zai` |
67+| Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` |
68+| Xiaomi Token Plan Amsterdam | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | `xiaomi-token-plan-ams` |
69+| Xiaomi Token Plan China | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | `xiaomi-token-plan-cn` |
70+| Xiaomi Token Plan Singapore | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` | `xiaomi-token-plan-sgp` |
71+| MiniMax | `MINIMAX_API_KEY` | `minimax` |
72+| MiniMax China | `MINIMAX_CN_API_KEY` or `MINIMAX_API_KEY` | `minimax-cn` |
73+| Fireworks | `FIREWORKS_API_KEY` | `fireworks` |
74+| Vercel AI Gateway | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway` |
75+| OpenCode Zen | `OPENCODE_API_KEY` | `opencode` |
76+| OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` |
77+| GitHub Copilot token | `COPILOT_GITHUB_TOKEN` or `GITHUB_COPILOT_TOKEN` | `github-copilot` |
78+| Cloudflare Workers AI | `CLOUDFLARE_API_KEY` | `cloudflare-workers-ai` |
79+| Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` |
80+| Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
81+
82+Example:
83+
84+```bash
85+export OPENROUTER_API_KEY=...
86+zot --provider openrouter
87+```
88+
89+## Cloud providers
90+
91+### Amazon Bedrock
92+
93+Bedrock is configured with AWS credentials, not a generic zot API-key entry.
94+Use one of these credential sources:
95+
96+```bash
97+# AWS profile
98+export AWS_PROFILE=your-profile
99+
100+# IAM access keys
101+export AWS_ACCESS_KEY_ID=AKIA...
102+export AWS_SECRET_ACCESS_KEY=...
103+export AWS_SESSION_TOKEN=... # only for temporary credentials
104+
105+# Bedrock API key bearer token
106+export AWS_BEARER_TOKEN_BEDROCK=bedrock-api-key-...
107+
108+# Region
109+export AWS_REGION=us-east-1
110+```
111+
112+ECS task roles, IRSA, and other AWS SDK credential-chain sources are also
113+supported.
114+
115+Example:
116+
117+```bash
118+AWS_BEARER_TOKEN_BEDROCK=bedrock-api-key-... AWS_REGION=us-east-1 \
119+ zot --provider amazon-bedrock --model anthropic.claude-sonnet-4-5-20250929-v1:0
120+```
121+
122+Some Bedrock models require regional inference-profile IDs for on-demand
123+throughput, such as `us.` or `eu.` prefixed model IDs. zot rewrites known
124+families automatically where possible. Explicit profile IDs and ARNs are left
125+unchanged.
126+
127+### Google Vertex AI
128+
129+Vertex can use a Google API key when available:
130+
131+```bash
132+export GOOGLE_CLOUD_API_KEY=...
133+zot --provider google-vertex
134+```
135+
136+For service-account or application-default credentials, set the standard
137+Google environment variables used by your deployment.
138+
139+### Cloudflare AI Gateway
140+
141+Cloudflare AI Gateway needs a Cloudflare token plus account and gateway IDs:
142+
143+```bash
144+export CLOUDFLARE_API_KEY=...
145+export CLOUDFLARE_ACCOUNT_ID=...
146+export CLOUDFLARE_GATEWAY_ID=...
147+zot --provider cloudflare-ai-gateway
148+```
149+
150+### Cloudflare Workers AI
151+
152+Workers AI needs a Cloudflare token and account ID:
153+
154+```bash
155+export CLOUDFLARE_API_KEY=...
156+export CLOUDFLARE_ACCOUNT_ID=...
157+zot --provider cloudflare-workers-ai
158+```
159+
160+### Azure OpenAI Responses
161+
162+```bash
163+export AZURE_OPENAI_API_KEY=...
164+export AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com
165+export AZURE_OPENAI_API_VERSION=2024-02-01 # optional
166+zot --provider azure-openai-responses
167+```
168+
169+If your Azure deployment names differ from zot model IDs, add model overrides
170+in `$ZOT_HOME/models.json`.
171+
172+## Auth file
173+
174+Credentials are stored in `$ZOT_HOME/auth.json` with user-only permissions
175+when zot creates the file.
176+
177+Example:
178+
179+```json
180+{
181+ "anthropic": { "api_key": "sk-ant-..." },
182+ "openai": { "api_key": "sk-..." },
183+ "google": { "api_key": "..." },
184+ "additional_api_key_creds": {
185+ "openrouter": { "api_key": "..." },
186+ "mistral": { "api_key": "..." }
187+ }
188+}
189+```
190+
191+The top-level keys are used for providers with dedicated credential fields.
192+Other API-key providers are stored under `additional_api_key_creds`. Prefer
193+`/login` so zot writes the correct schema.
194+
195+## Custom providers and models
196+
197+Use `$ZOT_HOME/models.json` for private models, deployment aliases, local
198+servers, or OpenAI-compatible gateways that are not in the built-in catalog.
199+User entries override built-in entries with the same provider and model ID.
200+
201+## Credential resolution
202+
203+For each request, zot checks credentials in this order:
204+
205+1. Explicit CLI key, such as `--api-key`.
206+2. Provider-specific environment variables.
207+3. `$ZOT_HOME/auth.json`.
208+4. Custom provider credentials from `$ZOT_HOME/models.json`, when configured.
209+
210+Bedrock then uses the AWS SDK credential chain for the actual request.
@@ -0,0 +1,210 @@
1+# zot providers
2+
3+zot ships with built-in providers and a model catalog. You can select models
4+with `/model`, list them with `zot --list-models`, and add private models in
5+`$ZOT_HOME/models.json`.
6+
7+## Login methods
8+
9+Use `/login` in interactive mode.
10+
11+- `api key`: stores an API key in `$ZOT_HOME/auth.json` when the provider uses a normal key.
12+- `subscription`: stores OAuth credentials for subscription-backed providers.
13+
14+Use `/logout` to remove stored credentials.
15+
16+Some providers need more than a single pasted key. For those providers,
17+`/login` shows setup instructions instead of opening a localhost browser form.
18+This avoids broken browser flows in SSH, containers, and `kubectl exec`
19+sessions.
20+
21+Setup-instruction providers:
22+
23+- Amazon Bedrock
24+- Google Vertex AI
25+- Cloudflare Workers AI
26+- Cloudflare AI Gateway
27+- Azure OpenAI Responses
28+
29+## Subscription providers
30+
31+These providers support subscription login:
32+
33+| Provider | Notes |
34+| --- | --- |
35+| Anthropic | Claude Pro/Max OAuth credentials. |
36+| OpenAI Codex | ChatGPT Plus/Pro Codex subscription route. Separate from the OpenAI API-key provider. |
37+| Kimi | Kimi subscription login. |
38+| GitHub Copilot | GitHub Copilot token flow. |
39+
40+OAuth tokens are stored in `$ZOT_HOME/auth.json` and refreshed when refresh is
41+available.
42+
43+## API-key providers
44+
45+These providers can use environment variables. Simple API-key providers can
46+also be configured through `/login`. Providers that require extra cloud setup
47+show instructions and should be configured with environment variables.
48+
49+| Provider | Environment variable | Stored key |
50+| --- | --- | --- |
51+| Anthropic | `ANTHROPIC_API_KEY` | `anthropic` |
52+| OpenAI | `OPENAI_API_KEY` | `openai` |
53+| OpenAI Responses | `OPENAI_API_KEY` | `openai-responses` |
54+| Kimi | `KIMI_API_KEY` or `MOONSHOT_API_KEY` | `kimi` |
55+| Google Gemini | `GEMINI_API_KEY` or `GOOGLE_API_KEY` | `google` |
56+| DeepSeek | `DEEPSEEK_API_KEY` | `deepseek` |
57+| Moonshot AI | `MOONSHOT_API_KEY` | `moonshotai` |
58+| Moonshot AI China | `MOONSHOT_API_KEY` | `moonshotai-cn` |
59+| Groq | `GROQ_API_KEY` | `groq` |
60+| xAI | `XAI_API_KEY` | `xai` |
61+| Cerebras | `CEREBRAS_API_KEY` | `cerebras` |
62+| Together AI | `TOGETHER_API_KEY` | `together` |
63+| Hugging Face | `HF_TOKEN` | `huggingface` |
64+| OpenRouter | `OPENROUTER_API_KEY` | `openrouter` |
65+| Mistral | `MISTRAL_API_KEY` | `mistral` |
66+| ZAI | `ZAI_API_KEY` | `zai` |
67+| Xiaomi MiMo | `XIAOMI_API_KEY` | `xiaomi` |
68+| Xiaomi Token Plan Amsterdam | `XIAOMI_TOKEN_PLAN_AMS_API_KEY` | `xiaomi-token-plan-ams` |
69+| Xiaomi Token Plan China | `XIAOMI_TOKEN_PLAN_CN_API_KEY` | `xiaomi-token-plan-cn` |
70+| Xiaomi Token Plan Singapore | `XIAOMI_TOKEN_PLAN_SGP_API_KEY` | `xiaomi-token-plan-sgp` |
71+| MiniMax | `MINIMAX_API_KEY` | `minimax` |
72+| MiniMax China | `MINIMAX_CN_API_KEY` or `MINIMAX_API_KEY` | `minimax-cn` |
73+| Fireworks | `FIREWORKS_API_KEY` | `fireworks` |
74+| Vercel AI Gateway | `AI_GATEWAY_API_KEY` | `vercel-ai-gateway` |
75+| OpenCode Zen | `OPENCODE_API_KEY` | `opencode` |
76+| OpenCode Go | `OPENCODE_API_KEY` | `opencode-go` |
77+| GitHub Copilot token | `COPILOT_GITHUB_TOKEN` or `GITHUB_COPILOT_TOKEN` | `github-copilot` |
78+| Cloudflare Workers AI | `CLOUDFLARE_API_KEY` | `cloudflare-workers-ai` |
79+| Cloudflare AI Gateway | `CLOUDFLARE_API_KEY` | `cloudflare-ai-gateway` |
80+| Azure OpenAI Responses | `AZURE_OPENAI_API_KEY` | `azure-openai-responses` |
81+
82+Example:
83+
84+```bash
85+export OPENROUTER_API_KEY=...
86+zot --provider openrouter
87+```
88+
89+## Cloud providers
90+
91+### Amazon Bedrock
92+
93+Bedrock is configured with AWS credentials, not a generic zot API-key entry.
94+Use one of these credential sources:
95+
96+```bash
97+# AWS profile
98+export AWS_PROFILE=your-profile
99+
100+# IAM access keys
101+export AWS_ACCESS_KEY_ID=AKIA...
102+export AWS_SECRET_ACCESS_KEY=...
103+export AWS_SESSION_TOKEN=... # only for temporary credentials
104+
105+# Bedrock API key bearer token
106+export AWS_BEARER_TOKEN_BEDROCK=bedrock-api-key-...
107+
108+# Region
109+export AWS_REGION=us-east-1
110+```
111+
112+ECS task roles, IRSA, and other AWS SDK credential-chain sources are also
113+supported.
114+
115+Example:
116+
117+```bash
118+AWS_BEARER_TOKEN_BEDROCK=bedrock-api-key-... AWS_REGION=us-east-1 \
119+ zot --provider amazon-bedrock --model anthropic.claude-sonnet-4-5-20250929-v1:0
120+```
121+
122+Some Bedrock models require regional inference-profile IDs for on-demand
123+throughput, such as `us.` or `eu.` prefixed model IDs. zot rewrites known
124+families automatically where possible. Explicit profile IDs and ARNs are left
125+unchanged.
126+
127+### Google Vertex AI
128+
129+Vertex can use a Google API key when available:
130+
131+```bash
132+export GOOGLE_CLOUD_API_KEY=...
133+zot --provider google-vertex
134+```
135+
136+For service-account or application-default credentials, set the standard
137+Google environment variables used by your deployment.
138+
139+### Cloudflare AI Gateway
140+
141+Cloudflare AI Gateway needs a Cloudflare token plus account and gateway IDs:
142+
143+```bash
144+export CLOUDFLARE_API_KEY=...
145+export CLOUDFLARE_ACCOUNT_ID=...
146+export CLOUDFLARE_GATEWAY_ID=...
147+zot --provider cloudflare-ai-gateway
148+```
149+
150+### Cloudflare Workers AI
151+
152+Workers AI needs a Cloudflare token and account ID:
153+
154+```bash
155+export CLOUDFLARE_API_KEY=...
156+export CLOUDFLARE_ACCOUNT_ID=...
157+zot --provider cloudflare-workers-ai
158+```
159+
160+### Azure OpenAI Responses
161+
162+```bash
163+export AZURE_OPENAI_API_KEY=...
164+export AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com
165+export AZURE_OPENAI_API_VERSION=2024-02-01 # optional
166+zot --provider azure-openai-responses
167+```
168+
169+If your Azure deployment names differ from zot model IDs, add model overrides
170+in `$ZOT_HOME/models.json`.
171+
172+## Auth file
173+
174+Credentials are stored in `$ZOT_HOME/auth.json` with user-only permissions
175+when zot creates the file.
176+
177+Example:
178+
179+```json
180+{
181+ "anthropic": { "api_key": "sk-ant-..." },
182+ "openai": { "api_key": "sk-..." },
183+ "google": { "api_key": "..." },
184+ "additional_api_key_creds": {
185+ "openrouter": { "api_key": "..." },
186+ "mistral": { "api_key": "..." }
187+ }
188+}
189+```
190+
191+The top-level keys are used for providers with dedicated credential fields.
192+Other API-key providers are stored under `additional_api_key_creds`. Prefer
193+`/login` so zot writes the correct schema.
194+
195+## Custom providers and models
196+
197+Use `$ZOT_HOME/models.json` for private models, deployment aliases, local
198+servers, or OpenAI-compatible gateways that are not in the built-in catalog.
199+User entries override built-in entries with the same provider and model ID.
200+
201+## Credential resolution
202+
203+For each request, zot checks credentials in this order:
204+
205+1. Explicit CLI key, such as `--api-key`.
206+2. Provider-specific environment variables.
207+3. `$ZOT_HOME/auth.json`.
208+4. Custom provider credentials from `$ZOT_HOME/models.json`, when configured.
209+
210+Bedrock then uses the AWS SDK credential chain for the actual request.
M packages/agent/build.go +6 -4
@@ -305,9 +305,6 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
305305 credErr error
306306 )
307307 if provName == "ollama" {
308- if args.BaseURL == "" {
309- args.BaseURL = "http://localhost:11434"
310- }
311308 cred = firstNonEmpty(args.APIKey, "ollama")
312309 method = "apikey"
313310 } else {
@@ -398,10 +395,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
398395 }
399396
400397 // If the model defines a base URL (e.g. local ollama) and the
401- // user didn't pass --base-url, use the model's URL.
398+ // user didn't pass --base-url, use the model's URL. For ollama,
399+ // keep http://localhost:11434 as a fallback only after the model
400+ // metadata has had a chance to provide a custom baseUrl.
402401 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
403402 args.BaseURL = resolvedModel.BaseURL
404403 }
404+ if args.BaseURL == "" && provName == "ollama" {
405+ args.BaseURL = "http://localhost:11434"
406+ }
405407
406408 // If the model has a base URL, credentials are optional (local
407409 // models like ollama don't need real API keys).
@@ -305,9 +305,6 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
305 credErr error305 credErr error
306 )306 )
307 if provName == "ollama" {307 if provName == "ollama" {
308- if args.BaseURL == "" {
309- args.BaseURL = "http://localhost:11434"
310- }
311 cred = firstNonEmpty(args.APIKey, "ollama")308 cred = firstNonEmpty(args.APIKey, "ollama")
312 method = "apikey"309 method = "apikey"
313 } else {310 } else {
@@ -398,10 +395,15 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
398 }395 }
399 396
400 // If the model defines a base URL (e.g. local ollama) and the397 // If the model defines a base URL (e.g. local ollama) and the
401- // user didn't pass --base-url, use the model's URL.398+ // user didn't pass --base-url, use the model's URL. For ollama,
399+ // keep http://localhost:11434 as a fallback only after the model
400+ // metadata has had a chance to provide a custom baseUrl.
402 if args.BaseURL == "" && resolvedModel.BaseURL != "" {401 if args.BaseURL == "" && resolvedModel.BaseURL != "" {
403 args.BaseURL = resolvedModel.BaseURL402 args.BaseURL = resolvedModel.BaseURL
404 }403 }
404+ if args.BaseURL == "" && provName == "ollama" {
405+ args.BaseURL = "http://localhost:11434"
406+ }
405 407
406 // If the model has a base URL, credentials are optional (local408 // If the model has a base URL, credentials are optional (local
407 // models like ollama don't need real API keys).409 // models like ollama don't need real API keys).
M packages/agent/build_test.go +38 -0
@@ -5,6 +5,8 @@ import (
55 "path/filepath"
66 "strings"
77 "testing"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
810 )
911
1012 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
@@ -109,6 +111,42 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
109111 }
110112 }
111113
114+func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
115+ t.Setenv("ZOT_HOME", t.TempDir())
116+ provider.SetLiveModels(nil)
117+ defer provider.SetLiveModels(nil)
118+ provider.SetUserModels([]provider.Model{{
119+ Provider: "ollama",
120+ ID: "qwen-local",
121+ DisplayName: "Qwen Local",
122+ ContextWindow: 32768,
123+ MaxOutput: 8192,
124+ BaseURL: "http://localhost:8000/v1",
125+ }})
126+
127+ r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
128+ if err != nil {
129+ t.Fatalf("Resolve failed: %v", err)
130+ }
131+ if r.BaseURL != "http://localhost:8000/v1" {
132+ t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
133+ }
134+}
135+
136+func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
137+ t.Setenv("ZOT_HOME", t.TempDir())
138+ provider.SetLiveModels(nil)
139+ defer provider.SetLiveModels(nil)
140+
141+ r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
142+ if err != nil {
143+ t.Fatalf("Resolve failed: %v", err)
144+ }
145+ if r.BaseURL != "http://localhost:11434" {
146+ t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
147+ }
148+}
149+
112150 func TestCanonicalProviderResolvesAliases(t *testing.T) {
113151 cases := map[string]string{
114152 "bedrock": "amazon-bedrock",
@@ -5,6 +5,8 @@ import (
5 "path/filepath"5 "path/filepath"
6 "strings"6 "strings"
7 "testing"7 "testing"
8+
9+ "github.com/patriceckhart/zot/packages/provider"
8 )10 )
9 11
10 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {12 func TestReadAgentsContextLoadsGlobalAndAncestors(t *testing.T) {
@@ -109,6 +111,42 @@ func TestResolveExplicitFlagStaleDoesNotRepairConfig(t *testing.T) {
109 }111 }
110 }112 }
111 113
114+func TestResolveOllamaUsesModelBaseURLBeforeDefault(t *testing.T) {
115+ t.Setenv("ZOT_HOME", t.TempDir())
116+ provider.SetLiveModels(nil)
117+ defer provider.SetLiveModels(nil)
118+ provider.SetUserModels([]provider.Model{{
119+ Provider: "ollama",
120+ ID: "qwen-local",
121+ DisplayName: "Qwen Local",
122+ ContextWindow: 32768,
123+ MaxOutput: 8192,
124+ BaseURL: "http://localhost:8000/v1",
125+ }})
126+
127+ r, err := Resolve(Args{Provider: "ollama", Model: "qwen-local"}, false)
128+ if err != nil {
129+ t.Fatalf("Resolve failed: %v", err)
130+ }
131+ if r.BaseURL != "http://localhost:8000/v1" {
132+ t.Fatalf("BaseURL = %q, want models.json baseUrl", r.BaseURL)
133+ }
134+}
135+
136+func TestResolveOllamaFallsBackToDefaultBaseURL(t *testing.T) {
137+ t.Setenv("ZOT_HOME", t.TempDir())
138+ provider.SetLiveModels(nil)
139+ defer provider.SetLiveModels(nil)
140+
141+ r, err := Resolve(Args{Provider: "ollama", Model: "any-local-model"}, false)
142+ if err != nil {
143+ t.Fatalf("Resolve failed: %v", err)
144+ }
145+ if r.BaseURL != "http://localhost:11434" {
146+ t.Fatalf("BaseURL = %q, want ollama default", r.BaseURL)
147+ }
148+}
149+
112 func TestCanonicalProviderResolvesAliases(t *testing.T) {150 func TestCanonicalProviderResolvesAliases(t *testing.T) {
113 cases := map[string]string{151 cases := map[string]string{
114 "bedrock": "amazon-bedrock",152 "bedrock": "amazon-bedrock",
M packages/agent/cli.go +6 -0
@@ -67,6 +67,11 @@ func (h *interactiveExtHooks) Display(extName, text string) {
6767 iv.Display(extName, text)
6868 }
6969 }
70+func (h *interactiveExtHooks) ClearNotes(extName string) {
71+ if iv := h.iv(); iv != nil {
72+ iv.ClearNotes(extName)
73+ }
74+}
7075 func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
7176 if iv := h.iv(); iv != nil {
7277 iv.OpenPanel(extName, spec)
@@ -256,6 +261,7 @@ func (nonInteractiveExtHooks) Submit(string)
256261 func (nonInteractiveExtHooks) SubmitSlash(string) {}
257262 func (nonInteractiveExtHooks) Insert(string) {}
258263 func (nonInteractiveExtHooks) Display(string, string) {}
264+func (nonInteractiveExtHooks) ClearNotes(string) {}
259265 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
260266 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
261267 func (nonInteractiveExtHooks) ClosePanel(string, string) {}
@@ -67,6 +67,11 @@ func (h *interactiveExtHooks) Display(extName, text string) {
67 iv.Display(extName, text)67 iv.Display(extName, text)
68 }68 }
69 }69 }
70+func (h *interactiveExtHooks) ClearNotes(extName string) {
71+ if iv := h.iv(); iv != nil {
72+ iv.ClearNotes(extName)
73+ }
74+}
70 func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {75 func (h *interactiveExtHooks) OpenPanel(extName string, spec extproto.PanelSpec) {
71 if iv := h.iv(); iv != nil {76 if iv := h.iv(); iv != nil {
72 iv.OpenPanel(extName, spec)77 iv.OpenPanel(extName, spec)
@@ -256,6 +261,7 @@ func (nonInteractiveExtHooks) Submit(string)
256 func (nonInteractiveExtHooks) SubmitSlash(string) {}261 func (nonInteractiveExtHooks) SubmitSlash(string) {}
257 func (nonInteractiveExtHooks) Insert(string) {}262 func (nonInteractiveExtHooks) Insert(string) {}
258 func (nonInteractiveExtHooks) Display(string, string) {}263 func (nonInteractiveExtHooks) Display(string, string) {}
264+func (nonInteractiveExtHooks) ClearNotes(string) {}
259 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}265 func (nonInteractiveExtHooks) OpenPanel(string, extproto.PanelSpec) {}
260 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}266 func (nonInteractiveExtHooks) UpdatePanel(string, string, string, []string, string) {}
261 func (nonInteractiveExtHooks) ClosePanel(string, string) {}267 func (nonInteractiveExtHooks) ClosePanel(string, string) {}
M packages/agent/extcmd.go +129 -2
@@ -269,12 +269,24 @@ func extInstall(args []string) error {
269269 if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil {
270270 return fmt.Errorf("source lacks extension.json")
271271 }
272- name := filepath.Base(src)
272+ // Resolve to an absolute, cleaned path before deriving the install
273+ // name. Otherwise relative sources like "." or "./" collapse to a
274+ // basename of ".", and the destination wrongly resolves to the
275+ // extensions/ parent directory (which zot creates on first run),
276+ // triggering a false "already exists" failure.
277+ absSrc, err := filepath.Abs(src)
278+ if err != nil {
279+ return err
280+ }
281+ name := filepath.Base(absSrc)
282+ if name == "." || name == ".." || name == string(filepath.Separator) || name == "" {
283+ return fmt.Errorf("cannot derive extension name from %q", src)
284+ }
273285 out := filepath.Join(dest, name)
274286 if _, err := os.Stat(out); err == nil {
275287 return fmt.Errorf("destination %s already exists; remove it first", out)
276288 }
277- if err := copyDir(src, out); err != nil {
289+ if err := copyDir(absSrc, out); err != nil {
278290 return err
279291 }
280292 fmt.Fprintf(os.Stderr, "installed %s\n", out)
@@ -311,7 +323,14 @@ func dashIfEmpty(s string) string {
311323
312324 // copyDir does a recursive copy of src to dst preserving file mode
313325 // bits. Used by `zot ext install <local-path>`.
326+//
327+// Entries matched by the source's root .gitignore are skipped, and
328+// .git itself is always skipped. This keeps non-portable, regeneratable
329+// directories (e.g. .venv with hardcoded rpaths, node_modules, target/)
330+// out of the installed copy so the extension stays functional at its new
331+// location.
314332 func copyDir(src, dst string) error {
333+ ig := loadGitignore(src)
315334 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
316335 if err != nil {
317336 return err
@@ -320,6 +339,18 @@ func copyDir(src, dst string) error {
320339 if err != nil {
321340 return err
322341 }
342+ if rel != "." {
343+ name := filepath.Base(rel)
344+ if info.IsDir() && name == ".git" {
345+ return filepath.SkipDir
346+ }
347+ if ig.match(filepath.ToSlash(rel), info.IsDir()) {
348+ if info.IsDir() {
349+ return filepath.SkipDir
350+ }
351+ return nil
352+ }
353+ }
323354 target := filepath.Join(dst, rel)
324355 if info.IsDir() {
325356 return os.MkdirAll(target, info.Mode())
@@ -338,3 +369,99 @@ func copyDir(src, dst string) error {
338369 return err
339370 })
340371 }
372+
373+// gitignore is a minimal .gitignore matcher. It supports the common
374+// patterns used in real extension repos: blank lines, comments (#),
375+// negation (!), directory-only patterns (trailing /), anchored
376+// patterns (leading /), and the * / ? / [..] wildcards via
377+// filepath.Match. It intentionally does not implement ** globstar or
378+// nested per-directory .gitignore files; the goal is to drop obvious
379+// non-portable directories, not to be a faithful git reimplementation.
380+type gitignore struct {
381+ rules []gitignoreRule
382+}
383+
384+type gitignoreRule struct {
385+ pattern string
386+ negate bool
387+ dirOnly bool
388+ anchored bool
389+}
390+
391+func loadGitignore(root string) *gitignore {
392+ data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
393+ if err != nil {
394+ return &gitignore{}
395+ }
396+ return loadGitignoreFromString(string(data))
397+}
398+
399+func loadGitignoreFromString(data string) *gitignore {
400+ g := &gitignore{}
401+ for _, line := range strings.Split(data, "\n") {
402+ line = strings.TrimRight(line, "\r")
403+ trimmed := strings.TrimSpace(line)
404+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
405+ continue
406+ }
407+ r := gitignoreRule{pattern: trimmed}
408+ if strings.HasPrefix(r.pattern, "!") {
409+ r.negate = true
410+ r.pattern = r.pattern[1:]
411+ }
412+ if strings.HasSuffix(r.pattern, "/") {
413+ r.dirOnly = true
414+ r.pattern = strings.TrimSuffix(r.pattern, "/")
415+ }
416+ if strings.HasPrefix(r.pattern, "/") {
417+ r.anchored = true
418+ r.pattern = strings.TrimPrefix(r.pattern, "/")
419+ }
420+ if r.pattern == "" {
421+ continue
422+ }
423+ g.rules = append(g.rules, r)
424+ }
425+ return g
426+}
427+
428+// match reports whether the slash-separated relative path should be
429+// ignored. Later rules win, so a trailing negation can re-include a
430+// previously ignored path.
431+func (g *gitignore) match(rel string, isDir bool) bool {
432+ ignored := false
433+ for _, r := range g.rules {
434+ if r.dirOnly && !isDir {
435+ continue
436+ }
437+ if r.matchPath(rel) {
438+ ignored = !r.negate
439+ }
440+ }
441+ return ignored
442+}
443+
444+func (r gitignoreRule) matchPath(rel string) bool {
445+ if r.anchored || strings.Contains(r.pattern, "/") {
446+ if ok, _ := filepath.Match(r.pattern, rel); ok {
447+ return true
448+ }
449+ // Anchored directory pattern also matches everything beneath it.
450+ return strings.HasPrefix(rel, r.pattern+"/")
451+ }
452+ // Unanchored: match the basename of any path component.
453+ base := rel
454+ if i := strings.LastIndex(rel, "/"); i >= 0 {
455+ base = rel[i+1:]
456+ }
457+ if ok, _ := filepath.Match(r.pattern, base); ok {
458+ return true
459+ }
460+ // Match a directory component anywhere in the path.
461+ for _, part := range strings.Split(rel, "/") {
462+ if ok, _ := filepath.Match(r.pattern, part); ok {
463+ return true
464+ }
465+ }
466+ return false
467+}
@@ -269,12 +269,24 @@ func extInstall(args []string) error {
269 if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil {269 if _, err := os.Stat(filepath.Join(src, "extension.json")); err != nil {
270 return fmt.Errorf("source lacks extension.json")270 return fmt.Errorf("source lacks extension.json")
271 }271 }
272- name := filepath.Base(src)272+ // Resolve to an absolute, cleaned path before deriving the install
273+ // name. Otherwise relative sources like "." or "./" collapse to a
274+ // basename of ".", and the destination wrongly resolves to the
275+ // extensions/ parent directory (which zot creates on first run),
276+ // triggering a false "already exists" failure.
277+ absSrc, err := filepath.Abs(src)
278+ if err != nil {
279+ return err
280+ }
281+ name := filepath.Base(absSrc)
282+ if name == "." || name == ".." || name == string(filepath.Separator) || name == "" {
283+ return fmt.Errorf("cannot derive extension name from %q", src)
284+ }
273 out := filepath.Join(dest, name)285 out := filepath.Join(dest, name)
274 if _, err := os.Stat(out); err == nil {286 if _, err := os.Stat(out); err == nil {
275 return fmt.Errorf("destination %s already exists; remove it first", out)287 return fmt.Errorf("destination %s already exists; remove it first", out)
276 }288 }
277- if err := copyDir(src, out); err != nil {289+ if err := copyDir(absSrc, out); err != nil {
278 return err290 return err
279 }291 }
280 fmt.Fprintf(os.Stderr, "installed %s\n", out)292 fmt.Fprintf(os.Stderr, "installed %s\n", out)
@@ -311,7 +323,14 @@ func dashIfEmpty(s string) string {
311 323
312 // copyDir does a recursive copy of src to dst preserving file mode324 // copyDir does a recursive copy of src to dst preserving file mode
313 // bits. Used by `zot ext install <local-path>`.325 // bits. Used by `zot ext install <local-path>`.
326+//
327+// Entries matched by the source's root .gitignore are skipped, and
328+// .git itself is always skipped. This keeps non-portable, regeneratable
329+// directories (e.g. .venv with hardcoded rpaths, node_modules, target/)
330+// out of the installed copy so the extension stays functional at its new
331+// location.
314 func copyDir(src, dst string) error {332 func copyDir(src, dst string) error {
333+ ig := loadGitignore(src)
315 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {334 return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
316 if err != nil {335 if err != nil {
317 return err336 return err
@@ -320,6 +339,18 @@ func copyDir(src, dst string) error {
320 if err != nil {339 if err != nil {
321 return err340 return err
322 }341 }
342+ if rel != "." {
343+ name := filepath.Base(rel)
344+ if info.IsDir() && name == ".git" {
345+ return filepath.SkipDir
346+ }
347+ if ig.match(filepath.ToSlash(rel), info.IsDir()) {
348+ if info.IsDir() {
349+ return filepath.SkipDir
350+ }
351+ return nil
352+ }
353+ }
323 target := filepath.Join(dst, rel)354 target := filepath.Join(dst, rel)
324 if info.IsDir() {355 if info.IsDir() {
325 return os.MkdirAll(target, info.Mode())356 return os.MkdirAll(target, info.Mode())
@@ -338,3 +369,99 @@ func copyDir(src, dst string) error {
338 return err369 return err
339 })370 })
340 }371 }
372+
373+// gitignore is a minimal .gitignore matcher. It supports the common
374+// patterns used in real extension repos: blank lines, comments (#),
375+// negation (!), directory-only patterns (trailing /), anchored
376+// patterns (leading /), and the * / ? / [..] wildcards via
377+// filepath.Match. It intentionally does not implement ** globstar or
378+// nested per-directory .gitignore files; the goal is to drop obvious
379+// non-portable directories, not to be a faithful git reimplementation.
380+type gitignore struct {
381+ rules []gitignoreRule
382+}
383+
384+type gitignoreRule struct {
385+ pattern string
386+ negate bool
387+ dirOnly bool
388+ anchored bool
389+}
390+
391+func loadGitignore(root string) *gitignore {
392+ data, err := os.ReadFile(filepath.Join(root, ".gitignore"))
393+ if err != nil {
394+ return &gitignore{}
395+ }
396+ return loadGitignoreFromString(string(data))
397+}
398+
399+func loadGitignoreFromString(data string) *gitignore {
400+ g := &gitignore{}
401+ for _, line := range strings.Split(data, "\n") {
402+ line = strings.TrimRight(line, "\r")
403+ trimmed := strings.TrimSpace(line)
404+ if trimmed == "" || strings.HasPrefix(trimmed, "#") {
405+ continue
406+ }
407+ r := gitignoreRule{pattern: trimmed}
408+ if strings.HasPrefix(r.pattern, "!") {
409+ r.negate = true
410+ r.pattern = r.pattern[1:]
411+ }
412+ if strings.HasSuffix(r.pattern, "/") {
413+ r.dirOnly = true
414+ r.pattern = strings.TrimSuffix(r.pattern, "/")
415+ }
416+ if strings.HasPrefix(r.pattern, "/") {
417+ r.anchored = true
418+ r.pattern = strings.TrimPrefix(r.pattern, "/")
419+ }
420+ if r.pattern == "" {
421+ continue
422+ }
423+ g.rules = append(g.rules, r)
424+ }
425+ return g
426+}
427+
428+// match reports whether the slash-separated relative path should be
429+// ignored. Later rules win, so a trailing negation can re-include a
430+// previously ignored path.
431+func (g *gitignore) match(rel string, isDir bool) bool {
432+ ignored := false
433+ for _, r := range g.rules {
434+ if r.dirOnly && !isDir {
435+ continue
436+ }
437+ if r.matchPath(rel) {
438+ ignored = !r.negate
439+ }
440+ }
441+ return ignored
442+}
443+
444+func (r gitignoreRule) matchPath(rel string) bool {
445+ if r.anchored || strings.Contains(r.pattern, "/") {
446+ if ok, _ := filepath.Match(r.pattern, rel); ok {
447+ return true
448+ }
449+ // Anchored directory pattern also matches everything beneath it.
450+ return strings.HasPrefix(rel, r.pattern+"/")
451+ }
452+ // Unanchored: match the basename of any path component.
453+ base := rel
454+ if i := strings.LastIndex(rel, "/"); i >= 0 {
455+ base = rel[i+1:]
456+ }
457+ if ok, _ := filepath.Match(r.pattern, base); ok {
458+ return true
459+ }
460+ // Match a directory component anywhere in the path.
461+ for _, part := range strings.Split(rel, "/") {
462+ if ok, _ := filepath.Match(r.pattern, part); ok {
463+ return true
464+ }
465+ }
466+ return false
467+}
A packages/agent/extcmd_test.go +126 -0
@@ -0,0 +1,126 @@
1+package agent
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+// TestExtInstallDotSource verifies that `zot ext install .` derives the
10+// extension name from the resolved directory name rather than collapsing
11+// to the extensions/ parent directory (the false "already exists" bug).
12+func TestExtInstallDotSource(t *testing.T) {
13+ home := t.TempDir()
14+ t.Setenv("ZOT_HOME", home)
15+
16+ // Pre-create extensions/ to mimic a normal first run.
17+ if err := os.MkdirAll(filepath.Join(home, "extensions"), 0o755); err != nil {
18+ t.Fatal(err)
19+ }
20+
21+ srcParent := t.TempDir()
22+ src := filepath.Join(srcParent, "kagi")
23+ if err := os.MkdirAll(src, 0o755); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"kagi"}`), 0o644); err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ cwd, err := os.Getwd()
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ defer os.Chdir(cwd)
35+ if err := os.Chdir(src); err != nil {
36+ t.Fatal(err)
37+ }
38+
39+ if err := extInstall([]string{"."}); err != nil {
40+ t.Fatalf("install with '.' failed: %v", err)
41+ }
42+
43+ out := filepath.Join(home, "extensions", "kagi")
44+ if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil {
45+ t.Fatalf("expected installed extension at %s: %v", out, err)
46+ }
47+}
48+
49+// TestExtInstallRejectsParentName guards against deriving a name of ".."
50+// from a source that resolves to a filesystem root edge case. A normal
51+// directory always yields a real basename, so this just ensures the
52+// guard logic does not crash for well-formed input.
53+func TestExtInstallNamedDir(t *testing.T) {
54+ home := t.TempDir()
55+ t.Setenv("ZOT_HOME", home)
56+
57+ src := filepath.Join(t.TempDir(), "myext")
58+ if err := os.MkdirAll(src, 0o755); err != nil {
59+ t.Fatal(err)
60+ }
61+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"myext"}`), 0o644); err != nil {
62+ t.Fatal(err)
63+ }
64+
65+ if err := extInstall([]string{src}); err != nil {
66+ t.Fatalf("install failed: %v", err)
67+ }
68+ if _, err := os.Stat(filepath.Join(home, "extensions", "myext", "extension.json")); err != nil {
69+ t.Fatalf("expected installed extension: %v", err)
70+ }
71+}
72+
73+// TestCopyDirRespectsGitignore verifies that non-portable directories
74+// listed in the source .gitignore (e.g. .venv, node_modules) are not
75+// copied during install, while tracked files are.
76+func TestCopyDirRespectsGitignore(t *testing.T) {
77+ src := t.TempDir()
78+ dst := filepath.Join(t.TempDir(), "out")
79+
80+ mustWrite := func(rel, content string) {
81+ p := filepath.Join(src, filepath.FromSlash(rel))
82+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
83+ t.Fatal(err)
84+ }
85+ if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
86+ t.Fatal(err)
87+ }
88+ }
89+
90+ mustWrite("extension.json", `{"name":"x"}`)
91+ mustWrite("main.py", "print('hi')")
92+ mustWrite(".gitignore", ".venv/\nnode_modules/\n*.log\n")
93+ mustWrite(".venv/bin/python", "binary")
94+ mustWrite("node_modules/pkg/index.js", "module")
95+ mustWrite("debug.log", "noise")
96+ mustWrite("src/app.py", "code")
97+ mustWrite(".git/config", "gitdir")
98+
99+ if err := copyDir(src, dst); err != nil {
100+ t.Fatal(err)
101+ }
102+
103+ wantPresent := []string{"extension.json", "main.py", "src/app.py", ".gitignore"}
104+ for _, rel := range wantPresent {
105+ if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err != nil {
106+ t.Fatalf("expected %s to be copied: %v", rel, err)
107+ }
108+ }
109+
110+ wantAbsent := []string{".venv", "node_modules", "debug.log", ".git"}
111+ for _, rel := range wantAbsent {
112+ if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err == nil {
113+ t.Fatalf("expected %s to be skipped, but it was copied", rel)
114+ }
115+ }
116+}
117+
118+func TestGitignoreNegation(t *testing.T) {
119+ g := loadGitignoreFromString("build/\n!build/keep.txt\n")
120+ if !g.match("build", true) {
121+ t.Fatal("expected build/ dir to be ignored")
122+ }
123+ if g.match("build/keep.txt", false) {
124+ t.Fatal("expected build/keep.txt to be re-included by negation")
125+ }
126+}
@@ -0,0 +1,126 @@
1+package agent
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+// TestExtInstallDotSource verifies that `zot ext install .` derives the
10+// extension name from the resolved directory name rather than collapsing
11+// to the extensions/ parent directory (the false "already exists" bug).
12+func TestExtInstallDotSource(t *testing.T) {
13+ home := t.TempDir()
14+ t.Setenv("ZOT_HOME", home)
15+
16+ // Pre-create extensions/ to mimic a normal first run.
17+ if err := os.MkdirAll(filepath.Join(home, "extensions"), 0o755); err != nil {
18+ t.Fatal(err)
19+ }
20+
21+ srcParent := t.TempDir()
22+ src := filepath.Join(srcParent, "kagi")
23+ if err := os.MkdirAll(src, 0o755); err != nil {
24+ t.Fatal(err)
25+ }
26+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"kagi"}`), 0o644); err != nil {
27+ t.Fatal(err)
28+ }
29+
30+ cwd, err := os.Getwd()
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ defer os.Chdir(cwd)
35+ if err := os.Chdir(src); err != nil {
36+ t.Fatal(err)
37+ }
38+
39+ if err := extInstall([]string{"."}); err != nil {
40+ t.Fatalf("install with '.' failed: %v", err)
41+ }
42+
43+ out := filepath.Join(home, "extensions", "kagi")
44+ if _, err := os.Stat(filepath.Join(out, "extension.json")); err != nil {
45+ t.Fatalf("expected installed extension at %s: %v", out, err)
46+ }
47+}
48+
49+// TestExtInstallRejectsParentName guards against deriving a name of ".."
50+// from a source that resolves to a filesystem root edge case. A normal
51+// directory always yields a real basename, so this just ensures the
52+// guard logic does not crash for well-formed input.
53+func TestExtInstallNamedDir(t *testing.T) {
54+ home := t.TempDir()
55+ t.Setenv("ZOT_HOME", home)
56+
57+ src := filepath.Join(t.TempDir(), "myext")
58+ if err := os.MkdirAll(src, 0o755); err != nil {
59+ t.Fatal(err)
60+ }
61+ if err := os.WriteFile(filepath.Join(src, "extension.json"), []byte(`{"name":"myext"}`), 0o644); err != nil {
62+ t.Fatal(err)
63+ }
64+
65+ if err := extInstall([]string{src}); err != nil {
66+ t.Fatalf("install failed: %v", err)
67+ }
68+ if _, err := os.Stat(filepath.Join(home, "extensions", "myext", "extension.json")); err != nil {
69+ t.Fatalf("expected installed extension: %v", err)
70+ }
71+}
72+
73+// TestCopyDirRespectsGitignore verifies that non-portable directories
74+// listed in the source .gitignore (e.g. .venv, node_modules) are not
75+// copied during install, while tracked files are.
76+func TestCopyDirRespectsGitignore(t *testing.T) {
77+ src := t.TempDir()
78+ dst := filepath.Join(t.TempDir(), "out")
79+
80+ mustWrite := func(rel, content string) {
81+ p := filepath.Join(src, filepath.FromSlash(rel))
82+ if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
83+ t.Fatal(err)
84+ }
85+ if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
86+ t.Fatal(err)
87+ }
88+ }
89+
90+ mustWrite("extension.json", `{"name":"x"}`)
91+ mustWrite("main.py", "print('hi')")
92+ mustWrite(".gitignore", ".venv/\nnode_modules/\n*.log\n")
93+ mustWrite(".venv/bin/python", "binary")
94+ mustWrite("node_modules/pkg/index.js", "module")
95+ mustWrite("debug.log", "noise")
96+ mustWrite("src/app.py", "code")
97+ mustWrite(".git/config", "gitdir")
98+
99+ if err := copyDir(src, dst); err != nil {
100+ t.Fatal(err)
101+ }
102+
103+ wantPresent := []string{"extension.json", "main.py", "src/app.py", ".gitignore"}
104+ for _, rel := range wantPresent {
105+ if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err != nil {
106+ t.Fatalf("expected %s to be copied: %v", rel, err)
107+ }
108+ }
109+
110+ wantAbsent := []string{".venv", "node_modules", "debug.log", ".git"}
111+ for _, rel := range wantAbsent {
112+ if _, err := os.Stat(filepath.Join(dst, filepath.FromSlash(rel))); err == nil {
113+ t.Fatalf("expected %s to be skipped, but it was copied", rel)
114+ }
115+ }
116+}
117+
118+func TestGitignoreNegation(t *testing.T) {
119+ g := loadGitignoreFromString("build/\n!build/keep.txt\n")
120+ if !g.match("build", true) {
121+ t.Fatal("expected build/ dir to be ignored")
122+ }
123+ if g.match("build/keep.txt", false) {
124+ t.Fatal("expected build/keep.txt to be re-included by negation")
125+ }
126+}
M packages/agent/extensions/manager.go +6 -0
@@ -119,6 +119,10 @@ type HostHooks interface {
119119 // invoking the model and without writing to the transcript.
120120 Display(extName, text string)
121121
122+ // ClearNotes removes any notes previously pushed by extName via
123+ // Notify/Display so transient status lines do not stack forever.
124+ ClearNotes(extName string)
125+
122126 OpenPanel(extName string, spec extproto.PanelSpec)
123127 UpdatePanel(extName, panelID, title string, lines []string, footer string)
124128 ClosePanel(extName, panelID string)
@@ -750,6 +754,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
750754 if err := json.Unmarshal(line, &n); err == nil {
751755 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
752756 }
757+ case "clear_notes":
758+ m.hooks.ClearNotes(ext.Manifest.Name)
753759 case "submit_slash":
754760 // Spontaneous request to invoke a slash command in the
755761 // TUI. Refused unless the payload looks like a slash
@@ -119,6 +119,10 @@ type HostHooks interface {
119 // invoking the model and without writing to the transcript.119 // invoking the model and without writing to the transcript.
120 Display(extName, text string)120 Display(extName, text string)
121 121
122+ // ClearNotes removes any notes previously pushed by extName via
123+ // Notify/Display so transient status lines do not stack forever.
124+ ClearNotes(extName string)
125+
122 OpenPanel(extName string, spec extproto.PanelSpec)126 OpenPanel(extName string, spec extproto.PanelSpec)
123 UpdatePanel(extName, panelID, title string, lines []string, footer string)127 UpdatePanel(extName, panelID, title string, lines []string, footer string)
124 ClosePanel(extName, panelID string)128 ClosePanel(extName, panelID string)
@@ -750,6 +754,8 @@ func (m *Manager) readLoop(ext *Extension, scanner *bufio.Scanner) {
750 if err := json.Unmarshal(line, &n); err == nil {754 if err := json.Unmarshal(line, &n); err == nil {
751 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)755 m.hooks.Notify(ext.Manifest.Name, n.Level, n.Message)
752 }756 }
757+ case "clear_notes":
758+ m.hooks.ClearNotes(ext.Manifest.Name)
753 case "submit_slash":759 case "submit_slash":
754 // Spontaneous request to invoke a slash command in the760 // Spontaneous request to invoke a slash command in the
755 // TUI. Refused unless the payload looks like a slash761 // TUI. Refused unless the payload looks like a slash
M packages/agent/extensions/manager_test.go +9 -3
@@ -16,9 +16,10 @@ import (
1616
1717 // stubHooks records every callback so the test can assert on them.
1818 type stubHooks struct {
19- mu sync.Mutex
20- notifies []string
21- displays []string
19+ mu sync.Mutex
20+ notifies []string
21+ displays []string
22+ clearNotes []string
2223 }
2324
2425 func (s *stubHooks) Notify(name, level, message string) {
@@ -34,6 +35,11 @@ func (s *stubHooks) Display(name, text string) {
3435 defer s.mu.Unlock()
3536 s.displays = append(s.displays, name+":"+text)
3637 }
38+func (s *stubHooks) ClearNotes(name string) {
39+ s.mu.Lock()
40+ defer s.mu.Unlock()
41+ s.clearNotes = append(s.clearNotes, name)
42+}
3743 func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}
3844 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
3945 func (s *stubHooks) ClosePanel(string, string) {}
@@ -16,9 +16,10 @@ import (
16 16
17 // stubHooks records every callback so the test can assert on them.17 // stubHooks records every callback so the test can assert on them.
18 type stubHooks struct {18 type stubHooks struct {
19- mu sync.Mutex19+ mu sync.Mutex
20- notifies []string20+ notifies []string
21- displays []string21+ displays []string
22+ clearNotes []string
22 }23 }
23 24
24 func (s *stubHooks) Notify(name, level, message string) {25 func (s *stubHooks) Notify(name, level, message string) {
@@ -34,6 +35,11 @@ func (s *stubHooks) Display(name, text string) {
34 defer s.mu.Unlock()35 defer s.mu.Unlock()
35 s.displays = append(s.displays, name+":"+text)36 s.displays = append(s.displays, name+":"+text)
36 }37 }
38+func (s *stubHooks) ClearNotes(name string) {
39+ s.mu.Lock()
40+ defer s.mu.Unlock()
41+ s.clearNotes = append(s.clearNotes, name)
42+}
37 func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}43 func (s *stubHooks) OpenPanel(string, extproto.PanelSpec) {}
38 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}44 func (s *stubHooks) UpdatePanel(string, string, string, []string, string) {}
39 func (s *stubHooks) ClosePanel(string, string) {}45 func (s *stubHooks) ClosePanel(string, string) {}
M packages/agent/extproto/extproto.go +8 -0
@@ -116,6 +116,14 @@ type NotifyFromExt struct {
116116 Message string `json:"message"`
117117 }
118118
119+// ClearNotesFromExt is a spontaneous frame an extension can send to
120+// retract every note it previously pushed via notify/display, so
121+// transient status lines (e.g. an approval prompt) do not stack up
122+// forever in the bottom-sticky notes block.
123+type ClearNotesFromExt struct {
124+ Type string `json:"type"`
125+}
126+
119127 // SubmitSlashFromExt is a spontaneous frame an extension can send at
120128 // any time (typically from a panel_key handler) to invoke a slash
121129 // command in the host's TUI as if the user had typed it. Text must
@@ -116,6 +116,14 @@ type NotifyFromExt struct {
116 Message string `json:"message"`116 Message string `json:"message"`
117 }117 }
118 118
119+// ClearNotesFromExt is a spontaneous frame an extension can send to
120+// retract every note it previously pushed via notify/display, so
121+// transient status lines (e.g. an approval prompt) do not stack up
122+// forever in the bottom-sticky notes block.
123+type ClearNotesFromExt struct {
124+ Type string `json:"type"`
125+}
126+
119 // SubmitSlashFromExt is a spontaneous frame an extension can send at127 // SubmitSlashFromExt is a spontaneous frame an extension can send at
120 // any time (typically from a panel_key handler) to invoke a slash128 // any time (typically from a panel_key handler) to invoke a slash
121 // command in the host's TUI as if the user had typed it. Text must129 // command in the host's TUI as if the user had typed it. Text must
A packages/agent/modes/ext_notes_test.go +46 -0
@@ -0,0 +1,46 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/tui"
8+)
9+
10+func newNotesTestInteractive() *Interactive {
11+ i := &Interactive{dirty: make(chan struct{}, 1)}
12+ i.cfg.Theme = tui.Theme{Muted: 8, Warning: 3, Error: 1, Tool: 2, Accent: 4}
13+ return i
14+}
15+
16+func TestClearNotesRemovesOnlyOwnerNotes(t *testing.T) {
17+ i := newNotesTestInteractive()
18+
19+ i.Notify("kagi", "info", "pending")
20+ i.Notify("kagi", "success", "approved")
21+ i.Notify("other", "info", "keep me")
22+
23+ if len(i.extNotes) != 3 {
24+ t.Fatalf("expected 3 notes, got %d", len(i.extNotes))
25+ }
26+
27+ i.ClearNotes("kagi")
28+
29+ if len(i.extNotes) != 1 {
30+ t.Fatalf("expected 1 note after clear, got %d: %v", len(i.extNotes), i.extNotes)
31+ }
32+ if !strings.Contains(i.extNotes[0], "[other] ") {
33+ t.Fatalf("expected the surviving note to belong to other, got %q", i.extNotes[0])
34+ }
35+}
36+
37+func TestClearNotesNoMatchKeepsNotes(t *testing.T) {
38+ i := newNotesTestInteractive()
39+ i.Notify("kagi", "info", "pending")
40+
41+ i.ClearNotes("nope")
42+
43+ if len(i.extNotes) != 1 {
44+ t.Fatalf("expected note to survive, got %d", len(i.extNotes))
45+ }
46+}
@@ -0,0 +1,46 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/tui"
8+)
9+
10+func newNotesTestInteractive() *Interactive {
11+ i := &Interactive{dirty: make(chan struct{}, 1)}
12+ i.cfg.Theme = tui.Theme{Muted: 8, Warning: 3, Error: 1, Tool: 2, Accent: 4}
13+ return i
14+}
15+
16+func TestClearNotesRemovesOnlyOwnerNotes(t *testing.T) {
17+ i := newNotesTestInteractive()
18+
19+ i.Notify("kagi", "info", "pending")
20+ i.Notify("kagi", "success", "approved")
21+ i.Notify("other", "info", "keep me")
22+
23+ if len(i.extNotes) != 3 {
24+ t.Fatalf("expected 3 notes, got %d", len(i.extNotes))
25+ }
26+
27+ i.ClearNotes("kagi")
28+
29+ if len(i.extNotes) != 1 {
30+ t.Fatalf("expected 1 note after clear, got %d: %v", len(i.extNotes), i.extNotes)
31+ }
32+ if !strings.Contains(i.extNotes[0], "[other] ") {
33+ t.Fatalf("expected the surviving note to belong to other, got %q", i.extNotes[0])
34+ }
35+}
36+
37+func TestClearNotesNoMatchKeepsNotes(t *testing.T) {
38+ i := newNotesTestInteractive()
39+ i.Notify("kagi", "info", "pending")
40+
41+ i.ClearNotes("nope")
42+
43+ if len(i.extNotes) != 1 {
44+ t.Fatalf("expected note to survive, got %d", len(i.extNotes))
45+ }
46+}
M packages/agent/modes/interactive.go +192 -11
@@ -260,17 +260,28 @@ type Interactive struct {
260260 streamFlushPending bool
261261 toolCalls map[string]*tui.ToolCallView
262262 toolOrder []string
263- statusErr string
264- statusOK string
265- liveBlock []string // live streaming/tool progress rendered outside scrollback
266- helpBlock []string // rendered above the chat when /help was typed
267- cumUsage provider.Usage
268- lastCtxInput int // input_tokens of the most recent turn — approximates current context size
269- busy bool
270- dirty chan struct{}
271- cancelTurn context.CancelFunc
272- scrollOffset int // rows from the bottom; 0 = pinned to latest
273- prevScrollOffset int // last value redraw snapped against; tracks intent
263+ // toolGate records, per tool-call id, how many runes of paced
264+ // assistant text must have drained into `streaming` before that
265+ // tool block may appear. It exists to make stream ordering
266+ // deterministic: a tool call can arrive from the provider while
267+ // the prose that logically precedes it is still being typed out
268+ // by the pacer. Without gating, the tool block would render
269+ // immediately while the intro paragraph keeps filling in below
270+ // it. We snapshot the total expected stream length (already
271+ // streamed + still pending) at the moment the tool starts, and
272+ // hold the block back until the pacer reaches it.
273+ toolGate map[string]int
274+ statusErr string
275+ statusOK string
276+ liveBlock []string // live streaming/tool progress rendered outside scrollback
277+ helpBlock []string // rendered above the chat when /help was typed
278+ cumUsage provider.Usage
279+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
280+ busy bool
281+ dirty chan struct{}
282+ cancelTurn context.CancelFunc
283+ scrollOffset int // rows from the bottom; 0 = pinned to latest
284+ prevScrollOffset int // last value redraw snapped against; tracks intent
274285
275286 // prevChatLen and prevChatCols track the chat buffer's size at the
276287 // last redraw so that when content grows below the user's viewport
@@ -440,6 +451,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
440451 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
441452 rend: renderer,
442453 toolCalls: map[string]*tui.ToolCallView{},
454+ toolGate: map[string]int{},
443455 dirty: make(chan struct{}, 8),
444456 dialog: newLoginDialog(),
445457 modelDialog: newModelDialog(),
@@ -824,6 +836,14 @@ func (i *Interactive) buildChatLocked(cols int) []string {
824836 i.view.ToolCalls = i.view.ToolCalls[:0]
825837 if i.busy {
826838 for _, id := range i.toolOrder {
839+ // Deterministic ordering: a tool block stays hidden until
840+ // the paced assistant text that preceded it has finished
841+ // typing out. toolOrder is append-only in arrival order,
842+ // so once one tool is still gated, every later tool is too,
843+ // so stop here to avoid showing a tool out of sequence.
844+ if !i.toolGateOpenLocked(id) {
845+ break
846+ }
827847 if tc, ok := i.toolCalls[id]; ok {
828848 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
829849 }
@@ -2356,6 +2376,36 @@ func (i *Interactive) Notify(extName, level, message string) {
23562376 i.invalidate()
23572377 }
23582378
2379+// ClearNotes removes every note line owned by extName from the
2380+// bottom-sticky ext-notes block. Extensions use this to retract a
2381+// transient status line (e.g. an approval prompt) once it no longer
2382+// applies, instead of leaving it stacked forever. Notes from other
2383+// extensions and internal notes (auto-compact) are left untouched.
2384+func (i *Interactive) ClearNotes(extName string) {
2385+ marker := "[" + extName + "] "
2386+ i.mu.Lock()
2387+ if len(i.extNotes) == 0 {
2388+ i.mu.Unlock()
2389+ return
2390+ }
2391+ kept := i.extNotes[:0:0]
2392+ changed := false
2393+ for _, line := range i.extNotes {
2394+ if strings.Contains(line, marker) {
2395+ changed = true
2396+ continue
2397+ }
2398+ kept = append(kept, line)
2399+ }
2400+ if changed {
2401+ i.extNotes = kept
2402+ }
2403+ i.mu.Unlock()
2404+ if changed {
2405+ i.invalidate()
2406+ }
2407+}
2408+
23592409 // Submit feeds text through the agent loop as if the user had typed it.
23602410 func (i *Interactive) Submit(text string) {
23612411 if cmd, ok := shellEscapeCommand(text); ok {
@@ -2382,6 +2432,7 @@ func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd strin
23822432 i.cfg.Model = model
23832433 i.toolCalls = map[string]*tui.ToolCallView{}
23842434 i.toolOrder = nil
2435+ i.toolGate = map[string]int{}
23852436 i.helpBlock = nil
23862437 i.parkedTurn = 0
23872438 i.statusErr = ""
@@ -3051,6 +3102,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
30513102 i.mu.Lock()
30523103 i.toolCalls = map[string]*tui.ToolCallView{}
30533104 i.toolOrder = nil
3105+ i.toolGate = map[string]int{}
30543106 i.statusErr = ""
30553107 i.statusOK = ""
30563108 i.helpBlock = nil
@@ -3170,6 +3222,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
31703222 i.mu.Lock()
31713223 i.toolCalls = map[string]*tui.ToolCallView{}
31723224 i.toolOrder = nil
3225+ i.toolGate = map[string]int{}
31733226 i.helpBlock = nil
31743227 i.parkedTurn = 0
31753228 i.statusOK = "cwd " + i.cfg.CWD
@@ -3405,7 +3458,79 @@ func (i *Interactive) doLogout(target string) {
34053458 }
34063459 }
34073460
3461+func providerSetupInfo(provider string) (string, []string, bool) {
3462+ const docsURL = "https://raw.githubusercontent.com/patriceckhart/zot/main/docs/providers.md"
3463+ switch provider {
3464+ case "amazon-bedrock":
3465+ return "Amazon Bedrock setup", []string{
3466+ "Amazon Bedrock uses AWS credentials instead of a generic zot API-key entry.",
3467+ "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
3468+ "",
3469+ "For Bedrock API keys, set:",
3470+ " AWS_BEARER_TOKEN_BEDROCK=...",
3471+ " AWS_REGION=us-east-1",
3472+ "",
3473+ "Docs:",
3474+ " " + docsURL,
3475+ }, true
3476+ case "google-vertex":
3477+ return "Google Vertex AI setup", []string{
3478+ "Google Vertex AI usually uses Google Cloud credentials and project settings.",
3479+ "Set a Google API key, application-default credentials, or a service account.",
3480+ "",
3481+ "Common environment:",
3482+ " GOOGLE_CLOUD_API_KEY=...",
3483+ " GOOGLE_CLOUD_PROJECT=...",
3484+ " GOOGLE_CLOUD_LOCATION=us-central1",
3485+ "",
3486+ "Docs:",
3487+ " " + docsURL,
3488+ }, true
3489+ case "cloudflare-workers-ai":
3490+ return "Cloudflare Workers AI setup", []string{
3491+ "Cloudflare Workers AI needs both an API token and an account ID.",
3492+ "",
3493+ "Set:",
3494+ " CLOUDFLARE_API_KEY=...",
3495+ " CLOUDFLARE_ACCOUNT_ID=...",
3496+ "",
3497+ "Docs:",
3498+ " " + docsURL,
3499+ }, true
3500+ case "cloudflare-ai-gateway":
3501+ return "Cloudflare AI Gateway setup", []string{
3502+ "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
3503+ "",
3504+ "Set:",
3505+ " CLOUDFLARE_API_KEY=...",
3506+ " CLOUDFLARE_ACCOUNT_ID=...",
3507+ " CLOUDFLARE_GATEWAY_ID=...",
3508+ "",
3509+ "Docs:",
3510+ " " + docsURL,
3511+ }, true
3512+ case "azure-openai-responses":
3513+ return "Azure OpenAI Responses setup", []string{
3514+ "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
3515+ "",
3516+ "Set:",
3517+ " AZURE_OPENAI_API_KEY=...",
3518+ " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
3519+ " AZURE_OPENAI_API_VERSION=2024-02-01",
3520+ "",
3521+ "Docs:",
3522+ " " + docsURL,
3523+ }, true
3524+ default:
3525+ return "", nil, false
3526+ }
3527+}
3528+
34083529 func (i *Interactive) startAPIKeyFlow(provider string) {
3530+ if title, lines, ok := providerSetupInfo(provider); ok {
3531+ i.dialog.ShowInfo(title, lines)
3532+ return
3533+ }
34093534 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
34103535 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
34113536 }
@@ -3976,6 +4101,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
39764101 i.lastCtxInput = 0
39774102 i.toolCalls = map[string]*tui.ToolCallView{}
39784103 i.toolOrder = nil
4104+ i.toolGate = map[string]int{}
39794105 i.view.InvalidateRenderCache()
39804106 // Pop queued prompt if any.
39814107 if len(i.queued) > 0 {
@@ -4164,7 +4290,9 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
41644290 i.streamOn = true
41654291 i.toolCalls = map[string]*tui.ToolCallView{}
41664292 i.toolOrder = nil
4293+ i.toolGate = map[string]int{}
41674294 i.shellBlock = nil // sending a prompt clears any parked shell-escape log
4295+ i.extNotes = nil // ext notes are one-shot; a new prompt clears them
41684296 i.scrollOffset = 0 // jump back to the bottom on new turn
41694297 // Reset the auto-follow baseline so the very next render at
41704298 // interactive.go:1053 doesn't see a synthetic shrink between
@@ -4430,6 +4558,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
44304558 // will populate fresh entries for this turn's tools.
44314559 i.toolCalls = map[string]*tui.ToolCallView{}
44324560 i.toolOrder = nil
4561+ i.toolGate = map[string]int{}
44334562 case core.EvTextDelta:
44344563 // Buffer into streamPending; the paintPace ticker drains
44354564 // it into i.streaming a few runes at a time for a smooth
@@ -4463,6 +4592,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
44634592 Streaming: true,
44644593 }
44654594 i.toolOrder = append(i.toolOrder, e.ID)
4595+ i.gateToolLocked(e.ID)
44664596 }
44674597 case core.EvToolUseArgs:
44684598 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -4494,6 +4624,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
44944624 Args: tui.ShortArgs(e.Name, e.Args),
44954625 }
44964626 i.toolOrder = append(i.toolOrder, e.ID)
4627+ i.gateToolLocked(e.ID)
44974628 }
44984629 case core.EvToolResult:
44994630 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -5428,6 +5559,55 @@ func (i *Interactive) resetStreamingStateLocked() {
54285559 i.streamPending = i.streamPending[:0]
54295560 i.streamFlushPending = false
54305561 i.streamOn = false
5562+ i.openAllToolGatesLocked()
5563+}
5564+
5565+// openAllToolGatesLocked drops every pending tool gate so that any
5566+// tool registered during this turn renders unconditionally from now
5567+// on. Called when streaming finalizes (the paced text has fully
5568+// drained and `streaming` is about to reset to length 0): without
5569+// this, the gate comparison against a freshly-reset streaming buffer
5570+// would wrongly re-hide tools that had already cleared their gate.
5571+// Must be called with i.mu held.
5572+func (i *Interactive) openAllToolGatesLocked() {
5573+ for id := range i.toolGate {
5574+ i.toolGate[id] = 0
5575+ }
5576+}
5577+
5578+// gateToolLocked records the stream position at which a tool call may
5579+// become visible. The gate is the total length the streaming buffer
5580+// will reach once the pacer has drained everything currently queued
5581+// (already painted + still pending). Holding the tool block back
5582+// until the pacer crosses that mark guarantees the prose emitted
5583+// before the tool call finishes typing out above it, instead of the
5584+// tool block snapping in while the paragraph is still filling in.
5585+//
5586+// We only gate while text is actively streaming. If no stream is in
5587+// flight (gate 0), the tool shows immediately, which is the correct
5588+// behaviour for tool-only turns and replayed sessions. First
5589+// registration wins so a later EvToolCall can't move an existing
5590+// gate. Must be called with i.mu held.
5591+func (i *Interactive) gateToolLocked(id string) {
5592+ if _, ok := i.toolGate[id]; ok {
5593+ return
5594+ }
5595+ if !i.streamOn {
5596+ i.toolGate[id] = 0
5597+ return
5598+ }
5599+ i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
5600+}
5601+
5602+// toolGateOpenLocked reports whether the gated tool block may render
5603+// yet, i.e. the pacer has drained enough text to reach the position
5604+// recorded when the tool call arrived. Must be called with i.mu held.
5605+func (i *Interactive) toolGateOpenLocked(id string) bool {
5606+ gate, ok := i.toolGate[id]
5607+ if !ok || gate == 0 {
5608+ return true
5609+ }
5610+ return i.streaming.Len() >= gate
54315611 }
54325612
54335613 // assistantMessageSideEffects runs the non-visual hooks attached to
@@ -5500,6 +5680,7 @@ func (i *Interactive) runStreamPacer(ctx context.Context) {
55005680 i.streamFlushPending = false
55015681 i.streaming.Reset()
55025682 i.streamOn = false
5683+ i.openAllToolGatesLocked()
55035684 i.mu.Unlock()
55045685 i.invalidate()
55055686 continue
@@ -260,17 +260,28 @@ type Interactive struct {
260 streamFlushPending bool260 streamFlushPending bool
261 toolCalls map[string]*tui.ToolCallView261 toolCalls map[string]*tui.ToolCallView
262 toolOrder []string262 toolOrder []string
263- statusErr string263+ // toolGate records, per tool-call id, how many runes of paced
264- statusOK string264+ // assistant text must have drained into `streaming` before that
265- liveBlock []string // live streaming/tool progress rendered outside scrollback265+ // tool block may appear. It exists to make stream ordering
266- helpBlock []string // rendered above the chat when /help was typed266+ // deterministic: a tool call can arrive from the provider while
267- cumUsage provider.Usage267+ // the prose that logically precedes it is still being typed out
268- lastCtxInput int // input_tokens of the most recent turn — approximates current context size268+ // by the pacer. Without gating, the tool block would render
269- busy bool269+ // immediately while the intro paragraph keeps filling in below
270- dirty chan struct{}270+ // it. We snapshot the total expected stream length (already
271- cancelTurn context.CancelFunc271+ // streamed + still pending) at the moment the tool starts, and
272- scrollOffset int // rows from the bottom; 0 = pinned to latest272+ // hold the block back until the pacer reaches it.
273- prevScrollOffset int // last value redraw snapped against; tracks intent273+ toolGate map[string]int
274+ statusErr string
275+ statusOK string
276+ liveBlock []string // live streaming/tool progress rendered outside scrollback
277+ helpBlock []string // rendered above the chat when /help was typed
278+ cumUsage provider.Usage
279+ lastCtxInput int // input_tokens of the most recent turn — approximates current context size
280+ busy bool
281+ dirty chan struct{}
282+ cancelTurn context.CancelFunc
283+ scrollOffset int // rows from the bottom; 0 = pinned to latest
284+ prevScrollOffset int // last value redraw snapped against; tracks intent
274 285
275 // prevChatLen and prevChatCols track the chat buffer's size at the286 // prevChatLen and prevChatCols track the chat buffer's size at the
276 // last redraw so that when content grows below the user's viewport287 // last redraw so that when content grows below the user's viewport
@@ -440,6 +451,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
440 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),451 ed: tui.NewEditor(cfg.Theme.AccentBar(cfg.Theme.Accent)),
441 rend: renderer,452 rend: renderer,
442 toolCalls: map[string]*tui.ToolCallView{},453 toolCalls: map[string]*tui.ToolCallView{},
454+ toolGate: map[string]int{},
443 dirty: make(chan struct{}, 8),455 dirty: make(chan struct{}, 8),
444 dialog: newLoginDialog(),456 dialog: newLoginDialog(),
445 modelDialog: newModelDialog(),457 modelDialog: newModelDialog(),
@@ -824,6 +836,14 @@ func (i *Interactive) buildChatLocked(cols int) []string {
824 i.view.ToolCalls = i.view.ToolCalls[:0]836 i.view.ToolCalls = i.view.ToolCalls[:0]
825 if i.busy {837 if i.busy {
826 for _, id := range i.toolOrder {838 for _, id := range i.toolOrder {
839+ // Deterministic ordering: a tool block stays hidden until
840+ // the paced assistant text that preceded it has finished
841+ // typing out. toolOrder is append-only in arrival order,
842+ // so once one tool is still gated, every later tool is too,
843+ // so stop here to avoid showing a tool out of sequence.
844+ if !i.toolGateOpenLocked(id) {
845+ break
846+ }
827 if tc, ok := i.toolCalls[id]; ok {847 if tc, ok := i.toolCalls[id]; ok {
828 i.view.ToolCalls = append(i.view.ToolCalls, *tc)848 i.view.ToolCalls = append(i.view.ToolCalls, *tc)
829 }849 }
@@ -2356,6 +2376,36 @@ func (i *Interactive) Notify(extName, level, message string) {
2356 i.invalidate()2376 i.invalidate()
2357 }2377 }
2358 2378
2379+// ClearNotes removes every note line owned by extName from the
2380+// bottom-sticky ext-notes block. Extensions use this to retract a
2381+// transient status line (e.g. an approval prompt) once it no longer
2382+// applies, instead of leaving it stacked forever. Notes from other
2383+// extensions and internal notes (auto-compact) are left untouched.
2384+func (i *Interactive) ClearNotes(extName string) {
2385+ marker := "[" + extName + "] "
2386+ i.mu.Lock()
2387+ if len(i.extNotes) == 0 {
2388+ i.mu.Unlock()
2389+ return
2390+ }
2391+ kept := i.extNotes[:0:0]
2392+ changed := false
2393+ for _, line := range i.extNotes {
2394+ if strings.Contains(line, marker) {
2395+ changed = true
2396+ continue
2397+ }
2398+ kept = append(kept, line)
2399+ }
2400+ if changed {
2401+ i.extNotes = kept
2402+ }
2403+ i.mu.Unlock()
2404+ if changed {
2405+ i.invalidate()
2406+ }
2407+}
2408+
2359 // Submit feeds text through the agent loop as if the user had typed it.2409 // Submit feeds text through the agent loop as if the user had typed it.
2360 func (i *Interactive) Submit(text string) {2410 func (i *Interactive) Submit(text string) {
2361 if cmd, ok := shellEscapeCommand(text); ok {2411 if cmd, ok := shellEscapeCommand(text); ok {
@@ -2382,6 +2432,7 @@ func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd strin
2382 i.cfg.Model = model2432 i.cfg.Model = model
2383 i.toolCalls = map[string]*tui.ToolCallView{}2433 i.toolCalls = map[string]*tui.ToolCallView{}
2384 i.toolOrder = nil2434 i.toolOrder = nil
2435+ i.toolGate = map[string]int{}
2385 i.helpBlock = nil2436 i.helpBlock = nil
2386 i.parkedTurn = 02437 i.parkedTurn = 0
2387 i.statusErr = ""2438 i.statusErr = ""
@@ -3051,6 +3102,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
3051 i.mu.Lock()3102 i.mu.Lock()
3052 i.toolCalls = map[string]*tui.ToolCallView{}3103 i.toolCalls = map[string]*tui.ToolCallView{}
3053 i.toolOrder = nil3104 i.toolOrder = nil
3105+ i.toolGate = map[string]int{}
3054 i.statusErr = ""3106 i.statusErr = ""
3055 i.statusOK = ""3107 i.statusOK = ""
3056 i.helpBlock = nil3108 i.helpBlock = nil
@@ -3170,6 +3222,7 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) {
3170 i.mu.Lock()3222 i.mu.Lock()
3171 i.toolCalls = map[string]*tui.ToolCallView{}3223 i.toolCalls = map[string]*tui.ToolCallView{}
3172 i.toolOrder = nil3224 i.toolOrder = nil
3225+ i.toolGate = map[string]int{}
3173 i.helpBlock = nil3226 i.helpBlock = nil
3174 i.parkedTurn = 03227 i.parkedTurn = 0
3175 i.statusOK = "cwd " + i.cfg.CWD3228 i.statusOK = "cwd " + i.cfg.CWD
@@ -3405,7 +3458,79 @@ func (i *Interactive) doLogout(target string) {
3405 }3458 }
3406 }3459 }
3407 3460
3461+func providerSetupInfo(provider string) (string, []string, bool) {
3462+ const docsURL = "https://raw.githubusercontent.com/patriceckhart/zot/main/docs/providers.md"
3463+ switch provider {
3464+ case "amazon-bedrock":
3465+ return "Amazon Bedrock setup", []string{
3466+ "Amazon Bedrock uses AWS credentials instead of a generic zot API-key entry.",
3467+ "Configure an AWS profile, IAM keys, bearer token, or role-based credentials.",
3468+ "",
3469+ "For Bedrock API keys, set:",
3470+ " AWS_BEARER_TOKEN_BEDROCK=...",
3471+ " AWS_REGION=us-east-1",
3472+ "",
3473+ "Docs:",
3474+ " " + docsURL,
3475+ }, true
3476+ case "google-vertex":
3477+ return "Google Vertex AI setup", []string{
3478+ "Google Vertex AI usually uses Google Cloud credentials and project settings.",
3479+ "Set a Google API key, application-default credentials, or a service account.",
3480+ "",
3481+ "Common environment:",
3482+ " GOOGLE_CLOUD_API_KEY=...",
3483+ " GOOGLE_CLOUD_PROJECT=...",
3484+ " GOOGLE_CLOUD_LOCATION=us-central1",
3485+ "",
3486+ "Docs:",
3487+ " " + docsURL,
3488+ }, true
3489+ case "cloudflare-workers-ai":
3490+ return "Cloudflare Workers AI setup", []string{
3491+ "Cloudflare Workers AI needs both an API token and an account ID.",
3492+ "",
3493+ "Set:",
3494+ " CLOUDFLARE_API_KEY=...",
3495+ " CLOUDFLARE_ACCOUNT_ID=...",
3496+ "",
3497+ "Docs:",
3498+ " " + docsURL,
3499+ }, true
3500+ case "cloudflare-ai-gateway":
3501+ return "Cloudflare AI Gateway setup", []string{
3502+ "Cloudflare AI Gateway needs an API token, account ID, and gateway ID.",
3503+ "",
3504+ "Set:",
3505+ " CLOUDFLARE_API_KEY=...",
3506+ " CLOUDFLARE_ACCOUNT_ID=...",
3507+ " CLOUDFLARE_GATEWAY_ID=...",
3508+ "",
3509+ "Docs:",
3510+ " " + docsURL,
3511+ }, true
3512+ case "azure-openai-responses":
3513+ return "Azure OpenAI Responses setup", []string{
3514+ "Azure OpenAI needs an API key plus your Azure endpoint or deployment setup.",
3515+ "",
3516+ "Set:",
3517+ " AZURE_OPENAI_API_KEY=...",
3518+ " AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com",
3519+ " AZURE_OPENAI_API_VERSION=2024-02-01",
3520+ "",
3521+ "Docs:",
3522+ " " + docsURL,
3523+ }, true
3524+ default:
3525+ return "", nil, false
3526+ }
3527+}
3528+
3408 func (i *Interactive) startAPIKeyFlow(provider string) {3529 func (i *Interactive) startAPIKeyFlow(provider string) {
3530+ if title, lines, ok := providerSetupInfo(provider); ok {
3531+ i.dialog.ShowInfo(title, lines)
3532+ return
3533+ }
3409 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {3534 if provider == "kimi" && i.cfg.SetKimiCLIFallbackDisabled != nil {
3410 _ = i.cfg.SetKimiCLIFallbackDisabled(false)3535 _ = i.cfg.SetKimiCLIFallbackDisabled(false)
3411 }3536 }
@@ -3976,6 +4101,7 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
3976 i.lastCtxInput = 04101 i.lastCtxInput = 0
3977 i.toolCalls = map[string]*tui.ToolCallView{}4102 i.toolCalls = map[string]*tui.ToolCallView{}
3978 i.toolOrder = nil4103 i.toolOrder = nil
4104+ i.toolGate = map[string]int{}
3979 i.view.InvalidateRenderCache()4105 i.view.InvalidateRenderCache()
3980 // Pop queued prompt if any.4106 // Pop queued prompt if any.
3981 if len(i.queued) > 0 {4107 if len(i.queued) > 0 {
@@ -4164,7 +4290,9 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
4164 i.streamOn = true4290 i.streamOn = true
4165 i.toolCalls = map[string]*tui.ToolCallView{}4291 i.toolCalls = map[string]*tui.ToolCallView{}
4166 i.toolOrder = nil4292 i.toolOrder = nil
4293+ i.toolGate = map[string]int{}
4167 i.shellBlock = nil // sending a prompt clears any parked shell-escape log4294 i.shellBlock = nil // sending a prompt clears any parked shell-escape log
4295+ i.extNotes = nil // ext notes are one-shot; a new prompt clears them
4168 i.scrollOffset = 0 // jump back to the bottom on new turn4296 i.scrollOffset = 0 // jump back to the bottom on new turn
4169 // Reset the auto-follow baseline so the very next render at4297 // Reset the auto-follow baseline so the very next render at
4170 // interactive.go:1053 doesn't see a synthetic shrink between4298 // interactive.go:1053 doesn't see a synthetic shrink between
@@ -4430,6 +4558,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
4430 // will populate fresh entries for this turn's tools.4558 // will populate fresh entries for this turn's tools.
4431 i.toolCalls = map[string]*tui.ToolCallView{}4559 i.toolCalls = map[string]*tui.ToolCallView{}
4432 i.toolOrder = nil4560 i.toolOrder = nil
4561+ i.toolGate = map[string]int{}
4433 case core.EvTextDelta:4562 case core.EvTextDelta:
4434 // Buffer into streamPending; the paintPace ticker drains4563 // Buffer into streamPending; the paintPace ticker drains
4435 // it into i.streaming a few runes at a time for a smooth4564 // it into i.streaming a few runes at a time for a smooth
@@ -4463,6 +4592,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
4463 Streaming: true,4592 Streaming: true,
4464 }4593 }
4465 i.toolOrder = append(i.toolOrder, e.ID)4594 i.toolOrder = append(i.toolOrder, e.ID)
4595+ i.gateToolLocked(e.ID)
4466 }4596 }
4467 case core.EvToolUseArgs:4597 case core.EvToolUseArgs:
4468 if tc, ok := i.toolCalls[e.ID]; ok {4598 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -4494,6 +4624,7 @@ func (i *Interactive) handleEvent(ev core.AgentEvent) {
4494 Args: tui.ShortArgs(e.Name, e.Args),4624 Args: tui.ShortArgs(e.Name, e.Args),
4495 }4625 }
4496 i.toolOrder = append(i.toolOrder, e.ID)4626 i.toolOrder = append(i.toolOrder, e.ID)
4627+ i.gateToolLocked(e.ID)
4497 }4628 }
4498 case core.EvToolResult:4629 case core.EvToolResult:
4499 if tc, ok := i.toolCalls[e.ID]; ok {4630 if tc, ok := i.toolCalls[e.ID]; ok {
@@ -5428,6 +5559,55 @@ func (i *Interactive) resetStreamingStateLocked() {
5428 i.streamPending = i.streamPending[:0]5559 i.streamPending = i.streamPending[:0]
5429 i.streamFlushPending = false5560 i.streamFlushPending = false
5430 i.streamOn = false5561 i.streamOn = false
5562+ i.openAllToolGatesLocked()
5563+}
5564+
5565+// openAllToolGatesLocked drops every pending tool gate so that any
5566+// tool registered during this turn renders unconditionally from now
5567+// on. Called when streaming finalizes (the paced text has fully
5568+// drained and `streaming` is about to reset to length 0): without
5569+// this, the gate comparison against a freshly-reset streaming buffer
5570+// would wrongly re-hide tools that had already cleared their gate.
5571+// Must be called with i.mu held.
5572+func (i *Interactive) openAllToolGatesLocked() {
5573+ for id := range i.toolGate {
5574+ i.toolGate[id] = 0
5575+ }
5576+}
5577+
5578+// gateToolLocked records the stream position at which a tool call may
5579+// become visible. The gate is the total length the streaming buffer
5580+// will reach once the pacer has drained everything currently queued
5581+// (already painted + still pending). Holding the tool block back
5582+// until the pacer crosses that mark guarantees the prose emitted
5583+// before the tool call finishes typing out above it, instead of the
5584+// tool block snapping in while the paragraph is still filling in.
5585+//
5586+// We only gate while text is actively streaming. If no stream is in
5587+// flight (gate 0), the tool shows immediately, which is the correct
5588+// behaviour for tool-only turns and replayed sessions. First
5589+// registration wins so a later EvToolCall can't move an existing
5590+// gate. Must be called with i.mu held.
5591+func (i *Interactive) gateToolLocked(id string) {
5592+ if _, ok := i.toolGate[id]; ok {
5593+ return
5594+ }
5595+ if !i.streamOn {
5596+ i.toolGate[id] = 0
5597+ return
5598+ }
5599+ i.toolGate[id] = i.streaming.Len() + len(i.streamPending)
5600+}
5601+
5602+// toolGateOpenLocked reports whether the gated tool block may render
5603+// yet, i.e. the pacer has drained enough text to reach the position
5604+// recorded when the tool call arrived. Must be called with i.mu held.
5605+func (i *Interactive) toolGateOpenLocked(id string) bool {
5606+ gate, ok := i.toolGate[id]
5607+ if !ok || gate == 0 {
5608+ return true
5609+ }
5610+ return i.streaming.Len() >= gate
5431 }5611 }
5432 5612
5433 // assistantMessageSideEffects runs the non-visual hooks attached to5613 // assistantMessageSideEffects runs the non-visual hooks attached to
@@ -5500,6 +5680,7 @@ func (i *Interactive) runStreamPacer(ctx context.Context) {
5500 i.streamFlushPending = false5680 i.streamFlushPending = false
5501 i.streaming.Reset()5681 i.streaming.Reset()
5502 i.streamOn = false5682 i.streamOn = false
5683+ i.openAllToolGatesLocked()
5503 i.mu.Unlock()5684 i.mu.Unlock()
5504 i.invalidate()5685 i.invalidate()
5505 continue5686 continue
M packages/agent/modes/login_dialog.go +48 -10
@@ -3,6 +3,7 @@ package modes
33 import (
44 "fmt"
55 "path/filepath"
6+ "sort"
67
78 "github.com/patriceckhart/zot/packages/provider"
89 "github.com/patriceckhart/zot/packages/provider/auth"
@@ -21,6 +22,7 @@ const (
2122 loginStepProvider // pick anthropic vs openai vs kimi
2223 loginStepWaiting // browser open, waiting for callback
2324 loginStepPasteCode // user pastes the auth code here
25+ loginStepInfo // informational setup guidance
2426 loginStepDone // success or error, waiting for key to dismiss
2527 )
2628
@@ -29,14 +31,16 @@ const loginProviderPageSize = 8
2931 // loginDialog is a tiny inline dialog rendered above the editor while
3032 // the user picks their login method and provider.
3133 type loginDialog struct {
32- step loginStep
33- method string // "apikey" | "oauth"
34- provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google"
35- message string
36- success bool
37- url string
38- cursor int
39- codeEd *tui.Editor
34+ step loginStep
35+ method string // "apikey" | "oauth"
36+ provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google"
37+ message string
38+ success bool
39+ url string
40+ cursor int
41+ codeEd *tui.Editor
42+ infoTitle string
43+ infoLines []string
4044
4145 // status is a snapshot of the current login state for each
4246 // provider, captured when Open() runs. Rendered above the
@@ -68,6 +72,8 @@ func (d *loginDialog) Open(zotHome string) {
6872 d.success = false
6973 d.url = ""
7074 d.cursor = 0
75+ d.infoTitle = ""
76+ d.infoLines = nil
7177 d.status = map[string]string{}
7278 for _, p := range providersForMethod("apikey") {
7379 d.status[p] = ""
@@ -199,6 +205,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
199205 lines = append(lines, "")
200206 lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels"))
201207 lines = append(lines, frameRule(th, width))
208+ case loginStepInfo:
209+ title := d.infoTitle
210+ if title == "" {
211+ title = "login - setup"
212+ }
213+ lines = append(lines, frameHeader(th, title, width))
214+ for _, l := range d.infoLines {
215+ lines = append(lines, l)
216+ }
217+ lines = append(lines, "")
218+ lines = append(lines, th.FG256(th.Muted, "press any key to close"))
219+ lines = append(lines, frameRule(th, width))
202220 case loginStepDone:
203221 title := "login - failed"
204222 body := th.FG256(th.Error, d.message)
@@ -221,10 +239,16 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
221239 // consumer Gemini Advanced login does not, and DeepSeek has no
222240 // subscription product at all).
223241 func providersForMethod(method string) []string {
242+ var providers []string
224243 if method == "oauth" {
225- return []string{"anthropic", "openai-codex", "kimi", "github-copilot"}
244+ providers = []string{"anthropic", "openai-codex", "kimi", "github-copilot"}
245+ } else {
246+ providers = auth.APIKeyProviders()
226247 }
227- return auth.APIKeyProviders()
248+ sort.Slice(providers, func(a, b int) bool {
249+ return providerLabel(providers[a]) < providerLabel(providers[b])
250+ })
251+ return providers
228252 }
229253
230254 // providerLabel returns the user-facing label for a provider id.
@@ -347,6 +371,9 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
347371 return d.handleWaitingKey(k)
348372 case loginStepPasteCode:
349373 return d.handlePasteCodeKey(k)
374+ case loginStepInfo:
375+ d.Close()
376+ return loginDialogAction{Close: true}
350377 case loginStepDone:
351378 d.Close()
352379 return loginDialogAction{Close: true}
@@ -430,6 +457,17 @@ func (d *loginDialog) ShowWaiting(url string) {
430457 d.url = url
431458 }
432459
460+// ShowInfo transitions to an informational setup dialog.
461+// No-op if the user has already dismissed the dialog.
462+func (d *loginDialog) ShowInfo(title string, lines []string) {
463+ if d.step == loginStepClosed {
464+ return
465+ }
466+ d.step = loginStepInfo
467+ d.infoTitle = title
468+ d.infoLines = lines
469+}
470+
433471 // ShowResult transitions to the done state with the given outcome.
434472 // No-op if the user has already dismissed the dialog.
435473 func (d *loginDialog) ShowResult(success bool, message string) {
@@ -3,6 +3,7 @@ package modes
3 import (3 import (
4 "fmt"4 "fmt"
5 "path/filepath"5 "path/filepath"
6+ "sort"
6 7
7 "github.com/patriceckhart/zot/packages/provider"8 "github.com/patriceckhart/zot/packages/provider"
8 "github.com/patriceckhart/zot/packages/provider/auth"9 "github.com/patriceckhart/zot/packages/provider/auth"
@@ -21,6 +22,7 @@ const (
21 loginStepProvider // pick anthropic vs openai vs kimi22 loginStepProvider // pick anthropic vs openai vs kimi
22 loginStepWaiting // browser open, waiting for callback23 loginStepWaiting // browser open, waiting for callback
23 loginStepPasteCode // user pastes the auth code here24 loginStepPasteCode // user pastes the auth code here
25+ loginStepInfo // informational setup guidance
24 loginStepDone // success or error, waiting for key to dismiss26 loginStepDone // success or error, waiting for key to dismiss
25 )27 )
26 28
@@ -29,14 +31,16 @@ const loginProviderPageSize = 8
29 // loginDialog is a tiny inline dialog rendered above the editor while31 // loginDialog is a tiny inline dialog rendered above the editor while
30 // the user picks their login method and provider.32 // the user picks their login method and provider.
31 type loginDialog struct {33 type loginDialog struct {
32- step loginStep34+ step loginStep
33- method string // "apikey" | "oauth"35+ method string // "apikey" | "oauth"
34- provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google"36+ provider string // "anthropic" | "openai" | "openai-codex" | "kimi" | "google"
35- message string37+ message string
36- success bool38+ success bool
37- url string39+ url string
38- cursor int40+ cursor int
39- codeEd *tui.Editor41+ codeEd *tui.Editor
42+ infoTitle string
43+ infoLines []string
40 44
41 // status is a snapshot of the current login state for each45 // status is a snapshot of the current login state for each
42 // provider, captured when Open() runs. Rendered above the46 // provider, captured when Open() runs. Rendered above the
@@ -68,6 +72,8 @@ func (d *loginDialog) Open(zotHome string) {
68 d.success = false72 d.success = false
69 d.url = ""73 d.url = ""
70 d.cursor = 074 d.cursor = 0
75+ d.infoTitle = ""
76+ d.infoLines = nil
71 d.status = map[string]string{}77 d.status = map[string]string{}
72 for _, p := range providersForMethod("apikey") {78 for _, p := range providersForMethod("apikey") {
73 d.status[p] = ""79 d.status[p] = ""
@@ -199,6 +205,18 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
199 lines = append(lines, "")205 lines = append(lines, "")
200 lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels"))206 lines = append(lines, th.FG256(th.Muted, "enter submits - esc cancels"))
201 lines = append(lines, frameRule(th, width))207 lines = append(lines, frameRule(th, width))
208+ case loginStepInfo:
209+ title := d.infoTitle
210+ if title == "" {
211+ title = "login - setup"
212+ }
213+ lines = append(lines, frameHeader(th, title, width))
214+ for _, l := range d.infoLines {
215+ lines = append(lines, l)
216+ }
217+ lines = append(lines, "")
218+ lines = append(lines, th.FG256(th.Muted, "press any key to close"))
219+ lines = append(lines, frameRule(th, width))
202 case loginStepDone:220 case loginStepDone:
203 title := "login - failed"221 title := "login - failed"
204 body := th.FG256(th.Error, d.message)222 body := th.FG256(th.Error, d.message)
@@ -221,10 +239,16 @@ func (d *loginDialog) Render(th tui.Theme, width int) []string {
221 // consumer Gemini Advanced login does not, and DeepSeek has no239 // consumer Gemini Advanced login does not, and DeepSeek has no
222 // subscription product at all).240 // subscription product at all).
223 func providersForMethod(method string) []string {241 func providersForMethod(method string) []string {
242+ var providers []string
224 if method == "oauth" {243 if method == "oauth" {
225- return []string{"anthropic", "openai-codex", "kimi", "github-copilot"}244+ providers = []string{"anthropic", "openai-codex", "kimi", "github-copilot"}
245+ } else {
246+ providers = auth.APIKeyProviders()
226 }247 }
227- return auth.APIKeyProviders()248+ sort.Slice(providers, func(a, b int) bool {
249+ return providerLabel(providers[a]) < providerLabel(providers[b])
250+ })
251+ return providers
228 }252 }
229 253
230 // providerLabel returns the user-facing label for a provider id.254 // providerLabel returns the user-facing label for a provider id.
@@ -347,6 +371,9 @@ func (d *loginDialog) HandleKey(k tui.Key) loginDialogAction {
347 return d.handleWaitingKey(k)371 return d.handleWaitingKey(k)
348 case loginStepPasteCode:372 case loginStepPasteCode:
349 return d.handlePasteCodeKey(k)373 return d.handlePasteCodeKey(k)
374+ case loginStepInfo:
375+ d.Close()
376+ return loginDialogAction{Close: true}
350 case loginStepDone:377 case loginStepDone:
351 d.Close()378 d.Close()
352 return loginDialogAction{Close: true}379 return loginDialogAction{Close: true}
@@ -430,6 +457,17 @@ func (d *loginDialog) ShowWaiting(url string) {
430 d.url = url457 d.url = url
431 }458 }
432 459
460+// ShowInfo transitions to an informational setup dialog.
461+// No-op if the user has already dismissed the dialog.
462+func (d *loginDialog) ShowInfo(title string, lines []string) {
463+ if d.step == loginStepClosed {
464+ return
465+ }
466+ d.step = loginStepInfo
467+ d.infoTitle = title
468+ d.infoLines = lines
469+}
470+
433 // ShowResult transitions to the done state with the given outcome.471 // ShowResult transitions to the done state with the given outcome.
434 // No-op if the user has already dismissed the dialog.472 // No-op if the user has already dismissed the dialog.
435 func (d *loginDialog) ShowResult(success bool, message string) {473 func (d *loginDialog) ShowResult(success bool, message string) {
A packages/agent/modes/tool_gate_test.go +101 -0
@@ -0,0 +1,101 @@
1+package modes
2+
3+import "testing"
4+
5+func newGateTestInteractive() *Interactive {
6+ return &Interactive{
7+ toolGate: map[string]int{},
8+ dirty: make(chan struct{}, 1),
9+ }
10+}
11+
12+// While paced text is still draining, a tool that arrives mid-stream
13+// must stay gated until the streaming buffer reaches the position
14+// captured when the tool started.
15+func TestToolGateHoldsToolUntilTextDrains(t *testing.T) {
16+ i := newGateTestInteractive()
17+
18+ // Simulate a turn that streamed 10 runes already with 20 more
19+ // queued in the pacer when the tool call arrives.
20+ i.streamOn = true
21+ i.streaming.WriteString("0123456789") // 10 painted
22+ i.streamPending = []rune("01234567890123456789")
23+
24+ i.gateToolLocked("t1")
25+ if got := i.toolGate["t1"]; got != 30 {
26+ t.Fatalf("gate = %d, want 30 (10 painted + 20 pending)", got)
27+ }
28+
29+ if i.toolGateOpenLocked("t1") {
30+ t.Fatal("tool should be gated while only 10/30 runes painted")
31+ }
32+
33+ // Drain halfway: still gated.
34+ i.streaming.WriteString("0123456789") // 20 painted
35+ if i.toolGateOpenLocked("t1") {
36+ t.Fatal("tool should still be gated at 20/30")
37+ }
38+
39+ // Reach the gate: now visible.
40+ i.streaming.WriteString("0123456789") // 30 painted
41+ if !i.toolGateOpenLocked("t1") {
42+ t.Fatal("tool should be visible once streaming reaches the gate")
43+ }
44+}
45+
46+// A tool call on a turn with no active text stream shows immediately.
47+func TestToolGateOpenWhenNoStream(t *testing.T) {
48+ i := newGateTestInteractive()
49+ i.streamOn = false
50+
51+ i.gateToolLocked("t1")
52+ if got := i.toolGate["t1"]; got != 0 {
53+ t.Fatalf("gate = %d, want 0 for non-streaming turn", got)
54+ }
55+ if !i.toolGateOpenLocked("t1") {
56+ t.Fatal("tool should be visible immediately when no text is streaming")
57+ }
58+}
59+
60+// First registration wins: a later EvToolCall must not move an
61+// existing gate (e.g. push it forward as more text arrives).
62+func TestToolGateFirstRegistrationWins(t *testing.T) {
63+ i := newGateTestInteractive()
64+ i.streamOn = true
65+ i.streaming.WriteString("01234") // 5
66+ i.streamPending = []rune("01234")
67+
68+ i.gateToolLocked("t1") // gate 10
69+ first := i.toolGate["t1"]
70+
71+ // More text queues up, then the same tool is re-registered.
72+ i.streamPending = append(i.streamPending, []rune("567890")...)
73+ i.gateToolLocked("t1")
74+
75+ if i.toolGate["t1"] != first {
76+ t.Fatalf("gate moved from %d to %d; first registration must win", first, i.toolGate["t1"])
77+ }
78+}
79+
80+// Once streaming finalizes, the buffer resets to length 0; gates that
81+// were already satisfied must not re-hide their tools.
82+func TestOpenAllToolGatesSurvivesStreamReset(t *testing.T) {
83+ i := newGateTestInteractive()
84+ i.streamOn = true
85+ i.streaming.WriteString("0123456789")
86+ i.gateToolLocked("t1") // gate 10, satisfied
87+
88+ if !i.toolGateOpenLocked("t1") {
89+ t.Fatal("precondition: tool should be open before reset")
90+ }
91+
92+ // Finalize the stream (mirrors resetStreamingStateLocked / pacer
93+ // flush): buffer resets to 0 but gates are opened first.
94+ i.openAllToolGatesLocked()
95+ i.streaming.Reset()
96+ i.streamOn = false
97+
98+ if !i.toolGateOpenLocked("t1") {
99+ t.Fatal("tool re-hidden after stream reset; gate should have been opened")
100+ }
101+}
@@ -0,0 +1,101 @@
1+package modes
2+
3+import "testing"
4+
5+func newGateTestInteractive() *Interactive {
6+ return &Interactive{
7+ toolGate: map[string]int{},
8+ dirty: make(chan struct{}, 1),
9+ }
10+}
11+
12+// While paced text is still draining, a tool that arrives mid-stream
13+// must stay gated until the streaming buffer reaches the position
14+// captured when the tool started.
15+func TestToolGateHoldsToolUntilTextDrains(t *testing.T) {
16+ i := newGateTestInteractive()
17+
18+ // Simulate a turn that streamed 10 runes already with 20 more
19+ // queued in the pacer when the tool call arrives.
20+ i.streamOn = true
21+ i.streaming.WriteString("0123456789") // 10 painted
22+ i.streamPending = []rune("01234567890123456789")
23+
24+ i.gateToolLocked("t1")
25+ if got := i.toolGate["t1"]; got != 30 {
26+ t.Fatalf("gate = %d, want 30 (10 painted + 20 pending)", got)
27+ }
28+
29+ if i.toolGateOpenLocked("t1") {
30+ t.Fatal("tool should be gated while only 10/30 runes painted")
31+ }
32+
33+ // Drain halfway: still gated.
34+ i.streaming.WriteString("0123456789") // 20 painted
35+ if i.toolGateOpenLocked("t1") {
36+ t.Fatal("tool should still be gated at 20/30")
37+ }
38+
39+ // Reach the gate: now visible.
40+ i.streaming.WriteString("0123456789") // 30 painted
41+ if !i.toolGateOpenLocked("t1") {
42+ t.Fatal("tool should be visible once streaming reaches the gate")
43+ }
44+}
45+
46+// A tool call on a turn with no active text stream shows immediately.
47+func TestToolGateOpenWhenNoStream(t *testing.T) {
48+ i := newGateTestInteractive()
49+ i.streamOn = false
50+
51+ i.gateToolLocked("t1")
52+ if got := i.toolGate["t1"]; got != 0 {
53+ t.Fatalf("gate = %d, want 0 for non-streaming turn", got)
54+ }
55+ if !i.toolGateOpenLocked("t1") {
56+ t.Fatal("tool should be visible immediately when no text is streaming")
57+ }
58+}
59+
60+// First registration wins: a later EvToolCall must not move an
61+// existing gate (e.g. push it forward as more text arrives).
62+func TestToolGateFirstRegistrationWins(t *testing.T) {
63+ i := newGateTestInteractive()
64+ i.streamOn = true
65+ i.streaming.WriteString("01234") // 5
66+ i.streamPending = []rune("01234")
67+
68+ i.gateToolLocked("t1") // gate 10
69+ first := i.toolGate["t1"]
70+
71+ // More text queues up, then the same tool is re-registered.
72+ i.streamPending = append(i.streamPending, []rune("567890")...)
73+ i.gateToolLocked("t1")
74+
75+ if i.toolGate["t1"] != first {
76+ t.Fatalf("gate moved from %d to %d; first registration must win", first, i.toolGate["t1"])
77+ }
78+}
79+
80+// Once streaming finalizes, the buffer resets to length 0; gates that
81+// were already satisfied must not re-hide their tools.
82+func TestOpenAllToolGatesSurvivesStreamReset(t *testing.T) {
83+ i := newGateTestInteractive()
84+ i.streamOn = true
85+ i.streaming.WriteString("0123456789")
86+ i.gateToolLocked("t1") // gate 10, satisfied
87+
88+ if !i.toolGateOpenLocked("t1") {
89+ t.Fatal("precondition: tool should be open before reset")
90+ }
91+
92+ // Finalize the stream (mirrors resetStreamingStateLocked / pacer
93+ // flush): buffer resets to 0 but gates are opened first.
94+ i.openAllToolGatesLocked()
95+ i.streaming.Reset()
96+ i.streamOn = false
97+
98+ if !i.toolGateOpenLocked("t1") {
99+ t.Fatal("tool re-hidden after stream reset; gate should have been opened")
100+ }
101+}
M packages/agent/rpc.go +8 -0
@@ -142,6 +142,14 @@ func (h *rpcExtHooks) Display(extName, text string) {
142142 })
143143 }
144144 }
145+func (h *rpcExtHooks) ClearNotes(extName string) {
146+ if h.server != nil {
147+ h.server.writeEvent(map[string]any{
148+ "type": "ext_clear_notes",
149+ "extension": extName,
150+ })
151+ }
152+}
145153 func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
146154 func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode
147155 func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
@@ -142,6 +142,14 @@ func (h *rpcExtHooks) Display(extName, text string) {
142 })142 })
143 }143 }
144 }144 }
145+func (h *rpcExtHooks) ClearNotes(extName string) {
146+ if h.server != nil {
147+ h.server.writeEvent(map[string]any{
148+ "type": "ext_clear_notes",
149+ "extension": extName,
150+ })
151+ }
152+}
145 func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode153 func (h *rpcExtHooks) Submit(string) {} // ignored in rpc mode
146 func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode154 func (h *rpcExtHooks) SubmitSlash(string) {} // ignored in rpc mode
147 func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode155 func (h *rpcExtHooks) Insert(string) {} // ignored in rpc mode
M packages/agent/skills/builtin/write-zot-extension/SKILL.md +6 -0
@@ -145,9 +145,15 @@ you can — newer extensions on faster hosts shave that 250ms off.
145145 {"type":"event_intercept_response","id":"ghi","block":true,
146146 "reason":"refused: command matches the danger pattern \"rm -rf\""}
147147 {"type":"notify","level":"info","message":"refreshed cache"}
148+{"type":"clear_notes"}
148149 {"type":"shutdown_ack"}
149150 ```
150151
152+`notify` notes are one-shot: they clear when the user sends their next
153+prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every
154+note this extension pushed earlier (e.g. a transient approval prompt)
155+without waiting for the next turn; other extensions' notes are kept.
156+
151157 `command_response.action` values:
152158 - `"prompt"` — submit `prompt` as a fresh user message
153159 - `"insert"` — drop `insert` into the editor at the cursor
@@ -145,9 +145,15 @@ you can — newer extensions on faster hosts shave that 250ms off.
145 {"type":"event_intercept_response","id":"ghi","block":true,145 {"type":"event_intercept_response","id":"ghi","block":true,
146 "reason":"refused: command matches the danger pattern \"rm -rf\""}146 "reason":"refused: command matches the danger pattern \"rm -rf\""}
147 {"type":"notify","level":"info","message":"refreshed cache"}147 {"type":"notify","level":"info","message":"refreshed cache"}
148+{"type":"clear_notes"}
148 {"type":"shutdown_ack"}149 {"type":"shutdown_ack"}
149 ```150 ```
150 151
152+`notify` notes are one-shot: they clear when the user sends their next
153+prompt (and on `esc` / `/clear`). Send `clear_notes` to retract every
154+note this extension pushed earlier (e.g. a transient approval prompt)
155+without waiting for the next turn; other extensions' notes are kept.
156+
151 `command_response.action` values:157 `command_response.action` values:
152 - `"prompt"` — submit `prompt` as a fresh user message158 - `"prompt"` — submit `prompt` as a fresh user message
153 - `"insert"` — drop `insert` into the editor at the cursor159 - `"insert"` — drop `insert` into the editor at the cursor
M packages/provider/amazon_bedrock.go +95 -7
@@ -216,6 +216,61 @@ type bedrockRequest struct {
216216 } `json:"toolConfig,omitempty"`
217217 }
218218
219+func normalizeBedrockToolResults(msgs []Message) []Message {
220+ resultByID := map[string]ToolResultBlock{}
221+ for _, m := range msgs {
222+ for _, c := range m.Content {
223+ if tr, ok := c.(ToolResultBlock); ok {
224+ if _, exists := resultByID[tr.CallID]; !exists {
225+ resultByID[tr.CallID] = tr
226+ }
227+ }
228+ }
229+ }
230+
231+ out := make([]Message, 0, len(msgs))
232+ for _, m := range msgs {
233+ copy := m
234+ copy.Content = nil
235+ var toolCalls []ToolCallBlock
236+ for _, c := range m.Content {
237+ switch v := c.(type) {
238+ case ToolResultBlock:
239+ // Bedrock requires toolResult blocks immediately after the
240+ // assistant toolUse they answer. Reinsert them from the
241+ // assistant pass below instead of preserving their original
242+ // location, which may be separated by user text in active
243+ // sessions.
244+ continue
245+ case ToolCallBlock:
246+ toolCalls = append(toolCalls, v)
247+ }
248+ copy.Content = append(copy.Content, c)
249+ }
250+ if len(copy.Content) > 0 {
251+ out = append(out, copy)
252+ }
253+ if m.Role != RoleAssistant || len(toolCalls) == 0 {
254+ continue
255+ }
256+
257+ results := make([]Content, 0, len(toolCalls))
258+ for _, tc := range toolCalls {
259+ if tr, ok := resultByID[tc.ID]; ok {
260+ results = append(results, tr)
261+ continue
262+ }
263+ results = append(results, ToolResultBlock{
264+ CallID: tc.ID,
265+ IsError: true,
266+ Content: []Content{TextBlock{Text: "tool call did not complete before the next user message"}},
267+ })
268+ }
269+ out = append(out, Message{Role: RoleTool, Content: results, Time: m.Time})
270+ }
271+ return out
272+}
273+
219274 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
220275 out := &bedrockRequest{}
221276 if req.System != "" {
@@ -226,7 +281,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
226281 if out.InferenceConfig.MaxTokens == 0 {
227282 out.InferenceConfig.MaxTokens = 4096
228283 }
229- for _, m := range req.Messages {
284+ for _, m := range normalizeBedrockToolResults(req.Messages) {
230285 role := string(m.Role)
231286 if role == "tool" {
232287 role = "user"
@@ -267,6 +322,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
267322 })
268323 }
269324 }
325+ if len(bm.Content) == 0 {
326+ continue
327+ }
270328 out.Messages = append(out.Messages, bm)
271329 }
272330 if len(req.Tools) > 0 {
@@ -450,6 +508,9 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
450508 return
451509 }
452510 eventType := evt.headerString(":event-type")
511+ if eventType == "" {
512+ eventType = bedrockEventTypeFromPayload(evt.payload)
513+ }
453514 messageType := evt.headerString(":message-type")
454515 if messageType == "exception" {
455516 out <- EventDone{Stop: StopError, Err: fmt.Errorf("bedrock exception (%s): %s", evt.headerString(":exception-type"), string(evt.payload)), Message: finalMsg}
@@ -468,7 +529,7 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
468529 } `json:"toolUse"`
469530 } `json:"start"`
470531 }
471- if err := json.Unmarshal(evt.payload, &d); err != nil {
532+ if err := unmarshalBedrockEventPayload(evt.payload, "contentBlockStart", &d); err != nil {
472533 continue
473534 }
474535 st := &bedrockBlockState{}
@@ -489,12 +550,13 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
489550 } `json:"toolUse"`
490551 } `json:"delta"`
491552 }
492- if err := json.Unmarshal(evt.payload, &d); err != nil {
553+ if err := unmarshalBedrockEventPayload(evt.payload, "contentBlockDelta", &d); err != nil {
493554 continue
494555 }
495556 st := contentBlocks[d.ContentBlockIndex]
496557 if st == nil {
497- continue
558+ st = &bedrockBlockState{}
559+ contentBlocks[d.ContentBlockIndex] = st
498560 }
499561 if d.Delta.Text != "" {
500562 st.text.WriteString(d.Delta.Text)
@@ -508,7 +570,7 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
508570 var d struct {
509571 ContentBlockIndex int `json:"contentBlockIndex"`
510572 }
511- if err := json.Unmarshal(evt.payload, &d); err != nil {
573+ if err := unmarshalBedrockEventPayload(evt.payload, "contentBlockStop", &d); err != nil {
512574 continue
513575 }
514576 st := contentBlocks[d.ContentBlockIndex]
@@ -531,7 +593,7 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
531593 var d struct {
532594 StopReason string `json:"stopReason"`
533595 }
534- _ = json.Unmarshal(evt.payload, &d)
596+ _ = unmarshalBedrockEventPayload(evt.payload, "messageStop", &d)
535597 switch d.StopReason {
536598 case "tool_use":
537599 stop = StopToolUse
@@ -551,7 +613,7 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
551613 CacheWriteInputTokens int `json:"cacheWriteInputTokens"`
552614 } `json:"usage"`
553615 }
554- if err := json.Unmarshal(evt.payload, &d); err == nil {
616+ if err := unmarshalBedrockEventPayload(evt.payload, "metadata", &d); err == nil {
555617 usage.InputTokens = d.Usage.InputTokens
556618 usage.OutputTokens = d.Usage.OutputTokens
557619 usage.CacheReadTokens = d.Usage.CacheReadInputTokens
@@ -574,6 +636,32 @@ type bedrockBlockState struct {
574636 text strings.Builder
575637 }
576638
639+func bedrockEventTypeFromPayload(payload []byte) string {
640+ var outer map[string]json.RawMessage
641+ if err := json.Unmarshal(payload, &outer); err != nil {
642+ return ""
643+ }
644+ for _, name := range []string{"messageStart", "contentBlockStart", "contentBlockDelta", "contentBlockStop", "messageStop", "metadata"} {
645+ if _, ok := outer[name]; ok {
646+ return name
647+ }
648+ }
649+ return ""
650+}
651+
652+func unmarshalBedrockEventPayload(payload []byte, eventType string, dst any) error {
653+ var outer map[string]json.RawMessage
654+ if err := json.Unmarshal(payload, &outer); err == nil {
655+ if wrapped, ok := outer[eventType]; ok {
656+ return json.Unmarshal(wrapped, dst)
657+ }
658+ }
659+ if err := json.Unmarshal(payload, dst); err != nil {
660+ return fmt.Errorf("bedrock: parse %s payload: %w", eventType, err)
661+ }
662+ return nil
663+}
664+
577665 // ---- event-stream binary framing parser ----
578666
579667 type eventStreamMessage struct {
@@ -216,6 +216,61 @@ type bedrockRequest struct {
216 } `json:"toolConfig,omitempty"`216 } `json:"toolConfig,omitempty"`
217 }217 }
218 218
219+func normalizeBedrockToolResults(msgs []Message) []Message {
220+ resultByID := map[string]ToolResultBlock{}
221+ for _, m := range msgs {
222+ for _, c := range m.Content {
223+ if tr, ok := c.(ToolResultBlock); ok {
224+ if _, exists := resultByID[tr.CallID]; !exists {
225+ resultByID[tr.CallID] = tr
226+ }
227+ }
228+ }
229+ }
230+
231+ out := make([]Message, 0, len(msgs))
232+ for _, m := range msgs {
233+ copy := m
234+ copy.Content = nil
235+ var toolCalls []ToolCallBlock
236+ for _, c := range m.Content {
237+ switch v := c.(type) {
238+ case ToolResultBlock:
239+ // Bedrock requires toolResult blocks immediately after the
240+ // assistant toolUse they answer. Reinsert them from the
241+ // assistant pass below instead of preserving their original
242+ // location, which may be separated by user text in active
243+ // sessions.
244+ continue
245+ case ToolCallBlock:
246+ toolCalls = append(toolCalls, v)
247+ }
248+ copy.Content = append(copy.Content, c)
249+ }
250+ if len(copy.Content) > 0 {
251+ out = append(out, copy)
252+ }
253+ if m.Role != RoleAssistant || len(toolCalls) == 0 {
254+ continue
255+ }
256+
257+ results := make([]Content, 0, len(toolCalls))
258+ for _, tc := range toolCalls {
259+ if tr, ok := resultByID[tc.ID]; ok {
260+ results = append(results, tr)
261+ continue
262+ }
263+ results = append(results, ToolResultBlock{
264+ CallID: tc.ID,
265+ IsError: true,
266+ Content: []Content{TextBlock{Text: "tool call did not complete before the next user message"}},
267+ })
268+ }
269+ out = append(out, Message{Role: RoleTool, Content: results, Time: m.Time})
270+ }
271+ return out
272+}
273+
219 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {274 func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
220 out := &bedrockRequest{}275 out := &bedrockRequest{}
221 if req.System != "" {276 if req.System != "" {
@@ -226,7 +281,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
226 if out.InferenceConfig.MaxTokens == 0 {281 if out.InferenceConfig.MaxTokens == 0 {
227 out.InferenceConfig.MaxTokens = 4096282 out.InferenceConfig.MaxTokens = 4096
228 }283 }
229- for _, m := range req.Messages {284+ for _, m := range normalizeBedrockToolResults(req.Messages) {
230 role := string(m.Role)285 role := string(m.Role)
231 if role == "tool" {286 if role == "tool" {
232 role = "user"287 role = "user"
@@ -267,6 +322,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
267 })322 })
268 }323 }
269 }324 }
325+ if len(bm.Content) == 0 {
326+ continue
327+ }
270 out.Messages = append(out.Messages, bm)328 out.Messages = append(out.Messages, bm)
271 }329 }
272 if len(req.Tools) > 0 {330 if len(req.Tools) > 0 {
@@ -450,6 +508,9 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
450 return508 return
451 }509 }
452 eventType := evt.headerString(":event-type")510 eventType := evt.headerString(":event-type")
511+ if eventType == "" {
512+ eventType = bedrockEventTypeFromPayload(evt.payload)
513+ }
453 messageType := evt.headerString(":message-type")514 messageType := evt.headerString(":message-type")
454 if messageType == "exception" {515 if messageType == "exception" {
455 out <- EventDone{Stop: StopError, Err: fmt.Errorf("bedrock exception (%s): %s", evt.headerString(":exception-type"), string(evt.payload)), Message: finalMsg}516 out <- EventDone{Stop: StopError, Err: fmt.Errorf("bedrock exception (%s): %s", evt.headerString(":exception-type"), string(evt.payload)), Message: finalMsg}
@@ -468,7 +529,7 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
468 } `json:"toolUse"`529 } `json:"toolUse"`
469 } `json:"start"`530 } `json:"start"`
470 }531 }
471- if err := json.Unmarshal(evt.payload, &d); err != nil {532+ if err := unmarshalBedrockEventPayload(evt.payload, "contentBlockStart", &d); err != nil {
472 continue533 continue
473 }534 }
474 st := &bedrockBlockState{}535 st := &bedrockBlockState{}
@@ -489,12 +550,13 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
489 } `json:"toolUse"`550 } `json:"toolUse"`
490 } `json:"delta"`551 } `json:"delta"`
491 }552 }
492- if err := json.Unmarshal(evt.payload, &d); err != nil {553+ if err := unmarshalBedrockEventPayload(evt.payload, "contentBlockDelta", &d); err != nil {
493 continue554 continue
494 }555 }
495 st := contentBlocks[d.ContentBlockIndex]556 st := contentBlocks[d.ContentBlockIndex]
496 if st == nil {557 if st == nil {
497- continue558+ st = &bedrockBlockState{}
559+ contentBlocks[d.ContentBlockIndex] = st
498 }560 }
499 if d.Delta.Text != "" {561 if d.Delta.Text != "" {
500 st.text.WriteString(d.Delta.Text)562 st.text.WriteString(d.Delta.Text)
@@ -508,7 +570,7 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
508 var d struct {570 var d struct {
509 ContentBlockIndex int `json:"contentBlockIndex"`571 ContentBlockIndex int `json:"contentBlockIndex"`
510 }572 }
511- if err := json.Unmarshal(evt.payload, &d); err != nil {573+ if err := unmarshalBedrockEventPayload(evt.payload, "contentBlockStop", &d); err != nil {
512 continue574 continue
513 }575 }
514 st := contentBlocks[d.ContentBlockIndex]576 st := contentBlocks[d.ContentBlockIndex]
@@ -531,7 +593,7 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
531 var d struct {593 var d struct {
532 StopReason string `json:"stopReason"`594 StopReason string `json:"stopReason"`
533 }595 }
534- _ = json.Unmarshal(evt.payload, &d)596+ _ = unmarshalBedrockEventPayload(evt.payload, "messageStop", &d)
535 switch d.StopReason {597 switch d.StopReason {
536 case "tool_use":598 case "tool_use":
537 stop = StopToolUse599 stop = StopToolUse
@@ -551,7 +613,7 @@ func (c *bedrockClient) runStream(ctx context.Context, resp *http.Response, req
551 CacheWriteInputTokens int `json:"cacheWriteInputTokens"`613 CacheWriteInputTokens int `json:"cacheWriteInputTokens"`
552 } `json:"usage"`614 } `json:"usage"`
553 }615 }
554- if err := json.Unmarshal(evt.payload, &d); err == nil {616+ if err := unmarshalBedrockEventPayload(evt.payload, "metadata", &d); err == nil {
555 usage.InputTokens = d.Usage.InputTokens617 usage.InputTokens = d.Usage.InputTokens
556 usage.OutputTokens = d.Usage.OutputTokens618 usage.OutputTokens = d.Usage.OutputTokens
557 usage.CacheReadTokens = d.Usage.CacheReadInputTokens619 usage.CacheReadTokens = d.Usage.CacheReadInputTokens
@@ -574,6 +636,32 @@ type bedrockBlockState struct {
574 text strings.Builder636 text strings.Builder
575 }637 }
576 638
639+func bedrockEventTypeFromPayload(payload []byte) string {
640+ var outer map[string]json.RawMessage
641+ if err := json.Unmarshal(payload, &outer); err != nil {
642+ return ""
643+ }
644+ for _, name := range []string{"messageStart", "contentBlockStart", "contentBlockDelta", "contentBlockStop", "messageStop", "metadata"} {
645+ if _, ok := outer[name]; ok {
646+ return name
647+ }
648+ }
649+ return ""
650+}
651+
652+func unmarshalBedrockEventPayload(payload []byte, eventType string, dst any) error {
653+ var outer map[string]json.RawMessage
654+ if err := json.Unmarshal(payload, &outer); err == nil {
655+ if wrapped, ok := outer[eventType]; ok {
656+ return json.Unmarshal(wrapped, dst)
657+ }
658+ }
659+ if err := json.Unmarshal(payload, dst); err != nil {
660+ return fmt.Errorf("bedrock: parse %s payload: %w", eventType, err)
661+ }
662+ return nil
663+}
664+
577 // ---- event-stream binary framing parser ----665 // ---- event-stream binary framing parser ----
578 666
579 type eventStreamMessage struct {667 type eventStreamMessage struct {
M packages/provider/amazon_bedrock_test.go +93 -0
@@ -2,6 +2,7 @@ package provider
22
33 import (
44 "bytes"
5+ "encoding/json"
56 "net/http"
67 "strings"
78 "testing"
@@ -82,6 +83,98 @@ func TestReadAWSCredentialsFile(t *testing.T) {
8283 }
8384 }
8485
86+func TestNormalizeBedrockToolResultsMovesResultsAdjacentToToolUse(t *testing.T) {
87+ msgs := []Message{
88+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "tool-1", Name: "edit", Arguments: json.RawMessage(`{"path":"a"}`)}}},
89+ {Role: RoleUser, Content: []Content{TextBlock{Text: "what's wrong"}}},
90+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "tool-1", Content: []Content{TextBlock{Text: "edited"}}}}},
91+ }
92+
93+ out := normalizeBedrockToolResults(msgs)
94+ if len(out) != 3 {
95+ t.Fatalf("got %d messages, want 3: %+v", len(out), out)
96+ }
97+ if out[0].Role != RoleAssistant {
98+ t.Fatalf("first role = %s, want assistant", out[0].Role)
99+ }
100+ if out[1].Role != RoleTool {
101+ t.Fatalf("second role = %s, want tool", out[1].Role)
102+ }
103+ tr, ok := out[1].Content[0].(ToolResultBlock)
104+ if !ok {
105+ t.Fatalf("second message content = %T, want ToolResultBlock", out[1].Content[0])
106+ }
107+ if tr.CallID != "tool-1" || tr.IsError {
108+ t.Fatalf("unexpected tool result: %+v", tr)
109+ }
110+ if out[2].Role != RoleUser {
111+ t.Fatalf("third role = %s, want user", out[2].Role)
112+ }
113+}
114+
115+func TestNormalizeBedrockToolResultsInjectsMissingResult(t *testing.T) {
116+ msgs := []Message{
117+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "tool-1", Name: "edit", Arguments: json.RawMessage(`{}`)}}},
118+ {Role: RoleUser, Content: []Content{TextBlock{Text: "what's wrong"}}},
119+ }
120+
121+ out := normalizeBedrockToolResults(msgs)
122+ if len(out) != 3 {
123+ t.Fatalf("got %d messages, want 3: %+v", len(out), out)
124+ }
125+ tr, ok := out[1].Content[0].(ToolResultBlock)
126+ if !ok {
127+ t.Fatalf("second message content = %T, want ToolResultBlock", out[1].Content[0])
128+ }
129+ if tr.CallID != "tool-1" || !tr.IsError {
130+ t.Fatalf("unexpected synthetic result: %+v", tr)
131+ }
132+}
133+
134+func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {
135+ client := &bedrockClient{}
136+ req, err := client.buildRequest(Request{Messages: []Message{
137+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "missing", Content: []Content{TextBlock{Text: "orphan"}}}}},
138+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
139+ }})
140+ if err != nil {
141+ t.Fatal(err)
142+ }
143+ if len(req.Messages) != 1 {
144+ t.Fatalf("got %d bedrock messages, want 1: %+v", len(req.Messages), req.Messages)
145+ }
146+ if req.Messages[0].Role != "user" {
147+ t.Fatalf("role = %s, want user", req.Messages[0].Role)
148+ }
149+}
150+
151+func TestBedrockEventPayloadHelpers(t *testing.T) {
152+ wrapped := []byte(`{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"text":"Hello"}}}`)
153+ if got := bedrockEventTypeFromPayload(wrapped); got != "contentBlockDelta" {
154+ t.Fatalf("event type = %q, want contentBlockDelta", got)
155+ }
156+ var delta struct {
157+ ContentBlockIndex int `json:"contentBlockIndex"`
158+ Delta struct {
159+ Text string `json:"text"`
160+ } `json:"delta"`
161+ }
162+ if err := unmarshalBedrockEventPayload(wrapped, "contentBlockDelta", &delta); err != nil {
163+ t.Fatal(err)
164+ }
165+ if delta.ContentBlockIndex != 0 || delta.Delta.Text != "Hello" {
166+ t.Fatalf("unexpected wrapped delta: %+v", delta)
167+ }
168+
169+ direct := []byte(`{"contentBlockIndex":1,"delta":{"text":"world"}}`)
170+ if err := unmarshalBedrockEventPayload(direct, "contentBlockDelta", &delta); err != nil {
171+ t.Fatal(err)
172+ }
173+ if delta.ContentBlockIndex != 1 || delta.Delta.Text != "world" {
174+ t.Fatalf("unexpected direct delta: %+v", delta)
175+ }
176+}
177+
85178 func TestResolveBedrockInferenceProfileID(t *testing.T) {
86179 cases := []struct {
87180 model string
@@ -2,6 +2,7 @@ package provider
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "encoding/json"
5 "net/http"6 "net/http"
6 "strings"7 "strings"
7 "testing"8 "testing"
@@ -82,6 +83,98 @@ func TestReadAWSCredentialsFile(t *testing.T) {
82 }83 }
83 }84 }
84 85
86+func TestNormalizeBedrockToolResultsMovesResultsAdjacentToToolUse(t *testing.T) {
87+ msgs := []Message{
88+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "tool-1", Name: "edit", Arguments: json.RawMessage(`{"path":"a"}`)}}},
89+ {Role: RoleUser, Content: []Content{TextBlock{Text: "what's wrong"}}},
90+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "tool-1", Content: []Content{TextBlock{Text: "edited"}}}}},
91+ }
92+
93+ out := normalizeBedrockToolResults(msgs)
94+ if len(out) != 3 {
95+ t.Fatalf("got %d messages, want 3: %+v", len(out), out)
96+ }
97+ if out[0].Role != RoleAssistant {
98+ t.Fatalf("first role = %s, want assistant", out[0].Role)
99+ }
100+ if out[1].Role != RoleTool {
101+ t.Fatalf("second role = %s, want tool", out[1].Role)
102+ }
103+ tr, ok := out[1].Content[0].(ToolResultBlock)
104+ if !ok {
105+ t.Fatalf("second message content = %T, want ToolResultBlock", out[1].Content[0])
106+ }
107+ if tr.CallID != "tool-1" || tr.IsError {
108+ t.Fatalf("unexpected tool result: %+v", tr)
109+ }
110+ if out[2].Role != RoleUser {
111+ t.Fatalf("third role = %s, want user", out[2].Role)
112+ }
113+}
114+
115+func TestNormalizeBedrockToolResultsInjectsMissingResult(t *testing.T) {
116+ msgs := []Message{
117+ {Role: RoleAssistant, Content: []Content{ToolCallBlock{ID: "tool-1", Name: "edit", Arguments: json.RawMessage(`{}`)}}},
118+ {Role: RoleUser, Content: []Content{TextBlock{Text: "what's wrong"}}},
119+ }
120+
121+ out := normalizeBedrockToolResults(msgs)
122+ if len(out) != 3 {
123+ t.Fatalf("got %d messages, want 3: %+v", len(out), out)
124+ }
125+ tr, ok := out[1].Content[0].(ToolResultBlock)
126+ if !ok {
127+ t.Fatalf("second message content = %T, want ToolResultBlock", out[1].Content[0])
128+ }
129+ if tr.CallID != "tool-1" || !tr.IsError {
130+ t.Fatalf("unexpected synthetic result: %+v", tr)
131+ }
132+}
133+
134+func TestBedrockBuildRequestSkipsEmptyToolMessages(t *testing.T) {
135+ client := &bedrockClient{}
136+ req, err := client.buildRequest(Request{Messages: []Message{
137+ {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "missing", Content: []Content{TextBlock{Text: "orphan"}}}}},
138+ {Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}},
139+ }})
140+ if err != nil {
141+ t.Fatal(err)
142+ }
143+ if len(req.Messages) != 1 {
144+ t.Fatalf("got %d bedrock messages, want 1: %+v", len(req.Messages), req.Messages)
145+ }
146+ if req.Messages[0].Role != "user" {
147+ t.Fatalf("role = %s, want user", req.Messages[0].Role)
148+ }
149+}
150+
151+func TestBedrockEventPayloadHelpers(t *testing.T) {
152+ wrapped := []byte(`{"contentBlockDelta":{"contentBlockIndex":0,"delta":{"text":"Hello"}}}`)
153+ if got := bedrockEventTypeFromPayload(wrapped); got != "contentBlockDelta" {
154+ t.Fatalf("event type = %q, want contentBlockDelta", got)
155+ }
156+ var delta struct {
157+ ContentBlockIndex int `json:"contentBlockIndex"`
158+ Delta struct {
159+ Text string `json:"text"`
160+ } `json:"delta"`
161+ }
162+ if err := unmarshalBedrockEventPayload(wrapped, "contentBlockDelta", &delta); err != nil {
163+ t.Fatal(err)
164+ }
165+ if delta.ContentBlockIndex != 0 || delta.Delta.Text != "Hello" {
166+ t.Fatalf("unexpected wrapped delta: %+v", delta)
167+ }
168+
169+ direct := []byte(`{"contentBlockIndex":1,"delta":{"text":"world"}}`)
170+ if err := unmarshalBedrockEventPayload(direct, "contentBlockDelta", &delta); err != nil {
171+ t.Fatal(err)
172+ }
173+ if delta.ContentBlockIndex != 1 || delta.Delta.Text != "world" {
174+ t.Fatalf("unexpected direct delta: %+v", delta)
175+ }
176+}
177+
85 func TestResolveBedrockInferenceProfileID(t *testing.T) {178 func TestResolveBedrockInferenceProfileID(t *testing.T) {
86 cases := []struct {179 cases := []struct {
87 model string180 model string
M packages/provider/catalog_builtin.go +2 -0
@@ -792,6 +792,8 @@ var builtinCatalog = []Model{
792792 {Provider: "openrouter", ID: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", DisplayName: "NVIDIA: Nemotron 3 Nano Omni (free)", ContextWindow: 256000, MaxOutput: 65536, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
793793 {Provider: "openrouter", ID: "nvidia/nemotron-3-super-120b-a12b", DisplayName: "NVIDIA: Nemotron 3 Super", ContextWindow: 1000000, MaxOutput: 4096, Reasoning: true, PriceInput: 0.09, PriceOutput: 0.45, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
794794 {Provider: "openrouter", ID: "nvidia/nemotron-3-super-120b-a12b:free", DisplayName: "NVIDIA: Nemotron 3 Super (free)", ContextWindow: 1000000, MaxOutput: 262144, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
795+ {Provider: "openrouter", ID: "nvidia/nemotron-3-ultra-550b-a55b", DisplayName: "NVIDIA: Nemotron 3 Ultra", ContextWindow: 1000000, MaxOutput: 16384, Reasoning: true, PriceInput: 0.5, PriceOutput: 2.5, PriceCacheRead: 0.15, BaseURL: "https://openrouter.ai/api/v1"},
796+ {Provider: "openrouter", ID: "nvidia/nemotron-3-ultra-550b-a55b:free", DisplayName: "NVIDIA: Nemotron 3 Ultra (free)", ContextWindow: 1000000, MaxOutput: 65536, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
795797 {Provider: "openrouter", ID: "nvidia/nemotron-nano-12b-v2-vl:free", DisplayName: "NVIDIA: Nemotron Nano 12B 2 VL (free)", ContextWindow: 128000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
796798 {Provider: "openrouter", ID: "nvidia/nemotron-nano-9b-v2", DisplayName: "NVIDIA: Nemotron Nano 9B V2", ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, PriceInput: 0.04, PriceOutput: 0.16, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
797799 {Provider: "openrouter", ID: "nvidia/nemotron-nano-9b-v2:free", DisplayName: "NVIDIA: Nemotron Nano 9B V2 (free)", ContextWindow: 128000, MaxOutput: 4096, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
@@ -792,6 +792,8 @@ var builtinCatalog = []Model{
792 {Provider: "openrouter", ID: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", DisplayName: "NVIDIA: Nemotron 3 Nano Omni (free)", ContextWindow: 256000, MaxOutput: 65536, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},792 {Provider: "openrouter", ID: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free", DisplayName: "NVIDIA: Nemotron 3 Nano Omni (free)", ContextWindow: 256000, MaxOutput: 65536, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
793 {Provider: "openrouter", ID: "nvidia/nemotron-3-super-120b-a12b", DisplayName: "NVIDIA: Nemotron 3 Super", ContextWindow: 1000000, MaxOutput: 4096, Reasoning: true, PriceInput: 0.09, PriceOutput: 0.45, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},793 {Provider: "openrouter", ID: "nvidia/nemotron-3-super-120b-a12b", DisplayName: "NVIDIA: Nemotron 3 Super", ContextWindow: 1000000, MaxOutput: 4096, Reasoning: true, PriceInput: 0.09, PriceOutput: 0.45, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
794 {Provider: "openrouter", ID: "nvidia/nemotron-3-super-120b-a12b:free", DisplayName: "NVIDIA: Nemotron 3 Super (free)", ContextWindow: 1000000, MaxOutput: 262144, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},794 {Provider: "openrouter", ID: "nvidia/nemotron-3-super-120b-a12b:free", DisplayName: "NVIDIA: Nemotron 3 Super (free)", ContextWindow: 1000000, MaxOutput: 262144, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
795+ {Provider: "openrouter", ID: "nvidia/nemotron-3-ultra-550b-a55b", DisplayName: "NVIDIA: Nemotron 3 Ultra", ContextWindow: 1000000, MaxOutput: 16384, Reasoning: true, PriceInput: 0.5, PriceOutput: 2.5, PriceCacheRead: 0.15, BaseURL: "https://openrouter.ai/api/v1"},
796+ {Provider: "openrouter", ID: "nvidia/nemotron-3-ultra-550b-a55b:free", DisplayName: "NVIDIA: Nemotron 3 Ultra (free)", ContextWindow: 1000000, MaxOutput: 65536, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
795 {Provider: "openrouter", ID: "nvidia/nemotron-nano-12b-v2-vl:free", DisplayName: "NVIDIA: Nemotron Nano 12B 2 VL (free)", ContextWindow: 128000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},797 {Provider: "openrouter", ID: "nvidia/nemotron-nano-12b-v2-vl:free", DisplayName: "NVIDIA: Nemotron Nano 12B 2 VL (free)", ContextWindow: 128000, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
796 {Provider: "openrouter", ID: "nvidia/nemotron-nano-9b-v2", DisplayName: "NVIDIA: Nemotron Nano 9B V2", ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, PriceInput: 0.04, PriceOutput: 0.16, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},798 {Provider: "openrouter", ID: "nvidia/nemotron-nano-9b-v2", DisplayName: "NVIDIA: Nemotron Nano 9B V2", ContextWindow: 131072, MaxOutput: 16384, Reasoning: true, PriceInput: 0.04, PriceOutput: 0.16, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
797 {Provider: "openrouter", ID: "nvidia/nemotron-nano-9b-v2:free", DisplayName: "NVIDIA: Nemotron Nano 9B V2 (free)", ContextWindow: 128000, MaxOutput: 4096, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},799 {Provider: "openrouter", ID: "nvidia/nemotron-nano-9b-v2:free", DisplayName: "NVIDIA: Nemotron Nano 9B V2 (free)", ContextWindow: 128000, MaxOutput: 4096, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://openrouter.ai/api/v1"},
M packages/provider/models.go +0 -12
@@ -292,18 +292,6 @@ var Catalog = []Model{
292292 // ---- OpenAI Codex / ChatGPT subscription backend ----
293293 // Same model ids as the OpenAI family, but routed through the
294294 // ChatGPT Codex OAuth backend rather than api.openai.com.
295- {
296- Provider: "openai-codex", ID: "gpt-5.2", DisplayName: "GPT-5.2",
297- ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
298- PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
299- Speculative: true,
300- },
301- {
302- Provider: "openai-codex", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3 Codex",
303- ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
304- PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
305- Speculative: true,
306- },
307295 {
308296 Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark",
309297 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
@@ -292,18 +292,6 @@ var Catalog = []Model{
292 // ---- OpenAI Codex / ChatGPT subscription backend ----292 // ---- OpenAI Codex / ChatGPT subscription backend ----
293 // Same model ids as the OpenAI family, but routed through the293 // Same model ids as the OpenAI family, but routed through the
294 // ChatGPT Codex OAuth backend rather than api.openai.com.294 // ChatGPT Codex OAuth backend rather than api.openai.com.
295- {
296- Provider: "openai-codex", ID: "gpt-5.2", DisplayName: "GPT-5.2",
297- ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
298- PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
299- Speculative: true,
300- },
301- {
302- Provider: "openai-codex", ID: "gpt-5.3-codex", DisplayName: "GPT-5.3 Codex",
303- ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
304- PriceInput: 1.75, PriceOutput: 14, PriceCacheRead: 0.175,
305- Speculative: true,
306- },
307 {295 {
308 Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark",296 Provider: "openai-codex", ID: "gpt-5.3-codex-spark", DisplayName: "GPT-5.3 Codex Spark",
309 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,297 ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,

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