detached 7b0ddf3

Merge branch 'main' into feature/zotfile-local-runtime

commit
7b0ddf31482d4a49a099f05658cdbd38ad8eb386
author
Patric Eckhart · · 2026-07-12 11:23 UTC
committer
GitHub · · 2026-07-12 11:23 UTC
merge of
8c6874a + 92b87ec · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
43 files · +1439 -247
M README.md +14 -7
@@ -3,6 +3,12 @@
33 <img src="packages/provider/auth/assets/zot-logo.png" alt="zot coding agent harness" width="130" height="130" />
44 </a>
55 </div>
6+<br>
7+<p align="center">
8+ <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
9+ <img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
10+ <img alt="30+ providers" src="https://img.shields.io/badge/providers-30+-34E2EA">
11+</p>
612 <p align="center">
713 <a href="https://www.zot.sh">zot.sh</a>
814 </p>
@@ -73,9 +79,10 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
7379 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)
7480
7581 `$ZOT_HOME` defaults to:
76-- macOS: `~/Library/Application Support/zot`
77-- Linux: `$XDG_STATE_HOME/zot` or `~/.local/state/zot`
78-- Windows: `%LOCALAPPDATA%\zot`
82+- All platforms: `$XDG_STATE_HOME/zot` when `XDG_STATE_HOME` is set
83+- macOS fallback: `~/Library/Application Support/zot`
84+- Linux fallback: `~/.local/state/zot`
85+- Windows fallback: `%LOCALAPPDATA%\zot`
7986
8087 ### `/login` flow
8188
@@ -174,7 +181,7 @@ zot --help
174181 | `--insecure` | Skip TLS certificate verification for the explicit `--base-url` endpoint or a `baseUrl` defined for a user model in `models.json` (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |
175182 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
176183 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
177-| `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |
184+| `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set thinking level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |
178185 | `-c`, `--continue` | Resume the latest session for this cwd. |
179186 | `-r`, `--resume` | Pick a session to resume. |
180187 | `--session <path>` | Resume a specific session file. |
@@ -333,7 +340,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
333340 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
334341 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
335342 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
336-- **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.
343+- **thinking level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call.
337344 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).
338345 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
339346
@@ -392,7 +399,7 @@ No configuration is required — the candidate list is built dynamically from yo
392399
393400 ### Custom models
394401
395-Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries:
402+Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise the platform default above) to add models that aren't in the baked-in catalog or to override existing entries:
396403
397404 ```json
398405 {
@@ -530,7 +537,7 @@ Use `/login` and pick **api key** to paste an AI Studio key. zot probes `/v1beta
530537
531538 > **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).
532539
533-Reasoning levels (`--reasoning off|minimum|low|medium|high|maximum`, also configurable in `/settings` as **thinking level**) map differently per generation. Budget-based providers use roughly 1k/2k/8k/16k/32k thinking tokens for minimum/low/medium/high/maximum, with provider/model caps applied (Gemini 2.5 Pro caps at 32k; Flash at 24k). Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any "medium" or higher request. Effort-based OpenAI-compatible chat providers map minimum to `low`, low/medium directly, and high/maximum to `high`; the Codex/Responses backend maps maximum to `xhigh` where supported. `off` sends no reasoning config. 2.0-family Gemini models have no thinking config at all.
540+Reasoning levels (`--reasoning off|minimum|low|medium|high|xhigh|max`, also configurable in `/settings` as **thinking level**) map differently per generation. `max` is a distinct opt-in tier above `xhigh`. GPT-5.6 and adaptive-thinking Claude models receive native `max`; unsupported providers clamp it to their highest accepted effort. Budget-based providers retain their provider/model caps. Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any medium-or-higher request. `off` sends no reasoning config. Gemini 2.0 models have no thinking config.
534541
535542 You can add additional Gemini model IDs to `models.json` under the `google` provider.
536543
@@ -3,6 +3,12 @@
3 <img src="packages/provider/auth/assets/zot-logo.png" alt="zot coding agent harness" width="130" height="130" />3 <img src="packages/provider/auth/assets/zot-logo.png" alt="zot coding agent harness" width="130" height="130" />
4 </a>4 </a>
5 </div>5 </div>
6+<br>
7+<p align="center">
8+ <a href="LICENSE"><img alt="license" src="https://img.shields.io/badge/license-MIT-blue"></a>
9+ <img alt="Go 1.25+" src="https://img.shields.io/badge/Go-1.25+-00ADD8?logo=go&logoColor=white">
10+ <img alt="30+ providers" src="https://img.shields.io/badge/providers-30+-34E2EA">
11+</p>
6 <p align="center">12 <p align="center">
7 <a href="https://www.zot.sh">zot.sh</a>13 <a href="https://www.zot.sh">zot.sh</a>
8 </p>14 </p>
@@ -73,9 +79,10 @@ The easiest way is to just run `zot` and type `/login`. The TUI opens even witho
73 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)79 3. `$ZOT_HOME/auth.json` (API key or OAuth token; mode 0600)
74 80
75 `$ZOT_HOME` defaults to:81 `$ZOT_HOME` defaults to:
76-- macOS: `~/Library/Application Support/zot`82+- All platforms: `$XDG_STATE_HOME/zot` when `XDG_STATE_HOME` is set
77-- Linux: `$XDG_STATE_HOME/zot` or `~/.local/state/zot`83+- macOS fallback: `~/Library/Application Support/zot`
78-- Windows: `%LOCALAPPDATA%\zot`84+- Linux fallback: `~/.local/state/zot`
85+- Windows fallback: `%LOCALAPPDATA%\zot`
79 86
80 ### `/login` flow87 ### `/login` flow
81 88
@@ -174,7 +181,7 @@ zot --help
174 | `--insecure` | Skip TLS certificate verification for the explicit `--base-url` endpoint or a `baseUrl` defined for a user model in `models.json` (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |181 | `--insecure` | Skip TLS certificate verification for the explicit `--base-url` endpoint or a `baseUrl` defined for a user model in `models.json` (self-signed local/internal inference servers). Built-in providers, auth, and model discovery keep normal TLS verification. |
175 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |182 | `--system-prompt <text>` | Replace the default system prompt for this run (also overrides `$ZOT_HOME/SYSTEM.md`). |
176 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |183 | `--append-system-prompt <text>` | Append text to the system prompt (repeatable). |
177-| `--reasoning off\|minimum\|low\|medium\|high\|maximum` | Set thinking level on supported models (default: off). |184+| `--reasoning off\|minimum\|low\|medium\|high\|xhigh\|max` | Set thinking level on supported models (default: off). `max` is a separate opt-in tier above `xhigh`. |
178 | `-c`, `--continue` | Resume the latest session for this cwd. |185 | `-c`, `--continue` | Resume the latest session for this cwd. |
179 | `-r`, `--resume` | Pick a session to resume. |186 | `-r`, `--resume` | Pick a session to resume. |
180 | `--session <path>` | Resume a specific session file. |187 | `--session <path>` | Resume a specific session file. |
@@ -333,7 +340,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
333 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.340 - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents.
334 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.341 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
335 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).342 - **TUI settings**: opens a sub-view for input layout and status placement. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, and `tui_working_position` (`above_input` or `below_input` for the position fields).
336-- **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call.343+- **thinking level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call.
337 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).344 - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md).
338 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.345 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
339 346
@@ -392,7 +399,7 @@ No configuration is required — the candidate list is built dynamically from yo
392 399
393 ### Custom models400 ### Custom models
394 401
395-Place a `models.json` in `$ZOT_HOME` (macOS: `~/Library/Application Support/zot/`, Linux: `~/.local/state/zot/`) to add models that aren't in the baked-in catalog or to override existing entries:402+Place a `models.json` in `$ZOT_HOME` (`$XDG_STATE_HOME/zot/` when set, otherwise the platform default above) to add models that aren't in the baked-in catalog or to override existing entries:
396 403
397 ```json404 ```json
398 {405 {
@@ -530,7 +537,7 @@ Use `/login` and pick **api key** to paste an AI Studio key. zot probes `/v1beta
530 537
531 > **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).538 > **Free-tier rate limits.** AI Studio's free tier has tight per-minute and per-day caps that vary by model: `gemini-2.5-pro` is the strictest (a few requests per minute, ~50 per day), Flash and Flash-Lite are far more generous. If a Pro turn 429s with `"You exceeded your current quota"` while Flash on the same key still works, you've hit the Pro free-tier RPD. Either switch to Flash for agent loops, or [enable billing](https://aistudio.google.com/app/apikey) on your AI Studio project to flip the same key from free to pay-as-you-go pricing (`$1.25/M` input, `$10/M` output for Pro).
532 539
533-Reasoning levels (`--reasoning off|minimum|low|medium|high|maximum`, also configurable in `/settings` as **thinking level**) map differently per generation. Budget-based providers use roughly 1k/2k/8k/16k/32k thinking tokens for minimum/low/medium/high/maximum, with provider/model caps applied (Gemini 2.5 Pro caps at 32k; Flash at 24k). Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any "medium" or higher request. Effort-based OpenAI-compatible chat providers map minimum to `low`, low/medium directly, and high/maximum to `high`; the Codex/Responses backend maps maximum to `xhigh` where supported. `off` sends no reasoning config. 2.0-family Gemini models have no thinking config at all.540+Reasoning levels (`--reasoning off|minimum|low|medium|high|xhigh|max`, also configurable in `/settings` as **thinking level**) map differently per generation. `max` is a distinct opt-in tier above `xhigh`. GPT-5.6 and adaptive-thinking Claude models receive native `max`; unsupported providers clamp it to their highest accepted effort. Budget-based providers retain their provider/model caps. Gemini 3.x uses the `thinkingLevel` enum (`MINIMAL`/`LOW`/`MEDIUM`/`HIGH`), with Gemini-3-Pro pinned to `LOW` minimum and `HIGH` for any medium-or-higher request. `off` sends no reasoning config. Gemini 2.0 models have no thinking config.
534 541
535 You can add additional Gemini model IDs to `models.json` under the `google` provider.542 You can add additional Gemini model IDs to `models.json` under the `google` provider.
536 543
M docs/extensions.md +3 -2
@@ -97,8 +97,9 @@ zot scans two directories on startup, in this order:
9797 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json`
9898
9999 A project-local extension with the same name wins over a global one.
100-On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`;
101-on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`.
100+When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
101+`$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
102+on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
102103
103104 Because each extension owns its own directory, the recommended place
104105 for extension state is inside that directory itself (for example
@@ -97,8 +97,9 @@ zot scans two directories on startup, in this order:
97 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json`97 2. **Global**: `$ZOT_HOME/extensions/<name>/extension.json`
98 98
99 A project-local extension with the same name wins over a global one.99 A project-local extension with the same name wins over a global one.
100-On macOS `$ZOT_HOME` defaults to `~/Library/Application Support/zot/`;100+When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
101-on Linux it's `$XDG_STATE_HOME/zot` or `~/.local/state/zot`.101+`$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
102+on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
102 103
103 Because each extension owns its own directory, the recommended place104 Because each extension owns its own directory, the recommended place
104 for extension state is inside that directory itself (for example105 for extension state is inside that directory itself (for example
M docs/providers.md +7 -3
@@ -162,12 +162,16 @@ zot --provider cloudflare-workers-ai
162162 ```bash
163163 export AZURE_OPENAI_API_KEY=...
164164 export AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com
165-export AZURE_OPENAI_API_VERSION=2024-02-01 # optional
165+export AZURE_OPENAI_API_VERSION=v1 # optional, v1 is the default
166166 zot --provider azure-openai-responses
167167 ```
168168
169-If your Azure deployment names differ from zot model IDs, add model overrides
170-in `$ZOT_HOME/models.json`.
169+The provider uses Azure's Responses API. If deployment names differ from zot
170+model IDs, map them without changing the catalog:
171+
172+```bash
173+export AZURE_OPENAI_DEPLOYMENT_NAME_MAP='gpt-5.6-luna=luna-preview,gpt-5.6-sol=sol-preview'
174+```
171175
172176 ## Auth file
173177
@@ -162,12 +162,16 @@ zot --provider cloudflare-workers-ai
162 ```bash162 ```bash
163 export AZURE_OPENAI_API_KEY=...163 export AZURE_OPENAI_API_KEY=...
164 export AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com164 export AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com
165-export AZURE_OPENAI_API_VERSION=2024-02-01 # optional165+export AZURE_OPENAI_API_VERSION=v1 # optional, v1 is the default
166 zot --provider azure-openai-responses166 zot --provider azure-openai-responses
167 ```167 ```
168 168
169-If your Azure deployment names differ from zot model IDs, add model overrides169+The provider uses Azure's Responses API. If deployment names differ from zot
170-in `$ZOT_HOME/models.json`.170+model IDs, map them without changing the catalog:
171+
172+```bash
173+export AZURE_OPENAI_DEPLOYMENT_NAME_MAP='gpt-5.6-luna=luna-preview,gpt-5.6-sol=sol-preview'
174+```
171 175
172 ## Auth file176 ## Auth file
173 177
M docs/rpc.md +10 -0
@@ -153,6 +153,16 @@ Switch model within the same provider.
153153
154154 Cross-provider swaps require relaunching `zot rpc` with the new `--provider`.
155155
156+### `set_reasoning`
157+
158+Set the thinking level for subsequent prompts without restarting the process. Accepted values are `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, and `max`.
159+
160+```json
161+{"id":"8","type":"set_reasoning","reasoning":"max"}
162+```
163+
164+The `max` tier is sent natively to supported models and clamped for other backends.
165+
156166 ### `get_models`
157167
158168 List models known for the current provider.
@@ -153,6 +153,16 @@ Switch model within the same provider.
153 153
154 Cross-provider swaps require relaunching `zot rpc` with the new `--provider`.154 Cross-provider swaps require relaunching `zot rpc` with the new `--provider`.
155 155
156+### `set_reasoning`
157+
158+Set the thinking level for subsequent prompts without restarting the process. Accepted values are `off`, `minimum`, `low`, `medium`, `high`, `xhigh`, and `max`.
159+
160+```json
161+{"id":"8","type":"set_reasoning","reasoning":"max"}
162+```
163+
164+The `max` tier is sent natively to supported models and clamped for other backends.
165+
156 ### `get_models`166 ### `get_models`
157 167
158 List models known for the current provider.168 List models known for the current provider.
M docs/skills.md +3 -2
@@ -64,8 +64,9 @@ skill ecosystem works in zot unchanged. Drop your existing
6464 `.claude/skills/` or `.agents/skills/` directories into a project and
6565 zot will pick them up.
6666
67-`$ZOT_HOME` defaults to `~/Library/Application Support/zot/` on macOS,
68-`$XDG_STATE_HOME/zot` on Linux, `%LOCALAPPDATA%\zot` on Windows.
67+When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
68+`$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
69+on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
6970
7071 ## Inspecting installed skills
7172
@@ -64,8 +64,9 @@ skill ecosystem works in zot unchanged. Drop your existing
64 `.claude/skills/` or `.agents/skills/` directories into a project and64 `.claude/skills/` or `.agents/skills/` directories into a project and
65 zot will pick them up.65 zot will pick them up.
66 66
67-`$ZOT_HOME` defaults to `~/Library/Application Support/zot/` on macOS,67+When `XDG_STATE_HOME` is set on any platform, `$ZOT_HOME` defaults to
68-`$XDG_STATE_HOME/zot` on Linux, `%LOCALAPPDATA%\zot` on Windows.68+`$XDG_STATE_HOME/zot`. Otherwise it defaults to `~/Library/Application Support/zot/`
69+on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
69 70
70 ## Inspecting installed skills71 ## Inspecting installed skills
71 72
M docs/themes.md +1 -0
@@ -191,6 +191,7 @@ Most color fields are xterm-256 indexes (`0`–`255`).
191191 - `error` — errors, refused calls, diff deletions.
192192 - `warning` — warnings and high context-usage state.
193193 - `spinner` — reserved spinner color slot.
194+- `thinkingMax` or `thinking_max` — status color used when the opt-in `max` thinking level is active. Missing values inherit the built-in theme color.
194195 - `selection_bg` — highlighted row background.
195196 - `selection_fg` — highlighted row foreground.
196197
@@ -191,6 +191,7 @@ Most color fields are xterm-256 indexes (`0`–`255`).
191 - `error` — errors, refused calls, diff deletions.191 - `error` — errors, refused calls, diff deletions.
192 - `warning` — warnings and high context-usage state.192 - `warning` — warnings and high context-usage state.
193 - `spinner` — reserved spinner color slot.193 - `spinner` — reserved spinner color slot.
194+- `thinkingMax` or `thinking_max` — status color used when the opt-in `max` thinking level is active. Missing values inherit the built-in theme color.
194 - `selection_bg` — highlighted row background.195 - `selection_bg` — highlighted row background.
195 - `selection_fg` — highlighted row foreground.196 - `selection_fg` — highlighted row foreground.
196 197
M examples/extensions/mcp-bridge/Makefile +3 -3
@@ -4,10 +4,10 @@ BIN := $(notdir $(CURDIR))
44 # Must match "name" in extension.json (slash command/logical extension name).
55 EXT := mcp
66 ZOT_HOME ?= $(shell \
7- if [ "$$(uname -s)" = "Darwin" ]; then \
8- printf '%s/Library/Application Support/zot' "$$HOME"; \
9- elif [ -n "$$XDG_STATE_HOME" ]; then \
7+ if [ -n "$$XDG_STATE_HOME" ]; then \
108 printf '%s/zot' "$$XDG_STATE_HOME"; \
9+ elif [ "$$(uname -s)" = "Darwin" ]; then \
10+ printf '%s/Library/Application Support/zot' "$$HOME"; \
1111 else \
1212 printf '%s/.local/state/zot' "$$HOME"; \
1313 fi)
@@ -4,10 +4,10 @@ BIN := $(notdir $(CURDIR))
4 # Must match "name" in extension.json (slash command/logical extension name).4 # Must match "name" in extension.json (slash command/logical extension name).
5 EXT := mcp5 EXT := mcp
6 ZOT_HOME ?= $(shell \6 ZOT_HOME ?= $(shell \
7- if [ "$$(uname -s)" = "Darwin" ]; then \7+ if [ -n "$$XDG_STATE_HOME" ]; then \
8- printf '%s/Library/Application Support/zot' "$$HOME"; \
9- elif [ -n "$$XDG_STATE_HOME" ]; then \
10 printf '%s/zot' "$$XDG_STATE_HOME"; \8 printf '%s/zot' "$$XDG_STATE_HOME"; \
9+ elif [ "$$(uname -s)" = "Darwin" ]; then \
10+ printf '%s/Library/Application Support/zot' "$$HOME"; \
11 else \11 else \
12 printf '%s/.local/state/zot' "$$HOME"; \12 printf '%s/.local/state/zot' "$$HOME"; \
13 fi)13 fi)
M examples/extensions/mcp-bridge/README.md +1 -1
@@ -61,7 +61,7 @@ Config files are loaded from two locations (project overrides global per-server)
6161
6262 | Location | Scope |
6363 |---|---|
64-| `$ZOT_HOME/mcp.json` | Global (macOS: `~/Library/Application Support/zot/mcp.json`) |
64+| `$ZOT_HOME/mcp.json` | Global (`$XDG_STATE_HOME/zot/mcp.json` when `XDG_STATE_HOME` is set) |
6565 | `.zot/mcp.json` | Project-level (in your project root) |
6666
6767 ### Config Format
@@ -61,7 +61,7 @@ Config files are loaded from two locations (project overrides global per-server)
61 61
62 | Location | Scope |62 | Location | Scope |
63 |---|---|63 |---|---|
64-| `$ZOT_HOME/mcp.json` | Global (macOS: `~/Library/Application Support/zot/mcp.json`) |64+| `$ZOT_HOME/mcp.json` | Global (`$XDG_STATE_HOME/zot/mcp.json` when `XDG_STATE_HOME` is set) |
65 | `.zot/mcp.json` | Project-level (in your project root) |65 | `.zot/mcp.json` | Project-level (in your project root) |
66 66
67 ### Config Format67 ### Config Format
M examples/extensions/mcp-bridge/config.go +4 -4
@@ -3,7 +3,7 @@
33 // Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.)
44 // from two locations:
55 //
6-// 1. Global: $ZOT_HOME/mcp.json (macOS: ~/Library/Application Support/zot/mcp.json)
6+// 1. Global: $ZOT_HOME/mcp.json
77 // 2. Project: .zot/mcp.json (in the current working directory)
88 //
99 // Project config overrides global config per-server (shallow merge).
@@ -47,6 +47,9 @@ func zotHome() string {
4747 if h := os.Getenv("ZOT_HOME"); h != "" {
4848 return h
4949 }
50+ if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
51+ return filepath.Join(xdg, "zot")
52+ }
5053 switch runtime.GOOS {
5154 case "darwin":
5255 home, _ := os.UserHomeDir()
@@ -54,9 +57,6 @@ func zotHome() string {
5457 case "windows":
5558 return filepath.Join(os.Getenv("APPDATA"), "zot")
5659 default: // linux, freebsd, etc.
57- if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
58- return filepath.Join(xdg, "zot")
59- }
6060 home, _ := os.UserHomeDir()
6161 return filepath.Join(home, ".local", "state", "zot")
6262 }
@@ -3,7 +3,7 @@
3 // Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.)3 // Reads standard MCP config files (same format as Claude Desktop, Cursor, etc.)
4 // from two locations:4 // from two locations:
5 //5 //
6-// 1. Global: $ZOT_HOME/mcp.json (macOS: ~/Library/Application Support/zot/mcp.json)6+// 1. Global: $ZOT_HOME/mcp.json
7 // 2. Project: .zot/mcp.json (in the current working directory)7 // 2. Project: .zot/mcp.json (in the current working directory)
8 //8 //
9 // Project config overrides global config per-server (shallow merge).9 // Project config overrides global config per-server (shallow merge).
@@ -47,6 +47,9 @@ func zotHome() string {
47 if h := os.Getenv("ZOT_HOME"); h != "" {47 if h := os.Getenv("ZOT_HOME"); h != "" {
48 return h48 return h
49 }49 }
50+ if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
51+ return filepath.Join(xdg, "zot")
52+ }
50 switch runtime.GOOS {53 switch runtime.GOOS {
51 case "darwin":54 case "darwin":
52 home, _ := os.UserHomeDir()55 home, _ := os.UserHomeDir()
@@ -54,9 +57,6 @@ func zotHome() string {
54 case "windows":57 case "windows":
55 return filepath.Join(os.Getenv("APPDATA"), "zot")58 return filepath.Join(os.Getenv("APPDATA"), "zot")
56 default: // linux, freebsd, etc.59 default: // linux, freebsd, etc.
57- if xdg := os.Getenv("XDG_STATE_HOME"); xdg != "" {
58- return filepath.Join(xdg, "zot")
59- }
60 home, _ := os.UserHomeDir()60 home, _ := os.UserHomeDir()
61 return filepath.Join(home, ".local", "state", "zot")61 return filepath.Join(home, ".local", "state", "zot")
62 }62 }
M packages/agent/args.go +3 -3
@@ -211,10 +211,10 @@ func ParseArgs(in []string) (Args, error) {
211211 return a, err
212212 }
213213 switch strings.ToLower(v) {
214- case "", "off", "minimum", "minimal", "low", "medium", "high", "maximum", "max":
214+ case "", "off", "minimum", "minimal", "low", "medium", "high", "xhigh", "maximum", "max":
215215 a.Reasoning = strings.ToLower(v)
216216 default:
217- return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|maximum")
217+ return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|xhigh|max")
218218 }
219219 case "--temperature":
220220 v, err := want(&i, arg)
@@ -387,7 +387,7 @@ func PrintHelp(version string) {
387387 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
388388 row{"--base-url URL", "override provider api base url"},
389389 row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},
390- row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"},
390+ row{"--reasoning off|minimum|low|medium|high|xhigh|max", "set thinking level on supported models"},
391391 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
392392 )
393393 section("prompt and session flags",
@@ -211,10 +211,10 @@ func ParseArgs(in []string) (Args, error) {
211 return a, err211 return a, err
212 }212 }
213 switch strings.ToLower(v) {213 switch strings.ToLower(v) {
214- case "", "off", "minimum", "minimal", "low", "medium", "high", "maximum", "max":214+ case "", "off", "minimum", "minimal", "low", "medium", "high", "xhigh", "maximum", "max":
215 a.Reasoning = strings.ToLower(v)215 a.Reasoning = strings.ToLower(v)
216 default:216 default:
217- return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|maximum")217+ return a, fmt.Errorf("--reasoning must be off|minimum|low|medium|high|xhigh|max")
218 }218 }
219 case "--temperature":219 case "--temperature":
220 v, err := want(&i, arg)220 v, err := want(&i, arg)
@@ -387,7 +387,7 @@ func PrintHelp(version string) {
387 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},387 row{"--api-key KEY", "api key for this run (env / auth.json fallback)"},
388 row{"--base-url URL", "override provider api base url"},388 row{"--base-url URL", "override provider api base url"},
389 row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},389 row{"--insecure", "skip TLS certificate verification (for self-signed-cert endpoints)"},
390- row{"--reasoning off|minimum|low|medium|high|maximum", "set thinking level on supported models"},390+ row{"--reasoning off|minimum|low|medium|high|xhigh|max", "set thinking level on supported models"},
391 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},391 row{"--temperature N", "sampling temperature, 0 to 2 (omit for provider default)"},
392 )392 )
393 section("prompt and session flags",393 section("prompt and session flags",
M packages/agent/build.go +3 -0
@@ -751,6 +751,9 @@ func (r Resolved) NewClient() provider.Client {
751751 case "deepseek":
752752 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
753753 case "openai":
754+ if strings.HasPrefix(r.Model, "gpt-5.6-") {
755+ return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai"))
756+ }
754757 return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
755758 case "openai-codex":
756759 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
@@ -751,6 +751,9 @@ func (r Resolved) NewClient() provider.Client {
751 case "deepseek":751 case "deepseek":
752 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))752 return wrap(provider.NewDeepSeek(r.Credential, r.BaseURL))
753 case "openai":753 case "openai":
754+ if strings.HasPrefix(r.Model, "gpt-5.6-") {
755+ return wrap(provider.NewOpenAIResponsesNamed(r.Credential, r.BaseURL, "openai"))
756+ }
754 return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))757 return wrap(provider.NewOpenAI(r.Credential, r.BaseURL))
755 case "openai-codex":758 case "openai-codex":
756 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))759 inner := wrap(provider.NewOpenAICodex(r.Credential, r.AccountID, r.BaseURL))
M packages/agent/config.go +3 -3
@@ -108,6 +108,9 @@ func ZotHome() string {
108108 if v := os.Getenv("ZOT_HOME"); v != "" {
109109 return v
110110 }
111+ if v := os.Getenv("XDG_STATE_HOME"); v != "" {
112+ return filepath.Join(v, "zot")
113+ }
111114 switch runtime.GOOS {
112115 case "darwin":
113116 if home, err := os.UserHomeDir(); err == nil {
@@ -118,9 +121,6 @@ func ZotHome() string {
118121 return filepath.Join(v, "zot")
119122 }
120123 }
121- if v := os.Getenv("XDG_STATE_HOME"); v != "" {
122- return filepath.Join(v, "zot")
123- }
124124 if home, err := os.UserHomeDir(); err == nil {
125125 return filepath.Join(home, ".local", "state", "zot")
126126 }
@@ -108,6 +108,9 @@ func ZotHome() string {
108 if v := os.Getenv("ZOT_HOME"); v != "" {108 if v := os.Getenv("ZOT_HOME"); v != "" {
109 return v109 return v
110 }110 }
111+ if v := os.Getenv("XDG_STATE_HOME"); v != "" {
112+ return filepath.Join(v, "zot")
113+ }
111 switch runtime.GOOS {114 switch runtime.GOOS {
112 case "darwin":115 case "darwin":
113 if home, err := os.UserHomeDir(); err == nil {116 if home, err := os.UserHomeDir(); err == nil {
@@ -118,9 +121,6 @@ func ZotHome() string {
118 return filepath.Join(v, "zot")121 return filepath.Join(v, "zot")
119 }122 }
120 }123 }
121- if v := os.Getenv("XDG_STATE_HOME"); v != "" {
122- return filepath.Join(v, "zot")
123- }
124 if home, err := os.UserHomeDir(); err == nil {124 if home, err := os.UserHomeDir(); err == nil {
125 return filepath.Join(home, ".local", "state", "zot")125 return filepath.Join(home, ".local", "state", "zot")
126 }126 }
A packages/agent/config_home_test.go +24 -0
@@ -0,0 +1,24 @@
1+package agent
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+func TestZotHomePrefersExplicitHomeOverXDGStateHome(t *testing.T) {
9+ t.Setenv("ZOT_HOME", filepath.Join("explicit", "zot"))
10+ t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state"))
11+
12+ if got, want := ZotHome(), filepath.Join("explicit", "zot"); got != want {
13+ t.Fatalf("ZotHome() = %q, want %q", got, want)
14+ }
15+}
16+
17+func TestZotHomeUsesXDGStateHome(t *testing.T) {
18+ t.Setenv("ZOT_HOME", "")
19+ t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state"))
20+
21+ if got, want := ZotHome(), filepath.Join("xdg", "state", "zot"); got != want {
22+ t.Fatalf("ZotHome() = %q, want %q", got, want)
23+ }
24+}
@@ -0,0 +1,24 @@
1+package agent
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+)
7+
8+func TestZotHomePrefersExplicitHomeOverXDGStateHome(t *testing.T) {
9+ t.Setenv("ZOT_HOME", filepath.Join("explicit", "zot"))
10+ t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state"))
11+
12+ if got, want := ZotHome(), filepath.Join("explicit", "zot"); got != want {
13+ t.Fatalf("ZotHome() = %q, want %q", got, want)
14+ }
15+}
16+
17+func TestZotHomeUsesXDGStateHome(t *testing.T) {
18+ t.Setenv("ZOT_HOME", "")
19+ t.Setenv("XDG_STATE_HOME", filepath.Join("xdg", "state"))
20+
21+ if got, want := ZotHome(), filepath.Join("xdg", "state", "zot"); got != want {
22+ t.Fatalf("ZotHome() = %q, want %q", got, want)
23+ }
24+}
M packages/agent/modes/interactive.go +65 -31
@@ -1532,6 +1532,12 @@ func (i *Interactive) redraw() {
15321532 cursorCol = c
15331533 }
15341534 }
1535+ if i.sessionTreeDialog.Active() {
1536+ if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1537+ cursorRow = dialogLead + r
1538+ cursorCol = c
1539+ }
1540+ }
15351541 if i.swarmDialog.Active() {
15361542 if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
15371543 cursorRow = dialogLead + r
@@ -1961,7 +1967,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
19611967 }
19621968 act := i.sessionTreeDialog.HandleKey(k)
19631969 if act.Select {
1964- i.applySessionTreeSelection(act.Path)
1970+ i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
19651971 }
19661972 i.invalidate()
19671973 return false
@@ -2921,7 +2927,8 @@ func (i *Interactive) openSettingsDialog() {
29212927 {value: "low", label: "low", desc: "light (~2k tokens)"},
29222928 {value: "medium", label: "medium", desc: "moderate (~8k tokens)"},
29232929 {value: "high", label: "high", desc: "deep (~16k tokens)"},
2924- {value: "maximum", label: "maximum", desc: "highest (~32k tokens)"},
2930+ {value: "xhigh", label: "xhigh", desc: "extra-high effort"},
2931+ {value: "max", label: "max", desc: "unconstrained effort on supported models"},
29252932 }
29262933 reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
29272934 reasoningChoice := 0
@@ -6138,62 +6145,89 @@ func (i *Interactive) doSessionFork() {
61386145 i.invalidate()
61396146 }
61406147
6141-// doSessionTree shows the branch topology picker for this cwd.
6142-// Pick an entry to switch into it (same semantics as /sessions
6143-// picking a past session, but with the parent/child indentation).
6148+// doSessionTree shows the current session family as an inline branch tree.
6149+// Pick an entry to check out that point into a new branch. Subsequent
6150+// prompts use only context up to the selected message.
61446151 func (i *Interactive) doSessionTree() {
6145- if i.cfg.ZotHome == "" || i.cfg.CWD == "" {
6146- i.mu.Lock()
6147- i.statusErr = "tree: session storage not configured"
6148- i.mu.Unlock()
6152+ current := ""
6153+ if i.cfg.CurrentSessionPath != nil {
6154+ current = i.cfg.CurrentSessionPath()
6155+ }
6156+ if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) {
61496157 i.invalidate()
61506158 return
61516159 }
6152- // Flush the running agent's transcript first so its message
6153- // count + latest preview are accurate in the tree.
6154- if i.cfg.FlushSession != nil {
6155- i.cfg.FlushSession()
6160+ msgs := []provider.Message{}
6161+ if i.agent != nil {
6162+ msgs = i.agent.Messages()
61566163 }
6157- roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)
6158- if len(roots) == 0 {
6164+ if len(msgs) == 0 {
61596165 i.mu.Lock()
6160- i.statusErr = "tree: no sessions in this directory yet"
6166+ i.statusErr = "tree: no messages in this session yet"
61616167 i.mu.Unlock()
61626168 i.invalidate()
61636169 return
61646170 }
6165- current := ""
6166- if i.cfg.CurrentSessionPath != nil {
6167- current = i.cfg.CurrentSessionPath()
6168- }
6169- if !i.sessionTreeDialog.Open(roots, current) {
6171+ if !i.sessionTreeDialog.OpenMessages(msgs) {
61706172 i.mu.Lock()
6171- i.statusErr = "tree: no branches to show"
6173+ i.statusErr = "tree: no messages to show"
61726174 i.mu.Unlock()
61736175 }
61746176 i.invalidate()
61756177 }
61766178
6177-// applySessionTreeSelection switches the running agent to the
6178-// session file at path. Thin wrapper around LoadSession that also
6179-// writes a status line.
6180-func (i *Interactive) applySessionTreeSelection(path string) {
6181- if i.cfg.LoadSession == nil {
6179+// applySessionTreeMessageSelection checks out the selected message into a
6180+// new branch, then switches the running agent to that branch. Unlike /jump,
6181+// this changes future model context: only messages up to the selected row are
6182+// sent on subsequent turns.
6183+func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
6184+ if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
61826185 i.mu.Lock()
6183- i.statusErr = "tree: session swap not available in this build"
6186+ i.statusErr = "tree: session branching is not available in this build"
61846187 i.mu.Unlock()
61856188 i.invalidate()
61866189 return
61876190 }
6188- if err := i.cfg.LoadSession(path); err != nil {
6191+ if src == "" {
6192+ src = i.cfg.CurrentSessionPath()
6193+ }
6194+ if src == "" {
6195+ i.mu.Lock()
6196+ i.statusErr = "tree: no session is active"
6197+ i.mu.Unlock()
6198+ i.invalidate()
6199+ return
6200+ }
6201+ if i.cfg.FlushSession != nil {
6202+ i.cfg.FlushSession()
6203+ }
6204+ upTo := msgIdx + 1
6205+ if role == provider.RoleUser {
6206+ upTo = msgIdx
6207+ }
6208+ newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
6209+ if err != nil {
61896210 i.mu.Lock()
6190- i.statusErr = "tree: load failed: " + err.Error()
6211+ i.statusErr = "tree: " + err.Error()
61916212 i.mu.Unlock()
61926213 i.invalidate()
61936214 return
61946215 }
6216+ if err := i.cfg.LoadSession(newPath); err != nil {
6217+ i.mu.Lock()
6218+ i.statusErr = "tree: checkout failed: " + err.Error()
6219+ i.mu.Unlock()
6220+ i.invalidate()
6221+ return
6222+ }
6223+ i.scrollToBottom()
61956224 i.mu.Lock()
6196- i.statusOK = "switched to branch " + friendlyPath(path)
6225+ if role == provider.RoleUser {
6226+ i.ed.SetValue(prompt)
6227+ i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
6228+ } else {
6229+ i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
6230+ }
61976231 i.statusErr = ""
61986232 i.mu.Unlock()
61996233 i.invalidate()
@@ -1532,6 +1532,12 @@ func (i *Interactive) redraw() {
1532 cursorCol = c1532 cursorCol = c
1533 }1533 }
1534 }1534 }
1535+ if i.sessionTreeDialog.Active() {
1536+ if r, c := i.sessionTreeDialog.CursorPos(); r >= 0 {
1537+ cursorRow = dialogLead + r
1538+ cursorCol = c
1539+ }
1540+ }
1535 if i.swarmDialog.Active() {1541 if i.swarmDialog.Active() {
1536 if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {1542 if r, c := i.swarmDialog.CursorPos(cols); r >= 0 {
1537 cursorRow = dialogLead + r1543 cursorRow = dialogLead + r
@@ -1961,7 +1967,7 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1961 }1967 }
1962 act := i.sessionTreeDialog.HandleKey(k)1968 act := i.sessionTreeDialog.HandleKey(k)
1963 if act.Select {1969 if act.Select {
1964- i.applySessionTreeSelection(act.Path)1970+ i.applySessionTreeMessageSelection(act.Path, act.MessageIdx, act.TurnNo, act.Role, act.Prompt)
1965 }1971 }
1966 i.invalidate()1972 i.invalidate()
1967 return false1973 return false
@@ -2921,7 +2927,8 @@ func (i *Interactive) openSettingsDialog() {
2921 {value: "low", label: "low", desc: "light (~2k tokens)"},2927 {value: "low", label: "low", desc: "light (~2k tokens)"},
2922 {value: "medium", label: "medium", desc: "moderate (~8k tokens)"},2928 {value: "medium", label: "medium", desc: "moderate (~8k tokens)"},
2923 {value: "high", label: "high", desc: "deep (~16k tokens)"},2929 {value: "high", label: "high", desc: "deep (~16k tokens)"},
2924- {value: "maximum", label: "maximum", desc: "highest (~32k tokens)"},2930+ {value: "xhigh", label: "xhigh", desc: "extra-high effort"},
2931+ {value: "max", label: "max", desc: "unconstrained effort on supported models"},
2925 }2932 }
2926 reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)2933 reasoning := provider.NormalizeReasoning(i.cfg.Reasoning)
2927 reasoningChoice := 02934 reasoningChoice := 0
@@ -6138,62 +6145,89 @@ func (i *Interactive) doSessionFork() {
6138 i.invalidate()6145 i.invalidate()
6139 }6146 }
6140 6147
6141-// doSessionTree shows the branch topology picker for this cwd.6148+// doSessionTree shows the current session family as an inline branch tree.
6142-// Pick an entry to switch into it (same semantics as /sessions6149+// Pick an entry to check out that point into a new branch. Subsequent
6143-// picking a past session, but with the parent/child indentation).6150+// prompts use only context up to the selected message.
6144 func (i *Interactive) doSessionTree() {6151 func (i *Interactive) doSessionTree() {
6145- if i.cfg.ZotHome == "" || i.cfg.CWD == "" {6152+ current := ""
6146- i.mu.Lock()6153+ if i.cfg.CurrentSessionPath != nil {
6147- i.statusErr = "tree: session storage not configured"6154+ current = i.cfg.CurrentSessionPath()
6148- i.mu.Unlock()6155+ }
6156+ if current != "" && i.sessionTreeDialog.OpenSessionFamily(i.cfg.ZotHome, i.cfg.CWD, current) {
6149 i.invalidate()6157 i.invalidate()
6150 return6158 return
6151 }6159 }
6152- // Flush the running agent's transcript first so its message6160+ msgs := []provider.Message{}
6153- // count + latest preview are accurate in the tree.6161+ if i.agent != nil {
6154- if i.cfg.FlushSession != nil {6162+ msgs = i.agent.Messages()
6155- i.cfg.FlushSession()
6156 }6163 }
6157- roots := core.BuildSessionTree(i.cfg.ZotHome, i.cfg.CWD)6164+ if len(msgs) == 0 {
6158- if len(roots) == 0 {
6159 i.mu.Lock()6165 i.mu.Lock()
6160- i.statusErr = "tree: no sessions in this directory yet"6166+ i.statusErr = "tree: no messages in this session yet"
6161 i.mu.Unlock()6167 i.mu.Unlock()
6162 i.invalidate()6168 i.invalidate()
6163 return6169 return
6164 }6170 }
6165- current := ""6171+ if !i.sessionTreeDialog.OpenMessages(msgs) {
6166- if i.cfg.CurrentSessionPath != nil {
6167- current = i.cfg.CurrentSessionPath()
6168- }
6169- if !i.sessionTreeDialog.Open(roots, current) {
6170 i.mu.Lock()6172 i.mu.Lock()
6171- i.statusErr = "tree: no branches to show"6173+ i.statusErr = "tree: no messages to show"
6172 i.mu.Unlock()6174 i.mu.Unlock()
6173 }6175 }
6174 i.invalidate()6176 i.invalidate()
6175 }6177 }
6176 6178
6177-// applySessionTreeSelection switches the running agent to the6179+// applySessionTreeMessageSelection checks out the selected message into a
6178-// session file at path. Thin wrapper around LoadSession that also6180+// new branch, then switches the running agent to that branch. Unlike /jump,
6179-// writes a status line.6181+// this changes future model context: only messages up to the selected row are
6180-func (i *Interactive) applySessionTreeSelection(path string) {6182+// sent on subsequent turns.
6181- if i.cfg.LoadSession == nil {6183+func (i *Interactive) applySessionTreeMessageSelection(src string, msgIdx, turnNo int, role provider.Role, prompt string) {
6184+ if i.cfg.CurrentSessionPath == nil || i.cfg.LoadSession == nil {
6182 i.mu.Lock()6185 i.mu.Lock()
6183- i.statusErr = "tree: session swap not available in this build"6186+ i.statusErr = "tree: session branching is not available in this build"
6184 i.mu.Unlock()6187 i.mu.Unlock()
6185 i.invalidate()6188 i.invalidate()
6186 return6189 return
6187 }6190 }
6188- if err := i.cfg.LoadSession(path); err != nil {6191+ if src == "" {
6192+ src = i.cfg.CurrentSessionPath()
6193+ }
6194+ if src == "" {
6195+ i.mu.Lock()
6196+ i.statusErr = "tree: no session is active"
6197+ i.mu.Unlock()
6198+ i.invalidate()
6199+ return
6200+ }
6201+ if i.cfg.FlushSession != nil {
6202+ i.cfg.FlushSession()
6203+ }
6204+ upTo := msgIdx + 1
6205+ if role == provider.RoleUser {
6206+ upTo = msgIdx
6207+ }
6208+ newPath, err := core.BranchSessionHidden(src, i.cfg.ZotHome, i.cfg.CWD, i.cfg.Version, upTo)
6209+ if err != nil {
6189 i.mu.Lock()6210 i.mu.Lock()
6190- i.statusErr = "tree: load failed: " + err.Error()6211+ i.statusErr = "tree: " + err.Error()
6191 i.mu.Unlock()6212 i.mu.Unlock()
6192 i.invalidate()6213 i.invalidate()
6193 return6214 return
6194 }6215 }
6216+ if err := i.cfg.LoadSession(newPath); err != nil {
6217+ i.mu.Lock()
6218+ i.statusErr = "tree: checkout failed: " + err.Error()
6219+ i.mu.Unlock()
6220+ i.invalidate()
6221+ return
6222+ }
6223+ i.scrollToBottom()
6195 i.mu.Lock()6224 i.mu.Lock()
6196- i.statusOK = "switched to branch " + friendlyPath(path)6225+ if role == provider.RoleUser {
6226+ i.ed.SetValue(prompt)
6227+ i.statusOK = fmt.Sprintf("checked out before turn %d; edit and send to branch", turnNo)
6228+ } else {
6229+ i.statusOK = fmt.Sprintf("checked out turn %d into a new branch", turnNo)
6230+ }
6197 i.statusErr = ""6231 i.statusErr = ""
6198 i.mu.Unlock()6232 i.mu.Unlock()
6199 i.invalidate()6233 i.invalidate()
M packages/agent/modes/session_dialog.go +11 -5
@@ -68,9 +68,9 @@ func (d *sessionDialog) CursorPos() (row, col int) {
6868 if !d.renaming {
6969 return -1, -1
7070 }
71- // Row: frameHeader(1) + rename hint(1) = row 2 (0-indexed)
72- // Col: 2 spaces indent + text length
73- return 2, 2 + len([]rune(d.rename))
71+ // Row: frameHeader + padDialogFrame blank + rename hint = row 3 (0-indexed).
72+ // Col: " ▌ " prefix + text length.
73+ return 3, 4 + len([]rune(d.rename))
7474 }
7575
7676 // Close hides the dialog.
@@ -93,8 +93,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
9393 return lines
9494 }
9595 if d.renaming {
96- lines = append(lines, th.FG256(th.Muted, "rename session (enter to save, esc to cancel):"))
97- lines = append(lines, " "+th.FG256(th.FG, d.rename))
96+ lines = append(lines, th.FG256(th.Muted, "rename session (enter save, esc cancel):"))
97+ text := d.rename
98+ if text == "" {
99+ text = th.FG256(th.Muted, "session name")
100+ } else {
101+ text = th.FG256(th.FG, text)
102+ }
103+ lines = append(lines, " "+th.FG256(th.Accent, "▌ ")+text)
98104 lines = append(lines, frameRule(th, width))
99105 return lines
100106 }
@@ -68,9 +68,9 @@ func (d *sessionDialog) CursorPos() (row, col int) {
68 if !d.renaming {68 if !d.renaming {
69 return -1, -169 return -1, -1
70 }70 }
71- // Row: frameHeader(1) + rename hint(1) = row 2 (0-indexed)71+ // Row: frameHeader + padDialogFrame blank + rename hint = row 3 (0-indexed).
72- // Col: 2 spaces indent + text length72+ // Col: " ▌ " prefix + text length.
73- return 2, 2 + len([]rune(d.rename))73+ return 3, 4 + len([]rune(d.rename))
74 }74 }
75 75
76 // Close hides the dialog.76 // Close hides the dialog.
@@ -93,8 +93,14 @@ func (d *sessionDialog) Render(th tui.Theme, width int) []string {
93 return lines93 return lines
94 }94 }
95 if d.renaming {95 if d.renaming {
96- lines = append(lines, th.FG256(th.Muted, "rename session (enter to save, esc to cancel):"))96+ lines = append(lines, th.FG256(th.Muted, "rename session (enter save, esc cancel):"))
97- lines = append(lines, " "+th.FG256(th.FG, d.rename))97+ text := d.rename
98+ if text == "" {
99+ text = th.FG256(th.Muted, "session name")
100+ } else {
101+ text = th.FG256(th.FG, text)
102+ }
103+ lines = append(lines, " "+th.FG256(th.Accent, "▌ ")+text)
98 lines = append(lines, frameRule(th, width))104 lines = append(lines, frameRule(th, width))
99 return lines105 return lines
100 }106 }
M packages/agent/modes/session_tree_dialog.go +277 -77
@@ -5,57 +5,74 @@ import (
55 "strings"
66
77 "github.com/patriceckhart/zot/packages/core"
8+ "github.com/patriceckhart/zot/packages/provider"
89 "github.com/patriceckhart/zot/packages/tui"
910 )
1011
11-// sessionTreeDialog renders the branch topology for the current
12-// cwd (parents, children, siblings) and lets the user pick another
13-// session file to switch into. Reached via /session tree.
14-//
15-// Layout is a single flat list with indentation-based nesting so
16-// we can reuse the simple up/down cursor model the other dialogs
17-// use. The current session is tagged with a muted "[current]".
12+// sessionTreeDialog renders a compact outline of the current session family.
13+// Branches are shown inline at the point where they forked, using indentation
14+// rather than file-level rows. Selecting a node checks out that point into a
15+// new branch.
1816 type sessionTreeDialog struct {
19- active bool
20- items []treeItem
21- cursor int
22- current string // path of the session we're currently in (not selectable)
17+ active bool
18+ items []sessionTreeItem
19+ cursor int
2320 }
2421
25-// treeItem is one visible row. Depth drives the indent; Path is the
26-// on-disk session file to swap to when the user hits enter.
27-type treeItem struct {
28- label string
29- path string
30- depth int
31- isCur bool
22+type sessionTreeItem struct {
23+ label string
24+ messageIdx int
25+ turnNo int
26+ role provider.Role
27+ prompt string
28+ path string
29+ depth int
30+ current bool
3231 }
3332
3433 type sessionTreeAction struct {
35- Select bool
36- Path string
37- Close bool
34+ Select bool
35+ MessageIdx int
36+ TurnNo int
37+ Role provider.Role
38+ Prompt string
39+ Path string
40+ Close bool
3841 }
3942
4043 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
4144
42-// Open flattens the given forest into indented rows. currentPath
43-// is highlighted and non-selectable (enter on it closes the dialog).
44-func (d *sessionTreeDialog) Open(roots []*core.TreeNode, currentPath string) bool {
45- items := flattenTree(roots, currentPath)
46- if len(items) == 0 {
45+func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool {
46+ d.items = buildSessionTreeItems("", msgs, 0, false)
47+ d.cursor = len(d.items) - 1
48+ if d.cursor < 0 {
49+ d.cursor = 0
50+ }
51+ d.active = true
52+ return len(d.items) > 0
53+}
54+
55+func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {
56+ roots := core.BuildSessionTree(root, cwd)
57+ if len(roots) == 0 {
4758 return false
4859 }
49- d.items = items
50- d.current = currentPath
51- d.cursor = indexOfCurrent(items, currentPath)
60+ rootNode := findTreeRootForPath(roots, currentPath)
61+ if rootNode == nil {
62+ rootNode = roots[0]
63+ }
64+ d.items = flattenSessionFamily(rootNode, currentPath)
65+ d.cursor = indexCurrentTreeItem(d.items)
5266 d.active = true
53- return true
67+ return len(d.items) > 0
5468 }
5569
5670 // Close hides the dialog.
5771 func (d *sessionTreeDialog) Close() { d.active = false }
5872
73+// CursorPos returns -1 because this dialog has no inline editor.
74+func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 }
75+
5976 // Active reports whether the dialog consumes input.
6077 func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }
6178
@@ -70,6 +87,19 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
7087 if d.cursor < len(d.items)-1 {
7188 d.cursor++
7289 }
90+ case tui.KeyPageUp:
91+ d.cursor -= 5
92+ if d.cursor < 0 {
93+ d.cursor = 0
94+ }
95+ case tui.KeyPageDown:
96+ d.cursor += 5
97+ if d.cursor >= len(d.items) {
98+ d.cursor = len(d.items) - 1
99+ }
100+ if d.cursor < 0 {
101+ d.cursor = 0
102+ }
73103 case tui.KeyEsc:
74104 d.Close()
75105 return sessionTreeAction{Close: true}
@@ -80,10 +110,7 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
80110 }
81111 it := d.items[d.cursor]
82112 d.Close()
83- if it.isCur {
84- return sessionTreeAction{Close: true}
85- }
86- return sessionTreeAction{Select: true, Path: it.path}
113+ return sessionTreeAction{Select: true, MessageIdx: it.messageIdx, TurnNo: it.turnNo, Role: it.role, Prompt: it.prompt, Path: it.path}
87114 }
88115 return sessionTreeAction{}
89116 }
@@ -95,72 +122,245 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
95122 }
96123 var lines []string
97124 lines = append(lines, frameHeader(th, "session tree", width))
98- lines = append(lines, th.FG256(th.Muted, "pick a branch to switch to (\u2191/\u2193, enter, esc to cancel):"))
99- for i, it := range d.items {
125+ if len(d.items) == 0 {
126+ lines = append(lines, th.FG256(th.Muted, "no messages in this session yet"))
127+ lines = append(lines, th.FG256(th.Muted, "press esc to close"))
128+ lines = append(lines, frameRule(th, width))
129+ return lines
130+ }
131+ lines = append(lines, th.FG256(th.Muted, "session branches (↑/↓, pgup/pgdn, enter checkout, esc cancel):"))
132+
133+ const maxRows = 12
134+ start := 0
135+ end := len(d.items)
136+ if end > maxRows {
137+ start = d.cursor - maxRows/2
138+ if start < 0 {
139+ start = 0
140+ }
141+ end = start + maxRows
142+ if end > len(d.items) {
143+ end = len(d.items)
144+ start = end - maxRows
145+ }
146+ }
147+ if start > 0 {
148+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))
149+ }
150+ for i := start; i < end; i++ {
151+ it := d.items[i]
100152 indent := strings.Repeat(" ", it.depth)
101- label := " " + indent + it.label
102- if it.isCur {
103- label += " " + th.FG256(th.Muted, "[current]")
153+ plain := " " + indent + fitSessionTreeLabel(it.label, width-2-len([]rune(indent)))
154+ if it.current {
155+ plain += " [current]"
104156 }
105157 if i == d.cursor {
106- lines = append(lines, th.PadHighlight(label, width))
158+ lines = append(lines, th.PadHighlight(plain, width))
107159 } else {
108- lines = append(lines, th.FG256(th.Muted, label))
160+ lines = append(lines, colorSessionTreeLine(th, plain))
109161 }
110162 }
163+ if end < len(d.items) {
164+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end)))
165+ }
166+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items))))
111167 lines = append(lines, frameRule(th, width))
112168 return lines
113169 }
114170
115-// flattenTree walks the forest depth-first and returns one treeItem
116-// per node. Each label has the shape "<when> <first-prompt> (N msgs)".
117-func flattenTree(roots []*core.TreeNode, currentPath string) []treeItem {
118- var out []treeItem
119- var walk func(n *core.TreeNode, depth int)
120- walk = func(n *core.TreeNode, depth int) {
121- label := formatTreeRow(n)
122- out = append(out, treeItem{
123- label: label,
124- path: n.Summary.Path,
125- depth: depth,
126- isCur: n.Summary.Path == currentPath,
127- })
128- for _, c := range n.Children {
129- walk(c, depth+1)
171+func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem {
172+ childrenByFork := map[int][]*core.TreeNode{}
173+ for _, child := range root.Children {
174+ childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)
175+ }
176+ sess, msgs, err := core.OpenSession(root.Summary.Path)
177+ if err != nil {
178+ return nil
179+ }
180+ _ = sess.Close()
181+ items := buildSessionTreeItems(root.Summary.Path, msgs, 0, root.Summary.Path == currentPath)
182+ var out []sessionTreeItem
183+ for idx, item := range items {
184+ out = append(out, item)
185+ for _, child := range childrenByFork[idx+1] {
186+ out = append(out, flattenSessionBranch(child, currentPath, 1)...)
130187 }
131188 }
132- for _, r := range roots {
133- walk(r, 0)
189+ for _, child := range childrenByFork[0] {
190+ out = append(out, flattenSessionBranch(child, currentPath, 1)...)
134191 }
135192 return out
136193 }
137194
138-// formatTreeRow renders one node's label line. Tries to fit a
139-// compact "date preview (N msgs, $cost)" shape; falls back to
140-// the meta id suffix when everything else is empty.
141-func formatTreeRow(n *core.TreeNode) string {
142- when := formatRelative(n.Summary.Started)
143- preview := strings.TrimSpace(n.Summary.FirstUserText)
144- if preview == "" {
145- if n.Meta.ID != "" && len(n.Meta.ID) >= 8 {
146- preview = "(" + n.Meta.ID[:8] + ")"
147- } else {
148- preview = "(empty)"
195+func flattenSessionBranch(node *core.TreeNode, currentPath string, depth int) []sessionTreeItem {
196+ sess, msgs, err := core.OpenSession(node.Summary.Path)
197+ if err != nil {
198+ return nil
199+ }
200+ _ = sess.Close()
201+ start := node.Meta.ForkPoint
202+ if start < 0 {
203+ start = 0
204+ }
205+ if start > len(msgs) {
206+ start = len(msgs)
207+ }
208+ items := buildSessionTreeItems(node.Summary.Path, msgs[start:], depth, node.Summary.Path == currentPath)
209+ for idx := range items {
210+ items[idx].messageIdx += start
211+ }
212+ childrenByFork := map[int][]*core.TreeNode{}
213+ for _, child := range node.Children {
214+ childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)
215+ }
216+ var out []sessionTreeItem
217+ for relIdx, item := range items {
218+ out = append(out, item)
219+ absAfter := start + relIdx + 1
220+ for _, child := range childrenByFork[absAfter] {
221+ out = append(out, flattenSessionBranch(child, currentPath, depth+1)...)
222+ }
223+ }
224+ return out
225+}
226+
227+func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {
228+ out := make([]sessionTreeItem, 0, len(msgs))
229+ turn := 0
230+ lastTurn := 0
231+ for idx, msg := range msgs {
232+ label := sessionTreeRoleLabel(msg.Role)
233+ if msg.Role == provider.RoleUser {
234+ turn++
235+ lastTurn = turn
236+ } else if lastTurn == 0 {
237+ lastTurn = 1
149238 }
239+ preview := sessionTreePreview(msg)
240+ out = append(out, sessionTreeItem{
241+ label: fmt.Sprintf("%s: %s", label, preview),
242+ messageIdx: idx,
243+ turnNo: lastTurn,
244+ role: msg.Role,
245+ prompt: firstTextFromTreeMessage(msg),
246+ path: path,
247+ depth: depth,
248+ current: currentPath && idx == len(msgs)-1,
249+ })
150250 }
151- if len(preview) > 50 {
152- preview = preview[:47] + "..."
251+ return out
252+}
253+
254+func findTreeRootForPath(roots []*core.TreeNode, path string) *core.TreeNode {
255+ for _, root := range roots {
256+ if treeContainsPath(root, path) {
257+ return root
258+ }
153259 }
154- return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount)
260+ return nil
155261 }
156262
157-// indexOfCurrent returns the flat-list index of the row whose path
158-// matches currentPath. -1 when not found; the caller defaults to 0.
159-func indexOfCurrent(items []treeItem, currentPath string) int {
263+func treeContainsPath(n *core.TreeNode, path string) bool {
264+ if n == nil {
265+ return false
266+ }
267+ if n.Summary.Path == path {
268+ return true
269+ }
270+ for _, child := range n.Children {
271+ if treeContainsPath(child, path) {
272+ return true
273+ }
274+ }
275+ return false
276+}
277+
278+func indexCurrentTreeItem(items []sessionTreeItem) int {
160279 for i, it := range items {
161- if it.path == currentPath {
280+ if it.current {
162281 return i
163282 }
164283 }
165- return 0
284+ if len(items) == 0 {
285+ return 0
286+ }
287+ return len(items) - 1
288+}
289+
290+func firstTextFromTreeMessage(msg provider.Message) string {
291+ for _, c := range msg.Content {
292+ if tb, ok := c.(provider.TextBlock); ok {
293+ return tb.Text
294+ }
295+ }
296+ return ""
297+}
298+
299+func sessionTreeRoleLabel(role provider.Role) string {
300+ switch role {
301+ case provider.RoleUser:
302+ return "you"
303+ case provider.RoleAssistant:
304+ return "zot"
305+ case provider.RoleTool:
306+ return "tool"
307+ default:
308+ return string(role)
309+ }
310+}
311+
312+func sessionTreePreview(msg provider.Message) string {
313+ var parts []string
314+ for _, c := range msg.Content {
315+ switch b := c.(type) {
316+ case provider.TextBlock:
317+ text := strings.TrimSpace(strings.ReplaceAll(b.Text, "\n", " "))
318+ if text != "" {
319+ parts = append(parts, text)
320+ }
321+ case provider.ImageBlock:
322+ parts = append(parts, "[image]")
323+ case provider.ToolCallBlock:
324+ parts = append(parts, "tool "+b.Name)
325+ case provider.ToolResultBlock:
326+ if b.IsError {
327+ parts = append(parts, "tool result error")
328+ } else {
329+ parts = append(parts, "tool result")
330+ }
331+ case provider.ReasoningBlock:
332+ if b.Summary != "" {
333+ parts = append(parts, "reasoning")
334+ }
335+ }
336+ }
337+ if len(parts) == 0 {
338+ return "(empty)"
339+ }
340+ return strings.Join(parts, " ")
341+}
342+
343+func colorSessionTreeLine(th tui.Theme, line string) string {
344+ plain := strings.TrimSpace(line)
345+ switch {
346+ case strings.HasPrefix(plain, "you:"):
347+ return th.FG256(th.FG, line)
348+ case strings.HasPrefix(plain, "zot:"):
349+ return th.FG256(th.Muted, line)
350+ case strings.HasPrefix(plain, "tool:"):
351+ return th.FG256(th.ToolOut, line)
352+ default:
353+ return th.FG256(th.Muted, line)
354+ }
355+}
356+
357+func fitSessionTreeLabel(label string, maxWidth int) string {
358+ if maxWidth < 4 {
359+ maxWidth = 4
360+ }
361+ runes := []rune(label)
362+ if len(runes) <= maxWidth {
363+ return label
364+ }
365+ return string(runes[:maxWidth-3]) + "..."
166366 }
@@ -5,57 +5,74 @@ import (
5 "strings"5 "strings"
6 6
7 "github.com/patriceckhart/zot/packages/core"7 "github.com/patriceckhart/zot/packages/core"
8+ "github.com/patriceckhart/zot/packages/provider"
8 "github.com/patriceckhart/zot/packages/tui"9 "github.com/patriceckhart/zot/packages/tui"
9 )10 )
10 11
11-// sessionTreeDialog renders the branch topology for the current12+// sessionTreeDialog renders a compact outline of the current session family.
12-// cwd (parents, children, siblings) and lets the user pick another13+// Branches are shown inline at the point where they forked, using indentation
13-// session file to switch into. Reached via /session tree.14+// rather than file-level rows. Selecting a node checks out that point into a
14-//15+// new branch.
15-// Layout is a single flat list with indentation-based nesting so
16-// we can reuse the simple up/down cursor model the other dialogs
17-// use. The current session is tagged with a muted "[current]".
18 type sessionTreeDialog struct {16 type sessionTreeDialog struct {
19- active bool17+ active bool
20- items []treeItem18+ items []sessionTreeItem
21- cursor int19+ cursor int
22- current string // path of the session we're currently in (not selectable)
23 }20 }
24 21
25-// treeItem is one visible row. Depth drives the indent; Path is the22+type sessionTreeItem struct {
26-// on-disk session file to swap to when the user hits enter.23+ label string
27-type treeItem struct {24+ messageIdx int
28- label string25+ turnNo int
29- path string26+ role provider.Role
30- depth int27+ prompt string
31- isCur bool28+ path string
29+ depth int
30+ current bool
32 }31 }
33 32
34 type sessionTreeAction struct {33 type sessionTreeAction struct {
35- Select bool34+ Select bool
36- Path string35+ MessageIdx int
37- Close bool36+ TurnNo int
37+ Role provider.Role
38+ Prompt string
39+ Path string
40+ Close bool
38 }41 }
39 42
40 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }43 func newSessionTreeDialog() *sessionTreeDialog { return &sessionTreeDialog{} }
41 44
42-// Open flattens the given forest into indented rows. currentPath45+func (d *sessionTreeDialog) OpenMessages(msgs []provider.Message) bool {
43-// is highlighted and non-selectable (enter on it closes the dialog).46+ d.items = buildSessionTreeItems("", msgs, 0, false)
44-func (d *sessionTreeDialog) Open(roots []*core.TreeNode, currentPath string) bool {47+ d.cursor = len(d.items) - 1
45- items := flattenTree(roots, currentPath)48+ if d.cursor < 0 {
46- if len(items) == 0 {49+ d.cursor = 0
50+ }
51+ d.active = true
52+ return len(d.items) > 0
53+}
54+
55+func (d *sessionTreeDialog) OpenSessionFamily(root, cwd, currentPath string) bool {
56+ roots := core.BuildSessionTree(root, cwd)
57+ if len(roots) == 0 {
47 return false58 return false
48 }59 }
49- d.items = items60+ rootNode := findTreeRootForPath(roots, currentPath)
50- d.current = currentPath61+ if rootNode == nil {
51- d.cursor = indexOfCurrent(items, currentPath)62+ rootNode = roots[0]
63+ }
64+ d.items = flattenSessionFamily(rootNode, currentPath)
65+ d.cursor = indexCurrentTreeItem(d.items)
52 d.active = true66 d.active = true
53- return true67+ return len(d.items) > 0
54 }68 }
55 69
56 // Close hides the dialog.70 // Close hides the dialog.
57 func (d *sessionTreeDialog) Close() { d.active = false }71 func (d *sessionTreeDialog) Close() { d.active = false }
58 72
73+// CursorPos returns -1 because this dialog has no inline editor.
74+func (d *sessionTreeDialog) CursorPos() (row, col int) { return -1, -1 }
75+
59 // Active reports whether the dialog consumes input.76 // Active reports whether the dialog consumes input.
60 func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }77 func (d *sessionTreeDialog) Active() bool { return d != nil && d.active }
61 78
@@ -70,6 +87,19 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
70 if d.cursor < len(d.items)-1 {87 if d.cursor < len(d.items)-1 {
71 d.cursor++88 d.cursor++
72 }89 }
90+ case tui.KeyPageUp:
91+ d.cursor -= 5
92+ if d.cursor < 0 {
93+ d.cursor = 0
94+ }
95+ case tui.KeyPageDown:
96+ d.cursor += 5
97+ if d.cursor >= len(d.items) {
98+ d.cursor = len(d.items) - 1
99+ }
100+ if d.cursor < 0 {
101+ d.cursor = 0
102+ }
73 case tui.KeyEsc:103 case tui.KeyEsc:
74 d.Close()104 d.Close()
75 return sessionTreeAction{Close: true}105 return sessionTreeAction{Close: true}
@@ -80,10 +110,7 @@ func (d *sessionTreeDialog) HandleKey(k tui.Key) sessionTreeAction {
80 }110 }
81 it := d.items[d.cursor]111 it := d.items[d.cursor]
82 d.Close()112 d.Close()
83- if it.isCur {113+ return sessionTreeAction{Select: true, MessageIdx: it.messageIdx, TurnNo: it.turnNo, Role: it.role, Prompt: it.prompt, Path: it.path}
84- return sessionTreeAction{Close: true}
85- }
86- return sessionTreeAction{Select: true, Path: it.path}
87 }114 }
88 return sessionTreeAction{}115 return sessionTreeAction{}
89 }116 }
@@ -95,72 +122,245 @@ func (d *sessionTreeDialog) Render(th tui.Theme, width int) []string {
95 }122 }
96 var lines []string123 var lines []string
97 lines = append(lines, frameHeader(th, "session tree", width))124 lines = append(lines, frameHeader(th, "session tree", width))
98- lines = append(lines, th.FG256(th.Muted, "pick a branch to switch to (\u2191/\u2193, enter, esc to cancel):"))125+ if len(d.items) == 0 {
99- for i, it := range d.items {126+ lines = append(lines, th.FG256(th.Muted, "no messages in this session yet"))
127+ lines = append(lines, th.FG256(th.Muted, "press esc to close"))
128+ lines = append(lines, frameRule(th, width))
129+ return lines
130+ }
131+ lines = append(lines, th.FG256(th.Muted, "session branches (↑/↓, pgup/pgdn, enter checkout, esc cancel):"))
132+
133+ const maxRows = 12
134+ start := 0
135+ end := len(d.items)
136+ if end > maxRows {
137+ start = d.cursor - maxRows/2
138+ if start < 0 {
139+ start = 0
140+ }
141+ end = start + maxRows
142+ if end > len(d.items) {
143+ end = len(d.items)
144+ start = end - maxRows
145+ }
146+ }
147+ if start > 0 {
148+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↑ %d more above", start)))
149+ }
150+ for i := start; i < end; i++ {
151+ it := d.items[i]
100 indent := strings.Repeat(" ", it.depth)152 indent := strings.Repeat(" ", it.depth)
101- label := " " + indent + it.label153+ plain := " " + indent + fitSessionTreeLabel(it.label, width-2-len([]rune(indent)))
102- if it.isCur {154+ if it.current {
103- label += " " + th.FG256(th.Muted, "[current]")155+ plain += " [current]"
104 }156 }
105 if i == d.cursor {157 if i == d.cursor {
106- lines = append(lines, th.PadHighlight(label, width))158+ lines = append(lines, th.PadHighlight(plain, width))
107 } else {159 } else {
108- lines = append(lines, th.FG256(th.Muted, label))160+ lines = append(lines, colorSessionTreeLine(th, plain))
109 }161 }
110 }162 }
163+ if end < len(d.items) {
164+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf(" ↓ %d more below", len(d.items)-end)))
165+ }
166+ lines = append(lines, th.FG256(th.Muted, fmt.Sprintf("%d/%d", d.cursor+1, len(d.items))))
111 lines = append(lines, frameRule(th, width))167 lines = append(lines, frameRule(th, width))
112 return lines168 return lines
113 }169 }
114 170
115-// flattenTree walks the forest depth-first and returns one treeItem171+func flattenSessionFamily(root *core.TreeNode, currentPath string) []sessionTreeItem {
116-// per node. Each label has the shape "<when> <first-prompt> (N msgs)".172+ childrenByFork := map[int][]*core.TreeNode{}
117-func flattenTree(roots []*core.TreeNode, currentPath string) []treeItem {173+ for _, child := range root.Children {
118- var out []treeItem174+ childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)
119- var walk func(n *core.TreeNode, depth int)175+ }
120- walk = func(n *core.TreeNode, depth int) {176+ sess, msgs, err := core.OpenSession(root.Summary.Path)
121- label := formatTreeRow(n)177+ if err != nil {
122- out = append(out, treeItem{178+ return nil
123- label: label,179+ }
124- path: n.Summary.Path,180+ _ = sess.Close()
125- depth: depth,181+ items := buildSessionTreeItems(root.Summary.Path, msgs, 0, root.Summary.Path == currentPath)
126- isCur: n.Summary.Path == currentPath,182+ var out []sessionTreeItem
127- })183+ for idx, item := range items {
128- for _, c := range n.Children {184+ out = append(out, item)
129- walk(c, depth+1)185+ for _, child := range childrenByFork[idx+1] {
186+ out = append(out, flattenSessionBranch(child, currentPath, 1)...)
130 }187 }
131 }188 }
132- for _, r := range roots {189+ for _, child := range childrenByFork[0] {
133- walk(r, 0)190+ out = append(out, flattenSessionBranch(child, currentPath, 1)...)
134 }191 }
135 return out192 return out
136 }193 }
137 194
138-// formatTreeRow renders one node's label line. Tries to fit a195+func flattenSessionBranch(node *core.TreeNode, currentPath string, depth int) []sessionTreeItem {
139-// compact "date preview (N msgs, $cost)" shape; falls back to196+ sess, msgs, err := core.OpenSession(node.Summary.Path)
140-// the meta id suffix when everything else is empty.197+ if err != nil {
141-func formatTreeRow(n *core.TreeNode) string {198+ return nil
142- when := formatRelative(n.Summary.Started)199+ }
143- preview := strings.TrimSpace(n.Summary.FirstUserText)200+ _ = sess.Close()
144- if preview == "" {201+ start := node.Meta.ForkPoint
145- if n.Meta.ID != "" && len(n.Meta.ID) >= 8 {202+ if start < 0 {
146- preview = "(" + n.Meta.ID[:8] + ")"203+ start = 0
147- } else {204+ }
148- preview = "(empty)"205+ if start > len(msgs) {
206+ start = len(msgs)
207+ }
208+ items := buildSessionTreeItems(node.Summary.Path, msgs[start:], depth, node.Summary.Path == currentPath)
209+ for idx := range items {
210+ items[idx].messageIdx += start
211+ }
212+ childrenByFork := map[int][]*core.TreeNode{}
213+ for _, child := range node.Children {
214+ childrenByFork[child.Meta.ForkPoint] = append(childrenByFork[child.Meta.ForkPoint], child)
215+ }
216+ var out []sessionTreeItem
217+ for relIdx, item := range items {
218+ out = append(out, item)
219+ absAfter := start + relIdx + 1
220+ for _, child := range childrenByFork[absAfter] {
221+ out = append(out, flattenSessionBranch(child, currentPath, depth+1)...)
222+ }
223+ }
224+ return out
225+}
226+
227+func buildSessionTreeItems(path string, msgs []provider.Message, depth int, currentPath bool) []sessionTreeItem {
228+ out := make([]sessionTreeItem, 0, len(msgs))
229+ turn := 0
230+ lastTurn := 0
231+ for idx, msg := range msgs {
232+ label := sessionTreeRoleLabel(msg.Role)
233+ if msg.Role == provider.RoleUser {
234+ turn++
235+ lastTurn = turn
236+ } else if lastTurn == 0 {
237+ lastTurn = 1
149 }238 }
239+ preview := sessionTreePreview(msg)
240+ out = append(out, sessionTreeItem{
241+ label: fmt.Sprintf("%s: %s", label, preview),
242+ messageIdx: idx,
243+ turnNo: lastTurn,
244+ role: msg.Role,
245+ prompt: firstTextFromTreeMessage(msg),
246+ path: path,
247+ depth: depth,
248+ current: currentPath && idx == len(msgs)-1,
249+ })
150 }250 }
151- if len(preview) > 50 {251+ return out
152- preview = preview[:47] + "..."252+}
253+
254+func findTreeRootForPath(roots []*core.TreeNode, path string) *core.TreeNode {
255+ for _, root := range roots {
256+ if treeContainsPath(root, path) {
257+ return root
258+ }
153 }259 }
154- return fmt.Sprintf("%-14s %s %d msgs", when, preview, n.Summary.MessageCount)260+ return nil
155 }261 }
156 262
157-// indexOfCurrent returns the flat-list index of the row whose path263+func treeContainsPath(n *core.TreeNode, path string) bool {
158-// matches currentPath. -1 when not found; the caller defaults to 0.264+ if n == nil {
159-func indexOfCurrent(items []treeItem, currentPath string) int {265+ return false
266+ }
267+ if n.Summary.Path == path {
268+ return true
269+ }
270+ for _, child := range n.Children {
271+ if treeContainsPath(child, path) {
272+ return true
273+ }
274+ }
275+ return false
276+}
277+
278+func indexCurrentTreeItem(items []sessionTreeItem) int {
160 for i, it := range items {279 for i, it := range items {
161- if it.path == currentPath {280+ if it.current {
162 return i281 return i
163 }282 }
164 }283 }
165- return 0284+ if len(items) == 0 {
285+ return 0
286+ }
287+ return len(items) - 1
288+}
289+
290+func firstTextFromTreeMessage(msg provider.Message) string {
291+ for _, c := range msg.Content {
292+ if tb, ok := c.(provider.TextBlock); ok {
293+ return tb.Text
294+ }
295+ }
296+ return ""
297+}
298+
299+func sessionTreeRoleLabel(role provider.Role) string {
300+ switch role {
301+ case provider.RoleUser:
302+ return "you"
303+ case provider.RoleAssistant:
304+ return "zot"
305+ case provider.RoleTool:
306+ return "tool"
307+ default:
308+ return string(role)
309+ }
310+}
311+
312+func sessionTreePreview(msg provider.Message) string {
313+ var parts []string
314+ for _, c := range msg.Content {
315+ switch b := c.(type) {
316+ case provider.TextBlock:
317+ text := strings.TrimSpace(strings.ReplaceAll(b.Text, "\n", " "))
318+ if text != "" {
319+ parts = append(parts, text)
320+ }
321+ case provider.ImageBlock:
322+ parts = append(parts, "[image]")
323+ case provider.ToolCallBlock:
324+ parts = append(parts, "tool "+b.Name)
325+ case provider.ToolResultBlock:
326+ if b.IsError {
327+ parts = append(parts, "tool result error")
328+ } else {
329+ parts = append(parts, "tool result")
330+ }
331+ case provider.ReasoningBlock:
332+ if b.Summary != "" {
333+ parts = append(parts, "reasoning")
334+ }
335+ }
336+ }
337+ if len(parts) == 0 {
338+ return "(empty)"
339+ }
340+ return strings.Join(parts, " ")
341+}
342+
343+func colorSessionTreeLine(th tui.Theme, line string) string {
344+ plain := strings.TrimSpace(line)
345+ switch {
346+ case strings.HasPrefix(plain, "you:"):
347+ return th.FG256(th.FG, line)
348+ case strings.HasPrefix(plain, "zot:"):
349+ return th.FG256(th.Muted, line)
350+ case strings.HasPrefix(plain, "tool:"):
351+ return th.FG256(th.ToolOut, line)
352+ default:
353+ return th.FG256(th.Muted, line)
354+ }
355+}
356+
357+func fitSessionTreeLabel(label string, maxWidth int) string {
358+ if maxWidth < 4 {
359+ maxWidth = 4
360+ }
361+ runes := []rune(label)
362+ if len(runes) <= maxWidth {
363+ return label
364+ }
365+ return string(runes[:maxWidth-3]) + "..."
166 }366 }
M packages/agent/modes/telegram/bridge.go +61 -6
@@ -44,12 +44,13 @@ type Bridge struct {
4444 Save func(Config) error
4545 Host Host
4646
47- mu sync.Mutex
48- running bool
49- cancel context.CancelFunc
50- me *User
51- chatID int64 // populated after first DM from the paired user
52- replyBuf strings.Builder
47+ mu sync.Mutex
48+ running bool
49+ cancel context.CancelFunc
50+ me *User
51+ chatID int64 // populated after first DM from the paired user
52+ replyBuf strings.Builder
53+ workingCancel context.CancelFunc
5354
5455 // nextReplyFromTelegram is set when the next assistant reply
5556 // should be sent bare (no "zot: " prefix) because the turn was
@@ -140,9 +141,14 @@ func (b *Bridge) Start(parent context.Context) error {
140141 func (b *Bridge) Stop() {
141142 b.mu.Lock()
142143 cancel := b.cancel
144+ workingCancel := b.workingCancel
143145 b.running = false
144146 b.cancel = nil
147+ b.workingCancel = nil
145148 b.mu.Unlock()
149+ if workingCancel != nil {
150+ workingCancel()
151+ }
146152 if cancel != nil {
147153 cancel()
148154 }
@@ -160,11 +166,16 @@ func (b *Bridge) OnAssistantText(text string) {
160166 b.mu.Lock()
161167 // prefix := "zot: "
162168 prefix := ""
169+ workingCancel := b.workingCancel
170+ b.workingCancel = nil
163171 if b.nextReplyFromTelegram {
164172 prefix = ""
165173 b.nextReplyFromTelegram = false
166174 }
167175 b.mu.Unlock()
176+ if workingCancel != nil {
177+ workingCancel()
178+ }
168179 b.sendToPaired(text, prefix)
169180 }
170181
@@ -178,6 +189,47 @@ func (b *Bridge) OnUserTyped(text string) {
178189 b.sendToPaired(text, "you: ")
179190 }
180191
192+// indicateWorking keeps Telegram's typing indicator visible while a turn
193+// submitted from Telegram is waiting or running. Telegram clears chat actions
194+// after a few seconds, so refresh it until the reply is ready or the bridge
195+// stops.
196+func (b *Bridge) indicateWorking(ctx context.Context, chatID int64) {
197+ workingCtx, cancel := context.WithCancel(ctx)
198+
199+ b.mu.Lock()
200+ previous := b.workingCancel
201+ b.workingCancel = cancel
202+ b.mu.Unlock()
203+ if previous != nil {
204+ previous()
205+ }
206+
207+ go func() {
208+ ticker := time.NewTicker(4 * time.Second)
209+ defer ticker.Stop()
210+ for {
211+ _ = b.Client.SendChatAction(workingCtx, chatID, "typing")
212+ select {
213+ case <-workingCtx.Done():
214+ return
215+ case <-ticker.C:
216+ }
217+ }
218+ }()
219+}
220+
221+// stopWorking clears the local typing-indicator refresh loop. Telegram removes
222+// the visible indicator itself when the next message arrives or it expires.
223+func (b *Bridge) stopWorking() {
224+ b.mu.Lock()
225+ cancel := b.workingCancel
226+ b.workingCancel = nil
227+ b.mu.Unlock()
228+ if cancel != nil {
229+ cancel()
230+ }
231+}
232+
181233 // sendToPaired writes text (with an optional prefix, chunked to
182234 // Telegram's 4096-char cap) to the paired chat. No-op when the
183235 // bridge is stopped or before the paired chat id is known.
@@ -342,12 +394,14 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
342394 return
343395 case "/stop":
344396 b.Host.CancelTurn()
397+ b.stopWorking()
345398 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
346399 "cancelled the current turn.", msg.MessageID)
347400 return
348401 }
349402 if isStopCommand(text) {
350403 b.Host.CancelTurn()
404+ b.stopWorking()
351405 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
352406 "cancelled the current turn.", msg.MessageID)
353407 return
@@ -382,6 +436,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
382436 b.mu.Lock()
383437 b.nextReplyFromTelegram = true
384438 b.mu.Unlock()
439+ b.indicateWorking(ctx, msg.Chat.ID)
385440 b.Host.SubmitOrQueue(prompt, images)
386441 }
387442
@@ -44,12 +44,13 @@ type Bridge struct {
44 Save func(Config) error44 Save func(Config) error
45 Host Host45 Host Host
46 46
47- mu sync.Mutex47+ mu sync.Mutex
48- running bool48+ running bool
49- cancel context.CancelFunc49+ cancel context.CancelFunc
50- me *User50+ me *User
51- chatID int64 // populated after first DM from the paired user51+ chatID int64 // populated after first DM from the paired user
52- replyBuf strings.Builder52+ replyBuf strings.Builder
53+ workingCancel context.CancelFunc
53 54
54 // nextReplyFromTelegram is set when the next assistant reply55 // nextReplyFromTelegram is set when the next assistant reply
55 // should be sent bare (no "zot: " prefix) because the turn was56 // should be sent bare (no "zot: " prefix) because the turn was
@@ -140,9 +141,14 @@ func (b *Bridge) Start(parent context.Context) error {
140 func (b *Bridge) Stop() {141 func (b *Bridge) Stop() {
141 b.mu.Lock()142 b.mu.Lock()
142 cancel := b.cancel143 cancel := b.cancel
144+ workingCancel := b.workingCancel
143 b.running = false145 b.running = false
144 b.cancel = nil146 b.cancel = nil
147+ b.workingCancel = nil
145 b.mu.Unlock()148 b.mu.Unlock()
149+ if workingCancel != nil {
150+ workingCancel()
151+ }
146 if cancel != nil {152 if cancel != nil {
147 cancel()153 cancel()
148 }154 }
@@ -160,11 +166,16 @@ func (b *Bridge) OnAssistantText(text string) {
160 b.mu.Lock()166 b.mu.Lock()
161 // prefix := "zot: "167 // prefix := "zot: "
162 prefix := ""168 prefix := ""
169+ workingCancel := b.workingCancel
170+ b.workingCancel = nil
163 if b.nextReplyFromTelegram {171 if b.nextReplyFromTelegram {
164 prefix = ""172 prefix = ""
165 b.nextReplyFromTelegram = false173 b.nextReplyFromTelegram = false
166 }174 }
167 b.mu.Unlock()175 b.mu.Unlock()
176+ if workingCancel != nil {
177+ workingCancel()
178+ }
168 b.sendToPaired(text, prefix)179 b.sendToPaired(text, prefix)
169 }180 }
170 181
@@ -178,6 +189,47 @@ func (b *Bridge) OnUserTyped(text string) {
178 b.sendToPaired(text, "you: ")189 b.sendToPaired(text, "you: ")
179 }190 }
180 191
192+// indicateWorking keeps Telegram's typing indicator visible while a turn
193+// submitted from Telegram is waiting or running. Telegram clears chat actions
194+// after a few seconds, so refresh it until the reply is ready or the bridge
195+// stops.
196+func (b *Bridge) indicateWorking(ctx context.Context, chatID int64) {
197+ workingCtx, cancel := context.WithCancel(ctx)
198+
199+ b.mu.Lock()
200+ previous := b.workingCancel
201+ b.workingCancel = cancel
202+ b.mu.Unlock()
203+ if previous != nil {
204+ previous()
205+ }
206+
207+ go func() {
208+ ticker := time.NewTicker(4 * time.Second)
209+ defer ticker.Stop()
210+ for {
211+ _ = b.Client.SendChatAction(workingCtx, chatID, "typing")
212+ select {
213+ case <-workingCtx.Done():
214+ return
215+ case <-ticker.C:
216+ }
217+ }
218+ }()
219+}
220+
221+// stopWorking clears the local typing-indicator refresh loop. Telegram removes
222+// the visible indicator itself when the next message arrives or it expires.
223+func (b *Bridge) stopWorking() {
224+ b.mu.Lock()
225+ cancel := b.workingCancel
226+ b.workingCancel = nil
227+ b.mu.Unlock()
228+ if cancel != nil {
229+ cancel()
230+ }
231+}
232+
181 // sendToPaired writes text (with an optional prefix, chunked to233 // sendToPaired writes text (with an optional prefix, chunked to
182 // Telegram's 4096-char cap) to the paired chat. No-op when the234 // Telegram's 4096-char cap) to the paired chat. No-op when the
183 // bridge is stopped or before the paired chat id is known.235 // bridge is stopped or before the paired chat id is known.
@@ -342,12 +394,14 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
342 return394 return
343 case "/stop":395 case "/stop":
344 b.Host.CancelTurn()396 b.Host.CancelTurn()
397+ b.stopWorking()
345 _ = b.Client.SendMessage(ctx, msg.Chat.ID,398 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
346 "cancelled the current turn.", msg.MessageID)399 "cancelled the current turn.", msg.MessageID)
347 return400 return
348 }401 }
349 if isStopCommand(text) {402 if isStopCommand(text) {
350 b.Host.CancelTurn()403 b.Host.CancelTurn()
404+ b.stopWorking()
351 _ = b.Client.SendMessage(ctx, msg.Chat.ID,405 _ = b.Client.SendMessage(ctx, msg.Chat.ID,
352 "cancelled the current turn.", msg.MessageID)406 "cancelled the current turn.", msg.MessageID)
353 return407 return
@@ -382,6 +436,7 @@ func (b *Bridge) handleUpdate(ctx context.Context, u Update) {
382 b.mu.Lock()436 b.mu.Lock()
383 b.nextReplyFromTelegram = true437 b.nextReplyFromTelegram = true
384 b.mu.Unlock()438 b.mu.Unlock()
439+ b.indicateWorking(ctx, msg.Chat.ID)
385 b.Host.SubmitOrQueue(prompt, images)440 b.Host.SubmitOrQueue(prompt, images)
386 }441 }
387 442
M packages/agent/rpc.go +20 -0
@@ -30,6 +30,7 @@ import (
3030 // {"id":"3","type":"compact"}
3131 // {"id":"4","type":"get_state"}
3232 // {"id":"5","type":"set_model","model":"claude-opus-4-5"}
33+// {"id":"6","type":"set_reasoning","reasoning":"max"}
3334 // {"id":"6","type":"get_messages"}
3435 // {"id":"7","type":"clear"}
3536 // {"id":"8","type":"get_models"}
@@ -301,6 +302,24 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) {
301302 s.model = req.Model
302303 s.writeResponse(id, cmd, map[string]any{"model": req.Model})
303304
305+ case "set_reasoning":
306+ var req struct {
307+ Reasoning string `json:"reasoning"`
308+ }
309+ if err := json.Unmarshal(raw, &req); err != nil {
310+ s.writeError(id, cmd, err.Error())
311+ return
312+ }
313+ reasoning := provider.NormalizeReasoning(req.Reasoning)
314+ switch reasoning {
315+ case "", "minimum", "low", "medium", "high", "xhigh", "max":
316+ default:
317+ s.writeError(id, cmd, "reasoning must be off|minimum|low|medium|high|xhigh|max")
318+ return
319+ }
320+ s.agent.Reasoning = reasoning
321+ s.writeResponse(id, cmd, map[string]any{"reasoning": reasoning})
322+
304323 case "get_models":
305324 out := []map[string]any{}
306325 for _, m := range provider.ModelsForProvider(s.provider) {
@@ -388,6 +407,7 @@ func (s *rpcServer) snapshotState() map[string]any {
388407 return map[string]any{
389408 "provider": s.provider,
390409 "model": s.model,
410+ "reasoning": s.agent.Reasoning,
391411 "cwd": s.args.CWD,
392412 "message_count": len(s.agent.Messages()),
393413 "busy": s.activeCancel != nil,
@@ -30,6 +30,7 @@ import (
30 // {"id":"3","type":"compact"}30 // {"id":"3","type":"compact"}
31 // {"id":"4","type":"get_state"}31 // {"id":"4","type":"get_state"}
32 // {"id":"5","type":"set_model","model":"claude-opus-4-5"}32 // {"id":"5","type":"set_model","model":"claude-opus-4-5"}
33+// {"id":"6","type":"set_reasoning","reasoning":"max"}
33 // {"id":"6","type":"get_messages"}34 // {"id":"6","type":"get_messages"}
34 // {"id":"7","type":"clear"}35 // {"id":"7","type":"clear"}
35 // {"id":"8","type":"get_models"}36 // {"id":"8","type":"get_models"}
@@ -301,6 +302,24 @@ func (s *rpcServer) dispatch(cmd, id string, raw []byte) {
301 s.model = req.Model302 s.model = req.Model
302 s.writeResponse(id, cmd, map[string]any{"model": req.Model})303 s.writeResponse(id, cmd, map[string]any{"model": req.Model})
303 304
305+ case "set_reasoning":
306+ var req struct {
307+ Reasoning string `json:"reasoning"`
308+ }
309+ if err := json.Unmarshal(raw, &req); err != nil {
310+ s.writeError(id, cmd, err.Error())
311+ return
312+ }
313+ reasoning := provider.NormalizeReasoning(req.Reasoning)
314+ switch reasoning {
315+ case "", "minimum", "low", "medium", "high", "xhigh", "max":
316+ default:
317+ s.writeError(id, cmd, "reasoning must be off|minimum|low|medium|high|xhigh|max")
318+ return
319+ }
320+ s.agent.Reasoning = reasoning
321+ s.writeResponse(id, cmd, map[string]any{"reasoning": reasoning})
322+
304 case "get_models":323 case "get_models":
305 out := []map[string]any{}324 out := []map[string]any{}
306 for _, m := range provider.ModelsForProvider(s.provider) {325 for _, m := range provider.ModelsForProvider(s.provider) {
@@ -388,6 +407,7 @@ func (s *rpcServer) snapshotState() map[string]any {
388 return map[string]any{407 return map[string]any{
389 "provider": s.provider,408 "provider": s.provider,
390 "model": s.model,409 "model": s.model,
410+ "reasoning": s.agent.Reasoning,
391 "cwd": s.args.CWD,411 "cwd": s.args.CWD,
392 "message_count": len(s.agent.Messages()),412 "message_count": len(s.agent.Messages()),
393 "busy": s.activeCancel != nil,413 "busy": s.activeCancel != nil,
A packages/agent/rpc_reasoning_test.go +35 -0
@@ -0,0 +1,35 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+)
10+
11+func TestRPCSetReasoningMax(t *testing.T) {
12+ var out bytes.Buffer
13+ s := &rpcServer{agent: &core.Agent{}, out: &out}
14+ s.dispatch("set_reasoning", "1", []byte(`{"reasoning":"max"}`))
15+
16+ if s.agent.Reasoning != "max" {
17+ t.Fatalf("reasoning = %q, want max", s.agent.Reasoning)
18+ }
19+ if !strings.Contains(out.String(), `"reasoning":"max"`) {
20+ t.Fatalf("response = %q", out.String())
21+ }
22+}
23+
24+func TestRPCSetReasoningRejectsUnknownLevel(t *testing.T) {
25+ var out bytes.Buffer
26+ s := &rpcServer{agent: &core.Agent{}, out: &out}
27+ s.dispatch("set_reasoning", "1", []byte(`{"reasoning":"extreme"}`))
28+
29+ if s.agent.Reasoning != "" {
30+ t.Fatalf("reasoning = %q, want unchanged", s.agent.Reasoning)
31+ }
32+ if !strings.Contains(out.String(), `"success":false`) {
33+ t.Fatalf("response = %q", out.String())
34+ }
35+}
@@ -0,0 +1,35 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "strings"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+)
10+
11+func TestRPCSetReasoningMax(t *testing.T) {
12+ var out bytes.Buffer
13+ s := &rpcServer{agent: &core.Agent{}, out: &out}
14+ s.dispatch("set_reasoning", "1", []byte(`{"reasoning":"max"}`))
15+
16+ if s.agent.Reasoning != "max" {
17+ t.Fatalf("reasoning = %q, want max", s.agent.Reasoning)
18+ }
19+ if !strings.Contains(out.String(), `"reasoning":"max"`) {
20+ t.Fatalf("response = %q", out.String())
21+ }
22+}
23+
24+func TestRPCSetReasoningRejectsUnknownLevel(t *testing.T) {
25+ var out bytes.Buffer
26+ s := &rpcServer{agent: &core.Agent{}, out: &out}
27+ s.dispatch("set_reasoning", "1", []byte(`{"reasoning":"extreme"}`))
28+
29+ if s.agent.Reasoning != "" {
30+ t.Fatalf("reasoning = %q, want unchanged", s.agent.Reasoning)
31+ }
32+ if !strings.Contains(out.String(), `"success":false`) {
33+ t.Fatalf("response = %q", out.String())
34+ }
35+}
A packages/agent/sdk/reasoning_test.go +24 -0
@@ -0,0 +1,24 @@
1+package sdk
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/core"
7+)
8+
9+func TestRuntimeSetReasoningMax(t *testing.T) {
10+ r := &Runtime{agent: &core.Agent{}}
11+ if err := r.SetReasoning("max"); err != nil {
12+ t.Fatal(err)
13+ }
14+ if r.agent.Reasoning != "max" {
15+ t.Fatalf("reasoning = %q, want max", r.agent.Reasoning)
16+ }
17+}
18+
19+func TestRuntimeSetReasoningRejectsUnknownLevel(t *testing.T) {
20+ r := &Runtime{agent: &core.Agent{}}
21+ if err := r.SetReasoning("extreme"); err == nil {
22+ t.Fatal("expected invalid reasoning error")
23+ }
24+}
@@ -0,0 +1,24 @@
1+package sdk
2+
3+import (
4+ "testing"
5+
6+ "github.com/patriceckhart/zot/packages/core"
7+)
8+
9+func TestRuntimeSetReasoningMax(t *testing.T) {
10+ r := &Runtime{agent: &core.Agent{}}
11+ if err := r.SetReasoning("max"); err != nil {
12+ t.Fatal(err)
13+ }
14+ if r.agent.Reasoning != "max" {
15+ t.Fatalf("reasoning = %q, want max", r.agent.Reasoning)
16+ }
17+}
18+
19+func TestRuntimeSetReasoningRejectsUnknownLevel(t *testing.T) {
20+ r := &Runtime{agent: &core.Agent{}}
21+ if err := r.SetReasoning("extreme"); err == nil {
22+ t.Fatal("expected invalid reasoning error")
23+ }
24+}
M packages/agent/sdk/sdk.go +19 -1
@@ -58,7 +58,7 @@ type Config struct {
5858 AppendSystemPrompt []string
5959
6060 // Reasoning sets the reasoning effort for models that support it
61- // ("low", "medium", "high"). Empty = no reasoning.
61+ // ("minimum", "low", "medium", "high", "xhigh", "max"). Empty disables it.
6262 Reasoning string
6363
6464 // Temperature sets the sampling temperature. Nil = provider default.
@@ -296,6 +296,23 @@ func (r *Runtime) SetModel(model string) error {
296296 return nil
297297 }
298298
299+// SetReasoning changes the thinking level used by subsequent prompts.
300+func (r *Runtime) SetReasoning(level string) error {
301+ r.mu.Lock()
302+ defer r.mu.Unlock()
303+ if r.agent == nil {
304+ return fmt.Errorf("sdk: no agent")
305+ }
306+ reasoning := provider.NormalizeReasoning(level)
307+ switch reasoning {
308+ case "", "minimum", "low", "medium", "high", "xhigh", "max":
309+ r.agent.Reasoning = reasoning
310+ return nil
311+ default:
312+ return fmt.Errorf("sdk: reasoning must be off|minimum|low|medium|high|xhigh|max")
313+ }
314+}
315+
299316 // State returns a snapshot of the runtime's current state. Useful for
300317 // driving UIs.
301318 func (r *Runtime) State() State {
@@ -304,6 +321,7 @@ func (r *Runtime) State() State {
304321 return State{
305322 Provider: r.provider,
306323 Model: r.model,
324+ Reasoning: r.agent.Reasoning,
307325 CWD: r.cwd,
308326 Busy: r.activeCancel != nil,
309327 MessageCount: len(r.Messages()),
@@ -58,7 +58,7 @@ type Config struct {
58 AppendSystemPrompt []string58 AppendSystemPrompt []string
59 59
60 // Reasoning sets the reasoning effort for models that support it60 // Reasoning sets the reasoning effort for models that support it
61- // ("low", "medium", "high"). Empty = no reasoning.61+ // ("minimum", "low", "medium", "high", "xhigh", "max"). Empty disables it.
62 Reasoning string62 Reasoning string
63 63
64 // Temperature sets the sampling temperature. Nil = provider default.64 // Temperature sets the sampling temperature. Nil = provider default.
@@ -296,6 +296,23 @@ func (r *Runtime) SetModel(model string) error {
296 return nil296 return nil
297 }297 }
298 298
299+// SetReasoning changes the thinking level used by subsequent prompts.
300+func (r *Runtime) SetReasoning(level string) error {
301+ r.mu.Lock()
302+ defer r.mu.Unlock()
303+ if r.agent == nil {
304+ return fmt.Errorf("sdk: no agent")
305+ }
306+ reasoning := provider.NormalizeReasoning(level)
307+ switch reasoning {
308+ case "", "minimum", "low", "medium", "high", "xhigh", "max":
309+ r.agent.Reasoning = reasoning
310+ return nil
311+ default:
312+ return fmt.Errorf("sdk: reasoning must be off|minimum|low|medium|high|xhigh|max")
313+ }
314+}
315+
299 // State returns a snapshot of the runtime's current state. Useful for316 // State returns a snapshot of the runtime's current state. Useful for
300 // driving UIs.317 // driving UIs.
301 func (r *Runtime) State() State {318 func (r *Runtime) State() State {
@@ -304,6 +321,7 @@ func (r *Runtime) State() State {
304 return State{321 return State{
305 Provider: r.provider,322 Provider: r.provider,
306 Model: r.model,323 Model: r.model,
324+ Reasoning: r.agent.Reasoning,
307 CWD: r.cwd,325 CWD: r.cwd,
308 Busy: r.activeCancel != nil,326 Busy: r.activeCancel != nil,
309 MessageCount: len(r.Messages()),327 MessageCount: len(r.Messages()),
M packages/agent/sdk/types.go +1 -0
@@ -94,6 +94,7 @@ type Usage struct {
9494 type State struct {
9595 Provider string `json:"provider"`
9696 Model string `json:"model"`
97+ Reasoning string `json:"reasoning"`
9798 CWD string `json:"cwd"`
9899 Busy bool `json:"busy"`
99100 MessageCount int `json:"message_count"`
@@ -94,6 +94,7 @@ type Usage struct {
94 type State struct {94 type State struct {
95 Provider string `json:"provider"`95 Provider string `json:"provider"`
96 Model string `json:"model"`96 Model string `json:"model"`
97+ Reasoning string `json:"reasoning"`
97 CWD string `json:"cwd"`98 CWD string `json:"cwd"`
98 Busy bool `json:"busy"`99 Busy bool `json:"busy"`
99 MessageCount int `json:"message_count"`100 MessageCount int `json:"message_count"`
M packages/core/session.go +99 -6
@@ -59,6 +59,9 @@ type SessionMeta struct {
5959 // are copied from the parent verbatim; the user's next turn on
6060 // the child session continues from there.
6161 ForkPoint int `json:"fork_point,omitempty"`
62+ // HideFromSessions hides internal tree-navigation branches from the
63+ // flat /sessions picker while keeping them available to /session tree.
64+ HideFromSessions bool `json:"hide_from_sessions,omitempty"`
6265 }
6366
6467 // sessionLine is the on-disk row type. Message is kept as a raw
@@ -389,6 +392,7 @@ type SessionSummary struct {
389392 FirstUserText string
390393 TotalCost float64
391394 Title string
395+ BranchDepth int
392396 }
393397
394398 // RenameSession updates the title field in the session's meta line.
@@ -409,18 +413,59 @@ func RenameSession(path, title string) error {
409413 return err
410414 }
411415
416+// DeleteSession removes a session file. It refuses an empty path so
417+// callers do not accidentally delete their cwd or another implicit target.
418+func DeleteSession(path string) error {
419+ if strings.TrimSpace(path) == "" {
420+ return fmt.Errorf("session path is empty")
421+ }
422+ return os.Remove(path)
423+}
424+
412425 // DescribeSessions returns lightweight summaries for every session in
413426 // cwd, newest first. Parses only the first few lines and the last usage
414427 // line so it's cheap to run on every dialog open.
415428 func DescribeSessions(root, cwd string) []SessionSummary {
416429 paths := ListSessions(root, cwd)
417430 summaries := make([]SessionSummary, 0, len(paths))
431+ metas := make(map[string]SessionMeta, len(paths))
432+ idToPath := make(map[string]string, len(paths))
433+ for _, p := range paths {
434+ if meta, err := readSessionMeta(p); err == nil && meta.ID != "" {
435+ metas[p] = meta
436+ idToPath[meta.ID] = p
437+ }
438+ }
418439 for _, p := range paths {
440+ if meta := metas[p]; meta.HideFromSessions {
441+ continue
442+ }
419443 summaries = append(summaries, describeSession(p))
420444 }
445+ for idx := range summaries {
446+ summaries[idx].BranchDepth = sessionBranchDepth(summaries[idx].Path, metas, idToPath)
447+ }
421448 return summaries
422449 }
423450
451+func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[string]string) int {
452+ depth := 0
453+ seen := map[string]bool{}
454+ for {
455+ meta, ok := metas[path]
456+ if !ok || meta.Parent == "" || seen[meta.ID] {
457+ return depth
458+ }
459+ seen[meta.ID] = true
460+ parentPath := idToPath[meta.Parent]
461+ if parentPath == "" {
462+ return depth
463+ }
464+ depth++
465+ path = parentPath
466+ }
467+}
468+
424469 func describeSession(path string) SessionSummary {
425470 s := SessionSummary{Path: path}
426471 f, err := os.Open(path)
@@ -428,6 +473,12 @@ func describeSession(path string) SessionSummary {
428473 return s
429474 }
430475 defer f.Close()
476+ isBranch := false
477+ forkPoint := 0
478+ messageIdx := 0
479+ branchPrompt := ""
480+ lastUser := ""
481+ renameMessageIdx := -1
431482 _ = forEachJSONLLine(f, func(line []byte) error {
432483 var head sessionLineHead
433484 if err := json.Unmarshal(line, &head); err != nil {
@@ -443,18 +494,28 @@ func describeSession(path string) SessionSummary {
443494 s.Model = row.Meta.Model
444495 s.Provider = row.Meta.Provider
445496 s.Title = row.Meta.Title
497+ isBranch = row.Meta.Parent != ""
498+ forkPoint = row.Meta.ForkPoint
446499 }
447500 case "message":
448- s.MessageCount++
449- if s.FirstUserText == "" {
450- s.FirstUserText = firstUserText(line)
501+ if text := firstUserText(line); text != "" {
502+ lastUser = text
503+ if s.FirstUserText == "" {
504+ s.FirstUserText = text
505+ }
506+ if isBranch && messageIdx >= forkPoint && branchPrompt == "" {
507+ branchPrompt = text
508+ }
451509 }
510+ messageIdx++
511+ s.MessageCount++
452512 case "compaction":
453513 if compacted, err := hydrateCompaction(line); err == nil {
454514 s.MessageCount = len(compacted)
455- if s.FirstUserText == "" && len(compacted) > 0 {
456- s.FirstUserText = firstTextFromMessage(compacted[0])
457- }
515+ messageIdx = len(compacted)
516+ s.FirstUserText = firstUserTextFromMessages(compacted)
517+ lastUser = lastUserText(compacted)
518+ branchPrompt = firstUserTextFromMessagesAfter(compacted, forkPoint)
458519 }
459520 case "rename":
460521 var row struct {
@@ -462,6 +523,7 @@ func describeSession(path string) SessionSummary {
462523 }
463524 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
464525 s.Title = row.Title
526+ renameMessageIdx = messageIdx
465527 }
466528 case "usage":
467529 var row struct {
@@ -473,6 +535,18 @@ func describeSession(path string) SessionSummary {
473535 }
474536 return nil
475537 })
538+ if isBranch {
539+ // A rename written after the branch has diverged is user intent
540+ // and must win. Older generated branch titles were written at
541+ // fork creation time, before any post-fork prompt, so those are
542+ // allowed to be replaced by the branch prompt fallback.
543+ userRenamedAfterFork := renameMessageIdx > forkPoint
544+ if !userRenamedAfterFork && branchPrompt != "" {
545+ s.Title = branchPrompt
546+ } else if s.Title == "" && lastUser != "" {
547+ s.Title = lastUser
548+ }
549+ }
476550 return s
477551 }
478552
@@ -499,6 +573,25 @@ func firstUserText(line []byte) string {
499573 return ""
500574 }
501575
576+func firstUserTextFromMessages(msgs []provider.Message) string {
577+ return firstUserTextFromMessagesAfter(msgs, 0)
578+}
579+
580+func firstUserTextFromMessagesAfter(msgs []provider.Message, start int) string {
581+ if start < 0 {
582+ start = 0
583+ }
584+ for idx, msg := range msgs {
585+ if idx < start || msg.Role != provider.RoleUser {
586+ continue
587+ }
588+ if text := firstTextFromMessage(msg); text != "" {
589+ return text
590+ }
591+ }
592+ return ""
593+}
594+
502595 func firstTextFromMessage(msg provider.Message) string {
503596 for _, c := range msg.Content {
504597 if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
@@ -59,6 +59,9 @@ type SessionMeta struct {
59 // are copied from the parent verbatim; the user's next turn on59 // are copied from the parent verbatim; the user's next turn on
60 // the child session continues from there.60 // the child session continues from there.
61 ForkPoint int `json:"fork_point,omitempty"`61 ForkPoint int `json:"fork_point,omitempty"`
62+ // HideFromSessions hides internal tree-navigation branches from the
63+ // flat /sessions picker while keeping them available to /session tree.
64+ HideFromSessions bool `json:"hide_from_sessions,omitempty"`
62 }65 }
63 66
64 // sessionLine is the on-disk row type. Message is kept as a raw67 // sessionLine is the on-disk row type. Message is kept as a raw
@@ -389,6 +392,7 @@ type SessionSummary struct {
389 FirstUserText string392 FirstUserText string
390 TotalCost float64393 TotalCost float64
391 Title string394 Title string
395+ BranchDepth int
392 }396 }
393 397
394 // RenameSession updates the title field in the session's meta line.398 // RenameSession updates the title field in the session's meta line.
@@ -409,18 +413,59 @@ func RenameSession(path, title string) error {
409 return err413 return err
410 }414 }
411 415
416+// DeleteSession removes a session file. It refuses an empty path so
417+// callers do not accidentally delete their cwd or another implicit target.
418+func DeleteSession(path string) error {
419+ if strings.TrimSpace(path) == "" {
420+ return fmt.Errorf("session path is empty")
421+ }
422+ return os.Remove(path)
423+}
424+
412 // DescribeSessions returns lightweight summaries for every session in425 // DescribeSessions returns lightweight summaries for every session in
413 // cwd, newest first. Parses only the first few lines and the last usage426 // cwd, newest first. Parses only the first few lines and the last usage
414 // line so it's cheap to run on every dialog open.427 // line so it's cheap to run on every dialog open.
415 func DescribeSessions(root, cwd string) []SessionSummary {428 func DescribeSessions(root, cwd string) []SessionSummary {
416 paths := ListSessions(root, cwd)429 paths := ListSessions(root, cwd)
417 summaries := make([]SessionSummary, 0, len(paths))430 summaries := make([]SessionSummary, 0, len(paths))
431+ metas := make(map[string]SessionMeta, len(paths))
432+ idToPath := make(map[string]string, len(paths))
433+ for _, p := range paths {
434+ if meta, err := readSessionMeta(p); err == nil && meta.ID != "" {
435+ metas[p] = meta
436+ idToPath[meta.ID] = p
437+ }
438+ }
418 for _, p := range paths {439 for _, p := range paths {
440+ if meta := metas[p]; meta.HideFromSessions {
441+ continue
442+ }
419 summaries = append(summaries, describeSession(p))443 summaries = append(summaries, describeSession(p))
420 }444 }
445+ for idx := range summaries {
446+ summaries[idx].BranchDepth = sessionBranchDepth(summaries[idx].Path, metas, idToPath)
447+ }
421 return summaries448 return summaries
422 }449 }
423 450
451+func sessionBranchDepth(path string, metas map[string]SessionMeta, idToPath map[string]string) int {
452+ depth := 0
453+ seen := map[string]bool{}
454+ for {
455+ meta, ok := metas[path]
456+ if !ok || meta.Parent == "" || seen[meta.ID] {
457+ return depth
458+ }
459+ seen[meta.ID] = true
460+ parentPath := idToPath[meta.Parent]
461+ if parentPath == "" {
462+ return depth
463+ }
464+ depth++
465+ path = parentPath
466+ }
467+}
468+
424 func describeSession(path string) SessionSummary {469 func describeSession(path string) SessionSummary {
425 s := SessionSummary{Path: path}470 s := SessionSummary{Path: path}
426 f, err := os.Open(path)471 f, err := os.Open(path)
@@ -428,6 +473,12 @@ func describeSession(path string) SessionSummary {
428 return s473 return s
429 }474 }
430 defer f.Close()475 defer f.Close()
476+ isBranch := false
477+ forkPoint := 0
478+ messageIdx := 0
479+ branchPrompt := ""
480+ lastUser := ""
481+ renameMessageIdx := -1
431 _ = forEachJSONLLine(f, func(line []byte) error {482 _ = forEachJSONLLine(f, func(line []byte) error {
432 var head sessionLineHead483 var head sessionLineHead
433 if err := json.Unmarshal(line, &head); err != nil {484 if err := json.Unmarshal(line, &head); err != nil {
@@ -443,18 +494,28 @@ func describeSession(path string) SessionSummary {
443 s.Model = row.Meta.Model494 s.Model = row.Meta.Model
444 s.Provider = row.Meta.Provider495 s.Provider = row.Meta.Provider
445 s.Title = row.Meta.Title496 s.Title = row.Meta.Title
497+ isBranch = row.Meta.Parent != ""
498+ forkPoint = row.Meta.ForkPoint
446 }499 }
447 case "message":500 case "message":
448- s.MessageCount++501+ if text := firstUserText(line); text != "" {
449- if s.FirstUserText == "" {502+ lastUser = text
450- s.FirstUserText = firstUserText(line)503+ if s.FirstUserText == "" {
504+ s.FirstUserText = text
505+ }
506+ if isBranch && messageIdx >= forkPoint && branchPrompt == "" {
507+ branchPrompt = text
508+ }
451 }509 }
510+ messageIdx++
511+ s.MessageCount++
452 case "compaction":512 case "compaction":
453 if compacted, err := hydrateCompaction(line); err == nil {513 if compacted, err := hydrateCompaction(line); err == nil {
454 s.MessageCount = len(compacted)514 s.MessageCount = len(compacted)
455- if s.FirstUserText == "" && len(compacted) > 0 {515+ messageIdx = len(compacted)
456- s.FirstUserText = firstTextFromMessage(compacted[0])516+ s.FirstUserText = firstUserTextFromMessages(compacted)
457- }517+ lastUser = lastUserText(compacted)
518+ branchPrompt = firstUserTextFromMessagesAfter(compacted, forkPoint)
458 }519 }
459 case "rename":520 case "rename":
460 var row struct {521 var row struct {
@@ -462,6 +523,7 @@ func describeSession(path string) SessionSummary {
462 }523 }
463 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {524 if err := json.Unmarshal(line, &row); err == nil && row.Title != "" {
464 s.Title = row.Title525 s.Title = row.Title
526+ renameMessageIdx = messageIdx
465 }527 }
466 case "usage":528 case "usage":
467 var row struct {529 var row struct {
@@ -473,6 +535,18 @@ func describeSession(path string) SessionSummary {
473 }535 }
474 return nil536 return nil
475 })537 })
538+ if isBranch {
539+ // A rename written after the branch has diverged is user intent
540+ // and must win. Older generated branch titles were written at
541+ // fork creation time, before any post-fork prompt, so those are
542+ // allowed to be replaced by the branch prompt fallback.
543+ userRenamedAfterFork := renameMessageIdx > forkPoint
544+ if !userRenamedAfterFork && branchPrompt != "" {
545+ s.Title = branchPrompt
546+ } else if s.Title == "" && lastUser != "" {
547+ s.Title = lastUser
548+ }
549+ }
476 return s550 return s
477 }551 }
478 552
@@ -499,6 +573,25 @@ func firstUserText(line []byte) string {
499 return ""573 return ""
500 }574 }
501 575
576+func firstUserTextFromMessages(msgs []provider.Message) string {
577+ return firstUserTextFromMessagesAfter(msgs, 0)
578+}
579+
580+func firstUserTextFromMessagesAfter(msgs []provider.Message, start int) string {
581+ if start < 0 {
582+ start = 0
583+ }
584+ for idx, msg := range msgs {
585+ if idx < start || msg.Role != provider.RoleUser {
586+ continue
587+ }
588+ if text := firstTextFromMessage(msg); text != "" {
589+ return text
590+ }
591+ }
592+ return ""
593+}
594+
502 func firstTextFromMessage(msg provider.Message) string {595 func firstTextFromMessage(msg provider.Message) string {
503 for _, c := range msg.Content {596 for _, c := range msg.Content {
504 if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {597 if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
M packages/core/session_portable.go +45 -12
@@ -256,6 +256,16 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
256256 //
257257 // Returns the path of the new session file, ready for OpenSession.
258258 func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
259+ return branchSession(parentPath, root, cwd, version, upToMessageIdx, false)
260+}
261+
262+// BranchSessionHidden creates a branch that participates in /session tree but
263+// is hidden from the flat /sessions picker. Used for in-place tree navigation.
264+func BranchSessionHidden(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
265+ return branchSession(parentPath, root, cwd, version, upToMessageIdx, true)
266+}
267+
268+func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hideFromSessions bool) (string, error) {
259269 if parentPath == "" {
260270 return "", errors.New("branch: parent path is empty")
261271 }
@@ -302,14 +312,15 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
302312
303313 // Write the branch meta.
304314 branchMeta := SessionMeta{
305- ID: newID,
306- CWD: cwd,
307- Model: parentMeta.Model,
308- Provider: parentMeta.Provider,
309- Started: time.Now().UTC(),
310- Version: version,
311- Parent: parentMeta.ID,
312- ForkPoint: upToMessageIdx,
315+ ID: newID,
316+ CWD: cwd,
317+ Model: parentMeta.Model,
318+ Provider: parentMeta.Provider,
319+ Started: time.Now().UTC(),
320+ Version: version,
321+ Parent: parentMeta.ID,
322+ ForkPoint: upToMessageIdx,
323+ HideFromSessions: hideFromSessions,
313324 }
314325 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
315326 if err != nil {
@@ -364,11 +375,12 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
364375 }); err != nil && err != io.EOF {
365376 return "", fmt.Errorf("branch: read parent: %w", err)
366377 }
378+ limit := upToMessageIdx
379+ if limit > len(effective) {
380+ limit = len(effective)
381+ }
382+ branchTitle := lastUserText(effective[:limit])
367383 if sawCompaction {
368- limit := upToMessageIdx
369- if limit > len(effective) {
370- limit = len(effective)
371- }
372384 for i := 0; i < limit; i++ {
373385 msg := effective[i]
374386 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
@@ -392,12 +404,33 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
392404 }
393405 }
394406 }
407+ if branchTitle != "" {
408+ line, _ := json.Marshal(map[string]string{"type": "rename", "title": branchTitle})
409+ if _, err := bw.Write(line); err != nil {
410+ return "", err
411+ }
412+ if err := bw.WriteByte('\n'); err != nil {
413+ return "", err
414+ }
415+ }
395416 if err := bw.Flush(); err != nil {
396417 return "", err
397418 }
398419 return outPath, nil
399420 }
400421
422+func lastUserText(msgs []provider.Message) string {
423+ for i := len(msgs) - 1; i >= 0; i-- {
424+ if msgs[i].Role != provider.RoleUser {
425+ continue
426+ }
427+ if text := strings.TrimSpace(firstTextFromMessage(msgs[i])); text != "" {
428+ return text
429+ }
430+ }
431+ return ""
432+}
433+
401434 // TreeNode is one entry in the branch tree returned by
402435 // BuildSessionTree. Children are populated by linking on Parent ID.
403436 type TreeNode struct {
@@ -256,6 +256,16 @@ func ImportSession(srcPath, root, cwd, version string) (string, error) {
256 //256 //
257 // Returns the path of the new session file, ready for OpenSession.257 // Returns the path of the new session file, ready for OpenSession.
258 func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {258 func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
259+ return branchSession(parentPath, root, cwd, version, upToMessageIdx, false)
260+}
261+
262+// BranchSessionHidden creates a branch that participates in /session tree but
263+// is hidden from the flat /sessions picker. Used for in-place tree navigation.
264+func BranchSessionHidden(parentPath, root, cwd, version string, upToMessageIdx int) (string, error) {
265+ return branchSession(parentPath, root, cwd, version, upToMessageIdx, true)
266+}
267+
268+func branchSession(parentPath, root, cwd, version string, upToMessageIdx int, hideFromSessions bool) (string, error) {
259 if parentPath == "" {269 if parentPath == "" {
260 return "", errors.New("branch: parent path is empty")270 return "", errors.New("branch: parent path is empty")
261 }271 }
@@ -302,14 +312,15 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
302 312
303 // Write the branch meta.313 // Write the branch meta.
304 branchMeta := SessionMeta{314 branchMeta := SessionMeta{
305- ID: newID,315+ ID: newID,
306- CWD: cwd,316+ CWD: cwd,
307- Model: parentMeta.Model,317+ Model: parentMeta.Model,
308- Provider: parentMeta.Provider,318+ Provider: parentMeta.Provider,
309- Started: time.Now().UTC(),319+ Started: time.Now().UTC(),
310- Version: version,320+ Version: version,
311- Parent: parentMeta.ID,321+ Parent: parentMeta.ID,
312- ForkPoint: upToMessageIdx,322+ ForkPoint: upToMessageIdx,
323+ HideFromSessions: hideFromSessions,
313 }324 }
314 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})325 metaLine, err := json.Marshal(sessionLine{Type: "meta", Meta: &branchMeta})
315 if err != nil {326 if err != nil {
@@ -364,11 +375,12 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
364 }); err != nil && err != io.EOF {375 }); err != nil && err != io.EOF {
365 return "", fmt.Errorf("branch: read parent: %w", err)376 return "", fmt.Errorf("branch: read parent: %w", err)
366 }377 }
378+ limit := upToMessageIdx
379+ if limit > len(effective) {
380+ limit = len(effective)
381+ }
382+ branchTitle := lastUserText(effective[:limit])
367 if sawCompaction {383 if sawCompaction {
368- limit := upToMessageIdx
369- if limit > len(effective) {
370- limit = len(effective)
371- }
372 for i := 0; i < limit; i++ {384 for i := 0; i < limit; i++ {
373 msg := effective[i]385 msg := effective[i]
374 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})386 line, err := json.Marshal(sessionLine{Type: "message", Message: &msg})
@@ -392,12 +404,33 @@ func BranchSession(parentPath, root, cwd, version string, upToMessageIdx int) (s
392 }404 }
393 }405 }
394 }406 }
407+ if branchTitle != "" {
408+ line, _ := json.Marshal(map[string]string{"type": "rename", "title": branchTitle})
409+ if _, err := bw.Write(line); err != nil {
410+ return "", err
411+ }
412+ if err := bw.WriteByte('\n'); err != nil {
413+ return "", err
414+ }
415+ }
395 if err := bw.Flush(); err != nil {416 if err := bw.Flush(); err != nil {
396 return "", err417 return "", err
397 }418 }
398 return outPath, nil419 return outPath, nil
399 }420 }
400 421
422+func lastUserText(msgs []provider.Message) string {
423+ for i := len(msgs) - 1; i >= 0; i-- {
424+ if msgs[i].Role != provider.RoleUser {
425+ continue
426+ }
427+ if text := strings.TrimSpace(firstTextFromMessage(msgs[i])); text != "" {
428+ return text
429+ }
430+ }
431+ return ""
432+}
433+
401 // TreeNode is one entry in the branch tree returned by434 // TreeNode is one entry in the branch tree returned by
402 // BuildSessionTree. Children are populated by linking on Parent ID.435 // BuildSessionTree. Children are populated by linking on Parent ID.
403 type TreeNode struct {436 type TreeNode struct {
M packages/core/session_portable_test.go +87 -0
@@ -229,6 +229,93 @@ func TestBranchSessionCopiesPrefix(t *testing.T) {
229229 if branch.Meta.ID == parent.Meta.ID {
230230 t.Errorf("branch kept parent id; must rotate")
231231 }
232+ if branch.Meta.Title != "" {
233+ t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title)
234+ }
235+ if summary := describeSession(branchPath); summary.Title != "first" {
236+ t.Errorf("branch title: want %q, got %q", "first", summary.Title)
237+ }
238+}
239+
240+func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) {
241+ root := t.TempDir()
242+ cwd := "/project"
243+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
244+ if err != nil {
245+ t.Fatal(err)
246+ }
247+ for _, msg := range []provider.Message{
248+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}},
249+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}},
250+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}},
251+ } {
252+ _ = parent.AppendMessage(msg)
253+ }
254+ _ = parent.Close()
255+
256+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
257+ if err != nil {
258+ t.Fatalf("BranchSession: %v", err)
259+ }
260+ if summary := describeSession(branchPath); summary.Title != "second prompt" {
261+ t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title)
262+ }
263+}
264+
265+func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) {
266+ root := t.TempDir()
267+ cwd := "/project"
268+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
269+ if err != nil {
270+ t.Fatal(err)
271+ }
272+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
273+ _ = parent.Close()
274+
275+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
276+ if err != nil {
277+ t.Fatalf("BranchSession: %v", err)
278+ }
279+ branch, _, err := OpenSession(branchPath)
280+ if err != nil {
281+ t.Fatal(err)
282+ }
283+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
284+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}})
285+ _ = branch.Close()
286+
287+ if summary := describeSession(branchPath); summary.Title != "how are you?" {
288+ t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title)
289+ }
290+}
291+
292+func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
293+ root := t.TempDir()
294+ cwd := "/project"
295+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
296+ if err != nil {
297+ t.Fatal(err)
298+ }
299+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
300+ _ = parent.Close()
301+
302+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
303+ if err != nil {
304+ t.Fatalf("BranchSession: %v", err)
305+ }
306+ branch, _, err := OpenSession(branchPath)
307+ if err != nil {
308+ t.Fatal(err)
309+ }
310+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
311+ _ = branch.Close()
312+ if err := RenameSession(branchPath, "friendly branch name"); err != nil {
313+ t.Fatal(err)
314+ }
315+
316+ if summary := describeSession(branchPath); summary.Title != "friendly branch name" {
317+ t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title)
318+ }
232319 }
233320
234321 func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
@@ -229,6 +229,93 @@ func TestBranchSessionCopiesPrefix(t *testing.T) {
229 if branch.Meta.ID == parent.Meta.ID {229 if branch.Meta.ID == parent.Meta.ID {
230 t.Errorf("branch kept parent id; must rotate")230 t.Errorf("branch kept parent id; must rotate")
231 }231 }
232+ if branch.Meta.Title != "" {
233+ t.Errorf("meta title should remain append-only, got %q", branch.Meta.Title)
234+ }
235+ if summary := describeSession(branchPath); summary.Title != "first" {
236+ t.Errorf("branch title: want %q, got %q", "first", summary.Title)
237+ }
238+}
239+
240+func TestBranchSessionTitleUsesSelectedUserPrompt(t *testing.T) {
241+ root := t.TempDir()
242+ cwd := "/project"
243+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
244+ if err != nil {
245+ t.Fatal(err)
246+ }
247+ for _, msg := range []provider.Message{
248+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first prompt"}}},
249+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "first reply"}}},
250+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "second prompt"}}},
251+ } {
252+ _ = parent.AppendMessage(msg)
253+ }
254+ _ = parent.Close()
255+
256+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 3)
257+ if err != nil {
258+ t.Fatalf("BranchSession: %v", err)
259+ }
260+ if summary := describeSession(branchPath); summary.Title != "second prompt" {
261+ t.Errorf("branch title: want %q, got %q", "second prompt", summary.Title)
262+ }
263+}
264+
265+func TestBranchSessionTitleUsesFirstPromptAfterForkWhenBranchDiverges(t *testing.T) {
266+ root := t.TempDir()
267+ cwd := "/project"
268+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
269+ if err != nil {
270+ t.Fatal(err)
271+ }
272+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
273+ _ = parent.Close()
274+
275+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
276+ if err != nil {
277+ t.Fatalf("BranchSession: %v", err)
278+ }
279+ branch, _, err := OpenSession(branchPath)
280+ if err != nil {
281+ t.Fatal(err)
282+ }
283+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
284+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "fine"}}})
285+ _ = branch.Close()
286+
287+ if summary := describeSession(branchPath); summary.Title != "how are you?" {
288+ t.Errorf("branch title: want %q, got %q", "how are you?", summary.Title)
289+ }
290+}
291+
292+func TestRenamedBranchKeepsExplicitTitle(t *testing.T) {
293+ root := t.TempDir()
294+ cwd := "/project"
295+ parent, err := NewSession(root, cwd, "anthropic", "claude-opus-4-7", "0.0.0-test")
296+ if err != nil {
297+ t.Fatal(err)
298+ }
299+ _ = parent.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hello"}}})
300+ _ = parent.Close()
301+
302+ branchPath, err := BranchSession(parent.Path, root, cwd, "0.0.0-test", 1)
303+ if err != nil {
304+ t.Fatalf("BranchSession: %v", err)
305+ }
306+ branch, _, err := OpenSession(branchPath)
307+ if err != nil {
308+ t.Fatal(err)
309+ }
310+ _ = branch.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "how are you?"}}})
311+ _ = branch.Close()
312+ if err := RenameSession(branchPath, "friendly branch name"); err != nil {
313+ t.Fatal(err)
314+ }
315+
316+ if summary := describeSession(branchPath); summary.Title != "friendly branch name" {
317+ t.Errorf("branch title: want %q, got %q", "friendly branch name", summary.Title)
318+ }
232 }319 }
233 320
234 func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {321 func TestBranchSessionUsesEffectiveTranscriptAfterCompaction(t *testing.T) {
M packages/provider/azure_openai.go +90 -1
@@ -30,7 +30,10 @@ import (
3030 "strings"
3131 )
3232
33-const defaultAzureAPIVersion = "2024-10-21"
33+const (
34+ defaultAzureAPIVersion = "2024-10-21"
35+ defaultAzureResponsesAPIVersion = "v1"
36+)
3437
3538 // azureRewriteTransport rewrites every outgoing OpenAI Chat Completions
3639 // URL into the Azure deployment-scoped shape:
@@ -134,3 +137,89 @@ func NewAzureOpenAI(apiKey, baseURL string) Client {
134137 http: httpClient,
135138 }
136139 }
140+
141+// azureResponsesTransport applies Azure API-key authentication and API
142+// versioning to requests using the OpenAI Responses wire format.
143+type azureResponsesTransport struct {
144+ inner http.RoundTripper
145+ apiKey string
146+ apiVersion string
147+}
148+
149+func (t *azureResponsesTransport) RoundTrip(req *http.Request) (*http.Response, error) {
150+ clone := req.Clone(req.Context())
151+ clone.Header.Del("authorization")
152+ clone.Header.Del("chatgpt-account-id")
153+ clone.Header.Del("openai-beta")
154+ clone.Header.Del("originator")
155+ clone.Header.Set("api-key", t.apiKey)
156+ q := clone.URL.Query()
157+ q.Set("api-version", t.apiVersion)
158+ clone.URL.RawQuery = q.Encode()
159+ return t.inner.RoundTrip(clone)
160+}
161+
162+// NewAzureOpenAIResponses creates an Azure OpenAI Responses API client.
163+// The configured model id is used as the deployment name unless overridden
164+// through AZURE_OPENAI_DEPLOYMENT_NAME_MAP (model=deployment pairs).
165+func NewAzureOpenAIResponses(apiKey, baseURL string) Client {
166+ if baseURL == "" {
167+ baseURL = os.Getenv("AZURE_OPENAI_BASE_URL")
168+ if baseURL == "" {
169+ if resource := os.Getenv("AZURE_OPENAI_RESOURCE_NAME"); resource != "" {
170+ baseURL = "https://" + resource + ".openai.azure.com"
171+ }
172+ }
173+ }
174+ if baseURL == "" {
175+ return &unimplementedClient{
176+ name: "azure-openai-responses",
177+ hint: "set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME (or pass --base-url)",
178+ }
179+ }
180+
181+ endpoint := strings.TrimRight(baseURL, "/")
182+ switch {
183+ case strings.HasSuffix(endpoint, "/openai/v1/responses"):
184+ case strings.HasSuffix(endpoint, "/openai/v1"):
185+ endpoint += "/responses"
186+ case strings.HasSuffix(endpoint, "/openai"):
187+ endpoint += "/v1/responses"
188+ default:
189+ endpoint += "/openai/v1/responses"
190+ }
191+ apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION")
192+ if apiVersion == "" {
193+ apiVersion = defaultAzureResponsesAPIVersion
194+ }
195+ httpClient := &http.Client{
196+ Transport: &azureResponsesTransport{
197+ inner: http.DefaultTransport,
198+ apiKey: apiKey,
199+ apiVersion: apiVersion,
200+ },
201+ Timeout: 0,
202+ }
203+ inner := &codexClient{
204+ token: apiKey,
205+ baseURL: endpoint,
206+ errorLabel: "azure openai",
207+ providerName: "azure-openai-responses",
208+ modelName: azureDeploymentName,
209+ disableCLIRouting: true,
210+ http: httpClient,
211+ }
212+ return &renamedClient{inner: inner, name: "azure-openai-responses"}
213+}
214+
215+func azureDeploymentName(model string) string {
216+ for _, pair := range strings.Split(os.Getenv("AZURE_OPENAI_DEPLOYMENT_NAME_MAP"), ",") {
217+ parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
218+ if len(parts) == 2 && strings.TrimSpace(parts[0]) == model {
219+ if deployment := strings.TrimSpace(parts[1]); deployment != "" {
220+ return deployment
221+ }
222+ }
223+ }
224+ return model
225+}
@@ -30,7 +30,10 @@ import (
30 "strings"30 "strings"
31 )31 )
32 32
33-const defaultAzureAPIVersion = "2024-10-21"33+const (
34+ defaultAzureAPIVersion = "2024-10-21"
35+ defaultAzureResponsesAPIVersion = "v1"
36+)
34 37
35 // azureRewriteTransport rewrites every outgoing OpenAI Chat Completions38 // azureRewriteTransport rewrites every outgoing OpenAI Chat Completions
36 // URL into the Azure deployment-scoped shape:39 // URL into the Azure deployment-scoped shape:
@@ -134,3 +137,89 @@ func NewAzureOpenAI(apiKey, baseURL string) Client {
134 http: httpClient,137 http: httpClient,
135 }138 }
136 }139 }
140+
141+// azureResponsesTransport applies Azure API-key authentication and API
142+// versioning to requests using the OpenAI Responses wire format.
143+type azureResponsesTransport struct {
144+ inner http.RoundTripper
145+ apiKey string
146+ apiVersion string
147+}
148+
149+func (t *azureResponsesTransport) RoundTrip(req *http.Request) (*http.Response, error) {
150+ clone := req.Clone(req.Context())
151+ clone.Header.Del("authorization")
152+ clone.Header.Del("chatgpt-account-id")
153+ clone.Header.Del("openai-beta")
154+ clone.Header.Del("originator")
155+ clone.Header.Set("api-key", t.apiKey)
156+ q := clone.URL.Query()
157+ q.Set("api-version", t.apiVersion)
158+ clone.URL.RawQuery = q.Encode()
159+ return t.inner.RoundTrip(clone)
160+}
161+
162+// NewAzureOpenAIResponses creates an Azure OpenAI Responses API client.
163+// The configured model id is used as the deployment name unless overridden
164+// through AZURE_OPENAI_DEPLOYMENT_NAME_MAP (model=deployment pairs).
165+func NewAzureOpenAIResponses(apiKey, baseURL string) Client {
166+ if baseURL == "" {
167+ baseURL = os.Getenv("AZURE_OPENAI_BASE_URL")
168+ if baseURL == "" {
169+ if resource := os.Getenv("AZURE_OPENAI_RESOURCE_NAME"); resource != "" {
170+ baseURL = "https://" + resource + ".openai.azure.com"
171+ }
172+ }
173+ }
174+ if baseURL == "" {
175+ return &unimplementedClient{
176+ name: "azure-openai-responses",
177+ hint: "set AZURE_OPENAI_BASE_URL or AZURE_OPENAI_RESOURCE_NAME (or pass --base-url)",
178+ }
179+ }
180+
181+ endpoint := strings.TrimRight(baseURL, "/")
182+ switch {
183+ case strings.HasSuffix(endpoint, "/openai/v1/responses"):
184+ case strings.HasSuffix(endpoint, "/openai/v1"):
185+ endpoint += "/responses"
186+ case strings.HasSuffix(endpoint, "/openai"):
187+ endpoint += "/v1/responses"
188+ default:
189+ endpoint += "/openai/v1/responses"
190+ }
191+ apiVersion := os.Getenv("AZURE_OPENAI_API_VERSION")
192+ if apiVersion == "" {
193+ apiVersion = defaultAzureResponsesAPIVersion
194+ }
195+ httpClient := &http.Client{
196+ Transport: &azureResponsesTransport{
197+ inner: http.DefaultTransport,
198+ apiKey: apiKey,
199+ apiVersion: apiVersion,
200+ },
201+ Timeout: 0,
202+ }
203+ inner := &codexClient{
204+ token: apiKey,
205+ baseURL: endpoint,
206+ errorLabel: "azure openai",
207+ providerName: "azure-openai-responses",
208+ modelName: azureDeploymentName,
209+ disableCLIRouting: true,
210+ http: httpClient,
211+ }
212+ return &renamedClient{inner: inner, name: "azure-openai-responses"}
213+}
214+
215+func azureDeploymentName(model string) string {
216+ for _, pair := range strings.Split(os.Getenv("AZURE_OPENAI_DEPLOYMENT_NAME_MAP"), ",") {
217+ parts := strings.SplitN(strings.TrimSpace(pair), "=", 2)
218+ if len(parts) == 2 && strings.TrimSpace(parts[0]) == model {
219+ if deployment := strings.TrimSpace(parts[1]); deployment != "" {
220+ return deployment
221+ }
222+ }
223+ }
224+ return model
225+}
A packages/provider/azure_openai_test.go +69 -0
@@ -0,0 +1,69 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestAzureOpenAIResponsesRequestShape(t *testing.T) {
13+ t.Setenv("AZURE_OPENAI_API_VERSION", "v1")
14+ t.Setenv("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", "gpt-5.6-sol=sol-preview")
15+
16+ named := NewAzureOpenAIResponses("azure-key", "https://example.openai.azure.com").(*renamedClient)
17+ c := named.inner.(*codexClient)
18+ transport := c.http.Transport.(*azureResponsesTransport)
19+ var gotReq *http.Request
20+ var gotBody codexRequest
21+ transport.inner = roundTripFunc(func(r *http.Request) (*http.Response, error) {
22+ gotReq = r
23+ if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
24+ t.Fatal(err)
25+ }
26+ return &http.Response{
27+ StatusCode: http.StatusOK,
28+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
29+ Body: io.NopCloser(strings.NewReader("")),
30+ }, nil
31+ })
32+
33+ events, err := c.Stream(context.Background(), Request{
34+ Model: "gpt-5.6-sol",
35+ Reasoning: "max",
36+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
37+ })
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ var startProvider string
42+ for event := range events {
43+ if start, ok := event.(EventStart); ok {
44+ startProvider = start.Provider
45+ }
46+ }
47+
48+ if startProvider != "azure-openai-responses" {
49+ t.Fatalf("start provider = %q", startProvider)
50+ }
51+ if gotReq == nil {
52+ t.Fatal("request was not sent")
53+ }
54+ if gotReq.URL.String() != "https://example.openai.azure.com/openai/v1/responses?api-version=v1" {
55+ t.Fatalf("url = %q", gotReq.URL.String())
56+ }
57+ if gotReq.Header.Get("api-key") != "azure-key" || gotReq.Header.Get("authorization") != "" {
58+ t.Fatalf("headers = %#v", gotReq.Header)
59+ }
60+ if gotBody.Model != "sol-preview" {
61+ t.Fatalf("model = %q", gotBody.Model)
62+ }
63+ if gotBody.Reasoning == nil || gotBody.Reasoning.Effort != "max" {
64+ t.Fatalf("reasoning = %+v", gotBody.Reasoning)
65+ }
66+ if gotBody.PromptCacheKey != "" {
67+ t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey)
68+ }
69+}
@@ -0,0 +1,69 @@
1+package provider
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "io"
7+ "net/http"
8+ "strings"
9+ "testing"
10+)
11+
12+func TestAzureOpenAIResponsesRequestShape(t *testing.T) {
13+ t.Setenv("AZURE_OPENAI_API_VERSION", "v1")
14+ t.Setenv("AZURE_OPENAI_DEPLOYMENT_NAME_MAP", "gpt-5.6-sol=sol-preview")
15+
16+ named := NewAzureOpenAIResponses("azure-key", "https://example.openai.azure.com").(*renamedClient)
17+ c := named.inner.(*codexClient)
18+ transport := c.http.Transport.(*azureResponsesTransport)
19+ var gotReq *http.Request
20+ var gotBody codexRequest
21+ transport.inner = roundTripFunc(func(r *http.Request) (*http.Response, error) {
22+ gotReq = r
23+ if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
24+ t.Fatal(err)
25+ }
26+ return &http.Response{
27+ StatusCode: http.StatusOK,
28+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
29+ Body: io.NopCloser(strings.NewReader("")),
30+ }, nil
31+ })
32+
33+ events, err := c.Stream(context.Background(), Request{
34+ Model: "gpt-5.6-sol",
35+ Reasoning: "max",
36+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
37+ })
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ var startProvider string
42+ for event := range events {
43+ if start, ok := event.(EventStart); ok {
44+ startProvider = start.Provider
45+ }
46+ }
47+
48+ if startProvider != "azure-openai-responses" {
49+ t.Fatalf("start provider = %q", startProvider)
50+ }
51+ if gotReq == nil {
52+ t.Fatal("request was not sent")
53+ }
54+ if gotReq.URL.String() != "https://example.openai.azure.com/openai/v1/responses?api-version=v1" {
55+ t.Fatalf("url = %q", gotReq.URL.String())
56+ }
57+ if gotReq.Header.Get("api-key") != "azure-key" || gotReq.Header.Get("authorization") != "" {
58+ t.Fatalf("headers = %#v", gotReq.Header)
59+ }
60+ if gotBody.Model != "sol-preview" {
61+ t.Fatalf("model = %q", gotBody.Model)
62+ }
63+ if gotBody.Reasoning == nil || gotBody.Reasoning.Effort != "max" {
64+ t.Fatalf("reasoning = %+v", gotBody.Reasoning)
65+ }
66+ if gotBody.PromptCacheKey != "" {
67+ t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey)
68+ }
69+}
M packages/provider/catalog_builtin.go +6 -0
@@ -466,6 +466,9 @@ var builtinCatalog = []Model{
466466 {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.4-pro", DisplayName: "GPT 5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"},
467467 {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5", DisplayName: "GPT 5.5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: "https://ai-gateway.vercel.sh"},
468468 {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5-pro", DisplayName: "GPT 5.5 Pro", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"},
469+ {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-luna", DisplayName: "GPT 5.6 Luna", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: "https://ai-gateway.vercel.sh"},
470+ {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-sol", DisplayName: "GPT 5.6 Sol", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://ai-gateway.vercel.sh"},
471+ {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-terra", DisplayName: "GPT 5.6 Terra", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, BaseURL: "https://ai-gateway.vercel.sh"},
469472 {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-20b", DisplayName: "GPT OSS 20B", ContextWindow: 131072, MaxOutput: 8192, Reasoning: true, PriceInput: 0.05, PriceOutput: 0.2, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"},
470473 {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-safeguard-20b", DisplayName: "GPT OSS Safeguard 20B", ContextWindow: 131072, MaxOutput: 65536, Reasoning: true, PriceInput: 0.075, PriceOutput: 0.3, PriceCacheRead: 0.037, BaseURL: "https://ai-gateway.vercel.sh"},
471474 {Provider: "vercel-ai-gateway", ID: "openai/o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: "https://ai-gateway.vercel.sh"},
@@ -622,6 +625,9 @@ var builtinCatalog = []Model{
622625 {Provider: "azure-openai-responses", ID: "gpt-5.4-pro", DisplayName: "GPT-5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""},
623626 {Provider: "azure-openai-responses", ID: "gpt-5.5", DisplayName: "GPT-5.5", ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: ""},
624627 {Provider: "azure-openai-responses", ID: "gpt-5.5-pro", DisplayName: "GPT-5.5 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""},
628+ {Provider: "azure-openai-responses", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: ""},
629+ {Provider: "azure-openai-responses", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: ""},
630+ {Provider: "azure-openai-responses", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, BaseURL: ""},
625631 {Provider: "azure-openai-responses", ID: "o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: ""},
626632 {Provider: "azure-openai-responses", ID: "o1-pro", DisplayName: "o1-pro", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 150, PriceOutput: 600, PriceCacheRead: 0, BaseURL: ""},
627633 {Provider: "azure-openai-responses", ID: "o3", DisplayName: "o3", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5, BaseURL: ""},
@@ -466,6 +466,9 @@ var builtinCatalog = []Model{
466 {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.4-pro", DisplayName: "GPT 5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"},466 {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.4-pro", DisplayName: "GPT 5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"},
467 {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5", DisplayName: "GPT 5.5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: "https://ai-gateway.vercel.sh"},467 {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5", DisplayName: "GPT 5.5", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: "https://ai-gateway.vercel.sh"},
468 {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5-pro", DisplayName: "GPT 5.5 Pro", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"},468 {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.5-pro", DisplayName: "GPT 5.5 Pro", ContextWindow: 1000000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"},
469+ {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-luna", DisplayName: "GPT 5.6 Luna", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: "https://ai-gateway.vercel.sh"},
470+ {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-sol", DisplayName: "GPT 5.6 Sol", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: "https://ai-gateway.vercel.sh"},
471+ {Provider: "vercel-ai-gateway", ID: "openai/gpt-5.6-terra", DisplayName: "GPT 5.6 Terra", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, BaseURL: "https://ai-gateway.vercel.sh"},
469 {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-20b", DisplayName: "GPT OSS 20B", ContextWindow: 131072, MaxOutput: 8192, Reasoning: true, PriceInput: 0.05, PriceOutput: 0.2, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"},472 {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-20b", DisplayName: "GPT OSS 20B", ContextWindow: 131072, MaxOutput: 8192, Reasoning: true, PriceInput: 0.05, PriceOutput: 0.2, PriceCacheRead: 0, BaseURL: "https://ai-gateway.vercel.sh"},
470 {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-safeguard-20b", DisplayName: "GPT OSS Safeguard 20B", ContextWindow: 131072, MaxOutput: 65536, Reasoning: true, PriceInput: 0.075, PriceOutput: 0.3, PriceCacheRead: 0.037, BaseURL: "https://ai-gateway.vercel.sh"},473 {Provider: "vercel-ai-gateway", ID: "openai/gpt-oss-safeguard-20b", DisplayName: "GPT OSS Safeguard 20B", ContextWindow: 131072, MaxOutput: 65536, Reasoning: true, PriceInput: 0.075, PriceOutput: 0.3, PriceCacheRead: 0.037, BaseURL: "https://ai-gateway.vercel.sh"},
471 {Provider: "vercel-ai-gateway", ID: "openai/o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: "https://ai-gateway.vercel.sh"},474 {Provider: "vercel-ai-gateway", ID: "openai/o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: "https://ai-gateway.vercel.sh"},
@@ -622,6 +625,9 @@ var builtinCatalog = []Model{
622 {Provider: "azure-openai-responses", ID: "gpt-5.4-pro", DisplayName: "GPT-5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""},625 {Provider: "azure-openai-responses", ID: "gpt-5.4-pro", DisplayName: "GPT-5.4 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""},
623 {Provider: "azure-openai-responses", ID: "gpt-5.5", DisplayName: "GPT-5.5", ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: ""},626 {Provider: "azure-openai-responses", ID: "gpt-5.5", DisplayName: "GPT-5.5", ContextWindow: 272000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, BaseURL: ""},
624 {Provider: "azure-openai-responses", ID: "gpt-5.5-pro", DisplayName: "GPT-5.5 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""},627 {Provider: "azure-openai-responses", ID: "gpt-5.5-pro", DisplayName: "GPT-5.5 Pro", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 30, PriceOutput: 180, PriceCacheRead: 0, BaseURL: ""},
628+ {Provider: "azure-openai-responses", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25, BaseURL: ""},
629+ {Provider: "azure-openai-responses", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25, BaseURL: ""},
630+ {Provider: "azure-openai-responses", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra", ContextWindow: 1050000, MaxOutput: 128000, Reasoning: true, PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125, BaseURL: ""},
625 {Provider: "azure-openai-responses", ID: "o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: ""},631 {Provider: "azure-openai-responses", ID: "o1", DisplayName: "o1", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 15, PriceOutput: 60, PriceCacheRead: 7.5, BaseURL: ""},
626 {Provider: "azure-openai-responses", ID: "o1-pro", DisplayName: "o1-pro", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 150, PriceOutput: 600, PriceCacheRead: 0, BaseURL: ""},632 {Provider: "azure-openai-responses", ID: "o1-pro", DisplayName: "o1-pro", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 150, PriceOutput: 600, PriceCacheRead: 0, BaseURL: ""},
627 {Provider: "azure-openai-responses", ID: "o3", DisplayName: "o3", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5, BaseURL: ""},633 {Provider: "azure-openai-responses", ID: "o3", DisplayName: "o3", ContextWindow: 200000, MaxOutput: 100000, Reasoning: true, PriceInput: 2, PriceOutput: 8, PriceCacheRead: 0.5, BaseURL: ""},
M packages/provider/extra_providers.go +0 -10
@@ -253,16 +253,6 @@ func NewGoogleVertex(apiKey, baseURL string) Client {
253253 return NewVertex(apiKey, baseURL)
254254 }
255255
256-// NewAzureOpenAIResponses delegates to the real Azure OpenAI client.
257-// Despite the provider id mentioning "responses", zot uses Azure's
258-// Chat Completions endpoint (older but functionally complete for our
259-// agent loop) to avoid duplicating the full openai-responses wire
260-// client. Models register under provider id `azure-openai-responses`
261-// so user catalogs keep working unchanged.
262-func NewAzureOpenAIResponses(apiKey, baseURL string) Client {
263- return NewAzureOpenAI(apiKey, baseURL)
264-}
265-
266256 // NewMistral returns a Mistral client using their OpenAI-compatible Chat
267257 // Completions endpoint at https://api.mistral.ai/v1. Mistral also offers a
268258 // bespoke "Conversations" API, but the OpenAI-compat endpoint supports
@@ -253,16 +253,6 @@ func NewGoogleVertex(apiKey, baseURL string) Client {
253 return NewVertex(apiKey, baseURL)253 return NewVertex(apiKey, baseURL)
254 }254 }
255 255
256-// NewAzureOpenAIResponses delegates to the real Azure OpenAI client.
257-// Despite the provider id mentioning "responses", zot uses Azure's
258-// Chat Completions endpoint (older but functionally complete for our
259-// agent loop) to avoid duplicating the full openai-responses wire
260-// client. Models register under provider id `azure-openai-responses`
261-// so user catalogs keep working unchanged.
262-func NewAzureOpenAIResponses(apiKey, baseURL string) Client {
263- return NewAzureOpenAI(apiKey, baseURL)
264-}
265-
266 // NewMistral returns a Mistral client using their OpenAI-compatible Chat256 // NewMistral returns a Mistral client using their OpenAI-compatible Chat
267 // Completions endpoint at https://api.mistral.ai/v1. Mistral also offers a257 // Completions endpoint at https://api.mistral.ai/v1. Mistral also offers a
268 // bespoke "Conversations" API, but the OpenAI-compat endpoint supports258 // bespoke "Conversations" API, but the OpenAI-compat endpoint supports
M packages/provider/gemini.go +1 -1
@@ -413,7 +413,7 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
413413 } else {
414414 lvl = "MEDIUM"
415415 }
416- case "high", "maximum":
416+ case "high", "xhigh", "max":
417417 lvl = "HIGH"
418418 default:
419419 return nil
@@ -413,7 +413,7 @@ func geminiThinkingConfig(modelID, level string) *gemThinkingConfig {
413 } else {413 } else {
414 lvl = "MEDIUM"414 lvl = "MEDIUM"
415 }415 }
416- case "high", "maximum":416+ case "high", "xhigh", "max":
417 lvl = "HIGH"417 lvl = "HIGH"
418 default:418 default:
419 return nil419 return nil
M packages/provider/models.go +36 -0
@@ -316,6 +316,24 @@ var Catalog = []Model{
316316 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
317317 Speculative: true,
318318 },
319+ {
320+ Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
321+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
322+ PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
323+ Speculative: true,
324+ },
325+ {
326+ Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
327+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
328+ PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
329+ Speculative: true,
330+ },
331+ {
332+ Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
333+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
334+ PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
335+ Speculative: true,
336+ },
319337
320338 // ---- OpenAI Codex / ChatGPT subscription backend ----
321339 // Same model ids as the OpenAI family, but routed through the
@@ -344,6 +362,24 @@ var Catalog = []Model{
344362 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
345363 Speculative: true,
346364 },
365+ {
366+ Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
367+ ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,
368+ PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
369+ Speculative: true,
370+ },
371+ {
372+ Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
373+ ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,
374+ PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
375+ Speculative: true,
376+ },
377+ {
378+ Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
379+ ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,
380+ PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
381+ Speculative: true,
382+ },
347383 }
348384
349385 // DefaultModel is used when the user does not specify one.
@@ -316,6 +316,24 @@ var Catalog = []Model{
316 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,316 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
317 Speculative: true,317 Speculative: true,
318 },318 },
319+ {
320+ Provider: "openai", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
321+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
322+ PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
323+ Speculative: true,
324+ },
325+ {
326+ Provider: "openai", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
327+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
328+ PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
329+ Speculative: true,
330+ },
331+ {
332+ Provider: "openai", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
333+ ContextWindow: 272000, MaxOutput: 128000, Reasoning: true,
334+ PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
335+ Speculative: true,
336+ },
319 337
320 // ---- OpenAI Codex / ChatGPT subscription backend ----338 // ---- OpenAI Codex / ChatGPT subscription backend ----
321 // Same model ids as the OpenAI family, but routed through the339 // Same model ids as the OpenAI family, but routed through the
@@ -344,6 +362,24 @@ var Catalog = []Model{
344 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,362 PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5,
345 Speculative: true,363 Speculative: true,
346 },364 },
365+ {
366+ Provider: "openai-codex", ID: "gpt-5.6-luna", DisplayName: "GPT-5.6 Luna",
367+ ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,
368+ PriceInput: 1, PriceOutput: 6, PriceCacheRead: 0.1, PriceCacheWrite: 1.25,
369+ Speculative: true,
370+ },
371+ {
372+ Provider: "openai-codex", ID: "gpt-5.6-sol", DisplayName: "GPT-5.6 Sol",
373+ ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,
374+ PriceInput: 5, PriceOutput: 30, PriceCacheRead: 0.5, PriceCacheWrite: 6.25,
375+ Speculative: true,
376+ },
377+ {
378+ Provider: "openai-codex", ID: "gpt-5.6-terra", DisplayName: "GPT-5.6 Terra",
379+ ContextWindow: 372000, MaxOutput: 128000, Reasoning: true,
380+ PriceInput: 2.5, PriceOutput: 15, PriceCacheRead: 0.25, PriceCacheWrite: 3.125,
381+ Speculative: true,
382+ },
347 }383 }
348 384
349 // DefaultModel is used when the user does not specify one.385 // DefaultModel is used when the user does not specify one.
M packages/provider/openai_codex.go +83 -13
@@ -3,7 +3,9 @@ package provider
33 import (
44 "bytes"
55 "context"
6+ "crypto/rand"
67 "encoding/base64"
8+ "encoding/hex"
79 "encoding/json"
810 "fmt"
911 "io"
@@ -36,10 +38,14 @@ import (
3638 const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses"
3739
3840 type codexClient struct {
39- token string
40- accountID string
41- baseURL string
42- http *http.Client
41+ token string
42+ accountID string
43+ baseURL string
44+ errorLabel string
45+ providerName string
46+ modelName func(string) string
47+ disableCLIRouting bool
48+ http *http.Client
4349 }
4450
4551 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
@@ -50,10 +56,12 @@ func NewOpenAICodex(token, accountID, baseURL string) Client {
5056 baseURL = codexDefaultBaseURL
5157 }
5258 return &codexClient{
53- token: token,
54- accountID: accountID,
55- baseURL: strings.TrimRight(baseURL, "/"),
56- http: &http.Client{Timeout: 0},
59+ token: token,
60+ accountID: accountID,
61+ baseURL: strings.TrimRight(baseURL, "/"),
62+ errorLabel: "codex",
63+ providerName: "openai-codex",
64+ http: &http.Client{Timeout: 0},
5765 }
5866 }
5967
@@ -146,6 +154,7 @@ type codexRequest struct {
146154 ParallelToolCalls bool `json:"parallel_tool_calls"`
147155 Include []string `json:"include,omitempty"`
148156 Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
157+ PromptCacheKey string `json:"prompt_cache_key,omitempty"`
149158 }
150159
151160 // ---- Request building ----
@@ -169,7 +178,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
169178 Include: []string{"reasoning.encrypted_content"},
170179 }
171180 if m.Reasoning {
172- if effort := OpenAICodexReasoningEffort(req.Reasoning); effort != "" {
181+ if effort := OpenAICodexReasoningEffort(req.Reasoning, req.Model); effort != "" {
173182 body.Reasoning = &codexReasoningConfig{Effort: effort}
174183 }
175184 }
@@ -310,6 +319,29 @@ func splitCallID(id string) (string, string) {
310319 return id, ""
311320 }
312321
322+func usesCodexCLIRouting(model string) bool {
323+ switch model {
324+ case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
325+ return true
326+ default:
327+ return false
328+ }
329+}
330+
331+func newCodexSessionID() string {
332+ var b [16]byte
333+ if _, err := rand.Read(b[:]); err != nil {
334+ return fmt.Sprintf("zot-%d", time.Now().UnixNano())
335+ }
336+ return strings.Join([]string{
337+ hex.EncodeToString(b[0:4]),
338+ hex.EncodeToString(b[4:6]),
339+ hex.EncodeToString(b[6:8]),
340+ hex.EncodeToString(b[8:10]),
341+ hex.EncodeToString(b[10:16]),
342+ }, "-")
343+}
344+
313345 // ---- Streaming ----
314346
315347 func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
@@ -317,6 +349,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
317349 if err != nil {
318350 return nil, err
319351 }
352+ if c.modelName != nil {
353+ wire.Model = c.modelName(wire.Model)
354+ }
355+ var codexCLISessionID string
356+ if !c.disableCLIRouting && usesCodexCLIRouting(wire.Model) {
357+ codexCLISessionID = newCodexSessionID()
358+ wire.PromptCacheKey = codexCLISessionID
359+ }
320360 body, err := json.Marshal(wire)
321361 if err != nil {
322362 return nil, err
@@ -332,8 +372,17 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
332372 httpReq.Header.Set("authorization", "Bearer "+c.token)
333373 httpReq.Header.Set("chatgpt-account-id", c.accountID)
334374 httpReq.Header.Set("openai-beta", "responses=experimental")
335- httpReq.Header.Set("originator", "zot")
336- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
375+ if codexCLISessionID != "" {
376+ // Some preview models are only admitted or reliably served by the
377+ // ChatGPT Codex backend when the request follows Codex CLI routing
378+ // metadata. Keep this narrow so Sol retains zot's proven shape.
379+ httpReq.Header.Set("originator", "codex_cli_rs")
380+ httpReq.Header.Set("session-id", codexCLISessionID)
381+ httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
382+ } else {
383+ httpReq.Header.Set("originator", "zot")
384+ httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
385+ }
337386 return httpReq, nil
338387 }
339388
@@ -360,7 +409,11 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
360409 if model.ID == "" {
361410 model, _ = FindModel("openai", req.Model)
362411 }
363- out <- EventStart{Model: req.Model, Provider: "openai-codex"}
412+ providerName := c.providerName
413+ if providerName == "" {
414+ providerName = "openai-codex"
415+ }
416+ out <- EventStart{Model: req.Model, Provider: providerName}
364417
365418 raw := make(chan sseEvent, 16)
366419 go readSSE(resp.Body, raw)
@@ -596,14 +649,31 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
596649 var p struct {
597650 Message string `json:"message"`
598651 Code string `json:"code"`
652+ Error struct {
653+ Message string `json:"message"`
654+ Code string `json:"code"`
655+ } `json:"error"`
599656 }
600657 _ = json.Unmarshal([]byte(ev.Data), &p)
601658 msg := p.Message
659+ if msg == "" {
660+ msg = p.Error.Message
661+ }
602662 if msg == "" {
603663 msg = p.Code
604664 }
665+ if msg == "" {
666+ msg = p.Error.Code
667+ }
668+ if msg == "" {
669+ msg = strings.TrimSpace(ev.Data)
670+ }
605671 stop = StopError
606- finalErr = fmt.Errorf("codex error: %s", msg)
672+ label := c.errorLabel
673+ if label == "" {
674+ label = "codex"
675+ }
676+ finalErr = fmt.Errorf("%s error: %s", label, msg)
607677 sendDone()
608678 return
609679 }
@@ -3,7 +3,9 @@ package provider
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "crypto/rand"
6 "encoding/base64"7 "encoding/base64"
8+ "encoding/hex"
7 "encoding/json"9 "encoding/json"
8 "fmt"10 "fmt"
9 "io"11 "io"
@@ -36,10 +38,14 @@ import (
36 const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses"38 const codexDefaultBaseURL = "https://chatgpt.com/backend-api/codex/responses"
37 39
38 type codexClient struct {40 type codexClient struct {
39- token string41+ token string
40- accountID string42+ accountID string
41- baseURL string43+ baseURL string
42- http *http.Client44+ errorLabel string
45+ providerName string
46+ modelName func(string) string
47+ disableCLIRouting bool
48+ http *http.Client
43 }49 }
44 50
45 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint51 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
@@ -50,10 +56,12 @@ func NewOpenAICodex(token, accountID, baseURL string) Client {
50 baseURL = codexDefaultBaseURL56 baseURL = codexDefaultBaseURL
51 }57 }
52 return &codexClient{58 return &codexClient{
53- token: token,59+ token: token,
54- accountID: accountID,60+ accountID: accountID,
55- baseURL: strings.TrimRight(baseURL, "/"),61+ baseURL: strings.TrimRight(baseURL, "/"),
56- http: &http.Client{Timeout: 0},62+ errorLabel: "codex",
63+ providerName: "openai-codex",
64+ http: &http.Client{Timeout: 0},
57 }65 }
58 }66 }
59 67
@@ -146,6 +154,7 @@ type codexRequest struct {
146 ParallelToolCalls bool `json:"parallel_tool_calls"`154 ParallelToolCalls bool `json:"parallel_tool_calls"`
147 Include []string `json:"include,omitempty"`155 Include []string `json:"include,omitempty"`
148 Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`156 Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
157+ PromptCacheKey string `json:"prompt_cache_key,omitempty"`
149 }158 }
150 159
151 // ---- Request building ----160 // ---- Request building ----
@@ -169,7 +178,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
169 Include: []string{"reasoning.encrypted_content"},178 Include: []string{"reasoning.encrypted_content"},
170 }179 }
171 if m.Reasoning {180 if m.Reasoning {
172- if effort := OpenAICodexReasoningEffort(req.Reasoning); effort != "" {181+ if effort := OpenAICodexReasoningEffort(req.Reasoning, req.Model); effort != "" {
173 body.Reasoning = &codexReasoningConfig{Effort: effort}182 body.Reasoning = &codexReasoningConfig{Effort: effort}
174 }183 }
175 }184 }
@@ -310,6 +319,29 @@ func splitCallID(id string) (string, string) {
310 return id, ""319 return id, ""
311 }320 }
312 321
322+func usesCodexCLIRouting(model string) bool {
323+ switch model {
324+ case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
325+ return true
326+ default:
327+ return false
328+ }
329+}
330+
331+func newCodexSessionID() string {
332+ var b [16]byte
333+ if _, err := rand.Read(b[:]); err != nil {
334+ return fmt.Sprintf("zot-%d", time.Now().UnixNano())
335+ }
336+ return strings.Join([]string{
337+ hex.EncodeToString(b[0:4]),
338+ hex.EncodeToString(b[4:6]),
339+ hex.EncodeToString(b[6:8]),
340+ hex.EncodeToString(b[8:10]),
341+ hex.EncodeToString(b[10:16]),
342+ }, "-")
343+}
344+
313 // ---- Streaming ----345 // ---- Streaming ----
314 346
315 func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {347 func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
@@ -317,6 +349,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
317 if err != nil {349 if err != nil {
318 return nil, err350 return nil, err
319 }351 }
352+ if c.modelName != nil {
353+ wire.Model = c.modelName(wire.Model)
354+ }
355+ var codexCLISessionID string
356+ if !c.disableCLIRouting && usesCodexCLIRouting(wire.Model) {
357+ codexCLISessionID = newCodexSessionID()
358+ wire.PromptCacheKey = codexCLISessionID
359+ }
320 body, err := json.Marshal(wire)360 body, err := json.Marshal(wire)
321 if err != nil {361 if err != nil {
322 return nil, err362 return nil, err
@@ -332,8 +372,17 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
332 httpReq.Header.Set("authorization", "Bearer "+c.token)372 httpReq.Header.Set("authorization", "Bearer "+c.token)
333 httpReq.Header.Set("chatgpt-account-id", c.accountID)373 httpReq.Header.Set("chatgpt-account-id", c.accountID)
334 httpReq.Header.Set("openai-beta", "responses=experimental")374 httpReq.Header.Set("openai-beta", "responses=experimental")
335- httpReq.Header.Set("originator", "zot")375+ if codexCLISessionID != "" {
336- httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))376+ // Some preview models are only admitted or reliably served by the
377+ // ChatGPT Codex backend when the request follows Codex CLI routing
378+ // metadata. Keep this narrow so Sol retains zot's proven shape.
379+ httpReq.Header.Set("originator", "codex_cli_rs")
380+ httpReq.Header.Set("session-id", codexCLISessionID)
381+ httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
382+ } else {
383+ httpReq.Header.Set("originator", "zot")
384+ httpReq.Header.Set("user-agent", fmt.Sprintf("zot (%s %s)", runtime.GOOS, runtime.GOARCH))
385+ }
337 return httpReq, nil386 return httpReq, nil
338 }387 }
339 388
@@ -360,7 +409,11 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
360 if model.ID == "" {409 if model.ID == "" {
361 model, _ = FindModel("openai", req.Model)410 model, _ = FindModel("openai", req.Model)
362 }411 }
363- out <- EventStart{Model: req.Model, Provider: "openai-codex"}412+ providerName := c.providerName
413+ if providerName == "" {
414+ providerName = "openai-codex"
415+ }
416+ out <- EventStart{Model: req.Model, Provider: providerName}
364 417
365 raw := make(chan sseEvent, 16)418 raw := make(chan sseEvent, 16)
366 go readSSE(resp.Body, raw)419 go readSSE(resp.Body, raw)
@@ -596,14 +649,31 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
596 var p struct {649 var p struct {
597 Message string `json:"message"`650 Message string `json:"message"`
598 Code string `json:"code"`651 Code string `json:"code"`
652+ Error struct {
653+ Message string `json:"message"`
654+ Code string `json:"code"`
655+ } `json:"error"`
599 }656 }
600 _ = json.Unmarshal([]byte(ev.Data), &p)657 _ = json.Unmarshal([]byte(ev.Data), &p)
601 msg := p.Message658 msg := p.Message
659+ if msg == "" {
660+ msg = p.Error.Message
661+ }
602 if msg == "" {662 if msg == "" {
603 msg = p.Code663 msg = p.Code
604 }664 }
665+ if msg == "" {
666+ msg = p.Error.Code
667+ }
668+ if msg == "" {
669+ msg = strings.TrimSpace(ev.Data)
670+ }
605 stop = StopError671 stop = StopError
606- finalErr = fmt.Errorf("codex error: %s", msg)672+ label := c.errorLabel
673+ if label == "" {
674+ label = "codex"
675+ }
676+ finalErr = fmt.Errorf("%s error: %s", label, msg)
607 sendDone()677 sendDone()
608 return678 return
609 }679 }
M packages/provider/openai_codex_test.go +158 -0
@@ -1,6 +1,11 @@
11 package provider
22
33 import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "net/http"
49 "strings"
510 "testing"
611 )
@@ -61,3 +66,156 @@ func TestCodexImageToolResultMirror(t *testing.T) {
6166 t.Fatalf("mirrored user image was not serialized as input_image")
6267 }
6368 }
69+
70+type roundTripFunc func(*http.Request) (*http.Response, error)
71+
72+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
73+
74+func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
75+ c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
76+ var gotReq *http.Request
77+ var gotBody codexRequest
78+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
79+ gotReq = r
80+ if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
81+ t.Fatal(err)
82+ }
83+ return &http.Response{
84+ StatusCode: http.StatusOK,
85+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
86+ Body: io.NopCloser(strings.NewReader("")),
87+ }, nil
88+ })
89+
90+ events, err := c.Stream(context.Background(), Request{
91+ Model: "gpt-5.6-terra",
92+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
93+ })
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ for range events {
98+ }
99+
100+ if gotReq == nil {
101+ t.Fatal("request was not sent")
102+ }
103+ if gotReq.Header.Get("originator") != "codex_cli_rs" {
104+ t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
105+ }
106+ if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
107+ t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
108+ }
109+ if gotBody.PromptCacheKey == "" {
110+ t.Fatal("prompt_cache_key was not set")
111+ }
112+ if gotReq.Header.Get("session-id") != gotBody.PromptCacheKey {
113+ t.Fatalf("session-id = %q, prompt_cache_key = %q", gotReq.Header.Get("session-id"), gotBody.PromptCacheKey)
114+ }
115+}
116+
117+func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
118+ c := NewOpenAICodex("token", "acct", "").(*codexClient)
119+ wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"})
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
124+ t.Fatalf("reasoning = %+v", wire.Reasoning)
125+ }
126+}
127+
128+func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
129+ named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient)
130+ c := named.inner.(*codexClient)
131+ var gotReq *http.Request
132+ var gotBody codexRequest
133+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
134+ gotReq = r
135+ if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
136+ t.Fatal(err)
137+ }
138+ return &http.Response{
139+ StatusCode: http.StatusOK,
140+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
141+ Body: io.NopCloser(strings.NewReader("")),
142+ }, nil
143+ })
144+
145+ events, err := c.Stream(context.Background(), Request{
146+ Model: "gpt-5.6-sol",
147+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
148+ })
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ for range events {
153+ }
154+
155+ if gotReq == nil {
156+ t.Fatal("request was not sent")
157+ }
158+ if gotReq.Header.Get("session-id") != "" {
159+ t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
160+ }
161+ if gotBody.PromptCacheKey != "" {
162+ t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey)
163+ }
164+}
165+
166+func TestCodexNestedStreamError(t *testing.T) {
167+ c := NewOpenAICodex("token", "acct", "").(*codexClient)
168+ resp := &http.Response{
169+ Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),
170+ }
171+ out := make(chan Event, 16)
172+ go c.runStream(context.Background(), resp, Request{Model: "gpt-5.6-sol"}, out)
173+
174+ var got error
175+ for ev := range out {
176+ if done, ok := ev.(EventDone); ok {
177+ got = done.Err
178+ }
179+ }
180+ if got == nil || got.Error() != "codex error: limited preview" {
181+ t.Fatalf("error = %v", got)
182+ }
183+}
184+
185+func TestCodexSolKeepsZotShape(t *testing.T) {
186+ c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
187+ var gotReq *http.Request
188+ var body bytes.Buffer
189+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
190+ gotReq = r
191+ _, _ = body.ReadFrom(r.Body)
192+ return &http.Response{
193+ StatusCode: http.StatusOK,
194+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
195+ Body: io.NopCloser(strings.NewReader("")),
196+ }, nil
197+ })
198+
199+ events, err := c.Stream(context.Background(), Request{
200+ Model: "gpt-5.6-sol",
201+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
202+ })
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ for range events {
207+ }
208+
209+ if gotReq == nil {
210+ t.Fatal("request was not sent")
211+ }
212+ if gotReq.Header.Get("originator") != "zot" {
213+ t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
214+ }
215+ if gotReq.Header.Get("session-id") != "" {
216+ t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
217+ }
218+ if strings.Contains(body.String(), "prompt_cache_key") {
219+ t.Fatalf("Sol request unexpectedly included prompt_cache_key: %s", body.String())
220+ }
221+}
@@ -1,6 +1,11 @@
1 package provider1 package provider
2 2
3 import (3 import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "io"
8+ "net/http"
4 "strings"9 "strings"
5 "testing"10 "testing"
6 )11 )
@@ -61,3 +66,156 @@ func TestCodexImageToolResultMirror(t *testing.T) {
61 t.Fatalf("mirrored user image was not serialized as input_image")66 t.Fatalf("mirrored user image was not serialized as input_image")
62 }67 }
63 }68 }
69+
70+type roundTripFunc func(*http.Request) (*http.Response, error)
71+
72+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
73+
74+func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
75+ c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
76+ var gotReq *http.Request
77+ var gotBody codexRequest
78+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
79+ gotReq = r
80+ if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
81+ t.Fatal(err)
82+ }
83+ return &http.Response{
84+ StatusCode: http.StatusOK,
85+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
86+ Body: io.NopCloser(strings.NewReader("")),
87+ }, nil
88+ })
89+
90+ events, err := c.Stream(context.Background(), Request{
91+ Model: "gpt-5.6-terra",
92+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
93+ })
94+ if err != nil {
95+ t.Fatal(err)
96+ }
97+ for range events {
98+ }
99+
100+ if gotReq == nil {
101+ t.Fatal("request was not sent")
102+ }
103+ if gotReq.Header.Get("originator") != "codex_cli_rs" {
104+ t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
105+ }
106+ if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
107+ t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
108+ }
109+ if gotBody.PromptCacheKey == "" {
110+ t.Fatal("prompt_cache_key was not set")
111+ }
112+ if gotReq.Header.Get("session-id") != gotBody.PromptCacheKey {
113+ t.Fatalf("session-id = %q, prompt_cache_key = %q", gotReq.Header.Get("session-id"), gotBody.PromptCacheKey)
114+ }
115+}
116+
117+func TestGPT56UsesNativeMaxReasoningEffort(t *testing.T) {
118+ c := NewOpenAICodex("token", "acct", "").(*codexClient)
119+ wire, err := c.buildRequest(Request{Model: "gpt-5.6-sol", Reasoning: "max"})
120+ if err != nil {
121+ t.Fatal(err)
122+ }
123+ if wire.Reasoning == nil || wire.Reasoning.Effort != "max" {
124+ t.Fatalf("reasoning = %+v", wire.Reasoning)
125+ }
126+}
127+
128+func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
129+ named := NewOpenAIResponsesNamed("token", "https://example.test/v1/responses", "openai").(*renamedClient)
130+ c := named.inner.(*codexClient)
131+ var gotReq *http.Request
132+ var gotBody codexRequest
133+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
134+ gotReq = r
135+ if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil {
136+ t.Fatal(err)
137+ }
138+ return &http.Response{
139+ StatusCode: http.StatusOK,
140+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
141+ Body: io.NopCloser(strings.NewReader("")),
142+ }, nil
143+ })
144+
145+ events, err := c.Stream(context.Background(), Request{
146+ Model: "gpt-5.6-sol",
147+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
148+ })
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ for range events {
153+ }
154+
155+ if gotReq == nil {
156+ t.Fatal("request was not sent")
157+ }
158+ if gotReq.Header.Get("session-id") != "" {
159+ t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
160+ }
161+ if gotBody.PromptCacheKey != "" {
162+ t.Fatalf("prompt_cache_key = %q", gotBody.PromptCacheKey)
163+ }
164+}
165+
166+func TestCodexNestedStreamError(t *testing.T) {
167+ c := NewOpenAICodex("token", "acct", "").(*codexClient)
168+ resp := &http.Response{
169+ Body: io.NopCloser(strings.NewReader("data: {\"type\":\"error\",\"error\":{\"code\":\"model_not_available\",\"message\":\"limited preview\"}}\n\n")),
170+ }
171+ out := make(chan Event, 16)
172+ go c.runStream(context.Background(), resp, Request{Model: "gpt-5.6-sol"}, out)
173+
174+ var got error
175+ for ev := range out {
176+ if done, ok := ev.(EventDone); ok {
177+ got = done.Err
178+ }
179+ }
180+ if got == nil || got.Error() != "codex error: limited preview" {
181+ t.Fatalf("error = %v", got)
182+ }
183+}
184+
185+func TestCodexSolKeepsZotShape(t *testing.T) {
186+ c := NewOpenAICodex("token", "acct", "https://example.test/backend-api/codex/responses").(*codexClient)
187+ var gotReq *http.Request
188+ var body bytes.Buffer
189+ c.http.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
190+ gotReq = r
191+ _, _ = body.ReadFrom(r.Body)
192+ return &http.Response{
193+ StatusCode: http.StatusOK,
194+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
195+ Body: io.NopCloser(strings.NewReader("")),
196+ }, nil
197+ })
198+
199+ events, err := c.Stream(context.Background(), Request{
200+ Model: "gpt-5.6-sol",
201+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
202+ })
203+ if err != nil {
204+ t.Fatal(err)
205+ }
206+ for range events {
207+ }
208+
209+ if gotReq == nil {
210+ t.Fatal("request was not sent")
211+ }
212+ if gotReq.Header.Get("originator") != "zot" {
213+ t.Fatalf("originator = %q", gotReq.Header.Get("originator"))
214+ }
215+ if gotReq.Header.Get("session-id") != "" {
216+ t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
217+ }
218+ if strings.Contains(body.String(), "prompt_cache_key") {
219+ t.Fatalf("Sol request unexpectedly included prompt_cache_key: %s", body.String())
220+ }
221+}
M packages/provider/openai_responses.go +14 -5
@@ -48,6 +48,13 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response,
4848 //
4949 // baseURL may be empty; defaults to https://api.openai.com/v1/responses.
5050 func NewOpenAIResponses(apiKey, baseURL string) Client {
51+ return NewOpenAIResponsesNamed(apiKey, baseURL, "openai-responses")
52+}
53+
54+// NewOpenAIResponsesNamed returns a public OpenAI Responses-API client
55+// reporting the supplied provider name. This lets the `openai` provider
56+// route Responses-only preview models without changing the visible provider.
57+func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client {
5158 if baseURL == "" {
5259 baseURL = openaiResponsesDefaultBaseURL
5360 }
@@ -56,10 +63,12 @@ func NewOpenAIResponses(apiKey, baseURL string) Client {
5663 Timeout: 0,
5764 }
5865 inner := &codexClient{
59- token: apiKey,
60- accountID: "", // unused; transport strips the header
61- baseURL: strings.TrimRight(baseURL, "/"),
62- http: httpClient,
66+ token: apiKey,
67+ accountID: "", // unused; transport strips the header
68+ baseURL: strings.TrimRight(baseURL, "/"),
69+ errorLabel: "openai",
70+ providerName: name,
71+ http: httpClient,
6372 }
64- return &renamedClient{inner: inner, name: "openai-responses"}
73+ return &renamedClient{inner: inner, name: name}
6574 }
@@ -48,6 +48,13 @@ func (t *openaiResponsesTransport) RoundTrip(req *http.Request) (*http.Response,
48 //48 //
49 // baseURL may be empty; defaults to https://api.openai.com/v1/responses.49 // baseURL may be empty; defaults to https://api.openai.com/v1/responses.
50 func NewOpenAIResponses(apiKey, baseURL string) Client {50 func NewOpenAIResponses(apiKey, baseURL string) Client {
51+ return NewOpenAIResponsesNamed(apiKey, baseURL, "openai-responses")
52+}
53+
54+// NewOpenAIResponsesNamed returns a public OpenAI Responses-API client
55+// reporting the supplied provider name. This lets the `openai` provider
56+// route Responses-only preview models without changing the visible provider.
57+func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client {
51 if baseURL == "" {58 if baseURL == "" {
52 baseURL = openaiResponsesDefaultBaseURL59 baseURL = openaiResponsesDefaultBaseURL
53 }60 }
@@ -56,10 +63,12 @@ func NewOpenAIResponses(apiKey, baseURL string) Client {
56 Timeout: 0,63 Timeout: 0,
57 }64 }
58 inner := &codexClient{65 inner := &codexClient{
59- token: apiKey,66+ token: apiKey,
60- accountID: "", // unused; transport strips the header67+ accountID: "", // unused; transport strips the header
61- baseURL: strings.TrimRight(baseURL, "/"),68+ baseURL: strings.TrimRight(baseURL, "/"),
62- http: httpClient,69+ errorLabel: "openai",
70+ providerName: name,
71+ http: httpClient,
63 }72 }
64- return &renamedClient{inner: inner, name: "openai-responses"}73+ return &renamedClient{inner: inner, name: name}
65 }74 }
M packages/provider/provider.go +3 -4
@@ -216,10 +216,9 @@ type Request struct {
216216 Tools []Tool
217217 MaxTokens int
218218 Temperature *float32
219- // Reasoning is "", "minimum", "low", "medium", "high", or "maximum".
220- // Empty disables reasoning. Budget-based providers map these to roughly
221- // 1k/2k/8k/16k/32k thinking tokens; effort-based providers map them onto
222- // their closest supported reasoning_effort values.
219+ // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or
220+ // "max". Empty disables reasoning. The max tier is sent natively only to
221+ // models that support it and clamped elsewhere.
223222 Reasoning string
224223 }
225224
@@ -216,10 +216,9 @@ type Request struct {
216 Tools []Tool216 Tools []Tool
217 MaxTokens int217 MaxTokens int
218 Temperature *float32218 Temperature *float32
219- // Reasoning is "", "minimum", "low", "medium", "high", or "maximum".219+ // Reasoning is "", "minimum", "low", "medium", "high", "xhigh", or
220- // Empty disables reasoning. Budget-based providers map these to roughly220+ // "max". Empty disables reasoning. The max tier is sent natively only to
221- // 1k/2k/8k/16k/32k thinking tokens; effort-based providers map them onto221+ // models that support it and clamped elsewhere.
222- // their closest supported reasoning_effort values.
223 Reasoning string222 Reasoning string
224 }223 }
225 224
M packages/provider/provider_test.go +50 -0
@@ -118,6 +118,19 @@ func TestAnthropicAdaptiveThinking(t *testing.T) {
118118 t.Fatalf("want effort=xhigh, got %+v", wire.OutputConfig)
119119 }
120120
121+ // max is a separate native tier above xhigh on adaptive models.
122+ wire, err = c.buildRequest(Request{
123+ Model: "claude-opus-4-8",
124+ Reasoning: "max",
125+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
126+ })
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if wire.OutputConfig == nil || wire.OutputConfig.Effort != "max" {
131+ t.Fatalf("want effort=max, got %+v", wire.OutputConfig)
132+ }
133+
121134 // Sonnet 5 -> adaptive thinking, effort set, no budget, no temperature.
122135 wire, err = c.buildRequest(Request{
123136 Model: "claude-sonnet-5",
@@ -385,6 +398,43 @@ func TestDiscoverOpenRouter(t *testing.T) {
385398 }
386399 }
387400
401+func TestGPT56CatalogEntries(t *testing.T) {
402+ cases := []struct {
403+ provider string
404+ id string
405+ context int
406+ priceInput float64
407+ priceOut float64
408+ cacheRead float64
409+ cacheWrite float64
410+ }{
411+ {"openai", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25},
412+ {"openai", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25},
413+ {"openai", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125},
414+ {"openai-codex", "gpt-5.6-luna", 372000, 1, 6, 0.1, 1.25},
415+ {"openai-codex", "gpt-5.6-sol", 372000, 5, 30, 0.5, 6.25},
416+ {"openai-codex", "gpt-5.6-terra", 372000, 2.5, 15, 0.25, 3.125},
417+ {"azure-openai-responses", "gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25},
418+ {"azure-openai-responses", "gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25},
419+ {"azure-openai-responses", "gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125},
420+ {"vercel-ai-gateway", "openai/gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25},
421+ {"vercel-ai-gateway", "openai/gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25},
422+ {"vercel-ai-gateway", "openai/gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125},
423+ }
424+ for _, tc := range cases {
425+ m, err := FindModel(tc.provider, tc.id)
426+ if err != nil {
427+ t.Fatal(err)
428+ }
429+ if m.ContextWindow != tc.context || m.MaxOutput != 128000 || !m.Reasoning {
430+ t.Fatalf("%s/%s limits: %+v", tc.provider, tc.id, m)
431+ }
432+ if m.PriceInput != tc.priceInput || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead || m.PriceCacheWrite != tc.cacheWrite {
433+ t.Fatalf("%s/%s prices: %+v", tc.provider, tc.id, m)
434+ }
435+ }
436+}
437+
388438 // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for
389439 // discovered models that don't advertise an output cap (MaxOutput == 0).
390440 func TestOpenAIOmitsZeroMaxTokens(t *testing.T) {
@@ -118,6 +118,19 @@ func TestAnthropicAdaptiveThinking(t *testing.T) {
118 t.Fatalf("want effort=xhigh, got %+v", wire.OutputConfig)118 t.Fatalf("want effort=xhigh, got %+v", wire.OutputConfig)
119 }119 }
120 120
121+ // max is a separate native tier above xhigh on adaptive models.
122+ wire, err = c.buildRequest(Request{
123+ Model: "claude-opus-4-8",
124+ Reasoning: "max",
125+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
126+ })
127+ if err != nil {
128+ t.Fatal(err)
129+ }
130+ if wire.OutputConfig == nil || wire.OutputConfig.Effort != "max" {
131+ t.Fatalf("want effort=max, got %+v", wire.OutputConfig)
132+ }
133+
121 // Sonnet 5 -> adaptive thinking, effort set, no budget, no temperature.134 // Sonnet 5 -> adaptive thinking, effort set, no budget, no temperature.
122 wire, err = c.buildRequest(Request{135 wire, err = c.buildRequest(Request{
123 Model: "claude-sonnet-5",136 Model: "claude-sonnet-5",
@@ -385,6 +398,43 @@ func TestDiscoverOpenRouter(t *testing.T) {
385 }398 }
386 }399 }
387 400
401+func TestGPT56CatalogEntries(t *testing.T) {
402+ cases := []struct {
403+ provider string
404+ id string
405+ context int
406+ priceInput float64
407+ priceOut float64
408+ cacheRead float64
409+ cacheWrite float64
410+ }{
411+ {"openai", "gpt-5.6-luna", 272000, 1, 6, 0.1, 1.25},
412+ {"openai", "gpt-5.6-sol", 272000, 5, 30, 0.5, 6.25},
413+ {"openai", "gpt-5.6-terra", 272000, 2.5, 15, 0.25, 3.125},
414+ {"openai-codex", "gpt-5.6-luna", 372000, 1, 6, 0.1, 1.25},
415+ {"openai-codex", "gpt-5.6-sol", 372000, 5, 30, 0.5, 6.25},
416+ {"openai-codex", "gpt-5.6-terra", 372000, 2.5, 15, 0.25, 3.125},
417+ {"azure-openai-responses", "gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25},
418+ {"azure-openai-responses", "gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25},
419+ {"azure-openai-responses", "gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125},
420+ {"vercel-ai-gateway", "openai/gpt-5.6-luna", 1050000, 1, 6, 0.1, 1.25},
421+ {"vercel-ai-gateway", "openai/gpt-5.6-sol", 1050000, 5, 30, 0.5, 6.25},
422+ {"vercel-ai-gateway", "openai/gpt-5.6-terra", 1050000, 2.5, 15, 0.25, 3.125},
423+ }
424+ for _, tc := range cases {
425+ m, err := FindModel(tc.provider, tc.id)
426+ if err != nil {
427+ t.Fatal(err)
428+ }
429+ if m.ContextWindow != tc.context || m.MaxOutput != 128000 || !m.Reasoning {
430+ t.Fatalf("%s/%s limits: %+v", tc.provider, tc.id, m)
431+ }
432+ if m.PriceInput != tc.priceInput || m.PriceOutput != tc.priceOut || m.PriceCacheRead != tc.cacheRead || m.PriceCacheWrite != tc.cacheWrite {
433+ t.Fatalf("%s/%s prices: %+v", tc.provider, tc.id, m)
434+ }
435+ }
436+}
437+
388 // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for438 // TestOpenAIOmitsZeroMaxTokens guards against sending max_tokens: 0 for
389 // discovered models that don't advertise an output cap (MaxOutput == 0).439 // discovered models that don't advertise an output cap (MaxOutput == 0).
390 func TestOpenAIOmitsZeroMaxTokens(t *testing.T) {440 func TestOpenAIOmitsZeroMaxTokens(t *testing.T) {
M packages/provider/reasoning.go +31 -28
@@ -3,7 +3,8 @@ package provider
33 import "strings"
44
55 // NormalizeReasoning canonicalizes zot's user-facing thinking levels.
6-// Empty string means reasoning/thinking is disabled.
6+// Empty string means reasoning/thinking is disabled. "maximum" remains
7+// an alias for xhigh; "max" is the separate opt-in tier above it.
78 func NormalizeReasoning(level string) string {
89 switch strings.ToLower(strings.TrimSpace(level)) {
910 case "", "off", "none", "no", "false", "disabled":
@@ -16,8 +17,10 @@ func NormalizeReasoning(level string) string {
1617 return "medium"
1718 case "hi", "high":
1819 return "high"
19- case "max", "maximum":
20- return "maximum"
20+ case "xhigh", "maximum":
21+ return "xhigh"
22+ case "max":
23+ return "max"
2124 default:
2225 return strings.ToLower(strings.TrimSpace(level))
2326 }
@@ -35,7 +38,7 @@ func ReasoningBudget(level string) int {
3538 return 8192
3639 case "high":
3740 return 16384
38- case "maximum":
41+ case "xhigh", "max":
3942 return 32768
4043 default:
4144 return 0
@@ -43,10 +46,8 @@ func ReasoningBudget(level string) int {
4346 }
4447
4548 // AnthropicAdaptiveEffort maps zot's user-facing thinking levels onto the
46-// effort enum used by Anthropic's adaptive-thinking models (Opus 4.7+).
47-// These models reject explicit thinking budgets; thinking depth is
48-// controlled by output_config.effort instead. Returns "" when reasoning
49-// is disabled.
49+// effort enum used by adaptive-thinking models. These models reject explicit
50+// thinking budgets; reasoning depth is controlled by output_config.effort.
5051 func AnthropicAdaptiveEffort(level string) string {
5152 switch NormalizeReasoning(level) {
5253 case "minimum", "low":
@@ -55,38 +56,34 @@ func AnthropicAdaptiveEffort(level string) string {
5556 return "medium"
5657 case "high":
5758 return "high"
58- case "maximum":
59+ case "xhigh":
5960 return "xhigh"
61+ case "max":
62+ return "max"
6063 default:
6164 return ""
6265 }
6366 }
6467
65-// OpenAIReasoningEffort maps zot's six-level setting onto the effort enum
66-// accepted by OpenAI-compatible chat-completions endpoints.
68+// OpenAIReasoningEffort maps zot's thinking setting onto the effort enum
69+// accepted by generic OpenAI-compatible chat-completions endpoints.
6770 func OpenAIReasoningEffort(level string) string {
6871 switch NormalizeReasoning(level) {
6972 case "minimum", "low":
70- // Many OpenAI-compatible endpoints only accept low/medium/high.
71- // Use low for zot's minimum instead of the newer minimal enum.
73+ // Many compatible endpoints only accept low/medium/high.
7274 return "low"
7375 case "medium":
7476 return "medium"
75- case "high", "maximum":
77+ case "high", "xhigh", "max":
7678 return "high"
7779 default:
7880 return ""
7981 }
8082 }
8183
82-// OpenAICompatAnthropicEffort maps zot's user-facing thinking levels
83-// onto reasoning_effort values when an adaptive-thinking Anthropic
84-// model (Opus 4.7+) is served over the OpenAI-compatible chat-
85-// completions wire (openrouter, opencode, ...). Differs from
86-// OpenAIReasoningEffort only at the top: zot's "maximum" maps to
87-// "xhigh" instead of being clamped to "high", so the model's full
88-// adaptive-thinking ceiling is preserved when reachable through a
89-// gateway that accepts the effort knob.
84+// OpenAICompatAnthropicEffort maps zot's thinking setting when an adaptive
85+// Anthropic model is served over an OpenAI-compatible chat-completions wire.
86+// Adaptive models accept native xhigh and max effort values.
9087 func OpenAICompatAnthropicEffort(level string) string {
9188 switch NormalizeReasoning(level) {
9289 case "minimum", "low":
@@ -95,17 +92,18 @@ func OpenAICompatAnthropicEffort(level string) string {
9592 return "medium"
9693 case "high":
9794 return "high"
98- case "maximum":
95+ case "xhigh":
9996 return "xhigh"
97+ case "max":
98+ return "max"
10099 default:
101100 return ""
102101 }
103102 }
104103
105-// OpenAICodexReasoningEffort maps zot levels onto the ChatGPT/Codex
106-// Responses backend enum. That backend rejects "minimal" and uses
107-// "xhigh" for the highest tier on recent GPT-5.x models.
108-func OpenAICodexReasoningEffort(level string) string {
104+// OpenAICodexReasoningEffort maps zot levels onto the Responses API effort
105+// enum. GPT-5.6 supports native max; other models clamp max to xhigh.
106+func OpenAICodexReasoningEffort(level, model string) string {
109107 switch NormalizeReasoning(level) {
110108 case "minimum", "low":
111109 return "low"
@@ -113,7 +111,12 @@ func OpenAICodexReasoningEffort(level string) string {
113111 return "medium"
114112 case "high":
115113 return "high"
116- case "maximum":
114+ case "xhigh":
115+ return "xhigh"
116+ case "max":
117+ if strings.HasPrefix(strings.ToLower(model), "gpt-5.6-") {
118+ return "max"
119+ }
117120 return "xhigh"
118121 default:
119122 return ""
@@ -3,7 +3,8 @@ package provider
3 import "strings"3 import "strings"
4 4
5 // NormalizeReasoning canonicalizes zot's user-facing thinking levels.5 // NormalizeReasoning canonicalizes zot's user-facing thinking levels.
6-// Empty string means reasoning/thinking is disabled.6+// Empty string means reasoning/thinking is disabled. "maximum" remains
7+// an alias for xhigh; "max" is the separate opt-in tier above it.
7 func NormalizeReasoning(level string) string {8 func NormalizeReasoning(level string) string {
8 switch strings.ToLower(strings.TrimSpace(level)) {9 switch strings.ToLower(strings.TrimSpace(level)) {
9 case "", "off", "none", "no", "false", "disabled":10 case "", "off", "none", "no", "false", "disabled":
@@ -16,8 +17,10 @@ func NormalizeReasoning(level string) string {
16 return "medium"17 return "medium"
17 case "hi", "high":18 case "hi", "high":
18 return "high"19 return "high"
19- case "max", "maximum":20+ case "xhigh", "maximum":
20- return "maximum"21+ return "xhigh"
22+ case "max":
23+ return "max"
21 default:24 default:
22 return strings.ToLower(strings.TrimSpace(level))25 return strings.ToLower(strings.TrimSpace(level))
23 }26 }
@@ -35,7 +38,7 @@ func ReasoningBudget(level string) int {
35 return 819238 return 8192
36 case "high":39 case "high":
37 return 1638440 return 16384
38- case "maximum":41+ case "xhigh", "max":
39 return 3276842 return 32768
40 default:43 default:
41 return 044 return 0
@@ -43,10 +46,8 @@ func ReasoningBudget(level string) int {
43 }46 }
44 47
45 // AnthropicAdaptiveEffort maps zot's user-facing thinking levels onto the48 // AnthropicAdaptiveEffort maps zot's user-facing thinking levels onto the
46-// effort enum used by Anthropic's adaptive-thinking models (Opus 4.7+).49+// effort enum used by adaptive-thinking models. These models reject explicit
47-// These models reject explicit thinking budgets; thinking depth is50+// thinking budgets; reasoning depth is controlled by output_config.effort.
48-// controlled by output_config.effort instead. Returns "" when reasoning
49-// is disabled.
50 func AnthropicAdaptiveEffort(level string) string {51 func AnthropicAdaptiveEffort(level string) string {
51 switch NormalizeReasoning(level) {52 switch NormalizeReasoning(level) {
52 case "minimum", "low":53 case "minimum", "low":
@@ -55,38 +56,34 @@ func AnthropicAdaptiveEffort(level string) string {
55 return "medium"56 return "medium"
56 case "high":57 case "high":
57 return "high"58 return "high"
58- case "maximum":59+ case "xhigh":
59 return "xhigh"60 return "xhigh"
61+ case "max":
62+ return "max"
60 default:63 default:
61 return ""64 return ""
62 }65 }
63 }66 }
64 67
65-// OpenAIReasoningEffort maps zot's six-level setting onto the effort enum68+// OpenAIReasoningEffort maps zot's thinking setting onto the effort enum
66-// accepted by OpenAI-compatible chat-completions endpoints.69+// accepted by generic OpenAI-compatible chat-completions endpoints.
67 func OpenAIReasoningEffort(level string) string {70 func OpenAIReasoningEffort(level string) string {
68 switch NormalizeReasoning(level) {71 switch NormalizeReasoning(level) {
69 case "minimum", "low":72 case "minimum", "low":
70- // Many OpenAI-compatible endpoints only accept low/medium/high.73+ // Many compatible endpoints only accept low/medium/high.
71- // Use low for zot's minimum instead of the newer minimal enum.
72 return "low"74 return "low"
73 case "medium":75 case "medium":
74 return "medium"76 return "medium"
75- case "high", "maximum":77+ case "high", "xhigh", "max":
76 return "high"78 return "high"
77 default:79 default:
78 return ""80 return ""
79 }81 }
80 }82 }
81 83
82-// OpenAICompatAnthropicEffort maps zot's user-facing thinking levels84+// OpenAICompatAnthropicEffort maps zot's thinking setting when an adaptive
83-// onto reasoning_effort values when an adaptive-thinking Anthropic85+// Anthropic model is served over an OpenAI-compatible chat-completions wire.
84-// model (Opus 4.7+) is served over the OpenAI-compatible chat-86+// Adaptive models accept native xhigh and max effort values.
85-// completions wire (openrouter, opencode, ...). Differs from
86-// OpenAIReasoningEffort only at the top: zot's "maximum" maps to
87-// "xhigh" instead of being clamped to "high", so the model's full
88-// adaptive-thinking ceiling is preserved when reachable through a
89-// gateway that accepts the effort knob.
90 func OpenAICompatAnthropicEffort(level string) string {87 func OpenAICompatAnthropicEffort(level string) string {
91 switch NormalizeReasoning(level) {88 switch NormalizeReasoning(level) {
92 case "minimum", "low":89 case "minimum", "low":
@@ -95,17 +92,18 @@ func OpenAICompatAnthropicEffort(level string) string {
95 return "medium"92 return "medium"
96 case "high":93 case "high":
97 return "high"94 return "high"
98- case "maximum":95+ case "xhigh":
99 return "xhigh"96 return "xhigh"
97+ case "max":
98+ return "max"
100 default:99 default:
101 return ""100 return ""
102 }101 }
103 }102 }
104 103
105-// OpenAICodexReasoningEffort maps zot levels onto the ChatGPT/Codex104+// OpenAICodexReasoningEffort maps zot levels onto the Responses API effort
106-// Responses backend enum. That backend rejects "minimal" and uses105+// enum. GPT-5.6 supports native max; other models clamp max to xhigh.
107-// "xhigh" for the highest tier on recent GPT-5.x models.106+func OpenAICodexReasoningEffort(level, model string) string {
108-func OpenAICodexReasoningEffort(level string) string {
109 switch NormalizeReasoning(level) {107 switch NormalizeReasoning(level) {
110 case "minimum", "low":108 case "minimum", "low":
111 return "low"109 return "low"
@@ -113,7 +111,12 @@ func OpenAICodexReasoningEffort(level string) string {
113 return "medium"111 return "medium"
114 case "high":112 case "high":
115 return "high"113 return "high"
116- case "maximum":114+ case "xhigh":
115+ return "xhigh"
116+ case "max":
117+ if strings.HasPrefix(strings.ToLower(model), "gpt-5.6-") {
118+ return "max"
119+ }
117 return "xhigh"120 return "xhigh"
118 default:121 default:
119 return ""122 return ""
M packages/provider/reasoning_test.go +13 -10
@@ -5,20 +5,23 @@ import "testing"
55 func TestReasoningEffortMappings(t *testing.T) {
66 cases := []struct {
77 level string
8+ model string
89 openai string
910 anthCompat string
1011 codex string
1112 budget int
1213 normalized string
1314 }{
14- {"off", "", "", "", 0, ""},
15- {"minimum", "low", "low", "low", 1024, "minimum"},
16- {"minimal", "low", "low", "low", 1024, "minimum"},
17- {"low", "low", "low", "low", 2048, "low"},
18- {"medium", "medium", "medium", "medium", 8192, "medium"},
19- {"high", "high", "high", "high", 16384, "high"},
20- {"maximum", "high", "xhigh", "xhigh", 32768, "maximum"},
21- {"max", "high", "xhigh", "xhigh", 32768, "maximum"},
15+ {"off", "gpt-5.6-sol", "", "", "", 0, ""},
16+ {"minimum", "gpt-5.6-sol", "low", "low", "low", 1024, "minimum"},
17+ {"minimal", "gpt-5.6-sol", "low", "low", "low", 1024, "minimum"},
18+ {"low", "gpt-5.6-sol", "low", "low", "low", 2048, "low"},
19+ {"medium", "gpt-5.6-sol", "medium", "medium", "medium", 8192, "medium"},
20+ {"high", "gpt-5.6-sol", "high", "high", "high", 16384, "high"},
21+ {"maximum", "gpt-5.6-sol", "high", "xhigh", "xhigh", 32768, "xhigh"},
22+ {"xhigh", "gpt-5.6-sol", "high", "xhigh", "xhigh", 32768, "xhigh"},
23+ {"max", "gpt-5.6-sol", "high", "max", "max", 32768, "max"},
24+ {"max", "gpt-5.5", "high", "max", "xhigh", 32768, "max"},
2225 }
2326 for _, tc := range cases {
2427 if got := NormalizeReasoning(tc.level); got != tc.normalized {
@@ -30,8 +33,8 @@ func TestReasoningEffortMappings(t *testing.T) {
3033 if got := OpenAICompatAnthropicEffort(tc.level); got != tc.anthCompat {
3134 t.Errorf("OpenAICompatAnthropicEffort(%q)=%q want %q", tc.level, got, tc.anthCompat)
3235 }
33- if got := OpenAICodexReasoningEffort(tc.level); got != tc.codex {
34- t.Errorf("OpenAICodexReasoningEffort(%q)=%q want %q", tc.level, got, tc.codex)
36+ if got := OpenAICodexReasoningEffort(tc.level, tc.model); got != tc.codex {
37+ t.Errorf("OpenAICodexReasoningEffort(%q, %q)=%q want %q", tc.level, tc.model, got, tc.codex)
3538 }
3639 if got := ReasoningBudget(tc.level); got != tc.budget {
3740 t.Errorf("ReasoningBudget(%q)=%d want %d", tc.level, got, tc.budget)
@@ -5,20 +5,23 @@ import "testing"
5 func TestReasoningEffortMappings(t *testing.T) {5 func TestReasoningEffortMappings(t *testing.T) {
6 cases := []struct {6 cases := []struct {
7 level string7 level string
8+ model string
8 openai string9 openai string
9 anthCompat string10 anthCompat string
10 codex string11 codex string
11 budget int12 budget int
12 normalized string13 normalized string
13 }{14 }{
14- {"off", "", "", "", 0, ""},15+ {"off", "gpt-5.6-sol", "", "", "", 0, ""},
15- {"minimum", "low", "low", "low", 1024, "minimum"},16+ {"minimum", "gpt-5.6-sol", "low", "low", "low", 1024, "minimum"},
16- {"minimal", "low", "low", "low", 1024, "minimum"},17+ {"minimal", "gpt-5.6-sol", "low", "low", "low", 1024, "minimum"},
17- {"low", "low", "low", "low", 2048, "low"},18+ {"low", "gpt-5.6-sol", "low", "low", "low", 2048, "low"},
18- {"medium", "medium", "medium", "medium", 8192, "medium"},19+ {"medium", "gpt-5.6-sol", "medium", "medium", "medium", 8192, "medium"},
19- {"high", "high", "high", "high", 16384, "high"},20+ {"high", "gpt-5.6-sol", "high", "high", "high", 16384, "high"},
20- {"maximum", "high", "xhigh", "xhigh", 32768, "maximum"},21+ {"maximum", "gpt-5.6-sol", "high", "xhigh", "xhigh", 32768, "xhigh"},
21- {"max", "high", "xhigh", "xhigh", 32768, "maximum"},22+ {"xhigh", "gpt-5.6-sol", "high", "xhigh", "xhigh", 32768, "xhigh"},
23+ {"max", "gpt-5.6-sol", "high", "max", "max", 32768, "max"},
24+ {"max", "gpt-5.5", "high", "max", "xhigh", 32768, "max"},
22 }25 }
23 for _, tc := range cases {26 for _, tc := range cases {
24 if got := NormalizeReasoning(tc.level); got != tc.normalized {27 if got := NormalizeReasoning(tc.level); got != tc.normalized {
@@ -30,8 +33,8 @@ func TestReasoningEffortMappings(t *testing.T) {
30 if got := OpenAICompatAnthropicEffort(tc.level); got != tc.anthCompat {33 if got := OpenAICompatAnthropicEffort(tc.level); got != tc.anthCompat {
31 t.Errorf("OpenAICompatAnthropicEffort(%q)=%q want %q", tc.level, got, tc.anthCompat)34 t.Errorf("OpenAICompatAnthropicEffort(%q)=%q want %q", tc.level, got, tc.anthCompat)
32 }35 }
33- if got := OpenAICodexReasoningEffort(tc.level); got != tc.codex {36+ if got := OpenAICodexReasoningEffort(tc.level, tc.model); got != tc.codex {
34- t.Errorf("OpenAICodexReasoningEffort(%q)=%q want %q", tc.level, got, tc.codex)37+ t.Errorf("OpenAICodexReasoningEffort(%q, %q)=%q want %q", tc.level, tc.model, got, tc.codex)
35 }38 }
36 if got := ReasoningBudget(tc.level); got != tc.budget {39 if got := ReasoningBudget(tc.level); got != tc.budget {
37 t.Errorf("ReasoningBudget(%q)=%d want %d", tc.level, got, tc.budget)40 t.Errorf("ReasoningBudget(%q)=%d want %d", tc.level, got, tc.budget)
M packages/tui/statusbar_test.go +16 -2
@@ -85,12 +85,26 @@ func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) {
8585 }
8686 }
8787
88+func TestStatusBarUsesThinkingMaxThemeColor(t *testing.T) {
89+ th := Dark
90+ th.ThinkingMax = 201
91+ lines := StatusBar(StatusBarParams{
92+ Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200,
93+ })
94+ if len(lines) != 1 {
95+ t.Fatalf("lines = %d, want 1", len(lines))
96+ }
97+ if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "thinking: max") {
98+ t.Fatalf("max thinking style missing: %q", lines[0])
99+ }
100+}
101+
88102 func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
89103 lines := StatusBar(StatusBarParams{
90104 Theme: Dark,
91105 Provider: "openai-codex",
92106 Model: "gpt-5.5",
93- Reasoning: "maximum",
107+ Reasoning: "xhigh",
94108 CWD: "/tmp/x",
95109 Usage: provider.Usage{
96110 CostUSD: 0,
@@ -107,7 +121,7 @@ func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
107121 for i, line := range lines {
108122 plain[i] = stripANSI(line)
109123 }
110- if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: maximum") {
124+ if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: xhigh") {
111125 t.Fatalf("line 1 should contain model and thinking level, got %q", plain[0])
112126 }
113127 if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "thinking level") {
@@ -85,12 +85,26 @@ func TestStatusBarThinkingLevelBetweenModelAndStats(t *testing.T) {
85 }85 }
86 }86 }
87 87
88+func TestStatusBarUsesThinkingMaxThemeColor(t *testing.T) {
89+ th := Dark
90+ th.ThinkingMax = 201
91+ lines := StatusBar(StatusBarParams{
92+ Theme: th, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max", Cols: 200,
93+ })
94+ if len(lines) != 1 {
95+ t.Fatalf("lines = %d, want 1", len(lines))
96+ }
97+ if !strings.Contains(lines[0], "\x1b[38;5;201m") || !strings.Contains(stripANSI(lines[0]), "thinking: max") {
98+ t.Fatalf("max thinking style missing: %q", lines[0])
99+ }
100+}
101+
88 func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {102 func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
89 lines := StatusBar(StatusBarParams{103 lines := StatusBar(StatusBarParams{
90 Theme: Dark,104 Theme: Dark,
91 Provider: "openai-codex",105 Provider: "openai-codex",
92 Model: "gpt-5.5",106 Model: "gpt-5.5",
93- Reasoning: "maximum",107+ Reasoning: "xhigh",
94 CWD: "/tmp/x",108 CWD: "/tmp/x",
95 Usage: provider.Usage{109 Usage: provider.Usage{
96 CostUSD: 0,110 CostUSD: 0,
@@ -107,7 +121,7 @@ func TestStatusBarNarrowKeepsModelAndThinkingTogetherWhenTheyFit(t *testing.T) {
107 for i, line := range lines {121 for i, line := range lines {
108 plain[i] = stripANSI(line)122 plain[i] = stripANSI(line)
109 }123 }
110- if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: maximum") {124+ if !strings.Contains(plain[0], "(openai-codex) gpt-5.5 thinking: xhigh") {
111 t.Fatalf("line 1 should contain model and thinking level, got %q", plain[0])125 t.Fatalf("line 1 should contain model and thinking level, got %q", plain[0])
112 }126 }
113 if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "thinking level") {127 if !strings.Contains(plain[1], "$0.000 (sub)") || strings.Contains(plain[1], "thinking level") {
M packages/tui/theme.go +3 -0
@@ -49,6 +49,7 @@ type Theme struct {
4949 Error int
5050 Warning int
5151 Spinner int // spinner + funny working line
52+ ThinkingMax int // status color for the opt-in max thinking level
5253 SelectionBG int // background for highlighted rows
5354 SelectionFG int // foreground for highlighted rows
5455
@@ -159,6 +160,7 @@ var Dark = Theme{
159160 Error: 203,
160161 Warning: 214,
161162 Spinner: 183, // soft purple
163+ ThinkingMax: 207, // vivid magenta
162164 SelectionBG: 24, // deep blue background
163165 SelectionFG: 231, // near-white foreground
164166 SpinnerFrames: defaultSpinnerFrames,
@@ -181,6 +183,7 @@ var Light = Theme{
181183 Error: 160,
182184 Warning: 166,
183185 Spinner: 91, // purple
186+ ThinkingMax: 127, // deep magenta
184187 SelectionBG: 153, // light blue
185188 SelectionFG: 232, // near-black
186189 SpinnerFrames: defaultSpinnerFrames,
@@ -49,6 +49,7 @@ type Theme struct {
49 Error int49 Error int
50 Warning int50 Warning int
51 Spinner int // spinner + funny working line51 Spinner int // spinner + funny working line
52+ ThinkingMax int // status color for the opt-in max thinking level
52 SelectionBG int // background for highlighted rows53 SelectionBG int // background for highlighted rows
53 SelectionFG int // foreground for highlighted rows54 SelectionFG int // foreground for highlighted rows
54 55
@@ -159,6 +160,7 @@ var Dark = Theme{
159 Error: 203,160 Error: 203,
160 Warning: 214,161 Warning: 214,
161 Spinner: 183, // soft purple162 Spinner: 183, // soft purple
163+ ThinkingMax: 207, // vivid magenta
162 SelectionBG: 24, // deep blue background164 SelectionBG: 24, // deep blue background
163 SelectionFG: 231, // near-white foreground165 SelectionFG: 231, // near-white foreground
164 SpinnerFrames: defaultSpinnerFrames,166 SpinnerFrames: defaultSpinnerFrames,
@@ -181,6 +183,7 @@ var Light = Theme{
181 Error: 160,183 Error: 160,
182 Warning: 166,184 Warning: 166,
183 Spinner: 91, // purple185 Spinner: 91, // purple
186+ ThinkingMax: 127, // deep magenta
184 SelectionBG: 153, // light blue187 SelectionBG: 153, // light blue
185 SelectionFG: 232, // near-black188 SelectionFG: 232, // near-black
186 SpinnerFrames: defaultSpinnerFrames,189 SpinnerFrames: defaultSpinnerFrames,
M packages/tui/theme_loader.go +7 -0
@@ -92,6 +92,8 @@ type ThemeOverrides struct {
9292 Error *int `json:"error,omitempty"`
9393 Warning *int `json:"warning,omitempty"`
9494 Spinner *int `json:"spinner,omitempty"`
95+ ThinkingMax *int `json:"thinking_max,omitempty"`
96+ ThinkingMaxCamel *int `json:"thinkingMax,omitempty"`
9597 SelectionBG *int `json:"selection_bg,omitempty"`
9698 SelectionFG *int `json:"selection_fg,omitempty"`
9799 SpinnerFrames []string `json:"spinner_frames,omitempty"`
@@ -383,6 +385,11 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
383385 if o.Spinner != nil {
384386 th.Spinner = *o.Spinner
385387 }
388+ if o.ThinkingMax != nil {
389+ th.ThinkingMax = *o.ThinkingMax
390+ } else if o.ThinkingMaxCamel != nil {
391+ th.ThinkingMax = *o.ThinkingMaxCamel
392+ }
386393 if o.SelectionBG != nil {
387394 th.SelectionBG = *o.SelectionBG
388395 }
@@ -92,6 +92,8 @@ type ThemeOverrides struct {
92 Error *int `json:"error,omitempty"`92 Error *int `json:"error,omitempty"`
93 Warning *int `json:"warning,omitempty"`93 Warning *int `json:"warning,omitempty"`
94 Spinner *int `json:"spinner,omitempty"`94 Spinner *int `json:"spinner,omitempty"`
95+ ThinkingMax *int `json:"thinking_max,omitempty"`
96+ ThinkingMaxCamel *int `json:"thinkingMax,omitempty"`
95 SelectionBG *int `json:"selection_bg,omitempty"`97 SelectionBG *int `json:"selection_bg,omitempty"`
96 SelectionFG *int `json:"selection_fg,omitempty"`98 SelectionFG *int `json:"selection_fg,omitempty"`
97 SpinnerFrames []string `json:"spinner_frames,omitempty"`99 SpinnerFrames []string `json:"spinner_frames,omitempty"`
@@ -383,6 +385,11 @@ func applyThemeOverrides(th Theme, o ThemeOverrides) Theme {
383 if o.Spinner != nil {385 if o.Spinner != nil {
384 th.Spinner = *o.Spinner386 th.Spinner = *o.Spinner
385 }387 }
388+ if o.ThinkingMax != nil {
389+ th.ThinkingMax = *o.ThinkingMax
390+ } else if o.ThinkingMaxCamel != nil {
391+ th.ThinkingMax = *o.ThinkingMaxCamel
392+ }
386 if o.SelectionBG != nil {393 if o.SelectionBG != nil {
387 th.SelectionBG = *o.SelectionBG394 th.SelectionBG = *o.SelectionBG
388 }395 }
M packages/tui/theme_loader_test.go +19 -0
@@ -34,6 +34,25 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
3434 }
3535 }
3636
37+func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) {
38+ home := t.TempDir()
39+ if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
40+ t.Fatal(err)
41+ }
42+ path := filepath.Join(home, "themes", "thinking.json")
43+ if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"thinkingMax":201}}}`), 0o644); err != nil {
44+ t.Fatal(err)
45+ }
46+
47+ th, _, err := LoadThemeFromHome(home, "thinking", Dark)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if th.ThinkingMax != 201 {
52+ t.Fatalf("thinking max = %d, want 201", th.ThinkingMax)
53+ }
54+}
55+
3756 func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
3857 home := t.TempDir()
3958 if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
@@ -34,6 +34,25 @@ func TestLoadThemeAllowsPartialColorOverrides(t *testing.T) {
34 }34 }
35 }35 }
36 36
37+func TestLoadThemeAllowsThinkingMaxOverride(t *testing.T) {
38+ home := t.TempDir()
39+ if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
40+ t.Fatal(err)
41+ }
42+ path := filepath.Join(home, "themes", "thinking.json")
43+ if err := os.WriteFile(path, []byte(`{"colors":{"dark":{"thinkingMax":201}}}`), 0o644); err != nil {
44+ t.Fatal(err)
45+ }
46+
47+ th, _, err := LoadThemeFromHome(home, "thinking", Dark)
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ if th.ThinkingMax != 201 {
52+ t.Fatalf("thinking max = %d, want 201", th.ThinkingMax)
53+ }
54+}
55+
37 func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {56 func TestLoadThemeAllowsSpinnerOnlyTopLevelOverrides(t *testing.T) {
38 home := t.TempDir()57 home := t.TempDir()
39 if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {58 if err := os.MkdirAll(filepath.Join(home, "themes"), 0o755); err != nil {
M packages/tui/view.go +17 -7
@@ -2453,8 +2453,8 @@ func StatusBar(p StatusBarParams) []string {
24532453 leftBuilder.WriteString(th.FG256(th.Muted, left))
24542454 if middle != "" {
24552455 leftBuilder.WriteString(pad)
2456- // `middle` already has colorized context segments; wrap the rest in muted.
2457- leftBuilder.WriteString(th.FG256(th.Muted, middle))
2456+ // Highlight the opt-in max tier; other status information stays muted.
2457+ leftBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle))
24582458 }
24592459
24602460 cwd := shortenHome(p.CWD)
@@ -2489,7 +2489,7 @@ func StatusBar(p StatusBarParams) []string {
24892489 infoBuilder.WriteString(modelLine)
24902490 if middle != "" {
24912491 infoBuilder.WriteString(pad)
2492- infoBuilder.WriteString(th.FG256(th.Muted, middle))
2492+ infoBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle))
24932493 }
24942494 lines = append(lines, infoBuilder.String())
24952495 }
@@ -2533,11 +2533,12 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking
25332533 }
25342534
25352535 modelThinkingPlain := pad + modelText + pad + thinkingText
2536+ thinkingColor := thinkingStatusColor(th, thinkingText)
25362537 if cols <= 0 || visibleWidth(modelThinkingPlain) <= cols {
2537- lines = append(lines, pad+th.FG256(th.Muted, modelText+pad+thinkingText))
2538+ lines = append(lines, pad+th.FG256(thinkingColor, modelText+pad+thinkingText))
25382539 } else {
25392540 lines = append(lines, modelLine)
2540- lines = append(lines, pad+th.FG256(th.Muted, thinkingText))
2541+ lines = append(lines, pad+th.FG256(thinkingColor, thinkingText))
25412542 }
25422543 if statsText != "" {
25432544 lines = append(lines, pad+th.FG256(th.Muted, statsText))
@@ -2545,14 +2546,23 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking
25452546 return lines
25462547 }
25472548
2549+func thinkingStatusColor(th Theme, thinkingText string) int {
2550+ if strings.HasSuffix(thinkingText, ": max") {
2551+ return th.ThinkingMax
2552+ }
2553+ return th.Muted
2554+}
2555+
25482556 func thinkingLevelLabel(level string) string {
25492557 switch strings.ToLower(strings.TrimSpace(level)) {
25502558 case "", "off", "none", "no", "false", "disabled":
25512559 return ""
25522560 case "minimum", "minimal", "min":
25532561 return "minimal"
2554- case "maximum", "max", "xhigh":
2555- return "maximum"
2562+ case "maximum", "xhigh":
2563+ return "xhigh"
2564+ case "max":
2565+ return "max"
25562566 default:
25572567 return strings.ToLower(strings.TrimSpace(level))
25582568 }
@@ -2453,8 +2453,8 @@ func StatusBar(p StatusBarParams) []string {
2453 leftBuilder.WriteString(th.FG256(th.Muted, left))2453 leftBuilder.WriteString(th.FG256(th.Muted, left))
2454 if middle != "" {2454 if middle != "" {
2455 leftBuilder.WriteString(pad)2455 leftBuilder.WriteString(pad)
2456- // `middle` already has colorized context segments; wrap the rest in muted.2456+ // Highlight the opt-in max tier; other status information stays muted.
2457- leftBuilder.WriteString(th.FG256(th.Muted, middle))2457+ leftBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle))
2458 }2458 }
2459 2459
2460 cwd := shortenHome(p.CWD)2460 cwd := shortenHome(p.CWD)
@@ -2489,7 +2489,7 @@ func StatusBar(p StatusBarParams) []string {
2489 infoBuilder.WriteString(modelLine)2489 infoBuilder.WriteString(modelLine)
2490 if middle != "" {2490 if middle != "" {
2491 infoBuilder.WriteString(pad)2491 infoBuilder.WriteString(pad)
2492- infoBuilder.WriteString(th.FG256(th.Muted, middle))2492+ infoBuilder.WriteString(th.FG256(thinkingStatusColor(th, thinkingText), middle))
2493 }2493 }
2494 lines = append(lines, infoBuilder.String())2494 lines = append(lines, infoBuilder.String())
2495 }2495 }
@@ -2533,11 +2533,12 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking
2533 }2533 }
2534 2534
2535 modelThinkingPlain := pad + modelText + pad + thinkingText2535 modelThinkingPlain := pad + modelText + pad + thinkingText
2536+ thinkingColor := thinkingStatusColor(th, thinkingText)
2536 if cols <= 0 || visibleWidth(modelThinkingPlain) <= cols {2537 if cols <= 0 || visibleWidth(modelThinkingPlain) <= cols {
2537- lines = append(lines, pad+th.FG256(th.Muted, modelText+pad+thinkingText))2538+ lines = append(lines, pad+th.FG256(thinkingColor, modelText+pad+thinkingText))
2538 } else {2539 } else {
2539 lines = append(lines, modelLine)2540 lines = append(lines, modelLine)
2540- lines = append(lines, pad+th.FG256(th.Muted, thinkingText))2541+ lines = append(lines, pad+th.FG256(thinkingColor, thinkingText))
2541 }2542 }
2542 if statsText != "" {2543 if statsText != "" {
2543 lines = append(lines, pad+th.FG256(th.Muted, statsText))2544 lines = append(lines, pad+th.FG256(th.Muted, statsText))
@@ -2545,14 +2546,23 @@ func appendWrappedStatusLines(lines []string, th Theme, pad, modelText, thinking
2545 return lines2546 return lines
2546 }2547 }
2547 2548
2549+func thinkingStatusColor(th Theme, thinkingText string) int {
2550+ if strings.HasSuffix(thinkingText, ": max") {
2551+ return th.ThinkingMax
2552+ }
2553+ return th.Muted
2554+}
2555+
2548 func thinkingLevelLabel(level string) string {2556 func thinkingLevelLabel(level string) string {
2549 switch strings.ToLower(strings.TrimSpace(level)) {2557 switch strings.ToLower(strings.TrimSpace(level)) {
2550 case "", "off", "none", "no", "false", "disabled":2558 case "", "off", "none", "no", "false", "disabled":
2551 return ""2559 return ""
2552 case "minimum", "minimal", "min":2560 case "minimum", "minimal", "min":
2553 return "minimal"2561 return "minimal"
2554- case "maximum", "max", "xhigh":2562+ case "maximum", "xhigh":
2555- return "maximum"2563+ return "xhigh"
2564+ case "max":
2565+ return "max"
2556 default:2566 default:
2557 return strings.ToLower(strings.TrimSpace(level))2567 return strings.ToLower(strings.TrimSpace(level))
2558 }2568 }

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