feat(profiles): persist model favorites with AZERTY shortcuts (#185)
* feat(profiles): persist model favorites with AZERTY shortcuts * feat(profiles): open empty slots in picker and default to slot one * fix(profiles): preserve settings compatibility and clarify save failures * fix(profiles): repair restored favorites and reject unbound control keys
- commit
- 699035813fc291ba0b9ed5d3c5fcb3cbf674aab2
- author
- bnema · · 2026-09-06 12:51 UTC
- committer
- GitHub · · 2026-09-06 12:51 UTC
- parent
- 61666d4
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
21 files · +1103 -97
M README.md +30 -2
| @@ -326,6 +326,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||
| 326 | 326 | | `/login` | Log in via API key or subscription (opens a dialog). | | |
| 327 | 327 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. | | |
| 328 | 328 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | |
| 329 | + | | `/profile <1-9>` | Activate a saved model + reasoning profile, like `Ctrl+1` through `Ctrl+9`; empty slots open the model picker. See [Model profiles](#model-profiles). | | |
| 329 | 330 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | |
| 330 | 331 | | `/fast` | Toggle fast mode for subsequent model calls. | | |
| 331 | 332 | | `/orchestrator` | Toggle proactive subagent delegation. When disabled, subagent tools remain available for explicit delegation. | | |
| @@ -468,7 +469,34 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||
| 468 | 469 | - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **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`. | |
| 469 | 470 | - **reasoning 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. Use `/reasoning` to open this selector directly, or press `Ctrl+R` to cycle through the active model's supported levels. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | |
| 470 | 471 | - **color theme** — `auto` follows the terminal foreground, background, ANSI palette, color depth, and supported live appearance changes. Choose fixed dark/light palettes or JSON overlays discovered under `$ZUT_HOME/themes` and loaded extensions. Active custom files reload safely; invalid edits retain the last valid appearance and persistent deletion resets to auto. See [docs/themes.md](docs/themes.md). | |
| 471 | - | - **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. | |
| 472 | + | - **model profiles** — nine favorites containing a provider, model, and reasoning level. `enter` on a slot opens the `/model` picker; `left`/`right` chooses that favorite's reasoning without changing the live model. `enter` saves the assignment; `backspace` clears it. Editing the active slot immediately applies and saves its new model and reasoning. Assigning other slots does not change the live model. Clearing the active slot detaches it for this run without changing the live model. See [Model profiles](#model-profiles). | |
| 473 | + | ||
| 474 | + | ### Model profiles | |
| 475 | + | ||
| 476 | + | Press `Ctrl+1` ... `Ctrl+9` from the editor, or run `/profile 1` ... `/profile 9`: | |
| 477 | + | ||
| 478 | + | - An empty slot opens the `/model` picker. Choose a model and use `left`/`right` to adjust reasoning; `enter` saves and activates the slot. `esc` cancels without changing the current selection. | |
| 479 | + | - An assigned slot restores its saved selection, carrying the transcript and accumulated cost across provider changes. Reasoning is clamped to the selected model's supported levels. | |
| 480 | + | - While a slot is active, `/model`, `/reasoning`, and `Ctrl+R` changes automatically save back to it. Choose another slot first if you want to keep the old favorite. | |
| 481 | + | - The status bar shows the active slot as `[1]` ... `[9]`. Status messages confirm creation, activation, and updates, or report switching/saving errors. Switching is rejected while a turn is running; dialogs retain their normal key handling. | |
| 482 | + | ||
| 483 | + | If a model switch succeeds but saving its profile fails, zut reports **model switched; profile not saved** and detaches the active indicator. The live model and any model/session metadata already saved are retained; the favorite and startup selection are not guaranteed to reflect that change. Fix the save error and select the profile again. Switching and saving are not a transaction across config and session files. | |
| 484 | + | ||
| 485 | + | Profiles and the active slot persist in `$ZUT_HOME/config.json`, using the existing `quick_model_shortcuts` array (positions 1–9) and `active_model_profile`. For example, merge these fields into your config while zut is closed: | |
| 486 | + | ||
| 487 | + | ```json | |
| 488 | + | { | |
| 489 | + | "active_model_profile": 1, | |
| 490 | + | "quick_model_shortcuts": [ | |
| 491 | + | {"provider": "openai-codex", "model": "gpt-6-astra", "reasoning": "medium"}, | |
| 492 | + | {"provider": "openai", "model": "gpt-5.6-sol", "reasoning": "max"} | |
| 493 | + | ] | |
| 494 | + | } | |
| 495 | + | ``` | |
| 496 | + | ||
| 497 | + | Use `{}` for an empty position. If `active_model_profile` is missing, `0`, or outside 1–9, **slot 1 is the default**. Once another slot is activated, that slot is remembered across restarts. On a fresh config, model/reasoning edits save automatically into the default slot; pressing its shortcut before assigning a model still opens the picker. Old model-only slots still load; missing or empty `reasoning` means off. The active profile supplies startup defaults, with provider aliases normalized and reasoning clamped to the model's supported levels. If its provider or model is no longer available, startup repairs that profile to the resolved fallback. Explicit CLI options and session restoration keep their usual precedence; a different model/reasoning selection detaches the profile for that run rather than overwriting a favorite. These favorites are separate from named Zutfile or subagent profiles. | |
| 498 | + | ||
| 499 | + | **AZERTY and terminals:** Ctrl plus the French AZERTY top row (`&`, `é`, `"`, `'`, `(`, `-`, `è`, `_`, `ç`) selects slots 1–9 without Shift. Modified digits also work. Zut requests enhanced keyboard reporting (Kitty/CSI-u or xterm modifyOtherKeys), but your terminal or multiplexer must forward the chord. If it reserves the shortcut for tabs or emits an indistinguishable legacy control byte, configure it to forward the enhanced key sequence, or use `/profile N`. Zut does not reinterpret bare punctuation or ambiguous control bytes as profile shortcuts. On macOS, Command plus these keys also works if the terminal forwards Super. | |
| 472 | 500 | ||
| 473 | 501 | ### `/skills` | |
| 474 | 502 | ||
| @@ -942,7 +970,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||
| 942 | 970 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | |
| 943 | 971 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). | | |
| 944 | 972 | | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. | | |
| 945 | - | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | |
| 973 | + | | `ctrl+1` ... `ctrl+9` | Activate a persistent model + reasoning profile; empty slots open the model picker. French AZERTY Ctrl + top-row symbols also work when forwarded by the terminal. Rejected while a turn is running. See [Model profiles](#model-profiles); `/profile N` is the terminal-independent fallback. | | |
| 946 | 974 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| 947 | 975 | ||
| 948 | 976 | ### File picker (`@`) | |
| @@ -326,6 +326,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu | |||||
| 326 | | `/login` | Log in via API key or subscription (opens a dialog). | | 326 | | `/login` | Log in via API key or subscription (opens a dialog). | | ||
| 327 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. | | 327 | | `/logout [provider]` | Clear credentials for any logged-in provider, or all when omitted. `/logout openai-codex` clears ChatGPT/Codex subscription auth while preserving a public OpenAI API key; `/logout kimi` also disables fallback to the official Kimi Code CLI token until you log in to Kimi through zut again. | | ||
| 328 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | 328 | | `/model` | Pick a model from a list (or `/model <id>` to set directly). | | ||
| 329 | + | | `/profile <1-9>` | Activate a saved model + reasoning profile, like `Ctrl+1` through `Ctrl+9`; empty slots open the model picker. See [Model profiles](#model-profiles). | | |||
| 329 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | 330 | | `/reasoning` | Set the reasoning level for subsequent model calls. | | ||
| 330 | | `/fast` | Toggle fast mode for subsequent model calls. | | 331 | | `/fast` | Toggle fast mode for subsequent model calls. | | ||
| 331 | | `/orchestrator` | Toggle proactive subagent delegation. When disabled, subagent tools remain available for explicit delegation. | | 332 | | `/orchestrator` | Toggle proactive subagent delegation. When disabled, subagent tools remain available for explicit delegation. | | ||
| @@ -468,7 +469,34 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o | |||||
| 468 | - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **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`. | 469 | - **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **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`. | ||
| 469 | - **reasoning 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. Use `/reasoning` to open this selector directly, or press `Ctrl+R` to cycle through the active model's supported levels. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | 470 | - **reasoning 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. Use `/reasoning` to open this selector directly, or press `Ctrl+R` to cycle through the active model's supported levels. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`. | ||
| 470 | - **color theme** — `auto` follows the terminal foreground, background, ANSI palette, color depth, and supported live appearance changes. Choose fixed dark/light palettes or JSON overlays discovered under `$ZUT_HOME/themes` and loaded extensions. Active custom files reload safely; invalid edits retain the last valid appearance and persistent deletion resets to auto. See [docs/themes.md](docs/themes.md). | 471 | - **color theme** — `auto` follows the terminal foreground, background, ANSI palette, color depth, and supported live appearance changes. Choose fixed dark/light palettes or JSON overlays discovered under `$ZUT_HOME/themes` and loaded extensions. Active custom files reload safely; invalid edits retain the last valid appearance and persistent deletion resets to auto. See [docs/themes.md](docs/themes.md). | ||
| 471 | - | - **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. | 472 | + | - **model profiles** — nine favorites containing a provider, model, and reasoning level. `enter` on a slot opens the `/model` picker; `left`/`right` chooses that favorite's reasoning without changing the live model. `enter` saves the assignment; `backspace` clears it. Editing the active slot immediately applies and saves its new model and reasoning. Assigning other slots does not change the live model. Clearing the active slot detaches it for this run without changing the live model. See [Model profiles](#model-profiles). |
| 473 | + | ||||
| 474 | + | ### Model profiles | |||
| 475 | + | ||||
| 476 | + | Press `Ctrl+1` ... `Ctrl+9` from the editor, or run `/profile 1` ... `/profile 9`: | |||
| 477 | + | ||||
| 478 | + | - An empty slot opens the `/model` picker. Choose a model and use `left`/`right` to adjust reasoning; `enter` saves and activates the slot. `esc` cancels without changing the current selection. | |||
| 479 | + | - An assigned slot restores its saved selection, carrying the transcript and accumulated cost across provider changes. Reasoning is clamped to the selected model's supported levels. | |||
| 480 | + | - While a slot is active, `/model`, `/reasoning`, and `Ctrl+R` changes automatically save back to it. Choose another slot first if you want to keep the old favorite. | |||
| 481 | + | - The status bar shows the active slot as `[1]` ... `[9]`. Status messages confirm creation, activation, and updates, or report switching/saving errors. Switching is rejected while a turn is running; dialogs retain their normal key handling. | |||
| 482 | + | ||||
| 483 | + | If a model switch succeeds but saving its profile fails, zut reports **model switched; profile not saved** and detaches the active indicator. The live model and any model/session metadata already saved are retained; the favorite and startup selection are not guaranteed to reflect that change. Fix the save error and select the profile again. Switching and saving are not a transaction across config and session files. | |||
| 484 | + | ||||
| 485 | + | Profiles and the active slot persist in `$ZUT_HOME/config.json`, using the existing `quick_model_shortcuts` array (positions 1–9) and `active_model_profile`. For example, merge these fields into your config while zut is closed: | |||
| 486 | + | ||||
| 487 | + | ```json | |||
| 488 | + | { | |||
| 489 | + | "active_model_profile": 1, | |||
| 490 | + | "quick_model_shortcuts": [ | |||
| 491 | + | {"provider": "openai-codex", "model": "gpt-6-astra", "reasoning": "medium"}, | |||
| 492 | + | {"provider": "openai", "model": "gpt-5.6-sol", "reasoning": "max"} | |||
| 493 | + | ] | |||
| 494 | + | } | |||
| 495 | + | ``` | |||
| 496 | + | ||||
| 497 | + | Use `{}` for an empty position. If `active_model_profile` is missing, `0`, or outside 1–9, **slot 1 is the default**. Once another slot is activated, that slot is remembered across restarts. On a fresh config, model/reasoning edits save automatically into the default slot; pressing its shortcut before assigning a model still opens the picker. Old model-only slots still load; missing or empty `reasoning` means off. The active profile supplies startup defaults, with provider aliases normalized and reasoning clamped to the model's supported levels. If its provider or model is no longer available, startup repairs that profile to the resolved fallback. Explicit CLI options and session restoration keep their usual precedence; a different model/reasoning selection detaches the profile for that run rather than overwriting a favorite. These favorites are separate from named Zutfile or subagent profiles. | |||
| 498 | + | ||||
| 499 | + | **AZERTY and terminals:** Ctrl plus the French AZERTY top row (`&`, `é`, `"`, `'`, `(`, `-`, `è`, `_`, `ç`) selects slots 1–9 without Shift. Modified digits also work. Zut requests enhanced keyboard reporting (Kitty/CSI-u or xterm modifyOtherKeys), but your terminal or multiplexer must forward the chord. If it reserves the shortcut for tabs or emits an indistinguishable legacy control byte, configure it to forward the enhanced key sequence, or use `/profile N`. Zut does not reinterpret bare punctuation or ambiguous control bytes as profile shortcuts. On macOS, Command plus these keys also works if the terminal forwards Super. | |||
| 472 | 500 | ||||
| 473 | ### `/skills` | 501 | ### `/skills` | ||
| 474 | 502 | ||||
| @@ -942,7 +970,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`, | |||||
| 942 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | 970 | | `ctrl+v` | Paste clipboard text into the focused chat, side chat, dialog, filter, or credential input. In the main chat, image clipboard content is attached to the next prompt when the platform exposes it (macOS pasteboard, Wayland `wl-paste`, or X11 `xclip`). On Linux, text uses `wl-paste`, `xclip`, or `xsel`; terminal-native bracketed paste remains available without those commands. | | ||
| 943 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). | | 971 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). | | ||
| 944 | | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. | | 972 | | `ctrl+r` | Cycle through the reasoning levels supported by the active model. The selected level applies to subsequent model calls and persists to `config.json`. | | ||
| 945 | - | | `ctrl+1` ... `ctrl+9` | Switch to the model bound to that quick-model slot (configured in `/settings` -> model shortcuts). No-op while a turn is running. | | 973 | + | | `ctrl+1` ... `ctrl+9` | Activate a persistent model + reasoning profile; empty slots open the model picker. French AZERTY Ctrl + top-row symbols also work when forwarded by the terminal. Rejected while a turn is running. See [Model profiles](#model-profiles); `/profile N` is the terminal-independent fallback. | |
| 946 | | `@` | Open the file picker. Browse files and directories in the working directory. | | 974 | | `@` | Open the file picker. Browse files and directories in the working directory. | | ||
| 947 | 975 | ||||
| 948 | ### File picker (`@`) | 976 | ### File picker (`@`) | ||
M packages/agent/build.go +18 -2
| @@ -494,6 +494,8 @@ func webSearchAllowedForRegistry(args Args) bool { | |||
| 494 | 494 | // hard error (used by print/json modes). | |
| 495 | 495 | func Resolve(args Args, requireCred bool) (Resolved, error) { | |
| 496 | 496 | cfg, cfgErr := LoadConfig() | |
| 497 | + | profileSlot := cfg.applyActiveModelProfile() | |
| 498 | + | repairConfig := false | |
| 497 | 499 | // Programmatic callers historically supplied Tools without the parser's | |
| 498 | 500 | // provenance bit. Treat a non-empty list as explicit before a named profile | |
| 499 | 501 | // can contribute its defaults. | |
| @@ -534,7 +536,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 534 | 536 | // Reset the saved config so this doesn't keep happening. | |
| 535 | 537 | cfg.Provider = provName | |
| 536 | 538 | cfg.Model = "" | |
| 537 | - | _ = SaveConfig(cfg) | |
| 539 | + | repairConfig = true | |
| 538 | 540 | } | |
| 539 | 541 | ||
| 540 | 542 | var ( | |
| @@ -702,13 +704,27 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||
| 702 | 704 | model, fm.ID) | |
| 703 | 705 | if args.Model == "" && cfg.Model == model { | |
| 704 | 706 | cfg.Model = fm.ID | |
| 705 | - | _ = SaveConfig(cfg) | |
| 707 | + | repairConfig = true | |
| 706 | 708 | } | |
| 707 | 709 | resolvedModel = fm | |
| 708 | 710 | model = fm.ID | |
| 709 | 711 | } | |
| 710 | 712 | } | |
| 711 | 713 | ||
| 714 | + | if repairConfig { | |
| 715 | + | // Repair the source favorite as well as legacy defaults, but never | |
| 716 | + | // overwrite it because an explicit CLI override required fallback. | |
| 717 | + | if profileSlot > 0 && args.Provider == "" && args.Model == "" { | |
| 718 | + | cfg.Provider, cfg.Model = provName, model | |
| 719 | + | cfg.Reasoning = provider.ClampReasoningForModel(resolvedModel, cfg.Reasoning) | |
| 720 | + | cfg.QuickModelShortcuts[profileSlot-1] = QuickModelShortcut{ | |
| 721 | + | Provider: cfg.Provider, Model: cfg.Model, Reasoning: cfg.Reasoning, | |
| 722 | + | } | |
| 723 | + | cfg.ActiveModelProfile = profileSlot | |
| 724 | + | } | |
| 725 | + | _ = SaveConfig(cfg) | |
| 726 | + | } | |
| 727 | + | ||
| 712 | 728 | explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "") | |
| 713 | 729 | ||
| 714 | 730 | // If the model defines a base URL (e.g. local ollama) and the | |
| @@ -494,6 +494,8 @@ func webSearchAllowedForRegistry(args Args) bool { | |||||
| 494 | // hard error (used by print/json modes). | 494 | // hard error (used by print/json modes). | ||
| 495 | func Resolve(args Args, requireCred bool) (Resolved, error) { | 495 | func Resolve(args Args, requireCred bool) (Resolved, error) { | ||
| 496 | cfg, cfgErr := LoadConfig() | 496 | cfg, cfgErr := LoadConfig() | ||
| 497 | + | profileSlot := cfg.applyActiveModelProfile() | |||
| 498 | + | repairConfig := false | |||
| 497 | // Programmatic callers historically supplied Tools without the parser's | 499 | // Programmatic callers historically supplied Tools without the parser's | ||
| 498 | // provenance bit. Treat a non-empty list as explicit before a named profile | 500 | // provenance bit. Treat a non-empty list as explicit before a named profile | ||
| 499 | // can contribute its defaults. | 501 | // can contribute its defaults. | ||
| @@ -534,7 +536,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 534 | // Reset the saved config so this doesn't keep happening. | 536 | // Reset the saved config so this doesn't keep happening. | ||
| 535 | cfg.Provider = provName | 537 | cfg.Provider = provName | ||
| 536 | cfg.Model = "" | 538 | cfg.Model = "" | ||
| 537 | - | _ = SaveConfig(cfg) | 539 | + | repairConfig = true |
| 538 | } | 540 | } | ||
| 539 | 541 | ||||
| 540 | var ( | 542 | var ( | ||
| @@ -702,13 +704,27 @@ func Resolve(args Args, requireCred bool) (Resolved, error) { | |||||
| 702 | model, fm.ID) | 704 | model, fm.ID) | ||
| 703 | if args.Model == "" && cfg.Model == model { | 705 | if args.Model == "" && cfg.Model == model { | ||
| 704 | cfg.Model = fm.ID | 706 | cfg.Model = fm.ID | ||
| 705 | - | _ = SaveConfig(cfg) | 707 | + | repairConfig = true |
| 706 | } | 708 | } | ||
| 707 | resolvedModel = fm | 709 | resolvedModel = fm | ||
| 708 | model = fm.ID | 710 | model = fm.ID | ||
| 709 | } | 711 | } | ||
| 710 | } | 712 | } | ||
| 711 | 713 | ||||
| 714 | + | if repairConfig { | |||
| 715 | + | // Repair the source favorite as well as legacy defaults, but never | |||
| 716 | + | // overwrite it because an explicit CLI override required fallback. | |||
| 717 | + | if profileSlot > 0 && args.Provider == "" && args.Model == "" { | |||
| 718 | + | cfg.Provider, cfg.Model = provName, model | |||
| 719 | + | cfg.Reasoning = provider.ClampReasoningForModel(resolvedModel, cfg.Reasoning) | |||
| 720 | + | cfg.QuickModelShortcuts[profileSlot-1] = QuickModelShortcut{ | |||
| 721 | + | Provider: cfg.Provider, Model: cfg.Model, Reasoning: cfg.Reasoning, | |||
| 722 | + | } | |||
| 723 | + | cfg.ActiveModelProfile = profileSlot | |||
| 724 | + | } | |||
| 725 | + | _ = SaveConfig(cfg) | |||
| 726 | + | } | |||
| 727 | + | ||||
| 712 | explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "") | 728 | explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "") | ||
| 713 | 729 | ||||
| 714 | // If the model defines a base URL (e.g. local ollama) and the | 730 | // If the model defines a base URL (e.g. local ollama) and the | ||
M packages/agent/cli.go +17 -4
| @@ -1489,6 +1489,15 @@ func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, | |||
| 1489 | 1489 | ||
| 1490 | 1490 | // ---- interactive mode: opens the TUI even without credentials ---- | |
| 1491 | 1491 | ||
| 1492 | + | func interactiveQuickModelShortcuts(shortcuts []QuickModelShortcut) []modes.QuickModelShortcut { | |
| 1493 | + | result := make([]modes.QuickModelShortcut, len(shortcuts)) | |
| 1494 | + | for idx, s := range shortcuts { | |
| 1495 | + | p := normalizedModelProfile(s) | |
| 1496 | + | result[idx] = modes.QuickModelShortcut{Provider: p.Provider, Model: p.Model, Reasoning: p.Reasoning} | |
| 1497 | + | } | |
| 1498 | + | return result | |
| 1499 | + | } | |
| 1500 | + | ||
| 1492 | 1501 | func runInteractive(ctx context.Context, args Args, version string) (runErr error) { | |
| 1493 | 1502 | initialCfg, _ := LoadConfig() | |
| 1494 | 1503 | if initialCfg.Goals.MaxTokenBudget != nil && *initialCfg.Goals.MaxTokenBudget == 0 { | |
| @@ -1499,6 +1508,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 1499 | 1508 | if err != nil { | |
| 1500 | 1509 | return err | |
| 1501 | 1510 | } | |
| 1511 | + | // Resolve may have repaired a stale favorite. Use the saved repair for | |
| 1512 | + | // the UI's active-profile comparison instead of the pre-resolution copy. | |
| 1513 | + | if resolvedCfg, err := LoadConfig(); err == nil { | |
| 1514 | + | initialCfg.QuickModelShortcuts = resolvedCfg.QuickModelShortcuts | |
| 1515 | + | initialCfg.ActiveModelProfile = resolvedCfg.ActiveModelProfile | |
| 1516 | + | } | |
| 1502 | 1517 | ||
| 1503 | 1518 | authStore := AuthStoreFor() | |
| 1504 | 1519 | mgr := auth.NewManager(authStore) | |
| @@ -2515,10 +2530,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2515 | 2530 | } | |
| 2516 | 2531 | ||
| 2517 | 2532 | fastMode := r.FastMode | |
| 2518 | - | quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) | |
| 2519 | - | for idx, s := range initialCfg.QuickModelShortcuts { | |
| 2520 | - | quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} | |
| 2521 | - | } | |
| 2533 | + | quickModelShortcuts := interactiveQuickModelShortcuts(initialCfg.QuickModelShortcuts) | |
| 2522 | 2534 | themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME"))) | |
| 2523 | 2535 | if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" { | |
| 2524 | 2536 | fmt.Fprintf(os.Stderr, "theme override: unsupported ZUT_THEME=%q; ignoring it\n", themeEnv) | |
| @@ -2589,6 +2601,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||
| 2589 | 2601 | GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget, | |
| 2590 | 2602 | JailByDefault: initialCfg.JailByDefault, | |
| 2591 | 2603 | QuickModelShortcuts: quickModelShortcuts, | |
| 2604 | + | ActiveModelProfile: initialCfg.ActiveModelProfile, | |
| 2592 | 2605 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 2593 | 2606 | RespectGitignore: initialCfg.RespectGitignore, | |
| 2594 | 2607 | CompactMode: initialCfg.CompactMode, | |
| @@ -1489,6 +1489,15 @@ func reloadResourcesAfterStartupPreWithRegistry(ctx context.Context, args Args, | |||||
| 1489 | 1489 | ||||
| 1490 | // ---- interactive mode: opens the TUI even without credentials ---- | 1490 | // ---- interactive mode: opens the TUI even without credentials ---- | ||
| 1491 | 1491 | ||||
| 1492 | + | func interactiveQuickModelShortcuts(shortcuts []QuickModelShortcut) []modes.QuickModelShortcut { | |||
| 1493 | + | result := make([]modes.QuickModelShortcut, len(shortcuts)) | |||
| 1494 | + | for idx, s := range shortcuts { | |||
| 1495 | + | p := normalizedModelProfile(s) | |||
| 1496 | + | result[idx] = modes.QuickModelShortcut{Provider: p.Provider, Model: p.Model, Reasoning: p.Reasoning} | |||
| 1497 | + | } | |||
| 1498 | + | return result | |||
| 1499 | + | } | |||
| 1500 | + | ||||
| 1492 | func runInteractive(ctx context.Context, args Args, version string) (runErr error) { | 1501 | func runInteractive(ctx context.Context, args Args, version string) (runErr error) { | ||
| 1493 | initialCfg, _ := LoadConfig() | 1502 | initialCfg, _ := LoadConfig() | ||
| 1494 | if initialCfg.Goals.MaxTokenBudget != nil && *initialCfg.Goals.MaxTokenBudget == 0 { | 1503 | if initialCfg.Goals.MaxTokenBudget != nil && *initialCfg.Goals.MaxTokenBudget == 0 { | ||
| @@ -1499,6 +1508,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 1499 | if err != nil { | 1508 | if err != nil { | ||
| 1500 | return err | 1509 | return err | ||
| 1501 | } | 1510 | } | ||
| 1511 | + | // Resolve may have repaired a stale favorite. Use the saved repair for | |||
| 1512 | + | // the UI's active-profile comparison instead of the pre-resolution copy. | |||
| 1513 | + | if resolvedCfg, err := LoadConfig(); err == nil { | |||
| 1514 | + | initialCfg.QuickModelShortcuts = resolvedCfg.QuickModelShortcuts | |||
| 1515 | + | initialCfg.ActiveModelProfile = resolvedCfg.ActiveModelProfile | |||
| 1516 | + | } | |||
| 1502 | 1517 | ||||
| 1503 | authStore := AuthStoreFor() | 1518 | authStore := AuthStoreFor() | ||
| 1504 | mgr := auth.NewManager(authStore) | 1519 | mgr := auth.NewManager(authStore) | ||
| @@ -2515,10 +2530,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2515 | } | 2530 | } | ||
| 2516 | 2531 | ||||
| 2517 | fastMode := r.FastMode | 2532 | fastMode := r.FastMode | ||
| 2518 | - | quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) | 2533 | + | quickModelShortcuts := interactiveQuickModelShortcuts(initialCfg.QuickModelShortcuts) |
| 2519 | - | for idx, s := range initialCfg.QuickModelShortcuts { | |||
| 2520 | - | quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} | |||
| 2521 | - | } | |||
| 2522 | themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME"))) | 2534 | themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME"))) | ||
| 2523 | if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" { | 2535 | if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" { | ||
| 2524 | fmt.Fprintf(os.Stderr, "theme override: unsupported ZUT_THEME=%q; ignoring it\n", themeEnv) | 2536 | fmt.Fprintf(os.Stderr, "theme override: unsupported ZUT_THEME=%q; ignoring it\n", themeEnv) | ||
| @@ -2589,6 +2601,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro | |||||
| 2589 | GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget, | 2601 | GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget, | ||
| 2590 | JailByDefault: initialCfg.JailByDefault, | 2602 | JailByDefault: initialCfg.JailByDefault, | ||
| 2591 | QuickModelShortcuts: quickModelShortcuts, | 2603 | QuickModelShortcuts: quickModelShortcuts, | ||
| 2604 | + | ActiveModelProfile: initialCfg.ActiveModelProfile, | |||
| 2592 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | 2605 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | ||
| 2593 | RespectGitignore: initialCfg.RespectGitignore, | 2606 | RespectGitignore: initialCfg.RespectGitignore, | ||
| 2594 | CompactMode: initialCfg.CompactMode, | 2607 | CompactMode: initialCfg.CompactMode, | ||
M packages/agent/config.go +33 -2
| @@ -18,8 +18,9 @@ import ( | |||
| 18 | 18 | ||
| 19 | 19 | // QuickModelShortcut is one configured keyboard shortcut slot. | |
| 20 | 20 | type QuickModelShortcut struct { | |
| 21 | - | Provider string `json:"provider"` | |
| 22 | - | Model string `json:"model"` | |
| 21 | + | Provider string `json:"provider"` | |
| 22 | + | Model string `json:"model"` | |
| 23 | + | Reasoning string `json:"reasoning,omitempty"` | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 25 | 26 | // SubagentsConfig contains the resident manager policy. max_concurrent limits | |
| @@ -69,6 +70,7 @@ type Config struct { | |||
| 69 | 70 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by | |
| 70 | 71 | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | |
| 71 | 72 | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | |
| 73 | + | ActiveModelProfile int `json:"active_model_profile,omitempty"` | |
| 72 | 74 | ||
| 73 | 75 | // InlineImagesEnabled controls whether zut draws screenshots inline | |
| 74 | 76 | // when the terminal supports an image protocol. nil/missing means | |
| @@ -321,6 +323,35 @@ func LoadConfig() (Config, error) { | |||
| 321 | 323 | return c, nil | |
| 322 | 324 | } | |
| 323 | 325 | ||
| 326 | + | // normalizedModelProfile keeps startup defaults and the TUI's copy consistent. | |
| 327 | + | // Unknown/open-catalog models retain their configured reasoning until resolved. | |
| 328 | + | func normalizedModelProfile(p QuickModelShortcut) QuickModelShortcut { | |
| 329 | + | p.Provider = canonicalProvider(p.Provider) | |
| 330 | + | p.Reasoning = providerpkg.NormalizeReasoning(p.Reasoning) | |
| 331 | + | if model, err := providerpkg.FindModel(p.Provider, p.Model); err == nil { | |
| 332 | + | p.Reasoning = providerpkg.ClampReasoningForModel(model, p.Reasoning) | |
| 333 | + | } | |
| 334 | + | return p | |
| 335 | + | } | |
| 336 | + | ||
| 337 | + | // applyActiveModelProfile supplies defaults without rewriting the config and | |
| 338 | + | // returns their source slot (0 when none). Explicit flags retain precedence. | |
| 339 | + | func (c *Config) applyActiveModelProfile() int { | |
| 340 | + | slot := c.ActiveModelProfile | |
| 341 | + | if slot < 1 || slot > 9 { | |
| 342 | + | slot = 1 | |
| 343 | + | } | |
| 344 | + | if slot > len(c.QuickModelShortcuts) { | |
| 345 | + | return 0 | |
| 346 | + | } | |
| 347 | + | p := normalizedModelProfile(c.QuickModelShortcuts[slot-1]) | |
| 348 | + | if p.Provider == "" || p.Model == "" { | |
| 349 | + | return 0 | |
| 350 | + | } | |
| 351 | + | c.Provider, c.Model, c.Reasoning = p.Provider, p.Model, p.Reasoning | |
| 352 | + | return slot | |
| 353 | + | } | |
| 354 | + | ||
| 324 | 355 | func validateSubagentConfig(cfg SubagentsConfig) error { | |
| 325 | 356 | values := []struct { | |
| 326 | 357 | name string | |
| @@ -18,8 +18,9 @@ import ( | |||||
| 18 | 18 | ||||
| 19 | // QuickModelShortcut is one configured keyboard shortcut slot. | 19 | // QuickModelShortcut is one configured keyboard shortcut slot. | ||
| 20 | type QuickModelShortcut struct { | 20 | type QuickModelShortcut struct { | ||
| 21 | - | Provider string `json:"provider"` | 21 | + | Provider string `json:"provider"` |
| 22 | - | Model string `json:"model"` | 22 | + | Model string `json:"model"` |
| 23 | + | Reasoning string `json:"reasoning,omitempty"` | |||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 25 | // SubagentsConfig contains the resident manager policy. max_concurrent limits | 26 | // SubagentsConfig contains the resident manager policy. max_concurrent limits | ||
| @@ -69,6 +70,7 @@ type Config struct { | |||||
| 69 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by | 70 | // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by | ||
| 70 | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | 71 | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | ||
| 71 | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | 72 | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | ||
| 73 | + | ActiveModelProfile int `json:"active_model_profile,omitempty"` | |||
| 72 | 74 | ||||
| 73 | // InlineImagesEnabled controls whether zut draws screenshots inline | 75 | // InlineImagesEnabled controls whether zut draws screenshots inline | ||
| 74 | // when the terminal supports an image protocol. nil/missing means | 76 | // when the terminal supports an image protocol. nil/missing means | ||
| @@ -321,6 +323,35 @@ func LoadConfig() (Config, error) { | |||||
| 321 | return c, nil | 323 | return c, nil | ||
| 322 | } | 324 | } | ||
| 323 | 325 | ||||
| 326 | + | // normalizedModelProfile keeps startup defaults and the TUI's copy consistent. | |||
| 327 | + | // Unknown/open-catalog models retain their configured reasoning until resolved. | |||
| 328 | + | func normalizedModelProfile(p QuickModelShortcut) QuickModelShortcut { | |||
| 329 | + | p.Provider = canonicalProvider(p.Provider) | |||
| 330 | + | p.Reasoning = providerpkg.NormalizeReasoning(p.Reasoning) | |||
| 331 | + | if model, err := providerpkg.FindModel(p.Provider, p.Model); err == nil { | |||
| 332 | + | p.Reasoning = providerpkg.ClampReasoningForModel(model, p.Reasoning) | |||
| 333 | + | } | |||
| 334 | + | return p | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | // applyActiveModelProfile supplies defaults without rewriting the config and | |||
| 338 | + | // returns their source slot (0 when none). Explicit flags retain precedence. | |||
| 339 | + | func (c *Config) applyActiveModelProfile() int { | |||
| 340 | + | slot := c.ActiveModelProfile | |||
| 341 | + | if slot < 1 || slot > 9 { | |||
| 342 | + | slot = 1 | |||
| 343 | + | } | |||
| 344 | + | if slot > len(c.QuickModelShortcuts) { | |||
| 345 | + | return 0 | |||
| 346 | + | } | |||
| 347 | + | p := normalizedModelProfile(c.QuickModelShortcuts[slot-1]) | |||
| 348 | + | if p.Provider == "" || p.Model == "" { | |||
| 349 | + | return 0 | |||
| 350 | + | } | |||
| 351 | + | c.Provider, c.Model, c.Reasoning = p.Provider, p.Model, p.Reasoning | |||
| 352 | + | return slot | |||
| 353 | + | } | |||
| 354 | + | ||||
| 324 | func validateSubagentConfig(cfg SubagentsConfig) error { | 355 | func validateSubagentConfig(cfg SubagentsConfig) error { | ||
| 325 | values := []struct { | 356 | values := []struct { | ||
| 326 | name string | 357 | name string | ||
A packages/agent/model_profile_restoration_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/agent/modes" | |
| 7 | + | "github.com/bnema/zut/packages/core" | |
| 8 | + | "github.com/bnema/zut/packages/tui" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestResolveRepairsActiveModelProfileFallback(t *testing.T) { | |
| 12 | + | for _, tc := range []struct { | |
| 13 | + | name string | |
| 14 | + | active int | |
| 15 | + | profile QuickModelShortcut | |
| 16 | + | }{ | |
| 17 | + | {"missing model", 2, QuickModelShortcut{Provider: "openai", Model: "removed-model", Reasoning: "high"}}, | |
| 18 | + | {"missing provider default slot", 0, QuickModelShortcut{Provider: "removed-provider", Model: "removed-model", Reasoning: "high"}}, | |
| 19 | + | } { | |
| 20 | + | t.Run(tc.name, func(t *testing.T) { | |
| 21 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 22 | + | slot := tc.active | |
| 23 | + | if slot == 0 { | |
| 24 | + | slot = 1 | |
| 25 | + | } | |
| 26 | + | profiles := make([]QuickModelShortcut, slot) | |
| 27 | + | profiles[slot-1] = tc.profile | |
| 28 | + | if err := SaveConfig(Config{ActiveModelProfile: tc.active, QuickModelShortcuts: profiles}); err != nil { | |
| 29 | + | t.Fatal(err) | |
| 30 | + | } | |
| 31 | + | args := Args{APIKey: "synthetic-test-key"} | |
| 32 | + | resolved, err := Resolve(args, false) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | saved, err := LoadConfig() | |
| 37 | + | if err != nil { | |
| 38 | + | t.Fatal(err) | |
| 39 | + | } | |
| 40 | + | p := saved.QuickModelShortcuts[slot-1] | |
| 41 | + | if p.Provider != resolved.Provider || p.Model != resolved.Model || p.Reasoning != resolved.Reasoning { | |
| 42 | + | t.Fatalf("fallback left stale profile: %+v; resolved %s/%s (%s)", p, resolved.Provider, resolved.Model, resolved.Reasoning) | |
| 43 | + | } | |
| 44 | + | again, err := Resolve(args, false) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | if again.Provider != resolved.Provider || again.Model != resolved.Model || again.Reasoning != resolved.Reasoning { | |
| 49 | + | t.Fatal("repaired selection changed on next resolution") | |
| 50 | + | } | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestResolveExplicitFallbackDoesNotRepairFavorite(t *testing.T) { | |
| 56 | + | for _, args := range []Args{{Model: "removed-model"}, {Provider: "removed-provider"}} { | |
| 57 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 58 | + | original := QuickModelShortcut{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high"} | |
| 59 | + | if err := SaveConfig(Config{ActiveModelProfile: 1, QuickModelShortcuts: []QuickModelShortcut{original}}); err != nil { | |
| 60 | + | t.Fatal(err) | |
| 61 | + | } | |
| 62 | + | args.APIKey = "synthetic-test-key" | |
| 63 | + | if _, err := Resolve(args, false); err != nil { | |
| 64 | + | t.Fatal(err) | |
| 65 | + | } | |
| 66 | + | saved, err := LoadConfig() | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | if saved.QuickModelShortcuts[0] != original { | |
| 71 | + | t.Fatalf("explicit fallback overwrote favorite: %+v", saved.QuickModelShortcuts[0]) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestModelProfileStartupClampsReasoningAndPreservesAutosave(t *testing.T) { | |
| 77 | + | for _, tc := range []struct { | |
| 78 | + | profile QuickModelShortcut | |
| 79 | + | want string | |
| 80 | + | }{ | |
| 81 | + | {QuickModelShortcut{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "minimum"}, "low"}, | |
| 82 | + | {QuickModelShortcut{Provider: "google", Model: "gemini-2.0-flash", Reasoning: "high"}, ""}, | |
| 83 | + | } { | |
| 84 | + | t.Run(tc.profile.Model, func(t *testing.T) { | |
| 85 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 86 | + | cfg := Config{ActiveModelProfile: 1, QuickModelShortcuts: []QuickModelShortcut{tc.profile}} | |
| 87 | + | if err := SaveConfig(cfg); err != nil { | |
| 88 | + | t.Fatal(err) | |
| 89 | + | } | |
| 90 | + | r, err := Resolve(Args{APIKey: "synthetic-test-key"}, false) | |
| 91 | + | if err != nil { | |
| 92 | + | t.Fatal(err) | |
| 93 | + | } | |
| 94 | + | if r.Reasoning != tc.want { | |
| 95 | + | t.Fatalf("startup reasoning=%q, want %q", r.Reasoning, tc.want) | |
| 96 | + | } | |
| 97 | + | i := modes.NewInteractive(modes.InteractiveConfig{ | |
| 98 | + | Theme: tui.Dark, Provider: r.Provider, Model: r.Model, Reasoning: r.Reasoning, | |
| 99 | + | Agent: core.NewAgent(nil, r.Model, "", nil), SettingsStore: configSettingsStore{}, | |
| 100 | + | QuickModelShortcuts: interactiveQuickModelShortcuts(cfg.QuickModelShortcuts), ActiveModelProfile: 1, | |
| 101 | + | }) | |
| 102 | + | i.SubmitSlash("/model " + r.Model) | |
| 103 | + | saved, err := LoadConfig() | |
| 104 | + | if err != nil { | |
| 105 | + | t.Fatal(err) | |
| 106 | + | } | |
| 107 | + | if saved.QuickModelShortcuts[0].Reasoning != tc.want { | |
| 108 | + | t.Fatalf("clamped active profile failed to autosave: %+v", saved.QuickModelShortcuts[0]) | |
| 109 | + | } | |
| 110 | + | }) | |
| 111 | + | } | |
| 112 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zut/packages/agent/modes" | |||
| 7 | + | "github.com/bnema/zut/packages/core" | |||
| 8 | + | "github.com/bnema/zut/packages/tui" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestResolveRepairsActiveModelProfileFallback(t *testing.T) { | |||
| 12 | + | for _, tc := range []struct { | |||
| 13 | + | name string | |||
| 14 | + | active int | |||
| 15 | + | profile QuickModelShortcut | |||
| 16 | + | }{ | |||
| 17 | + | {"missing model", 2, QuickModelShortcut{Provider: "openai", Model: "removed-model", Reasoning: "high"}}, | |||
| 18 | + | {"missing provider default slot", 0, QuickModelShortcut{Provider: "removed-provider", Model: "removed-model", Reasoning: "high"}}, | |||
| 19 | + | } { | |||
| 20 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 21 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 22 | + | slot := tc.active | |||
| 23 | + | if slot == 0 { | |||
| 24 | + | slot = 1 | |||
| 25 | + | } | |||
| 26 | + | profiles := make([]QuickModelShortcut, slot) | |||
| 27 | + | profiles[slot-1] = tc.profile | |||
| 28 | + | if err := SaveConfig(Config{ActiveModelProfile: tc.active, QuickModelShortcuts: profiles}); err != nil { | |||
| 29 | + | t.Fatal(err) | |||
| 30 | + | } | |||
| 31 | + | args := Args{APIKey: "synthetic-test-key"} | |||
| 32 | + | resolved, err := Resolve(args, false) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | saved, err := LoadConfig() | |||
| 37 | + | if err != nil { | |||
| 38 | + | t.Fatal(err) | |||
| 39 | + | } | |||
| 40 | + | p := saved.QuickModelShortcuts[slot-1] | |||
| 41 | + | if p.Provider != resolved.Provider || p.Model != resolved.Model || p.Reasoning != resolved.Reasoning { | |||
| 42 | + | t.Fatalf("fallback left stale profile: %+v; resolved %s/%s (%s)", p, resolved.Provider, resolved.Model, resolved.Reasoning) | |||
| 43 | + | } | |||
| 44 | + | again, err := Resolve(args, false) | |||
| 45 | + | if err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | if again.Provider != resolved.Provider || again.Model != resolved.Model || again.Reasoning != resolved.Reasoning { | |||
| 49 | + | t.Fatal("repaired selection changed on next resolution") | |||
| 50 | + | } | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestResolveExplicitFallbackDoesNotRepairFavorite(t *testing.T) { | |||
| 56 | + | for _, args := range []Args{{Model: "removed-model"}, {Provider: "removed-provider"}} { | |||
| 57 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 58 | + | original := QuickModelShortcut{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high"} | |||
| 59 | + | if err := SaveConfig(Config{ActiveModelProfile: 1, QuickModelShortcuts: []QuickModelShortcut{original}}); err != nil { | |||
| 60 | + | t.Fatal(err) | |||
| 61 | + | } | |||
| 62 | + | args.APIKey = "synthetic-test-key" | |||
| 63 | + | if _, err := Resolve(args, false); err != nil { | |||
| 64 | + | t.Fatal(err) | |||
| 65 | + | } | |||
| 66 | + | saved, err := LoadConfig() | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | if saved.QuickModelShortcuts[0] != original { | |||
| 71 | + | t.Fatalf("explicit fallback overwrote favorite: %+v", saved.QuickModelShortcuts[0]) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestModelProfileStartupClampsReasoningAndPreservesAutosave(t *testing.T) { | |||
| 77 | + | for _, tc := range []struct { | |||
| 78 | + | profile QuickModelShortcut | |||
| 79 | + | want string | |||
| 80 | + | }{ | |||
| 81 | + | {QuickModelShortcut{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "minimum"}, "low"}, | |||
| 82 | + | {QuickModelShortcut{Provider: "google", Model: "gemini-2.0-flash", Reasoning: "high"}, ""}, | |||
| 83 | + | } { | |||
| 84 | + | t.Run(tc.profile.Model, func(t *testing.T) { | |||
| 85 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 86 | + | cfg := Config{ActiveModelProfile: 1, QuickModelShortcuts: []QuickModelShortcut{tc.profile}} | |||
| 87 | + | if err := SaveConfig(cfg); err != nil { | |||
| 88 | + | t.Fatal(err) | |||
| 89 | + | } | |||
| 90 | + | r, err := Resolve(Args{APIKey: "synthetic-test-key"}, false) | |||
| 91 | + | if err != nil { | |||
| 92 | + | t.Fatal(err) | |||
| 93 | + | } | |||
| 94 | + | if r.Reasoning != tc.want { | |||
| 95 | + | t.Fatalf("startup reasoning=%q, want %q", r.Reasoning, tc.want) | |||
| 96 | + | } | |||
| 97 | + | i := modes.NewInteractive(modes.InteractiveConfig{ | |||
| 98 | + | Theme: tui.Dark, Provider: r.Provider, Model: r.Model, Reasoning: r.Reasoning, | |||
| 99 | + | Agent: core.NewAgent(nil, r.Model, "", nil), SettingsStore: configSettingsStore{}, | |||
| 100 | + | QuickModelShortcuts: interactiveQuickModelShortcuts(cfg.QuickModelShortcuts), ActiveModelProfile: 1, | |||
| 101 | + | }) | |||
| 102 | + | i.SubmitSlash("/model " + r.Model) | |||
| 103 | + | saved, err := LoadConfig() | |||
| 104 | + | if err != nil { | |||
| 105 | + | t.Fatal(err) | |||
| 106 | + | } | |||
| 107 | + | if saved.QuickModelShortcuts[0].Reasoning != tc.want { | |||
| 108 | + | t.Fatalf("clamped active profile failed to autosave: %+v", saved.QuickModelShortcuts[0]) | |||
| 109 | + | } | |||
| 110 | + | }) | |||
| 111 | + | } | |||
| 112 | + | } | |||
A packages/agent/model_profiles_test.go +163 -0
| @@ -0,0 +1,163 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/zut/packages/agent/modes" | |
| 8 | + | "github.com/bnema/zut/packages/core" | |
| 9 | + | "github.com/bnema/zut/packages/tui" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestActiveAliasedModelProfilePreservesAutosave(t *testing.T) { | |
| 13 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 14 | + | cfg := Config{ | |
| 15 | + | ActiveModelProfile: 2, | |
| 16 | + | QuickModelShortcuts: []QuickModelShortcut{ | |
| 17 | + | {}, | |
| 18 | + | {Provider: "bedrock", Model: "amazon.nova-lite-v1:0"}, | |
| 19 | + | }, | |
| 20 | + | } | |
| 21 | + | if err := SaveConfig(cfg); err != nil { | |
| 22 | + | t.Fatal(err) | |
| 23 | + | } | |
| 24 | + | r, err := Resolve(Args{APIKey: "synthetic-test-key"}, false) | |
| 25 | + | if err != nil { | |
| 26 | + | t.Fatal(err) | |
| 27 | + | } | |
| 28 | + | if r.Provider != "amazon-bedrock" { | |
| 29 | + | t.Fatalf("resolved provider = %q", r.Provider) | |
| 30 | + | } | |
| 31 | + | i := modes.NewInteractive(modes.InteractiveConfig{ | |
| 32 | + | Theme: tui.Dark, Provider: r.Provider, Model: r.Model, Reasoning: r.Reasoning, | |
| 33 | + | Agent: core.NewAgent(nil, r.Model, "", nil), | |
| 34 | + | SettingsStore: configSettingsStore{}, | |
| 35 | + | QuickModelShortcuts: interactiveQuickModelShortcuts(cfg.QuickModelShortcuts), | |
| 36 | + | ActiveModelProfile: cfg.ActiveModelProfile, | |
| 37 | + | }) | |
| 38 | + | // A manual model edit must save to the already-active slot, not require | |
| 39 | + | // selecting /profile again to repair a spurious startup detachment. | |
| 40 | + | const nextModel = "amazon.nova-micro-v1:0" | |
| 41 | + | i.SubmitSlash("/model " + nextModel) | |
| 42 | + | if i.Agent().Model != nextModel { | |
| 43 | + | t.Fatal("model edit was not applied") | |
| 44 | + | } | |
| 45 | + | saved, err := LoadConfig() | |
| 46 | + | if err != nil { | |
| 47 | + | t.Fatal(err) | |
| 48 | + | } | |
| 49 | + | want := QuickModelShortcut{Provider: "amazon-bedrock", Model: nextModel} | |
| 50 | + | if saved.ActiveModelProfile != 2 || saved.QuickModelShortcuts[1] != want { | |
| 51 | + | t.Fatalf("active aliased profile did not autosave: active=%d profile=%+v", saved.ActiveModelProfile, saved.QuickModelShortcuts[1]) | |
| 52 | + | } | |
| 53 | + | if saved.QuickModelShortcuts[0] != (QuickModelShortcut{}) || cfg.QuickModelShortcuts[1].Provider != "bedrock" { | |
| 54 | + | t.Fatal("conversion or saving changed an unrelated slot or the input config") | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func TestModelProfileConfigRoundTrip(t *testing.T) { | |
| 59 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 60 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |
| 61 | + | t.Fatal(err) | |
| 62 | + | } | |
| 63 | + | store := configSettingsStore{} | |
| 64 | + | p := modes.QuickModelShortcut{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max"} | |
| 65 | + | if err := store.SetModelProfile(9, p, 9); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | cfg, err := LoadConfig() | |
| 69 | + | if err != nil { | |
| 70 | + | t.Fatal(err) | |
| 71 | + | } | |
| 72 | + | if cfg.ActiveModelProfile != 9 || len(cfg.QuickModelShortcuts) != 9 || cfg.Reasoning != "max" || cfg.Model != p.Model || cfg.Theme != "dark" { | |
| 73 | + | t.Fatalf("config=%+v", cfg) | |
| 74 | + | } | |
| 75 | + | // The selected profile is authoritative even after hand-editing its model | |
| 76 | + | // and reasoning, without requiring duplicate changes to the legacy defaults. | |
| 77 | + | cfg.QuickModelShortcuts[8].Reasoning = "low" | |
| 78 | + | cfg.applyActiveModelProfile() | |
| 79 | + | if cfg.Reasoning != "low" { | |
| 80 | + | t.Fatal("active profile did not supply defaults") | |
| 81 | + | } | |
| 82 | + | if err := store.SetModelProfile(9, modes.QuickModelShortcut{}, 0); err != nil { | |
| 83 | + | t.Fatal(err) | |
| 84 | + | } | |
| 85 | + | cleared, err := LoadConfig() | |
| 86 | + | if err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | if cleared.ActiveModelProfile != 0 || len(cleared.QuickModelShortcuts) != 0 || cleared.Model != p.Model { | |
| 90 | + | t.Fatalf("clear=%+v", cleared) | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestModelProfileConfigLegacyAndInvalidActiveSlots(t *testing.T) { | |
| 95 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 96 | + | if err := os.WriteFile(ConfigPath(), []byte(`{"provider":"openai","model":"gpt-5.5","reasoning":"high","quick_model_shortcuts":[{"provider":"openai","model":"gpt-5.6-sol"}]}`), 0600); err != nil { | |
| 97 | + | t.Fatal(err) | |
| 98 | + | } | |
| 99 | + | cfg, err := LoadConfig() | |
| 100 | + | if err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | for _, slot := range []int{-1, 0, 2, 10} { | |
| 104 | + | copy := cfg | |
| 105 | + | copy.ActiveModelProfile = slot | |
| 106 | + | copy.applyActiveModelProfile() | |
| 107 | + | want := "gpt-5.6-sol" // missing/out-of-range active slot defaults to 1 | |
| 108 | + | if slot == 2 { | |
| 109 | + | want = "gpt-5.5" | |
| 110 | + | } // valid but unassigned slot | |
| 111 | + | if copy.Model != want { | |
| 112 | + | t.Fatalf("active slot %d: model %s, want %s", slot, copy.Model, want) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | cfg.ActiveModelProfile = 1 | |
| 116 | + | cfg.applyActiveModelProfile() | |
| 117 | + | if cfg.Model != "gpt-5.6-sol" || cfg.Reasoning != "" { | |
| 118 | + | t.Fatal("legacy slots should recall with reasoning off") | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestModelProfileStoreRejectsInvalidSlotsAndCorruptConfig(t *testing.T) { | |
| 123 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 124 | + | store := configSettingsStore{} | |
| 125 | + | for _, slot := range []int{-1, 0, 10} { | |
| 126 | + | if err := store.SetModelProfile(slot, modes.QuickModelShortcut{}, 0); err == nil { | |
| 127 | + | t.Fatalf("accepted slot %d", slot) | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | original := []byte(`{"invalid":`) | |
| 131 | + | if err := os.WriteFile(ConfigPath(), original, 0600); err != nil { | |
| 132 | + | t.Fatal(err) | |
| 133 | + | } | |
| 134 | + | if err := store.SetModelProfile(1, modes.QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}, 1); err == nil { | |
| 135 | + | t.Fatal("corrupt config was accepted") | |
| 136 | + | } | |
| 137 | + | got, err := os.ReadFile(ConfigPath()) | |
| 138 | + | if err != nil || string(got) != string(original) { | |
| 139 | + | t.Fatal("corrupt config was overwritten") | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestModelProfileResolveDefaultsAndExplicitOverrides(t *testing.T) { | |
| 144 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |
| 145 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5.5", Reasoning: "high", ActiveModelProfile: 1, QuickModelShortcuts: []QuickModelShortcut{{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max"}}}); err != nil { | |
| 146 | + | t.Fatal(err) | |
| 147 | + | } | |
| 148 | + | for _, override := range []bool{false, true} { | |
| 149 | + | args := Args{APIKey: "synthetic-test-key"} | |
| 150 | + | wantModel, wantReasoning := "gpt-5.6-sol", "max" | |
| 151 | + | if override { | |
| 152 | + | args.Model, args.Reasoning = "gpt-5.5", "off" | |
| 153 | + | wantModel, wantReasoning = "gpt-5.5", "" | |
| 154 | + | } | |
| 155 | + | r, err := Resolve(args, false) | |
| 156 | + | if err != nil { | |
| 157 | + | t.Fatal(err) | |
| 158 | + | } | |
| 159 | + | if r.Model != wantModel || r.Reasoning != wantReasoning { | |
| 160 | + | t.Fatalf("override=%v model=%s reasoning=%s", override, r.Model, r.Reasoning) | |
| 161 | + | } | |
| 162 | + | } | |
| 163 | + | } | |
| @@ -0,0 +1,163 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/zut/packages/agent/modes" | |||
| 8 | + | "github.com/bnema/zut/packages/core" | |||
| 9 | + | "github.com/bnema/zut/packages/tui" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestActiveAliasedModelProfilePreservesAutosave(t *testing.T) { | |||
| 13 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 14 | + | cfg := Config{ | |||
| 15 | + | ActiveModelProfile: 2, | |||
| 16 | + | QuickModelShortcuts: []QuickModelShortcut{ | |||
| 17 | + | {}, | |||
| 18 | + | {Provider: "bedrock", Model: "amazon.nova-lite-v1:0"}, | |||
| 19 | + | }, | |||
| 20 | + | } | |||
| 21 | + | if err := SaveConfig(cfg); err != nil { | |||
| 22 | + | t.Fatal(err) | |||
| 23 | + | } | |||
| 24 | + | r, err := Resolve(Args{APIKey: "synthetic-test-key"}, false) | |||
| 25 | + | if err != nil { | |||
| 26 | + | t.Fatal(err) | |||
| 27 | + | } | |||
| 28 | + | if r.Provider != "amazon-bedrock" { | |||
| 29 | + | t.Fatalf("resolved provider = %q", r.Provider) | |||
| 30 | + | } | |||
| 31 | + | i := modes.NewInteractive(modes.InteractiveConfig{ | |||
| 32 | + | Theme: tui.Dark, Provider: r.Provider, Model: r.Model, Reasoning: r.Reasoning, | |||
| 33 | + | Agent: core.NewAgent(nil, r.Model, "", nil), | |||
| 34 | + | SettingsStore: configSettingsStore{}, | |||
| 35 | + | QuickModelShortcuts: interactiveQuickModelShortcuts(cfg.QuickModelShortcuts), | |||
| 36 | + | ActiveModelProfile: cfg.ActiveModelProfile, | |||
| 37 | + | }) | |||
| 38 | + | // A manual model edit must save to the already-active slot, not require | |||
| 39 | + | // selecting /profile again to repair a spurious startup detachment. | |||
| 40 | + | const nextModel = "amazon.nova-micro-v1:0" | |||
| 41 | + | i.SubmitSlash("/model " + nextModel) | |||
| 42 | + | if i.Agent().Model != nextModel { | |||
| 43 | + | t.Fatal("model edit was not applied") | |||
| 44 | + | } | |||
| 45 | + | saved, err := LoadConfig() | |||
| 46 | + | if err != nil { | |||
| 47 | + | t.Fatal(err) | |||
| 48 | + | } | |||
| 49 | + | want := QuickModelShortcut{Provider: "amazon-bedrock", Model: nextModel} | |||
| 50 | + | if saved.ActiveModelProfile != 2 || saved.QuickModelShortcuts[1] != want { | |||
| 51 | + | t.Fatalf("active aliased profile did not autosave: active=%d profile=%+v", saved.ActiveModelProfile, saved.QuickModelShortcuts[1]) | |||
| 52 | + | } | |||
| 53 | + | if saved.QuickModelShortcuts[0] != (QuickModelShortcut{}) || cfg.QuickModelShortcuts[1].Provider != "bedrock" { | |||
| 54 | + | t.Fatal("conversion or saving changed an unrelated slot or the input config") | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestModelProfileConfigRoundTrip(t *testing.T) { | |||
| 59 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 60 | + | if err := SaveConfig(Config{Theme: "dark"}); err != nil { | |||
| 61 | + | t.Fatal(err) | |||
| 62 | + | } | |||
| 63 | + | store := configSettingsStore{} | |||
| 64 | + | p := modes.QuickModelShortcut{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max"} | |||
| 65 | + | if err := store.SetModelProfile(9, p, 9); err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | cfg, err := LoadConfig() | |||
| 69 | + | if err != nil { | |||
| 70 | + | t.Fatal(err) | |||
| 71 | + | } | |||
| 72 | + | if cfg.ActiveModelProfile != 9 || len(cfg.QuickModelShortcuts) != 9 || cfg.Reasoning != "max" || cfg.Model != p.Model || cfg.Theme != "dark" { | |||
| 73 | + | t.Fatalf("config=%+v", cfg) | |||
| 74 | + | } | |||
| 75 | + | // The selected profile is authoritative even after hand-editing its model | |||
| 76 | + | // and reasoning, without requiring duplicate changes to the legacy defaults. | |||
| 77 | + | cfg.QuickModelShortcuts[8].Reasoning = "low" | |||
| 78 | + | cfg.applyActiveModelProfile() | |||
| 79 | + | if cfg.Reasoning != "low" { | |||
| 80 | + | t.Fatal("active profile did not supply defaults") | |||
| 81 | + | } | |||
| 82 | + | if err := store.SetModelProfile(9, modes.QuickModelShortcut{}, 0); err != nil { | |||
| 83 | + | t.Fatal(err) | |||
| 84 | + | } | |||
| 85 | + | cleared, err := LoadConfig() | |||
| 86 | + | if err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | if cleared.ActiveModelProfile != 0 || len(cleared.QuickModelShortcuts) != 0 || cleared.Model != p.Model { | |||
| 90 | + | t.Fatalf("clear=%+v", cleared) | |||
| 91 | + | } | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestModelProfileConfigLegacyAndInvalidActiveSlots(t *testing.T) { | |||
| 95 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 96 | + | if err := os.WriteFile(ConfigPath(), []byte(`{"provider":"openai","model":"gpt-5.5","reasoning":"high","quick_model_shortcuts":[{"provider":"openai","model":"gpt-5.6-sol"}]}`), 0600); err != nil { | |||
| 97 | + | t.Fatal(err) | |||
| 98 | + | } | |||
| 99 | + | cfg, err := LoadConfig() | |||
| 100 | + | if err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | for _, slot := range []int{-1, 0, 2, 10} { | |||
| 104 | + | copy := cfg | |||
| 105 | + | copy.ActiveModelProfile = slot | |||
| 106 | + | copy.applyActiveModelProfile() | |||
| 107 | + | want := "gpt-5.6-sol" // missing/out-of-range active slot defaults to 1 | |||
| 108 | + | if slot == 2 { | |||
| 109 | + | want = "gpt-5.5" | |||
| 110 | + | } // valid but unassigned slot | |||
| 111 | + | if copy.Model != want { | |||
| 112 | + | t.Fatalf("active slot %d: model %s, want %s", slot, copy.Model, want) | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | cfg.ActiveModelProfile = 1 | |||
| 116 | + | cfg.applyActiveModelProfile() | |||
| 117 | + | if cfg.Model != "gpt-5.6-sol" || cfg.Reasoning != "" { | |||
| 118 | + | t.Fatal("legacy slots should recall with reasoning off") | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestModelProfileStoreRejectsInvalidSlotsAndCorruptConfig(t *testing.T) { | |||
| 123 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 124 | + | store := configSettingsStore{} | |||
| 125 | + | for _, slot := range []int{-1, 0, 10} { | |||
| 126 | + | if err := store.SetModelProfile(slot, modes.QuickModelShortcut{}, 0); err == nil { | |||
| 127 | + | t.Fatalf("accepted slot %d", slot) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | original := []byte(`{"invalid":`) | |||
| 131 | + | if err := os.WriteFile(ConfigPath(), original, 0600); err != nil { | |||
| 132 | + | t.Fatal(err) | |||
| 133 | + | } | |||
| 134 | + | if err := store.SetModelProfile(1, modes.QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}, 1); err == nil { | |||
| 135 | + | t.Fatal("corrupt config was accepted") | |||
| 136 | + | } | |||
| 137 | + | got, err := os.ReadFile(ConfigPath()) | |||
| 138 | + | if err != nil || string(got) != string(original) { | |||
| 139 | + | t.Fatal("corrupt config was overwritten") | |||
| 140 | + | } | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func TestModelProfileResolveDefaultsAndExplicitOverrides(t *testing.T) { | |||
| 144 | + | t.Setenv("ZUT_HOME", t.TempDir()) | |||
| 145 | + | if err := SaveConfig(Config{Provider: "openai", Model: "gpt-5.5", Reasoning: "high", ActiveModelProfile: 1, QuickModelShortcuts: []QuickModelShortcut{{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "max"}}}); err != nil { | |||
| 146 | + | t.Fatal(err) | |||
| 147 | + | } | |||
| 148 | + | for _, override := range []bool{false, true} { | |||
| 149 | + | args := Args{APIKey: "synthetic-test-key"} | |||
| 150 | + | wantModel, wantReasoning := "gpt-5.6-sol", "max" | |||
| 151 | + | if override { | |||
| 152 | + | args.Model, args.Reasoning = "gpt-5.5", "off" | |||
| 153 | + | wantModel, wantReasoning = "gpt-5.5", "" | |||
| 154 | + | } | |||
| 155 | + | r, err := Resolve(args, false) | |||
| 156 | + | if err != nil { | |||
| 157 | + | t.Fatal(err) | |||
| 158 | + | } | |||
| 159 | + | if r.Model != wantModel || r.Reasoning != wantReasoning { | |||
| 160 | + | t.Fatalf("override=%v model=%s reasoning=%s", override, r.Model, r.Reasoning) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | } | |||
M packages/agent/modes/help.go +1 -0
| @@ -23,6 +23,7 @@ var helpKeyRows = [][2]string{ | |||
| 23 | 23 | {"ctrl+l", "redraw the screen"}, | |
| 24 | 24 | {"ctrl+o", "expand / collapse long tool results"}, | |
| 25 | 25 | {"ctrl+r", "cycle reasoning levels for the active model"}, | |
| 26 | + | {"ctrl+1…9", "activate model + reasoning profile (AZERTY top row also works); empty slots open the model picker"}, | |
| 26 | 27 | {"pgup / pgdn", "scroll the chat one page up / down"}, | |
| 27 | 28 | {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"}, | |
| 28 | 29 | } | |
| @@ -23,6 +23,7 @@ var helpKeyRows = [][2]string{ | |||||
| 23 | {"ctrl+l", "redraw the screen"}, | 23 | {"ctrl+l", "redraw the screen"}, | ||
| 24 | {"ctrl+o", "expand / collapse long tool results"}, | 24 | {"ctrl+o", "expand / collapse long tool results"}, | ||
| 25 | {"ctrl+r", "cycle reasoning levels for the active model"}, | 25 | {"ctrl+r", "cycle reasoning levels for the active model"}, | ||
| 26 | + | {"ctrl+1…9", "activate model + reasoning profile (AZERTY top row also works); empty slots open the model picker"}, | |||
| 26 | {"pgup / pgdn", "scroll the chat one page up / down"}, | 27 | {"pgup / pgdn", "scroll the chat one page up / down"}, | ||
| 27 | {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"}, | 28 | {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"}, | ||
| 28 | } | 29 | } | ||
M packages/agent/modes/interactive.go +26 -2
| @@ -175,6 +175,7 @@ type InteractiveConfig struct { | |||
| 175 | 175 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | |
| 176 | 176 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | |
| 177 | 177 | QuickModelShortcuts []QuickModelShortcut | |
| 178 | + | ActiveModelProfile int // 0 means no active profile; otherwise 1–9 | |
| 178 | 179 | ||
| 179 | 180 | // ExtensionThemes returns themes bundled with loaded extensions. | |
| 180 | 181 | ExtensionThemes func() []tui.ThemeOption | |
| @@ -480,8 +481,9 @@ type chatCacheKey struct { | |||
| 480 | 481 | ||
| 481 | 482 | // QuickModelShortcut is one configured quick model switch slot. | |
| 482 | 483 | type QuickModelShortcut struct { | |
| 483 | - | Provider string | |
| 484 | - | Model string | |
| 484 | + | Provider string | |
| 485 | + | Model string | |
| 486 | + | Reasoning string | |
| 485 | 487 | } | |
| 486 | 488 | ||
| 487 | 489 | type extensionStatus struct { | |
| @@ -511,6 +513,12 @@ type SettingsStore interface { | |||
| 511 | 513 | SetTheme(name string) error | |
| 512 | 514 | } | |
| 513 | 515 | ||
| 516 | + | // Optional so existing SettingsStore implementations remain source-compatible. | |
| 517 | + | // Stores without this method retain model-only shortcuts. | |
| 518 | + | type modelProfileSettingsStore interface { | |
| 519 | + | SetModelProfile(slot int, profile QuickModelShortcut, activeSlot int) error | |
| 520 | + | } | |
| 521 | + | ||
| 514 | 522 | type terminalAlertsSettingsStore interface { | |
| 515 | 523 | SetTerminalAlertsEnabled(enabled bool) error | |
| 516 | 524 | } | |
| @@ -833,6 +841,7 @@ type Interactive struct { | |||
| 833 | 841 | settingsDialog *settingsDialog | |
| 834 | 842 | floatingPane tui.FloatingPane | |
| 835 | 843 | quickModelAssign int | |
| 844 | + | quickModelActivate bool // picker was opened by an empty-slot shortcut | |
| 836 | 845 | telegramBridge *telegram.Bridge | |
| 837 | 846 | sessionOpsDialog *sessionOpsDialog | |
| 838 | 847 | sessionTreeDialog *sessionTreeDialog | |
| @@ -980,6 +989,21 @@ type startupPreResult struct { | |||
| 980 | 989 | ||
| 981 | 990 | // NewInteractive constructs an Interactive from cfg. | |
| 982 | 991 | func NewInteractive(cfg InteractiveConfig) *Interactive { | |
| 992 | + | if cfg.ActiveModelProfile < 1 || cfg.ActiveModelProfile > 9 { | |
| 993 | + | cfg.ActiveModelProfile = 1 | |
| 994 | + | } | |
| 995 | + | // An unassigned default remains active so subsequent model/reasoning | |
| 996 | + | // edits save into slot 1. Explicit overrides must not overwrite a favorite. | |
| 997 | + | if slot := cfg.ActiveModelProfile; slot <= len(cfg.QuickModelShortcuts) { | |
| 998 | + | if p := cfg.QuickModelShortcuts[slot-1]; p.Provider != "" || p.Model != "" { | |
| 999 | + | cfg.detachMismatchedModelProfile() | |
| 1000 | + | } | |
| 1001 | + | } | |
| 1002 | + | if cfg.SettingsStore != nil { | |
| 1003 | + | if _, ok := cfg.SettingsStore.(modelProfileSettingsStore); !ok { | |
| 1004 | + | cfg.ActiveModelProfile = 0 | |
| 1005 | + | } | |
| 1006 | + | } | |
| 983 | 1007 | renderer := tui.NewRenderer(cfg.Terminal) | |
| 984 | 1008 | renderer.SetTheme(cfg.Theme) | |
| 985 | 1009 | startupAgentName := "" | |
| @@ -175,6 +175,7 @@ type InteractiveConfig struct { | |||||
| 175 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | 175 | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | ||
| 176 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | 176 | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | ||
| 177 | QuickModelShortcuts []QuickModelShortcut | 177 | QuickModelShortcuts []QuickModelShortcut | ||
| 178 | + | ActiveModelProfile int // 0 means no active profile; otherwise 1–9 | |||
| 178 | 179 | ||||
| 179 | // ExtensionThemes returns themes bundled with loaded extensions. | 180 | // ExtensionThemes returns themes bundled with loaded extensions. | ||
| 180 | ExtensionThemes func() []tui.ThemeOption | 181 | ExtensionThemes func() []tui.ThemeOption | ||
| @@ -480,8 +481,9 @@ type chatCacheKey struct { | |||||
| 480 | 481 | ||||
| 481 | // QuickModelShortcut is one configured quick model switch slot. | 482 | // QuickModelShortcut is one configured quick model switch slot. | ||
| 482 | type QuickModelShortcut struct { | 483 | type QuickModelShortcut struct { | ||
| 483 | - | Provider string | 484 | + | Provider string |
| 484 | - | Model string | 485 | + | Model string |
| 486 | + | Reasoning string | |||
| 485 | } | 487 | } | ||
| 486 | 488 | ||||
| 487 | type extensionStatus struct { | 489 | type extensionStatus struct { | ||
| @@ -511,6 +513,12 @@ type SettingsStore interface { | |||||
| 511 | SetTheme(name string) error | 513 | SetTheme(name string) error | ||
| 512 | } | 514 | } | ||
| 513 | 515 | ||||
| 516 | + | // Optional so existing SettingsStore implementations remain source-compatible. | |||
| 517 | + | // Stores without this method retain model-only shortcuts. | |||
| 518 | + | type modelProfileSettingsStore interface { | |||
| 519 | + | SetModelProfile(slot int, profile QuickModelShortcut, activeSlot int) error | |||
| 520 | + | } | |||
| 521 | + | ||||
| 514 | type terminalAlertsSettingsStore interface { | 522 | type terminalAlertsSettingsStore interface { | ||
| 515 | SetTerminalAlertsEnabled(enabled bool) error | 523 | SetTerminalAlertsEnabled(enabled bool) error | ||
| 516 | } | 524 | } | ||
| @@ -833,6 +841,7 @@ type Interactive struct { | |||||
| 833 | settingsDialog *settingsDialog | 841 | settingsDialog *settingsDialog | ||
| 834 | floatingPane tui.FloatingPane | 842 | floatingPane tui.FloatingPane | ||
| 835 | quickModelAssign int | 843 | quickModelAssign int | ||
| 844 | + | quickModelActivate bool // picker was opened by an empty-slot shortcut | |||
| 836 | telegramBridge *telegram.Bridge | 845 | telegramBridge *telegram.Bridge | ||
| 837 | sessionOpsDialog *sessionOpsDialog | 846 | sessionOpsDialog *sessionOpsDialog | ||
| 838 | sessionTreeDialog *sessionTreeDialog | 847 | sessionTreeDialog *sessionTreeDialog | ||
| @@ -980,6 +989,21 @@ type startupPreResult struct { | |||||
| 980 | 989 | ||||
| 981 | // NewInteractive constructs an Interactive from cfg. | 990 | // NewInteractive constructs an Interactive from cfg. | ||
| 982 | func NewInteractive(cfg InteractiveConfig) *Interactive { | 991 | func NewInteractive(cfg InteractiveConfig) *Interactive { | ||
| 992 | + | if cfg.ActiveModelProfile < 1 || cfg.ActiveModelProfile > 9 { | |||
| 993 | + | cfg.ActiveModelProfile = 1 | |||
| 994 | + | } | |||
| 995 | + | // An unassigned default remains active so subsequent model/reasoning | |||
| 996 | + | // edits save into slot 1. Explicit overrides must not overwrite a favorite. | |||
| 997 | + | if slot := cfg.ActiveModelProfile; slot <= len(cfg.QuickModelShortcuts) { | |||
| 998 | + | if p := cfg.QuickModelShortcuts[slot-1]; p.Provider != "" || p.Model != "" { | |||
| 999 | + | cfg.detachMismatchedModelProfile() | |||
| 1000 | + | } | |||
| 1001 | + | } | |||
| 1002 | + | if cfg.SettingsStore != nil { | |||
| 1003 | + | if _, ok := cfg.SettingsStore.(modelProfileSettingsStore); !ok { | |||
| 1004 | + | cfg.ActiveModelProfile = 0 | |||
| 1005 | + | } | |||
| 1006 | + | } | |||
| 983 | renderer := tui.NewRenderer(cfg.Terminal) | 1007 | renderer := tui.NewRenderer(cfg.Terminal) | ||
| 984 | renderer.SetTheme(cfg.Theme) | 1008 | renderer.SetTheme(cfg.Theme) | ||
| 985 | startupAgentName := "" | 1009 | startupAgentName := "" | ||
M packages/agent/modes/interactive_commands.go +24 -8
| @@ -299,6 +299,14 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||
| 299 | 299 | i.statusOK = "" | |
| 300 | 300 | i.mu.Unlock() | |
| 301 | 301 | } | |
| 302 | + | case "/profile": | |
| 303 | + | if len(parts) != 2 || len(parts[1]) != 1 || parts[1][0] < '1' || parts[1][0] > '9' { | |
| 304 | + | i.mu.Lock() | |
| 305 | + | i.statusErr, i.statusOK = "usage: /profile <1-9>", "" | |
| 306 | + | i.mu.Unlock() | |
| 307 | + | break | |
| 308 | + | } | |
| 309 | + | i.applyQuickModelShortcut(int(parts[1][0] - '0')) | |
| 302 | 310 | case "/reasoning": | |
| 303 | 311 | i.openReasoningDialog() | |
| 304 | 312 | case "/fast": | |
| @@ -1101,7 +1109,11 @@ func (i *Interactive) applyRescueModelSelection(prov, model string) { | |||
| 1101 | 1109 | func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) { | |
| 1102 | 1110 | var replaced bool | |
| 1103 | 1111 | swap := func() { | |
| 1104 | - | replaced = i.swapModelUnserialized(prov, model, builder, rescue) | |
| 1112 | + | var success bool | |
| 1113 | + | replaced, success = i.swapModelUnserialized(prov, model, builder, rescue) | |
| 1114 | + | if success { | |
| 1115 | + | i.saveActiveModelProfile() | |
| 1116 | + | } | |
| 1105 | 1117 | } | |
| 1106 | 1118 | if i.cfg.SessionTransition != nil { | |
| 1107 | 1119 | i.cfg.SessionTransition(swap) | |
| @@ -1112,16 +1124,19 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string) | |||
| 1112 | 1124 | i.resetCompactHandoff() | |
| 1113 | 1125 | } | |
| 1114 | 1126 | } | |
| 1115 | - | func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool { | |
| 1127 | + | ||
| 1128 | + | // swapModelUnserialized reports agent replacement separately from success: a | |
| 1129 | + | // same-provider model change succeeds without replacing the agent. | |
| 1130 | + | func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) (replaced, success bool) { | |
| 1116 | 1131 | if model == "" { | |
| 1117 | - | return false | |
| 1132 | + | return false, false | |
| 1118 | 1133 | } | |
| 1119 | 1134 | m, err := provider.FindModel(prov, model) | |
| 1120 | 1135 | if err != nil { | |
| 1121 | 1136 | i.mu.Lock() | |
| 1122 | 1137 | i.statusErr = err.Error() | |
| 1123 | 1138 | i.mu.Unlock() | |
| 1124 | - | return false | |
| 1139 | + | return false, false | |
| 1125 | 1140 | } | |
| 1126 | 1141 | // Same provider AND not a rescue retry: just swap the model on | |
| 1127 | 1142 | // the existing agent. Mixed-API providers dispatch from model metadata, | |
| @@ -1137,13 +1152,13 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||
| 1137 | 1152 | if i.cfg.PersistModel != nil { | |
| 1138 | 1153 | i.cfg.PersistModel(i.cfg.Provider, m.ID) | |
| 1139 | 1154 | } | |
| 1140 | - | return false | |
| 1155 | + | return false, true | |
| 1141 | 1156 | } | |
| 1142 | 1157 | if builder == nil { | |
| 1143 | 1158 | i.mu.Lock() | |
| 1144 | 1159 | i.statusErr = "cannot switch provider: no builder configured" | |
| 1145 | 1160 | i.mu.Unlock() | |
| 1146 | - | return false | |
| 1161 | + | return false, false | |
| 1147 | 1162 | } | |
| 1148 | 1163 | // Snapshot the current transcript and cumulative usage BEFORE we | |
| 1149 | 1164 | // build the replacement agent so we can hand them off. Without | |
| @@ -1161,7 +1176,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||
| 1161 | 1176 | i.mu.Lock() | |
| 1162 | 1177 | i.statusErr = err.Error() | |
| 1163 | 1178 | i.mu.Unlock() | |
| 1164 | - | return false | |
| 1179 | + | return false, false | |
| 1165 | 1180 | } | |
| 1166 | 1181 | ||
| 1167 | 1182 | // Replay the transcript and seed the cost on the freshly-built | |
| @@ -1204,7 +1219,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||
| 1204 | 1219 | if i.cfg.PersistModel != nil { | |
| 1205 | 1220 | i.cfg.PersistModel(p, md) | |
| 1206 | 1221 | } | |
| 1207 | - | return true | |
| 1222 | + | return true, true | |
| 1208 | 1223 | } | |
| 1209 | 1224 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | |
| 1210 | 1225 | switch ev.Kind { | |
| @@ -1234,6 +1249,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||
| 1234 | 1249 | i.agent = ag | |
| 1235 | 1250 | i.cfg.Provider = prov | |
| 1236 | 1251 | i.cfg.Model = model | |
| 1252 | + | i.cfg.detachMismatchedModelProfile() | |
| 1237 | 1253 | i.statusErr = "" | |
| 1238 | 1254 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | |
| 1239 | 1255 | i.mu.Unlock() | |
| @@ -299,6 +299,14 @@ func (i *Interactive) runSlash(ctx context.Context, cmd string) (done bool) { | |||||
| 299 | i.statusOK = "" | 299 | i.statusOK = "" | ||
| 300 | i.mu.Unlock() | 300 | i.mu.Unlock() | ||
| 301 | } | 301 | } | ||
| 302 | + | case "/profile": | |||
| 303 | + | if len(parts) != 2 || len(parts[1]) != 1 || parts[1][0] < '1' || parts[1][0] > '9' { | |||
| 304 | + | i.mu.Lock() | |||
| 305 | + | i.statusErr, i.statusOK = "usage: /profile <1-9>", "" | |||
| 306 | + | i.mu.Unlock() | |||
| 307 | + | break | |||
| 308 | + | } | |||
| 309 | + | i.applyQuickModelShortcut(int(parts[1][0] - '0')) | |||
| 302 | case "/reasoning": | 310 | case "/reasoning": | ||
| 303 | i.openReasoningDialog() | 311 | i.openReasoningDialog() | ||
| 304 | case "/fast": | 312 | case "/fast": | ||
| @@ -1101,7 +1109,11 @@ func (i *Interactive) applyRescueModelSelection(prov, model string) { | |||||
| 1101 | func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) { | 1109 | func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) { | ||
| 1102 | var replaced bool | 1110 | var replaced bool | ||
| 1103 | swap := func() { | 1111 | swap := func() { | ||
| 1104 | - | replaced = i.swapModelUnserialized(prov, model, builder, rescue) | 1112 | + | var success bool |
| 1113 | + | replaced, success = i.swapModelUnserialized(prov, model, builder, rescue) | |||
| 1114 | + | if success { | |||
| 1115 | + | i.saveActiveModelProfile() | |||
| 1116 | + | } | |||
| 1105 | } | 1117 | } | ||
| 1106 | if i.cfg.SessionTransition != nil { | 1118 | if i.cfg.SessionTransition != nil { | ||
| 1107 | i.cfg.SessionTransition(swap) | 1119 | i.cfg.SessionTransition(swap) | ||
| @@ -1112,16 +1124,19 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string) | |||||
| 1112 | i.resetCompactHandoff() | 1124 | i.resetCompactHandoff() | ||
| 1113 | } | 1125 | } | ||
| 1114 | } | 1126 | } | ||
| 1115 | - | func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) bool { | 1127 | + | |
| 1128 | + | // swapModelUnserialized reports agent replacement separately from success: a | |||
| 1129 | + | // same-provider model change succeeds without replacing the agent. | |||
| 1130 | + | func (i *Interactive) swapModelUnserialized(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) (replaced, success bool) { | |||
| 1116 | if model == "" { | 1131 | if model == "" { | ||
| 1117 | - | return false | 1132 | + | return false, false |
| 1118 | } | 1133 | } | ||
| 1119 | m, err := provider.FindModel(prov, model) | 1134 | m, err := provider.FindModel(prov, model) | ||
| 1120 | if err != nil { | 1135 | if err != nil { | ||
| 1121 | i.mu.Lock() | 1136 | i.mu.Lock() | ||
| 1122 | i.statusErr = err.Error() | 1137 | i.statusErr = err.Error() | ||
| 1123 | i.mu.Unlock() | 1138 | i.mu.Unlock() | ||
| 1124 | - | return false | 1139 | + | return false, false |
| 1125 | } | 1140 | } | ||
| 1126 | // Same provider AND not a rescue retry: just swap the model on | 1141 | // Same provider AND not a rescue retry: just swap the model on | ||
| 1127 | // the existing agent. Mixed-API providers dispatch from model metadata, | 1142 | // the existing agent. Mixed-API providers dispatch from model metadata, | ||
| @@ -1137,13 +1152,13 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||||
| 1137 | if i.cfg.PersistModel != nil { | 1152 | if i.cfg.PersistModel != nil { | ||
| 1138 | i.cfg.PersistModel(i.cfg.Provider, m.ID) | 1153 | i.cfg.PersistModel(i.cfg.Provider, m.ID) | ||
| 1139 | } | 1154 | } | ||
| 1140 | - | return false | 1155 | + | return false, true |
| 1141 | } | 1156 | } | ||
| 1142 | if builder == nil { | 1157 | if builder == nil { | ||
| 1143 | i.mu.Lock() | 1158 | i.mu.Lock() | ||
| 1144 | i.statusErr = "cannot switch provider: no builder configured" | 1159 | i.statusErr = "cannot switch provider: no builder configured" | ||
| 1145 | i.mu.Unlock() | 1160 | i.mu.Unlock() | ||
| 1146 | - | return false | 1161 | + | return false, false |
| 1147 | } | 1162 | } | ||
| 1148 | // Snapshot the current transcript and cumulative usage BEFORE we | 1163 | // Snapshot the current transcript and cumulative usage BEFORE we | ||
| 1149 | // build the replacement agent so we can hand them off. Without | 1164 | // build the replacement agent so we can hand them off. Without | ||
| @@ -1161,7 +1176,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||||
| 1161 | i.mu.Lock() | 1176 | i.mu.Lock() | ||
| 1162 | i.statusErr = err.Error() | 1177 | i.statusErr = err.Error() | ||
| 1163 | i.mu.Unlock() | 1178 | i.mu.Unlock() | ||
| 1164 | - | return false | 1179 | + | return false, false |
| 1165 | } | 1180 | } | ||
| 1166 | 1181 | ||||
| 1167 | // Replay the transcript and seed the cost on the freshly-built | 1182 | // Replay the transcript and seed the cost on the freshly-built | ||
| @@ -1204,7 +1219,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str | |||||
| 1204 | if i.cfg.PersistModel != nil { | 1219 | if i.cfg.PersistModel != nil { | ||
| 1205 | i.cfg.PersistModel(p, md) | 1220 | i.cfg.PersistModel(p, md) | ||
| 1206 | } | 1221 | } | ||
| 1207 | - | return true | 1222 | + | return true, true |
| 1208 | } | 1223 | } | ||
| 1209 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | 1224 | func (i *Interactive) handleAuthEvent(ev auth.Event) { | ||
| 1210 | switch ev.Kind { | 1225 | switch ev.Kind { | ||
| @@ -1234,6 +1249,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) { | |||||
| 1234 | i.agent = ag | 1249 | i.agent = ag | ||
| 1235 | i.cfg.Provider = prov | 1250 | i.cfg.Provider = prov | ||
| 1236 | i.cfg.Model = model | 1251 | i.cfg.Model = model | ||
| 1252 | + | i.cfg.detachMismatchedModelProfile() | |||
| 1237 | i.statusErr = "" | 1253 | i.statusErr = "" | ||
| 1238 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | 1254 | i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method | ||
| 1239 | i.mu.Unlock() | 1255 | i.mu.Unlock() | ||
M packages/agent/modes/interactive_host.go +2 -0
| @@ -105,6 +105,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||
| 105 | 105 | i.agent = ag | |
| 106 | 106 | i.cfg.Provider = providerName | |
| 107 | 107 | i.cfg.Model = model | |
| 108 | + | i.cfg.detachMismatchedModelProfile() | |
| 108 | 109 | i.view.Messages = filterHiddenTranscriptMessages(ag.Messages()) | |
| 109 | 110 | i.cumUsage = ag.Cost() | |
| 110 | 111 | last := ag.LastTurnUsage() | |
| @@ -192,6 +193,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||
| 192 | 193 | } | |
| 193 | 194 | i.cfg.Provider = provider | |
| 194 | 195 | i.cfg.Model = model | |
| 196 | + | i.cfg.detachMismatchedModelProfile() | |
| 195 | 197 | titleCancel := i.titleCancel | |
| 196 | 198 | i.titleCancel = nil | |
| 197 | 199 | i.titleVersion++ | |
| @@ -105,6 +105,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid | |||||
| 105 | i.agent = ag | 105 | i.agent = ag | ||
| 106 | i.cfg.Provider = providerName | 106 | i.cfg.Provider = providerName | ||
| 107 | i.cfg.Model = model | 107 | i.cfg.Model = model | ||
| 108 | + | i.cfg.detachMismatchedModelProfile() | |||
| 108 | i.view.Messages = filterHiddenTranscriptMessages(ag.Messages()) | 109 | i.view.Messages = filterHiddenTranscriptMessages(ag.Messages()) | ||
| 109 | i.cumUsage = ag.Cost() | 110 | i.cumUsage = ag.Cost() | ||
| 110 | last := ag.LastTurnUsage() | 111 | last := ag.LastTurnUsage() | ||
| @@ -192,6 +193,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin | |||||
| 192 | } | 193 | } | ||
| 193 | i.cfg.Provider = provider | 194 | i.cfg.Provider = provider | ||
| 194 | i.cfg.Model = model | 195 | i.cfg.Model = model | ||
| 196 | + | i.cfg.detachMismatchedModelProfile() | |||
| 195 | titleCancel := i.titleCancel | 197 | titleCancel := i.titleCancel | ||
| 196 | i.titleCancel = nil | 198 | i.titleCancel = nil | ||
| 197 | i.titleVersion++ | 199 | i.titleVersion++ | ||
M packages/agent/modes/interactive_input_routing.go +3 -1
| @@ -159,13 +159,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 159 | 159 | if k.Kind == tui.KeyCtrlC { | |
| 160 | 160 | i.modelDialog.Close() | |
| 161 | 161 | i.quickModelAssign = 0 | |
| 162 | + | i.quickModelActivate = false | |
| 162 | 163 | return false | |
| 163 | 164 | } | |
| 164 | 165 | act := i.modelDialog.HandleKey(k) | |
| 165 | 166 | if act.Close { | |
| 166 | 167 | i.quickModelAssign = 0 | |
| 168 | + | i.quickModelActivate = false | |
| 167 | 169 | } | |
| 168 | - | if act.ReasoningChanged { | |
| 170 | + | if act.ReasoningChanged && i.quickModelAssign == 0 { | |
| 169 | 171 | i.applyReasoningSetting(act.Reasoning) | |
| 170 | 172 | } | |
| 171 | 173 | if act.Select { | |
| @@ -159,13 +159,15 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 159 | if k.Kind == tui.KeyCtrlC { | 159 | if k.Kind == tui.KeyCtrlC { | ||
| 160 | i.modelDialog.Close() | 160 | i.modelDialog.Close() | ||
| 161 | i.quickModelAssign = 0 | 161 | i.quickModelAssign = 0 | ||
| 162 | + | i.quickModelActivate = false | |||
| 162 | return false | 163 | return false | ||
| 163 | } | 164 | } | ||
| 164 | act := i.modelDialog.HandleKey(k) | 165 | act := i.modelDialog.HandleKey(k) | ||
| 165 | if act.Close { | 166 | if act.Close { | ||
| 166 | i.quickModelAssign = 0 | 167 | i.quickModelAssign = 0 | ||
| 168 | + | i.quickModelActivate = false | |||
| 167 | } | 169 | } | ||
| 168 | - | if act.ReasoningChanged { | 170 | + | if act.ReasoningChanged && i.quickModelAssign == 0 { |
| 169 | i.applyReasoningSetting(act.Reasoning) | 171 | i.applyReasoningSetting(act.Reasoning) | ||
| 170 | } | 172 | } | ||
| 171 | if act.Select { | 173 | if act.Select { | ||
M packages/agent/modes/interactive_model_test.go +1 -1
| @@ -73,7 +73,7 @@ func TestCtrlRCyclesCurrentModelReasoningLevels(t *testing.T) { | |||
| 73 | 73 | if !slices.Equal(changed, []string{"xhigh", "max", ""}) { | |
| 74 | 74 | t.Fatalf("reasoning callbacks = %q", changed) | |
| 75 | 75 | } | |
| 76 | - | if i.statusOK != "reasoning level off" { | |
| 76 | + | if i.statusOK != "reasoning level off — profile 1 updated" { | |
| 77 | 77 | t.Fatalf("reasoning status = %q", i.statusOK) | |
| 78 | 78 | } | |
| 79 | 79 | } | |
| @@ -73,7 +73,7 @@ func TestCtrlRCyclesCurrentModelReasoningLevels(t *testing.T) { | |||||
| 73 | if !slices.Equal(changed, []string{"xhigh", "max", ""}) { | 73 | if !slices.Equal(changed, []string{"xhigh", "max", ""}) { | ||
| 74 | t.Fatalf("reasoning callbacks = %q", changed) | 74 | t.Fatalf("reasoning callbacks = %q", changed) | ||
| 75 | } | 75 | } | ||
| 76 | - | if i.statusOK != "reasoning level off" { | 76 | + | if i.statusOK != "reasoning level off — profile 1 updated" { |
| 77 | t.Fatalf("reasoning status = %q", i.statusOK) | 77 | t.Fatalf("reasoning status = %q", i.statusOK) | ||
| 78 | } | 78 | } | ||
| 79 | } | 79 | } | ||
M packages/agent/modes/interactive_rendering.go +1 -0
| @@ -673,6 +673,7 @@ func (i *Interactive) redraw() { | |||
| 673 | 673 | Provider: i.cfg.Provider, | |
| 674 | 674 | Model: i.cfg.Model, | |
| 675 | 675 | Reasoning: i.cfg.Reasoning, | |
| 676 | + | ModelProfile: i.cfg.ActiveModelProfile, | |
| 676 | 677 | FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode, | |
| 677 | 678 | Busy: i.busy, | |
| 678 | 679 | BusyPrefix: statusBusyPrefix, | |
| @@ -673,6 +673,7 @@ func (i *Interactive) redraw() { | |||||
| 673 | Provider: i.cfg.Provider, | 673 | Provider: i.cfg.Provider, | ||
| 674 | Model: i.cfg.Model, | 674 | Model: i.cfg.Model, | ||
| 675 | Reasoning: i.cfg.Reasoning, | 675 | Reasoning: i.cfg.Reasoning, | ||
| 676 | + | ModelProfile: i.cfg.ActiveModelProfile, | |||
| 676 | FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode, | 677 | FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode, | ||
| 677 | Busy: i.busy, | 678 | Busy: i.busy, | ||
| 678 | BusyPrefix: statusBusyPrefix, | 679 | BusyPrefix: statusBusyPrefix, | ||
M packages/agent/modes/interactive_settings.go +51 -55
| @@ -408,8 +408,8 @@ func (i *Interactive) openSettingsDialog() { | |||
| 408 | 408 | if len(quickItems) > 0 { | |
| 409 | 409 | items = append(items, settingsItem{ | |
| 410 | 410 | key: "quick_models", | |
| 411 | - | label: "model shortcuts", | |
| 412 | - | desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches", | |
| 411 | + | label: "model profiles", | |
| 412 | + | desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 model and reasoning profiles", | |
| 413 | 413 | children: quickItems, | |
| 414 | 414 | }) | |
| 415 | 415 | } | |
| @@ -479,12 +479,15 @@ func (i *Interactive) quickModelSettingItem(slot int) settingsItem { | |||
| 479 | 479 | } | |
| 480 | 480 | hint := "not assigned" | |
| 481 | 481 | if current.Provider != "" && current.Model != "" { | |
| 482 | - | hint = current.Provider + " / " + current.Model | |
| 482 | + | hint = modelProfileDescription(current) | |
| 483 | + | } | |
| 484 | + | if slot == i.cfg.ActiveModelProfile { | |
| 485 | + | hint += " (active)" | |
| 483 | 486 | } | |
| 484 | 487 | return settingsItem{ | |
| 485 | 488 | key: "quick_model_" + strconv.Itoa(slot), | |
| 486 | - | label: "model " + strconv.Itoa(slot), | |
| 487 | - | desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.", | |
| 489 | + | label: "profile " + strconv.Itoa(slot), | |
| 490 | + | desc: quickModelShortcutLabel(slot) + " recalls model + reasoning. Enter assigns, Left/Right in the picker sets reasoning, Backspace clears.", | |
| 488 | 491 | picker: true, | |
| 489 | 492 | hint: hint, | |
| 490 | 493 | } | |
| @@ -493,7 +496,7 @@ func (i *Interactive) compactModeEnabled() bool { | |||
| 493 | 496 | return i.cfg.CompactMode != nil && *i.cfg.CompactMode | |
| 494 | 497 | } | |
| 495 | 498 | func quickModelShortcutSlot(k tui.Key) int { | |
| 496 | - | if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' { | |
| 499 | + | if k.Kind != tui.KeyRune || k.Alt { | |
| 497 | 500 | return 0 | |
| 498 | 501 | } | |
| 499 | 502 | if runtime.GOOS == "darwin" { | |
| @@ -503,7 +506,17 @@ func quickModelShortcutSlot(k tui.Key) int { | |||
| 503 | 506 | } else if !k.Ctrl { | |
| 504 | 507 | return 0 | |
| 505 | 508 | } | |
| 506 | - | return int(k.Rune - '0') | |
| 509 | + | if k.Rune >= '1' && k.Rune <= '9' { | |
| 510 | + | return int(k.Rune - '0') | |
| 511 | + | } | |
| 512 | + | // French AZERTY's unshifted number row. Require a modifier so normal | |
| 513 | + | // typing and pasted punctuation never activate profiles. | |
| 514 | + | for idx, r := range []rune("&é\"'(-è_ç") { | |
| 515 | + | if k.Rune == r { | |
| 516 | + | return idx + 1 | |
| 517 | + | } | |
| 518 | + | } | |
| 519 | + | return 0 | |
| 507 | 520 | } | |
| 508 | 521 | func quickModelShortcutPrefix() string { | |
| 509 | 522 | return "Ctrl" | |
| @@ -516,18 +529,26 @@ func (i *Interactive) openQuickModelPicker(slot int) { | |||
| 516 | 529 | return | |
| 517 | 530 | } | |
| 518 | 531 | i.quickModelAssign = slot | |
| 519 | - | current := i.cfg.Model | |
| 532 | + | i.quickModelActivate = false | |
| 533 | + | current, reasoning := i.cfg.Model, i.cfg.Reasoning | |
| 520 | 534 | if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" { | |
| 521 | 535 | current = i.cfg.QuickModelShortcuts[slot-1].Model | |
| 536 | + | reasoning = i.cfg.QuickModelShortcuts[slot-1].Reasoning | |
| 522 | 537 | } | |
| 523 | 538 | var loggedIn []string | |
| 524 | 539 | if i.cfg.LoggedInProviders != nil { | |
| 525 | 540 | loggedIn = i.cfg.LoggedInProviders() | |
| 526 | 541 | } | |
| 527 | - | i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning) | |
| 542 | + | i.modelDialog.Open(current, loggedIn, reasoning) | |
| 528 | 543 | } | |
| 529 | 544 | func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) { | |
| 530 | - | i.setQuickModelShortcut(slot, providerName, model) | |
| 545 | + | p := QuickModelShortcut{Provider: providerName, Model: model, Reasoning: i.modelDialog.reasoning} | |
| 546 | + | if i.quickModelActivate { | |
| 547 | + | i.activateModelProfile(slot, p) | |
| 548 | + | } else { | |
| 549 | + | i.setQuickModelProfile(slot, p) | |
| 550 | + | } | |
| 551 | + | i.quickModelActivate = false | |
| 531 | 552 | } | |
| 532 | 553 | func (i *Interactive) applyQuickModelShortcut(slot int) { | |
| 533 | 554 | if slot < 1 || slot > 9 { | |
| @@ -541,25 +562,21 @@ func (i *Interactive) applyQuickModelShortcut(slot int) { | |||
| 541 | 562 | i.invalidate() | |
| 542 | 563 | return | |
| 543 | 564 | } | |
| 544 | - | if len(i.cfg.QuickModelShortcuts) < slot { | |
| 545 | - | i.mu.Lock() | |
| 546 | - | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | |
| 547 | - | i.statusOK = "" | |
| 548 | - | i.mu.Unlock() | |
| 549 | - | i.invalidate() | |
| 550 | - | return | |
| 565 | + | p := QuickModelShortcut{} | |
| 566 | + | if slot <= len(i.cfg.QuickModelShortcuts) { | |
| 567 | + | p = i.cfg.QuickModelShortcuts[slot-1] | |
| 551 | 568 | } | |
| 552 | - | shortcut := i.cfg.QuickModelShortcuts[slot-1] | |
| 553 | - | if shortcut.Provider == "" || shortcut.Model == "" { | |
| 569 | + | if p.Provider == "" && p.Model == "" { | |
| 570 | + | i.openQuickModelPicker(slot) | |
| 571 | + | i.quickModelActivate = true | |
| 554 | 572 | i.mu.Lock() | |
| 555 | - | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | |
| 556 | - | i.statusOK = "" | |
| 573 | + | i.statusErr = "" | |
| 574 | + | i.statusOK = fmt.Sprintf("choose a model for profile %d; Enter saves and activates", slot) | |
| 557 | 575 | i.mu.Unlock() | |
| 558 | 576 | i.invalidate() | |
| 559 | 577 | return | |
| 560 | 578 | } | |
| 561 | - | i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false) | |
| 562 | - | i.invalidate() | |
| 579 | + | i.activateModelProfile(slot, p) | |
| 563 | 580 | } | |
| 564 | 581 | func (i *Interactive) applyQuickModelSetting(key, value string) { | |
| 565 | 582 | slotText := strings.TrimPrefix(key, "quick_model_") | |
| @@ -577,30 +594,7 @@ func (i *Interactive) applyQuickModelSetting(key, value string) { | |||
| 577 | 594 | i.setQuickModelShortcut(slot, providerName, model) | |
| 578 | 595 | } | |
| 579 | 596 | func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) { | |
| 580 | - | if len(i.cfg.QuickModelShortcuts) < slot { | |
| 581 | - | next := make([]QuickModelShortcut, slot) | |
| 582 | - | copy(next, i.cfg.QuickModelShortcuts) | |
| 583 | - | i.cfg.QuickModelShortcuts = next | |
| 584 | - | } | |
| 585 | - | i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} | |
| 586 | - | if i.cfg.SettingsStore != nil { | |
| 587 | - | if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil { | |
| 588 | - | i.mu.Lock() | |
| 589 | - | i.statusErr = "settings: " + err.Error() | |
| 590 | - | i.mu.Unlock() | |
| 591 | - | return | |
| 592 | - | } | |
| 593 | - | } | |
| 594 | - | i.mu.Lock() | |
| 595 | - | if model == "" { | |
| 596 | - | i.statusOK = quickModelShortcutLabel(slot) + " cleared" | |
| 597 | - | } else { | |
| 598 | - | i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model | |
| 599 | - | } | |
| 600 | - | i.statusErr = "" | |
| 601 | - | i.mu.Unlock() | |
| 602 | - | i.refreshQuickModelSettingsItem(slot) | |
| 603 | - | i.invalidate() | |
| 597 | + | i.setQuickModelProfile(slot, QuickModelShortcut{Provider: providerName, Model: model, Reasoning: i.cfg.Reasoning}) | |
| 604 | 598 | } | |
| 605 | 599 | func (i *Interactive) refreshQuickModelSettingsItem(slot int) { | |
| 606 | 600 | if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 { | |
| @@ -1258,8 +1252,12 @@ func (i *Interactive) applyReasoningSetting(level string) { | |||
| 1258 | 1252 | i.invalidate() | |
| 1259 | 1253 | }() | |
| 1260 | 1254 | level = provider.NormalizeReasoning(level) | |
| 1261 | - | i.cfg.Reasoning = level | |
| 1262 | - | if i.cfg.SettingsStore != nil { | |
| 1255 | + | if slot := i.cfg.ActiveModelProfile; slot > 0 { | |
| 1256 | + | p := QuickModelShortcut{Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: level} | |
| 1257 | + | if !i.persistModelProfile(slot, p, slot) { | |
| 1258 | + | return | |
| 1259 | + | } | |
| 1260 | + | } else if i.cfg.SettingsStore != nil { | |
| 1263 | 1261 | if err := i.cfg.SettingsStore.SetReasoning(level); err != nil { | |
| 1264 | 1262 | i.mu.Lock() | |
| 1265 | 1263 | i.statusErr = "settings: " + err.Error() | |
| @@ -1267,18 +1265,16 @@ func (i *Interactive) applyReasoningSetting(level string) { | |||
| 1267 | 1265 | return | |
| 1268 | 1266 | } | |
| 1269 | 1267 | } | |
| 1270 | - | if i.cfg.OnReasoningChanged != nil { | |
| 1271 | - | i.cfg.OnReasoningChanged(level) | |
| 1272 | - | } | |
| 1268 | + | i.setLiveReasoning(level) | |
| 1273 | 1269 | i.mu.Lock() | |
| 1274 | - | if i.agent != nil { | |
| 1275 | - | i.agent.Reasoning = level | |
| 1276 | - | } | |
| 1277 | 1270 | label := level | |
| 1278 | 1271 | if label == "" { | |
| 1279 | 1272 | label = "off" | |
| 1280 | 1273 | } | |
| 1281 | 1274 | i.statusOK = "reasoning level " + label | |
| 1275 | + | if i.cfg.ActiveModelProfile > 0 { | |
| 1276 | + | i.statusOK += fmt.Sprintf(" — profile %d updated", i.cfg.ActiveModelProfile) | |
| 1277 | + | } | |
| 1282 | 1278 | i.statusErr = "" | |
| 1283 | 1279 | i.mu.Unlock() | |
| 1284 | 1280 | } | |
| @@ -408,8 +408,8 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 408 | if len(quickItems) > 0 { | 408 | if len(quickItems) > 0 { | ||
| 409 | items = append(items, settingsItem{ | 409 | items = append(items, settingsItem{ | ||
| 410 | key: "quick_models", | 410 | key: "quick_models", | ||
| 411 | - | label: "model shortcuts", | 411 | + | label: "model profiles", |
| 412 | - | desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches", | 412 | + | desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 model and reasoning profiles", |
| 413 | children: quickItems, | 413 | children: quickItems, | ||
| 414 | }) | 414 | }) | ||
| 415 | } | 415 | } | ||
| @@ -479,12 +479,15 @@ func (i *Interactive) quickModelSettingItem(slot int) settingsItem { | |||||
| 479 | } | 479 | } | ||
| 480 | hint := "not assigned" | 480 | hint := "not assigned" | ||
| 481 | if current.Provider != "" && current.Model != "" { | 481 | if current.Provider != "" && current.Model != "" { | ||
| 482 | - | hint = current.Provider + " / " + current.Model | 482 | + | hint = modelProfileDescription(current) |
| 483 | + | } | |||
| 484 | + | if slot == i.cfg.ActiveModelProfile { | |||
| 485 | + | hint += " (active)" | |||
| 483 | } | 486 | } | ||
| 484 | return settingsItem{ | 487 | return settingsItem{ | ||
| 485 | key: "quick_model_" + strconv.Itoa(slot), | 488 | key: "quick_model_" + strconv.Itoa(slot), | ||
| 486 | - | label: "model " + strconv.Itoa(slot), | 489 | + | label: "profile " + strconv.Itoa(slot), |
| 487 | - | desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.", | 490 | + | desc: quickModelShortcutLabel(slot) + " recalls model + reasoning. Enter assigns, Left/Right in the picker sets reasoning, Backspace clears.", |
| 488 | picker: true, | 491 | picker: true, | ||
| 489 | hint: hint, | 492 | hint: hint, | ||
| 490 | } | 493 | } | ||
| @@ -493,7 +496,7 @@ func (i *Interactive) compactModeEnabled() bool { | |||||
| 493 | return i.cfg.CompactMode != nil && *i.cfg.CompactMode | 496 | return i.cfg.CompactMode != nil && *i.cfg.CompactMode | ||
| 494 | } | 497 | } | ||
| 495 | func quickModelShortcutSlot(k tui.Key) int { | 498 | func quickModelShortcutSlot(k tui.Key) int { | ||
| 496 | - | if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' { | 499 | + | if k.Kind != tui.KeyRune || k.Alt { |
| 497 | return 0 | 500 | return 0 | ||
| 498 | } | 501 | } | ||
| 499 | if runtime.GOOS == "darwin" { | 502 | if runtime.GOOS == "darwin" { | ||
| @@ -503,7 +506,17 @@ func quickModelShortcutSlot(k tui.Key) int { | |||||
| 503 | } else if !k.Ctrl { | 506 | } else if !k.Ctrl { | ||
| 504 | return 0 | 507 | return 0 | ||
| 505 | } | 508 | } | ||
| 506 | - | return int(k.Rune - '0') | 509 | + | if k.Rune >= '1' && k.Rune <= '9' { |
| 510 | + | return int(k.Rune - '0') | |||
| 511 | + | } | |||
| 512 | + | // French AZERTY's unshifted number row. Require a modifier so normal | |||
| 513 | + | // typing and pasted punctuation never activate profiles. | |||
| 514 | + | for idx, r := range []rune("&é\"'(-è_ç") { | |||
| 515 | + | if k.Rune == r { | |||
| 516 | + | return idx + 1 | |||
| 517 | + | } | |||
| 518 | + | } | |||
| 519 | + | return 0 | |||
| 507 | } | 520 | } | ||
| 508 | func quickModelShortcutPrefix() string { | 521 | func quickModelShortcutPrefix() string { | ||
| 509 | return "Ctrl" | 522 | return "Ctrl" | ||
| @@ -516,18 +529,26 @@ func (i *Interactive) openQuickModelPicker(slot int) { | |||||
| 516 | return | 529 | return | ||
| 517 | } | 530 | } | ||
| 518 | i.quickModelAssign = slot | 531 | i.quickModelAssign = slot | ||
| 519 | - | current := i.cfg.Model | 532 | + | i.quickModelActivate = false |
| 533 | + | current, reasoning := i.cfg.Model, i.cfg.Reasoning | |||
| 520 | if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" { | 534 | if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" { | ||
| 521 | current = i.cfg.QuickModelShortcuts[slot-1].Model | 535 | current = i.cfg.QuickModelShortcuts[slot-1].Model | ||
| 536 | + | reasoning = i.cfg.QuickModelShortcuts[slot-1].Reasoning | |||
| 522 | } | 537 | } | ||
| 523 | var loggedIn []string | 538 | var loggedIn []string | ||
| 524 | if i.cfg.LoggedInProviders != nil { | 539 | if i.cfg.LoggedInProviders != nil { | ||
| 525 | loggedIn = i.cfg.LoggedInProviders() | 540 | loggedIn = i.cfg.LoggedInProviders() | ||
| 526 | } | 541 | } | ||
| 527 | - | i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning) | 542 | + | i.modelDialog.Open(current, loggedIn, reasoning) |
| 528 | } | 543 | } | ||
| 529 | func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) { | 544 | func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) { | ||
| 530 | - | i.setQuickModelShortcut(slot, providerName, model) | 545 | + | p := QuickModelShortcut{Provider: providerName, Model: model, Reasoning: i.modelDialog.reasoning} |
| 546 | + | if i.quickModelActivate { | |||
| 547 | + | i.activateModelProfile(slot, p) | |||
| 548 | + | } else { | |||
| 549 | + | i.setQuickModelProfile(slot, p) | |||
| 550 | + | } | |||
| 551 | + | i.quickModelActivate = false | |||
| 531 | } | 552 | } | ||
| 532 | func (i *Interactive) applyQuickModelShortcut(slot int) { | 553 | func (i *Interactive) applyQuickModelShortcut(slot int) { | ||
| 533 | if slot < 1 || slot > 9 { | 554 | if slot < 1 || slot > 9 { | ||
| @@ -541,25 +562,21 @@ func (i *Interactive) applyQuickModelShortcut(slot int) { | |||||
| 541 | i.invalidate() | 562 | i.invalidate() | ||
| 542 | return | 563 | return | ||
| 543 | } | 564 | } | ||
| 544 | - | if len(i.cfg.QuickModelShortcuts) < slot { | 565 | + | p := QuickModelShortcut{} |
| 545 | - | i.mu.Lock() | 566 | + | if slot <= len(i.cfg.QuickModelShortcuts) { |
| 546 | - | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | 567 | + | p = i.cfg.QuickModelShortcuts[slot-1] |
| 547 | - | i.statusOK = "" | |||
| 548 | - | i.mu.Unlock() | |||
| 549 | - | i.invalidate() | |||
| 550 | - | return | |||
| 551 | } | 568 | } | ||
| 552 | - | shortcut := i.cfg.QuickModelShortcuts[slot-1] | 569 | + | if p.Provider == "" && p.Model == "" { |
| 553 | - | if shortcut.Provider == "" || shortcut.Model == "" { | 570 | + | i.openQuickModelPicker(slot) |
| 571 | + | i.quickModelActivate = true | |||
| 554 | i.mu.Lock() | 572 | i.mu.Lock() | ||
| 555 | - | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | 573 | + | i.statusErr = "" |
| 556 | - | i.statusOK = "" | 574 | + | i.statusOK = fmt.Sprintf("choose a model for profile %d; Enter saves and activates", slot) |
| 557 | i.mu.Unlock() | 575 | i.mu.Unlock() | ||
| 558 | i.invalidate() | 576 | i.invalidate() | ||
| 559 | return | 577 | return | ||
| 560 | } | 578 | } | ||
| 561 | - | i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false) | 579 | + | i.activateModelProfile(slot, p) |
| 562 | - | i.invalidate() | |||
| 563 | } | 580 | } | ||
| 564 | func (i *Interactive) applyQuickModelSetting(key, value string) { | 581 | func (i *Interactive) applyQuickModelSetting(key, value string) { | ||
| 565 | slotText := strings.TrimPrefix(key, "quick_model_") | 582 | slotText := strings.TrimPrefix(key, "quick_model_") | ||
| @@ -577,30 +594,7 @@ func (i *Interactive) applyQuickModelSetting(key, value string) { | |||||
| 577 | i.setQuickModelShortcut(slot, providerName, model) | 594 | i.setQuickModelShortcut(slot, providerName, model) | ||
| 578 | } | 595 | } | ||
| 579 | func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) { | 596 | func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) { | ||
| 580 | - | if len(i.cfg.QuickModelShortcuts) < slot { | 597 | + | i.setQuickModelProfile(slot, QuickModelShortcut{Provider: providerName, Model: model, Reasoning: i.cfg.Reasoning}) |
| 581 | - | next := make([]QuickModelShortcut, slot) | |||
| 582 | - | copy(next, i.cfg.QuickModelShortcuts) | |||
| 583 | - | i.cfg.QuickModelShortcuts = next | |||
| 584 | - | } | |||
| 585 | - | i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} | |||
| 586 | - | if i.cfg.SettingsStore != nil { | |||
| 587 | - | if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil { | |||
| 588 | - | i.mu.Lock() | |||
| 589 | - | i.statusErr = "settings: " + err.Error() | |||
| 590 | - | i.mu.Unlock() | |||
| 591 | - | return | |||
| 592 | - | } | |||
| 593 | - | } | |||
| 594 | - | i.mu.Lock() | |||
| 595 | - | if model == "" { | |||
| 596 | - | i.statusOK = quickModelShortcutLabel(slot) + " cleared" | |||
| 597 | - | } else { | |||
| 598 | - | i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model | |||
| 599 | - | } | |||
| 600 | - | i.statusErr = "" | |||
| 601 | - | i.mu.Unlock() | |||
| 602 | - | i.refreshQuickModelSettingsItem(slot) | |||
| 603 | - | i.invalidate() | |||
| 604 | } | 598 | } | ||
| 605 | func (i *Interactive) refreshQuickModelSettingsItem(slot int) { | 599 | func (i *Interactive) refreshQuickModelSettingsItem(slot int) { | ||
| 606 | if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 { | 600 | if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 { | ||
| @@ -1258,8 +1252,12 @@ func (i *Interactive) applyReasoningSetting(level string) { | |||||
| 1258 | i.invalidate() | 1252 | i.invalidate() | ||
| 1259 | }() | 1253 | }() | ||
| 1260 | level = provider.NormalizeReasoning(level) | 1254 | level = provider.NormalizeReasoning(level) | ||
| 1261 | - | i.cfg.Reasoning = level | 1255 | + | if slot := i.cfg.ActiveModelProfile; slot > 0 { |
| 1262 | - | if i.cfg.SettingsStore != nil { | 1256 | + | p := QuickModelShortcut{Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: level} |
| 1257 | + | if !i.persistModelProfile(slot, p, slot) { | |||
| 1258 | + | return | |||
| 1259 | + | } | |||
| 1260 | + | } else if i.cfg.SettingsStore != nil { | |||
| 1263 | if err := i.cfg.SettingsStore.SetReasoning(level); err != nil { | 1261 | if err := i.cfg.SettingsStore.SetReasoning(level); err != nil { | ||
| 1264 | i.mu.Lock() | 1262 | i.mu.Lock() | ||
| 1265 | i.statusErr = "settings: " + err.Error() | 1263 | i.statusErr = "settings: " + err.Error() | ||
| @@ -1267,18 +1265,16 @@ func (i *Interactive) applyReasoningSetting(level string) { | |||||
| 1267 | return | 1265 | return | ||
| 1268 | } | 1266 | } | ||
| 1269 | } | 1267 | } | ||
| 1270 | - | if i.cfg.OnReasoningChanged != nil { | 1268 | + | i.setLiveReasoning(level) |
| 1271 | - | i.cfg.OnReasoningChanged(level) | |||
| 1272 | - | } | |||
| 1273 | i.mu.Lock() | 1269 | i.mu.Lock() | ||
| 1274 | - | if i.agent != nil { | |||
| 1275 | - | i.agent.Reasoning = level | |||
| 1276 | - | } | |||
| 1277 | label := level | 1270 | label := level | ||
| 1278 | if label == "" { | 1271 | if label == "" { | ||
| 1279 | label = "off" | 1272 | label = "off" | ||
| 1280 | } | 1273 | } | ||
| 1281 | i.statusOK = "reasoning level " + label | 1274 | i.statusOK = "reasoning level " + label | ||
| 1275 | + | if i.cfg.ActiveModelProfile > 0 { | |||
| 1276 | + | i.statusOK += fmt.Sprintf(" — profile %d updated", i.cfg.ActiveModelProfile) | |||
| 1277 | + | } | |||
| 1282 | i.statusErr = "" | 1278 | i.statusErr = "" | ||
| 1283 | i.mu.Unlock() | 1279 | i.mu.Unlock() | ||
| 1284 | } | 1280 | } | ||
A packages/agent/modes/model_profiles.go +200 -0
| @@ -0,0 +1,200 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/zut/packages/provider" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // detachMismatchedModelProfile is called under the UI lock (or before startup) | |
| 10 | + | // after host-driven session/model changes, which must not rewrite favorites. | |
| 11 | + | func (c *InteractiveConfig) detachMismatchedModelProfile() { | |
| 12 | + | slot := c.ActiveModelProfile | |
| 13 | + | if slot < 1 || slot > 9 || slot > len(c.QuickModelShortcuts) { | |
| 14 | + | c.ActiveModelProfile = 0 | |
| 15 | + | return | |
| 16 | + | } | |
| 17 | + | p := c.QuickModelShortcuts[slot-1] | |
| 18 | + | if p.Provider != c.Provider || p.Model != c.Model || provider.NormalizeReasoning(p.Reasoning) != provider.NormalizeReasoning(c.Reasoning) { | |
| 19 | + | c.ActiveModelProfile = 0 | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func modelProfileDescription(p QuickModelShortcut) string { | |
| 24 | + | level := p.Reasoning | |
| 25 | + | if level == "" { | |
| 26 | + | level = "off" | |
| 27 | + | } | |
| 28 | + | return p.Provider + " / " + p.Model + " (reasoning: " + level + ")" | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // persistModelProfile changes in-memory favorites only after saving succeeds. | |
| 32 | + | func (i *Interactive) persistModelProfile(slot int, p QuickModelShortcut, active int) bool { | |
| 33 | + | if i.cfg.SettingsStore != nil { | |
| 34 | + | var err error | |
| 35 | + | if store, ok := i.cfg.SettingsStore.(modelProfileSettingsStore); ok { | |
| 36 | + | err = store.SetModelProfile(slot, p, active) | |
| 37 | + | } else { | |
| 38 | + | err = i.cfg.SettingsStore.SetQuickModelShortcut(slot, p.Provider, p.Model) | |
| 39 | + | active = 0 // legacy stores only persist provider/model bindings | |
| 40 | + | } | |
| 41 | + | if err != nil { | |
| 42 | + | i.mu.Lock() | |
| 43 | + | i.statusOK = "" | |
| 44 | + | i.statusErr = "profile not saved: " + err.Error() | |
| 45 | + | i.mu.Unlock() | |
| 46 | + | i.invalidate() | |
| 47 | + | return false | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | i.mu.Lock() | |
| 51 | + | if len(i.cfg.QuickModelShortcuts) < slot { | |
| 52 | + | next := make([]QuickModelShortcut, slot) | |
| 53 | + | copy(next, i.cfg.QuickModelShortcuts) | |
| 54 | + | i.cfg.QuickModelShortcuts = next | |
| 55 | + | } | |
| 56 | + | i.cfg.QuickModelShortcuts[slot-1] = p | |
| 57 | + | i.cfg.ActiveModelProfile = active | |
| 58 | + | i.mu.Unlock() | |
| 59 | + | for n := 1; n <= 9; n++ { | |
| 60 | + | i.refreshQuickModelSettingsItem(n) | |
| 61 | + | } | |
| 62 | + | return true | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func (i *Interactive) setQuickModelProfile(slot int, p QuickModelShortcut) { | |
| 66 | + | if slot < 1 || slot > 9 { | |
| 67 | + | return | |
| 68 | + | } | |
| 69 | + | if p.Model == "" { | |
| 70 | + | p = QuickModelShortcut{} | |
| 71 | + | } else { | |
| 72 | + | m, err := provider.FindModel(p.Provider, p.Model) | |
| 73 | + | if err != nil { | |
| 74 | + | i.mu.Lock() | |
| 75 | + | i.statusErr, i.statusOK = err.Error(), "" | |
| 76 | + | i.mu.Unlock() | |
| 77 | + | i.invalidate() | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | p.Provider, p.Model = m.Provider, m.ID | |
| 81 | + | p.Reasoning = provider.ClampReasoningForModel(m, p.Reasoning) | |
| 82 | + | } | |
| 83 | + | active := i.cfg.ActiveModelProfile | |
| 84 | + | if active == slot && p.Model != "" { | |
| 85 | + | i.activateModelProfile(slot, p) | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | // Clearing the active favorite leaves the live model unchanged. | |
| 89 | + | if active == slot { | |
| 90 | + | active = 0 | |
| 91 | + | } | |
| 92 | + | if !i.persistModelProfile(slot, p, active) { | |
| 93 | + | return | |
| 94 | + | } | |
| 95 | + | i.mu.Lock() | |
| 96 | + | i.statusErr = "" | |
| 97 | + | if p.Model == "" { | |
| 98 | + | i.statusOK = quickModelShortcutLabel(slot) + " cleared" | |
| 99 | + | } else { | |
| 100 | + | i.statusOK = quickModelShortcutLabel(slot) + " saved: " + modelProfileDescription(p) | |
| 101 | + | } | |
| 102 | + | i.mu.Unlock() | |
| 103 | + | i.invalidate() | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func (i *Interactive) saveActiveModelProfile() { | |
| 107 | + | slot := i.cfg.ActiveModelProfile | |
| 108 | + | if slot < 1 || slot > 9 { | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | p := QuickModelShortcut{Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: i.cfg.Reasoning} | |
| 112 | + | if m, err := provider.FindModel(p.Provider, p.Model); err == nil { | |
| 113 | + | p.Reasoning = provider.ClampReasoningForModel(m, p.Reasoning) | |
| 114 | + | if p.Reasoning != i.cfg.Reasoning { | |
| 115 | + | i.setLiveReasoning(p.Reasoning) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | if i.persistModelProfile(slot, p, slot) { | |
| 119 | + | i.mu.Lock() | |
| 120 | + | i.statusOK = fmt.Sprintf("profile %d updated: %s", slot, modelProfileDescription(p)) | |
| 121 | + | i.mu.Unlock() | |
| 122 | + | } else { | |
| 123 | + | i.detachUnsavedModelProfile() | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func (i *Interactive) detachUnsavedModelProfile() { | |
| 128 | + | i.mu.Lock() | |
| 129 | + | defer i.mu.Unlock() | |
| 130 | + | i.cfg.ActiveModelProfile = 0 | |
| 131 | + | // The model/session callback already ran. Do not imply rollback: | |
| 132 | + | // only the favorite and active-slot save failed. | |
| 133 | + | i.statusErr = "model switched; " + i.statusErr | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | func (i *Interactive) activateModelProfile(slot int, p QuickModelShortcut) { | |
| 137 | + | defer i.invalidate() | |
| 138 | + | if slot < 1 || slot > 9 { | |
| 139 | + | return | |
| 140 | + | } | |
| 141 | + | if i.busy { | |
| 142 | + | i.mu.Lock() | |
| 143 | + | i.statusErr, i.statusOK = "cannot switch model while a turn is running", "" | |
| 144 | + | i.mu.Unlock() | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | if p.Provider == "" || p.Model == "" { | |
| 148 | + | i.mu.Lock() | |
| 149 | + | i.statusErr, i.statusOK = "profile requires a provider and model; assign it in /settings", "" | |
| 150 | + | i.mu.Unlock() | |
| 151 | + | return | |
| 152 | + | } | |
| 153 | + | if i.cfg.SettingsStore != nil { | |
| 154 | + | if _, ok := i.cfg.SettingsStore.(modelProfileSettingsStore); !ok { | |
| 155 | + | if i.persistModelProfile(slot, p, 0) { | |
| 156 | + | i.swapModel(p.Provider, p.Model, i.cfg.BuildAgentFor, false) | |
| 157 | + | } | |
| 158 | + | return | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | var replaced bool | |
| 162 | + | activate := func() { | |
| 163 | + | var success bool | |
| 164 | + | replaced, success = i.swapModelUnserialized(p.Provider, p.Model, i.cfg.BuildAgentFor, false) | |
| 165 | + | if !success { | |
| 166 | + | return | |
| 167 | + | } | |
| 168 | + | m, _ := provider.FindModel(i.cfg.Provider, i.cfg.Model) // swap validated the model | |
| 169 | + | p.Provider, p.Model = i.cfg.Provider, i.cfg.Model | |
| 170 | + | p.Reasoning = provider.ClampReasoningForModel(m, p.Reasoning) | |
| 171 | + | i.setLiveReasoning(p.Reasoning) | |
| 172 | + | if !i.persistModelProfile(slot, p, slot) { | |
| 173 | + | i.detachUnsavedModelProfile() | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | i.mu.Lock() | |
| 177 | + | i.statusOK = fmt.Sprintf("profile %d active: %s", slot, modelProfileDescription(p)) | |
| 178 | + | i.mu.Unlock() | |
| 179 | + | } | |
| 180 | + | if i.cfg.SessionTransition != nil { | |
| 181 | + | i.cfg.SessionTransition(activate) | |
| 182 | + | } else { | |
| 183 | + | activate() | |
| 184 | + | } | |
| 185 | + | if replaced { | |
| 186 | + | i.resetCompactHandoff() | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func (i *Interactive) setLiveReasoning(level string) { | |
| 191 | + | if i.cfg.OnReasoningChanged != nil { | |
| 192 | + | i.cfg.OnReasoningChanged(level) | |
| 193 | + | } | |
| 194 | + | i.mu.Lock() | |
| 195 | + | i.cfg.Reasoning = level | |
| 196 | + | if i.agent != nil { | |
| 197 | + | i.agent.Reasoning = level | |
| 198 | + | } | |
| 199 | + | i.mu.Unlock() | |
| 200 | + | } | |
| @@ -0,0 +1,200 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/zut/packages/provider" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // detachMismatchedModelProfile is called under the UI lock (or before startup) | |||
| 10 | + | // after host-driven session/model changes, which must not rewrite favorites. | |||
| 11 | + | func (c *InteractiveConfig) detachMismatchedModelProfile() { | |||
| 12 | + | slot := c.ActiveModelProfile | |||
| 13 | + | if slot < 1 || slot > 9 || slot > len(c.QuickModelShortcuts) { | |||
| 14 | + | c.ActiveModelProfile = 0 | |||
| 15 | + | return | |||
| 16 | + | } | |||
| 17 | + | p := c.QuickModelShortcuts[slot-1] | |||
| 18 | + | if p.Provider != c.Provider || p.Model != c.Model || provider.NormalizeReasoning(p.Reasoning) != provider.NormalizeReasoning(c.Reasoning) { | |||
| 19 | + | c.ActiveModelProfile = 0 | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func modelProfileDescription(p QuickModelShortcut) string { | |||
| 24 | + | level := p.Reasoning | |||
| 25 | + | if level == "" { | |||
| 26 | + | level = "off" | |||
| 27 | + | } | |||
| 28 | + | return p.Provider + " / " + p.Model + " (reasoning: " + level + ")" | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // persistModelProfile changes in-memory favorites only after saving succeeds. | |||
| 32 | + | func (i *Interactive) persistModelProfile(slot int, p QuickModelShortcut, active int) bool { | |||
| 33 | + | if i.cfg.SettingsStore != nil { | |||
| 34 | + | var err error | |||
| 35 | + | if store, ok := i.cfg.SettingsStore.(modelProfileSettingsStore); ok { | |||
| 36 | + | err = store.SetModelProfile(slot, p, active) | |||
| 37 | + | } else { | |||
| 38 | + | err = i.cfg.SettingsStore.SetQuickModelShortcut(slot, p.Provider, p.Model) | |||
| 39 | + | active = 0 // legacy stores only persist provider/model bindings | |||
| 40 | + | } | |||
| 41 | + | if err != nil { | |||
| 42 | + | i.mu.Lock() | |||
| 43 | + | i.statusOK = "" | |||
| 44 | + | i.statusErr = "profile not saved: " + err.Error() | |||
| 45 | + | i.mu.Unlock() | |||
| 46 | + | i.invalidate() | |||
| 47 | + | return false | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | i.mu.Lock() | |||
| 51 | + | if len(i.cfg.QuickModelShortcuts) < slot { | |||
| 52 | + | next := make([]QuickModelShortcut, slot) | |||
| 53 | + | copy(next, i.cfg.QuickModelShortcuts) | |||
| 54 | + | i.cfg.QuickModelShortcuts = next | |||
| 55 | + | } | |||
| 56 | + | i.cfg.QuickModelShortcuts[slot-1] = p | |||
| 57 | + | i.cfg.ActiveModelProfile = active | |||
| 58 | + | i.mu.Unlock() | |||
| 59 | + | for n := 1; n <= 9; n++ { | |||
| 60 | + | i.refreshQuickModelSettingsItem(n) | |||
| 61 | + | } | |||
| 62 | + | return true | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func (i *Interactive) setQuickModelProfile(slot int, p QuickModelShortcut) { | |||
| 66 | + | if slot < 1 || slot > 9 { | |||
| 67 | + | return | |||
| 68 | + | } | |||
| 69 | + | if p.Model == "" { | |||
| 70 | + | p = QuickModelShortcut{} | |||
| 71 | + | } else { | |||
| 72 | + | m, err := provider.FindModel(p.Provider, p.Model) | |||
| 73 | + | if err != nil { | |||
| 74 | + | i.mu.Lock() | |||
| 75 | + | i.statusErr, i.statusOK = err.Error(), "" | |||
| 76 | + | i.mu.Unlock() | |||
| 77 | + | i.invalidate() | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | p.Provider, p.Model = m.Provider, m.ID | |||
| 81 | + | p.Reasoning = provider.ClampReasoningForModel(m, p.Reasoning) | |||
| 82 | + | } | |||
| 83 | + | active := i.cfg.ActiveModelProfile | |||
| 84 | + | if active == slot && p.Model != "" { | |||
| 85 | + | i.activateModelProfile(slot, p) | |||
| 86 | + | return | |||
| 87 | + | } | |||
| 88 | + | // Clearing the active favorite leaves the live model unchanged. | |||
| 89 | + | if active == slot { | |||
| 90 | + | active = 0 | |||
| 91 | + | } | |||
| 92 | + | if !i.persistModelProfile(slot, p, active) { | |||
| 93 | + | return | |||
| 94 | + | } | |||
| 95 | + | i.mu.Lock() | |||
| 96 | + | i.statusErr = "" | |||
| 97 | + | if p.Model == "" { | |||
| 98 | + | i.statusOK = quickModelShortcutLabel(slot) + " cleared" | |||
| 99 | + | } else { | |||
| 100 | + | i.statusOK = quickModelShortcutLabel(slot) + " saved: " + modelProfileDescription(p) | |||
| 101 | + | } | |||
| 102 | + | i.mu.Unlock() | |||
| 103 | + | i.invalidate() | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func (i *Interactive) saveActiveModelProfile() { | |||
| 107 | + | slot := i.cfg.ActiveModelProfile | |||
| 108 | + | if slot < 1 || slot > 9 { | |||
| 109 | + | return | |||
| 110 | + | } | |||
| 111 | + | p := QuickModelShortcut{Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: i.cfg.Reasoning} | |||
| 112 | + | if m, err := provider.FindModel(p.Provider, p.Model); err == nil { | |||
| 113 | + | p.Reasoning = provider.ClampReasoningForModel(m, p.Reasoning) | |||
| 114 | + | if p.Reasoning != i.cfg.Reasoning { | |||
| 115 | + | i.setLiveReasoning(p.Reasoning) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | if i.persistModelProfile(slot, p, slot) { | |||
| 119 | + | i.mu.Lock() | |||
| 120 | + | i.statusOK = fmt.Sprintf("profile %d updated: %s", slot, modelProfileDescription(p)) | |||
| 121 | + | i.mu.Unlock() | |||
| 122 | + | } else { | |||
| 123 | + | i.detachUnsavedModelProfile() | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func (i *Interactive) detachUnsavedModelProfile() { | |||
| 128 | + | i.mu.Lock() | |||
| 129 | + | defer i.mu.Unlock() | |||
| 130 | + | i.cfg.ActiveModelProfile = 0 | |||
| 131 | + | // The model/session callback already ran. Do not imply rollback: | |||
| 132 | + | // only the favorite and active-slot save failed. | |||
| 133 | + | i.statusErr = "model switched; " + i.statusErr | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func (i *Interactive) activateModelProfile(slot int, p QuickModelShortcut) { | |||
| 137 | + | defer i.invalidate() | |||
| 138 | + | if slot < 1 || slot > 9 { | |||
| 139 | + | return | |||
| 140 | + | } | |||
| 141 | + | if i.busy { | |||
| 142 | + | i.mu.Lock() | |||
| 143 | + | i.statusErr, i.statusOK = "cannot switch model while a turn is running", "" | |||
| 144 | + | i.mu.Unlock() | |||
| 145 | + | return | |||
| 146 | + | } | |||
| 147 | + | if p.Provider == "" || p.Model == "" { | |||
| 148 | + | i.mu.Lock() | |||
| 149 | + | i.statusErr, i.statusOK = "profile requires a provider and model; assign it in /settings", "" | |||
| 150 | + | i.mu.Unlock() | |||
| 151 | + | return | |||
| 152 | + | } | |||
| 153 | + | if i.cfg.SettingsStore != nil { | |||
| 154 | + | if _, ok := i.cfg.SettingsStore.(modelProfileSettingsStore); !ok { | |||
| 155 | + | if i.persistModelProfile(slot, p, 0) { | |||
| 156 | + | i.swapModel(p.Provider, p.Model, i.cfg.BuildAgentFor, false) | |||
| 157 | + | } | |||
| 158 | + | return | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | var replaced bool | |||
| 162 | + | activate := func() { | |||
| 163 | + | var success bool | |||
| 164 | + | replaced, success = i.swapModelUnserialized(p.Provider, p.Model, i.cfg.BuildAgentFor, false) | |||
| 165 | + | if !success { | |||
| 166 | + | return | |||
| 167 | + | } | |||
| 168 | + | m, _ := provider.FindModel(i.cfg.Provider, i.cfg.Model) // swap validated the model | |||
| 169 | + | p.Provider, p.Model = i.cfg.Provider, i.cfg.Model | |||
| 170 | + | p.Reasoning = provider.ClampReasoningForModel(m, p.Reasoning) | |||
| 171 | + | i.setLiveReasoning(p.Reasoning) | |||
| 172 | + | if !i.persistModelProfile(slot, p, slot) { | |||
| 173 | + | i.detachUnsavedModelProfile() | |||
| 174 | + | return | |||
| 175 | + | } | |||
| 176 | + | i.mu.Lock() | |||
| 177 | + | i.statusOK = fmt.Sprintf("profile %d active: %s", slot, modelProfileDescription(p)) | |||
| 178 | + | i.mu.Unlock() | |||
| 179 | + | } | |||
| 180 | + | if i.cfg.SessionTransition != nil { | |||
| 181 | + | i.cfg.SessionTransition(activate) | |||
| 182 | + | } else { | |||
| 183 | + | activate() | |||
| 184 | + | } | |||
| 185 | + | if replaced { | |||
| 186 | + | i.resetCompactHandoff() | |||
| 187 | + | } | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func (i *Interactive) setLiveReasoning(level string) { | |||
| 191 | + | if i.cfg.OnReasoningChanged != nil { | |||
| 192 | + | i.cfg.OnReasoningChanged(level) | |||
| 193 | + | } | |||
| 194 | + | i.mu.Lock() | |||
| 195 | + | i.cfg.Reasoning = level | |||
| 196 | + | if i.agent != nil { | |||
| 197 | + | i.agent.Reasoning = level | |||
| 198 | + | } | |||
| 199 | + | i.mu.Unlock() | |||
| 200 | + | } | |||
A packages/agent/modes/model_profiles_test.go +305 -0
| @@ -0,0 +1,305 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "reflect" | |
| 7 | + | "strings" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/zut/packages/core" | |
| 11 | + | "github.com/bnema/zut/packages/provider" | |
| 12 | + | "github.com/bnema/zut/packages/tui" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type profileSettingsStore struct { | |
| 16 | + | SettingsStore | |
| 17 | + | profiles map[int]QuickModelShortcut | |
| 18 | + | active int | |
| 19 | + | err error | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func (s *profileSettingsStore) SetModelProfile(slot int, p QuickModelShortcut, active int) error { | |
| 23 | + | if s.err != nil { | |
| 24 | + | return s.err | |
| 25 | + | } | |
| 26 | + | s.profiles[slot], s.active = p, active | |
| 27 | + | return nil | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func newProfileInteractive(t *testing.T) (*Interactive, *profileSettingsStore) { | |
| 31 | + | t.Helper() | |
| 32 | + | s := &profileSettingsStore{profiles: make(map[int]QuickModelShortcut)} | |
| 33 | + | i := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s, | |
| 34 | + | QuickModelShortcuts: []QuickModelShortcut{{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high"}}, | |
| 35 | + | }) | |
| 36 | + | i.rend = nil | |
| 37 | + | i.agent = core.NewAgent(nil, i.cfg.Model, "", nil) | |
| 38 | + | i.agent.Reasoning = i.cfg.Reasoning | |
| 39 | + | return i, s | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func selectProfileModel(t *testing.T, i *Interactive, prov, model string) { | |
| 43 | + | t.Helper() | |
| 44 | + | m, err := provider.FindModel(prov, model) | |
| 45 | + | if err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | i.modelDialog.view = []provider.Model{m} | |
| 49 | + | i.modelDialog.cursor = 0 | |
| 50 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestModelProfileRecallAndAutosave(t *testing.T) { | |
| 54 | + | i, s := newProfileInteractive(t) | |
| 55 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '&', Ctrl: true}) | |
| 56 | + | if i.cfg.ActiveModelProfile != 1 || s.active != 1 || s.profiles[1].Reasoning != "high" { | |
| 57 | + | t.Fatalf("capture: cfg=%+v store=%+v", i.cfg.QuickModelShortcuts, s) | |
| 58 | + | } | |
| 59 | + | if i.modelDialog.Active() || !strings.Contains(i.statusOK, "profile 1 active") { | |
| 60 | + | t.Fatal(i.statusOK) | |
| 61 | + | } | |
| 62 | + | i.applyReasoningSetting("max") | |
| 63 | + | if s.profiles[1].Reasoning != "max" { | |
| 64 | + | t.Fatal("reasoning not saved to active slot") | |
| 65 | + | } | |
| 66 | + | i.applyQuickModelShortcut(2) | |
| 67 | + | selectProfileModel(t, i, "openai", "gpt-5.5") | |
| 68 | + | i.applyReasoningSetting("") | |
| 69 | + | i.applyModelSelection("openai", "gpt-5.5") | |
| 70 | + | if s.profiles[2].Model != "gpt-5.5" || s.profiles[2].Reasoning != "" { | |
| 71 | + | t.Fatal(s.profiles) | |
| 72 | + | } | |
| 73 | + | i.runSlash(context.Background(), "/profile 1") | |
| 74 | + | if i.cfg.Model != "gpt-5.6-sol" || i.agent.Model != i.cfg.Model || i.cfg.Reasoning != "max" || i.agent.Reasoning != "max" || s.active != 1 { | |
| 75 | + | t.Fatalf("recall failed: model=%s reasoning=%s active=%d", i.cfg.Model, i.cfg.Reasoning, s.active) | |
| 76 | + | } | |
| 77 | + | if s.profiles[2].Model != "gpt-5.5" { | |
| 78 | + | t.Fatal("recall overwrote previous slot") | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestModelProfileEmptyShortcutOpensPickerAndCancelPreservesSelection(t *testing.T) { | |
| 83 | + | i, s := newProfileInteractive(t) | |
| 84 | + | i.applyQuickModelShortcut(1) | |
| 85 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'é', Ctrl: true}) | |
| 86 | + | if !i.modelDialog.Active() || i.quickModelAssign != 2 || i.cfg.ActiveModelProfile != 1 || len(s.profiles) != 1 { | |
| 87 | + | t.Fatal("empty shortcut did not open a non-mutating picker") | |
| 88 | + | } | |
| 89 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |
| 90 | + | if i.quickModelActivate || i.quickModelAssign != 0 || i.cfg.ActiveModelProfile != 1 || len(s.profiles) != 1 { | |
| 91 | + | t.Fatal("cancellation changed profile state") | |
| 92 | + | } | |
| 93 | + | i.runSlash(context.Background(), "/profile 2") | |
| 94 | + | selectProfileModel(t, i, "openai", "gpt-5.5") | |
| 95 | + | if i.cfg.ActiveModelProfile != 2 || s.active != 2 || s.profiles[2].Model != "gpt-5.5" || i.modelDialog.Active() { | |
| 96 | + | t.Fatalf("picker did not save and activate: %+v", s) | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestModelProfileDefaultSlotAndActiveSettingsEdits(t *testing.T) { | |
| 101 | + | i, s := newProfileInteractive(t) | |
| 102 | + | if i.cfg.ActiveModelProfile != 1 { | |
| 103 | + | t.Fatal("missing active slot did not default to 1") | |
| 104 | + | } | |
| 105 | + | i.openQuickModelPicker(1) | |
| 106 | + | i.modelDialog.reasoning = "low" | |
| 107 | + | selectProfileModel(t, i, "openai", "gpt-5.5") | |
| 108 | + | if s.active != 1 || s.profiles[1].Model != "gpt-5.5" || s.profiles[1].Reasoning != "low" || i.agent.Model != "gpt-5.5" || i.agent.Reasoning != "low" { | |
| 109 | + | t.Fatalf("active settings edit did not apply and save: %+v", s) | |
| 110 | + | } | |
| 111 | + | fresh := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s}) | |
| 112 | + | fresh.rend = nil | |
| 113 | + | if fresh.cfg.ActiveModelProfile != 1 { | |
| 114 | + | t.Fatal("fresh configuration has no default slot") | |
| 115 | + | } | |
| 116 | + | fresh.applyReasoningSetting("low") | |
| 117 | + | if s.active != 1 || s.profiles[1].Model != "gpt-5.6-sol" || s.profiles[1].Reasoning != "low" { | |
| 118 | + | t.Fatal("default slot edits not saved") | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func TestModelProfileCrossProviderCarriesTranscriptAndUsage(t *testing.T) { | |
| 123 | + | i, s := newProfileInteractive(t) | |
| 124 | + | msgs := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "preserve me"}}}} | |
| 125 | + | i.agent.SetMessages(msgs) | |
| 126 | + | i.agent.SeedCost(provider.Usage{InputTokens: 123}) | |
| 127 | + | i.cfg.QuickModelShortcuts = []QuickModelShortcut{{Provider: "google", Model: "gemini-2.0-flash", Reasoning: "high"}} | |
| 128 | + | var changed string | |
| 129 | + | i.cfg.OnReasoningChanged = func(level string) { changed = level } | |
| 130 | + | i.cfg.BuildAgentFor = func(p, m string) (*core.Agent, string, string, error) { | |
| 131 | + | return core.NewAgent(nil, m, "", nil), p, m, nil | |
| 132 | + | } | |
| 133 | + | transitions := 0 | |
| 134 | + | i.cfg.SessionTransition = func(fn func()) { transitions++; fn() } | |
| 135 | + | i.applyQuickModelShortcut(1) | |
| 136 | + | if transitions != 1 || i.cfg.Provider != "google" || i.cfg.Reasoning != "" || changed != "" || s.profiles[1].Reasoning != "" { | |
| 137 | + | t.Fatalf("cross-provider state: provider=%s reasoning=%s store=%+v", i.cfg.Provider, i.cfg.Reasoning, s) | |
| 138 | + | } | |
| 139 | + | if !reflect.DeepEqual(i.agent.Messages(), msgs) || i.agent.Cost().InputTokens != 123 { | |
| 140 | + | t.Fatal("lost transcript or usage") | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func TestModelProfileFailureDoesNotSelectOrOverwrite(t *testing.T) { | |
| 145 | + | for _, failure := range []string{"busy", "unknown", "build", "save"} { | |
| 146 | + | t.Run(failure, func(t *testing.T) { | |
| 147 | + | i, s := newProfileInteractive(t) | |
| 148 | + | i.applyQuickModelShortcut(1) | |
| 149 | + | before := s.profiles[1] | |
| 150 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "google", Model: "gemini-2.0-flash"}) | |
| 151 | + | switch failure { | |
| 152 | + | case "busy": | |
| 153 | + | i.busy = true | |
| 154 | + | case "unknown": | |
| 155 | + | i.cfg.QuickModelShortcuts[1].Model = "does-not-exist" | |
| 156 | + | case "build": | |
| 157 | + | i.cfg.BuildAgentFor = func(string, string) (*core.Agent, string, string, error) { | |
| 158 | + | return nil, "", "", errors.New("build failed") | |
| 159 | + | } | |
| 160 | + | case "save": | |
| 161 | + | i.cfg.QuickModelShortcuts[1] = QuickModelShortcut{Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: "low"} | |
| 162 | + | s.err = errors.New("disk full") | |
| 163 | + | } | |
| 164 | + | i.applyQuickModelShortcut(2) | |
| 165 | + | if s.active != 1 || s.profiles[1] != before || i.statusErr == "" { | |
| 166 | + | t.Fatalf("failed switch changed persistence: %+v / %s", s, i.statusErr) | |
| 167 | + | } | |
| 168 | + | if failure != "save" && (i.cfg.ActiveModelProfile != 1 || i.cfg.Reasoning != "high") { | |
| 169 | + | t.Fatal("failed switch changed live profile") | |
| 170 | + | } | |
| 171 | + | if failure == "save" && (i.cfg.ActiveModelProfile != 0 || !strings.Contains(i.statusErr, "model switched; profile not saved")) { | |
| 172 | + | t.Fatal("save failure misrepresents persistence") | |
| 173 | + | } | |
| 174 | + | }) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func TestModelProfileSaveFailureReportsRetainedModelPersistence(t *testing.T) { | |
| 179 | + | for _, manual := range []bool{false, true} { | |
| 180 | + | i, s := newProfileInteractive(t) | |
| 181 | + | i.applyQuickModelShortcut(1) | |
| 182 | + | before := s.profiles[1] | |
| 183 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5", Reasoning: "low"}) | |
| 184 | + | var persistedModel string | |
| 185 | + | i.cfg.PersistModel = func(_, model string) { persistedModel = model } | |
| 186 | + | s.err = errors.New("disk full") | |
| 187 | + | if manual { | |
| 188 | + | i.applyModelSelection("openai", "gpt-5.5") | |
| 189 | + | } else { | |
| 190 | + | i.applyQuickModelShortcut(2) | |
| 191 | + | } | |
| 192 | + | if persistedModel != "gpt-5.5" || i.agent.Model != persistedModel { | |
| 193 | + | t.Fatal("live model and model/session persistence must agree") | |
| 194 | + | } | |
| 195 | + | if s.active != 1 || s.profiles[1] != before || i.cfg.ActiveModelProfile != 0 || i.statusOK != "" || !strings.Contains(i.statusErr, "model switched; profile not saved") { | |
| 196 | + | t.Fatalf("failed favorite save misreported: %s", i.statusErr) | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | type legacyProfileSettingsStore struct { | |
| 202 | + | SettingsStore | |
| 203 | + | shortcut QuickModelShortcut | |
| 204 | + | reasoning string | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func (s *legacyProfileSettingsStore) SetQuickModelShortcut(_ int, prov, model string) error { | |
| 208 | + | s.shortcut = QuickModelShortcut{Provider: prov, Model: model} | |
| 209 | + | return nil | |
| 210 | + | } | |
| 211 | + | func (s *legacyProfileSettingsStore) SetReasoning(level string) error { | |
| 212 | + | s.reasoning = level | |
| 213 | + | return nil | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func TestModelProfileLegacySettingsStoreCompatibility(t *testing.T) { | |
| 217 | + | s := &legacyProfileSettingsStore{} | |
| 218 | + | i := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s}) | |
| 219 | + | i.rend = nil | |
| 220 | + | i.agent = core.NewAgent(nil, i.cfg.Model, "", nil) | |
| 221 | + | if i.cfg.ActiveModelProfile != 0 { | |
| 222 | + | t.Fatal("legacy store cannot persist an active profile") | |
| 223 | + | } | |
| 224 | + | i.applyReasoningSetting("low") | |
| 225 | + | i.setQuickModelProfile(2, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}) | |
| 226 | + | i.applyQuickModelShortcut(2) | |
| 227 | + | if s.reasoning != "low" || s.shortcut.Model != "gpt-5.5" || i.agent.Model != "gpt-5.5" || i.cfg.ActiveModelProfile != 0 { | |
| 228 | + | t.Fatal("legacy reasoning/model-only settings no longer work") | |
| 229 | + | } | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestModelProfileAssignmentAndReasoningSaveFailure(t *testing.T) { | |
| 233 | + | i, s := newProfileInteractive(t) | |
| 234 | + | i.applyQuickModelShortcut(1) | |
| 235 | + | before := i.cfg.QuickModelShortcuts[0] | |
| 236 | + | s.err = errors.New("read-only config") | |
| 237 | + | i.setQuickModelProfile(1, QuickModelShortcut{}) | |
| 238 | + | i.applyReasoningSetting("low") | |
| 239 | + | if i.cfg.QuickModelShortcuts[0] != before || i.cfg.ActiveModelProfile != 1 || i.agent.Reasoning != "high" || i.cfg.Reasoning != "high" { | |
| 240 | + | t.Fatal("save failure changed selection") | |
| 241 | + | } | |
| 242 | + | s.err = nil | |
| 243 | + | i.setQuickModelProfile(1, QuickModelShortcut{}) | |
| 244 | + | if i.cfg.ActiveModelProfile != 0 || s.active != 0 || s.profiles[1].Model != "" || i.agent.Model != before.Model { | |
| 245 | + | t.Fatal("clear should detach without changing live model") | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func TestModelProfilePickerReasoningIsLocalUntilAssigned(t *testing.T) { | |
| 250 | + | i, s := newProfileInteractive(t) | |
| 251 | + | i.applyQuickModelShortcut(1) | |
| 252 | + | i.openQuickModelPicker(2) | |
| 253 | + | i.modelDialog.view = []provider.Model{{Provider: "openai", ID: "gpt-5.6-sol", Reasoning: true}} | |
| 254 | + | i.modelDialog.cursor = 0 | |
| 255 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyLeft}) | |
| 256 | + | if i.cfg.Reasoning != "high" || s.profiles[1].Reasoning != "high" { | |
| 257 | + | t.Fatal("picker changed active reasoning") | |
| 258 | + | } | |
| 259 | + | want := i.modelDialog.reasoning | |
| 260 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |
| 261 | + | if s.profiles[2].Reasoning != want || i.cfg.ActiveModelProfile != 1 || i.cfg.Reasoning != "high" { | |
| 262 | + | t.Fatalf("assignment=%+v reasoning=%s", s, want) | |
| 263 | + | } | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | func TestModelProfileRestoresActiveIndicatorOnlyForMatchingSelection(t *testing.T) { | |
| 267 | + | p := QuickModelShortcut{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high"} | |
| 268 | + | for _, override := range []bool{false, true} { | |
| 269 | + | cfg := InteractiveConfig{Theme: tui.Dark, Provider: p.Provider, Model: p.Model, Reasoning: p.Reasoning, QuickModelShortcuts: []QuickModelShortcut{p}, ActiveModelProfile: 1} | |
| 270 | + | want := 1 | |
| 271 | + | if override { | |
| 272 | + | cfg.Model = "gpt-5.5" | |
| 273 | + | want = 0 | |
| 274 | + | } | |
| 275 | + | i := NewInteractive(cfg) | |
| 276 | + | if i.cfg.ActiveModelProfile != want { | |
| 277 | + | t.Fatalf("override=%v active=%d", override, i.cfg.ActiveModelProfile) | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | ||
| 282 | + | func TestModelProfileShortcutLayouts(t *testing.T) { | |
| 283 | + | for _, row := range []string{"123456789", "&é\"'(-è_ç"} { | |
| 284 | + | for idx, r := range []rune(row) { | |
| 285 | + | if got := quickModelShortcutSlot(tui.Key{Kind: tui.KeyRune, Rune: r, Ctrl: true}); got != idx+1 { | |
| 286 | + | t.Fatalf("Ctrl+%c = %d", r, got) | |
| 287 | + | } | |
| 288 | + | for _, key := range []tui.Key{{Kind: tui.KeyRune, Rune: r}, {Kind: tui.KeyRune, Rune: r, Ctrl: true, Alt: true}, {Kind: tui.KeyPaste, Paste: string(r)}} { | |
| 289 | + | if quickModelShortcutSlot(key) != 0 { | |
| 290 | + | t.Fatalf("unexpected shortcut: %+v", key) | |
| 291 | + | } | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestModelProfileCommandRejectsInvalidSlots(t *testing.T) { | |
| 298 | + | for _, command := range []string{"/profile", "/profile 0", "/profile 10", "/profile x", "/profile 1 extra"} { | |
| 299 | + | i, s := newProfileInteractive(t) | |
| 300 | + | i.runSlash(context.Background(), command) | |
| 301 | + | if !strings.Contains(i.statusErr, "usage:") || s.active != 0 { | |
| 302 | + | t.Fatalf("%s: %s", command, i.statusErr) | |
| 303 | + | } | |
| 304 | + | } | |
| 305 | + | } | |
| @@ -0,0 +1,305 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "reflect" | |||
| 7 | + | "strings" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/zut/packages/core" | |||
| 11 | + | "github.com/bnema/zut/packages/provider" | |||
| 12 | + | "github.com/bnema/zut/packages/tui" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type profileSettingsStore struct { | |||
| 16 | + | SettingsStore | |||
| 17 | + | profiles map[int]QuickModelShortcut | |||
| 18 | + | active int | |||
| 19 | + | err error | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func (s *profileSettingsStore) SetModelProfile(slot int, p QuickModelShortcut, active int) error { | |||
| 23 | + | if s.err != nil { | |||
| 24 | + | return s.err | |||
| 25 | + | } | |||
| 26 | + | s.profiles[slot], s.active = p, active | |||
| 27 | + | return nil | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func newProfileInteractive(t *testing.T) (*Interactive, *profileSettingsStore) { | |||
| 31 | + | t.Helper() | |||
| 32 | + | s := &profileSettingsStore{profiles: make(map[int]QuickModelShortcut)} | |||
| 33 | + | i := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s, | |||
| 34 | + | QuickModelShortcuts: []QuickModelShortcut{{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high"}}, | |||
| 35 | + | }) | |||
| 36 | + | i.rend = nil | |||
| 37 | + | i.agent = core.NewAgent(nil, i.cfg.Model, "", nil) | |||
| 38 | + | i.agent.Reasoning = i.cfg.Reasoning | |||
| 39 | + | return i, s | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func selectProfileModel(t *testing.T, i *Interactive, prov, model string) { | |||
| 43 | + | t.Helper() | |||
| 44 | + | m, err := provider.FindModel(prov, model) | |||
| 45 | + | if err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | i.modelDialog.view = []provider.Model{m} | |||
| 49 | + | i.modelDialog.cursor = 0 | |||
| 50 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func TestModelProfileRecallAndAutosave(t *testing.T) { | |||
| 54 | + | i, s := newProfileInteractive(t) | |||
| 55 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: '&', Ctrl: true}) | |||
| 56 | + | if i.cfg.ActiveModelProfile != 1 || s.active != 1 || s.profiles[1].Reasoning != "high" { | |||
| 57 | + | t.Fatalf("capture: cfg=%+v store=%+v", i.cfg.QuickModelShortcuts, s) | |||
| 58 | + | } | |||
| 59 | + | if i.modelDialog.Active() || !strings.Contains(i.statusOK, "profile 1 active") { | |||
| 60 | + | t.Fatal(i.statusOK) | |||
| 61 | + | } | |||
| 62 | + | i.applyReasoningSetting("max") | |||
| 63 | + | if s.profiles[1].Reasoning != "max" { | |||
| 64 | + | t.Fatal("reasoning not saved to active slot") | |||
| 65 | + | } | |||
| 66 | + | i.applyQuickModelShortcut(2) | |||
| 67 | + | selectProfileModel(t, i, "openai", "gpt-5.5") | |||
| 68 | + | i.applyReasoningSetting("") | |||
| 69 | + | i.applyModelSelection("openai", "gpt-5.5") | |||
| 70 | + | if s.profiles[2].Model != "gpt-5.5" || s.profiles[2].Reasoning != "" { | |||
| 71 | + | t.Fatal(s.profiles) | |||
| 72 | + | } | |||
| 73 | + | i.runSlash(context.Background(), "/profile 1") | |||
| 74 | + | if i.cfg.Model != "gpt-5.6-sol" || i.agent.Model != i.cfg.Model || i.cfg.Reasoning != "max" || i.agent.Reasoning != "max" || s.active != 1 { | |||
| 75 | + | t.Fatalf("recall failed: model=%s reasoning=%s active=%d", i.cfg.Model, i.cfg.Reasoning, s.active) | |||
| 76 | + | } | |||
| 77 | + | if s.profiles[2].Model != "gpt-5.5" { | |||
| 78 | + | t.Fatal("recall overwrote previous slot") | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestModelProfileEmptyShortcutOpensPickerAndCancelPreservesSelection(t *testing.T) { | |||
| 83 | + | i, s := newProfileInteractive(t) | |||
| 84 | + | i.applyQuickModelShortcut(1) | |||
| 85 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyRune, Rune: 'é', Ctrl: true}) | |||
| 86 | + | if !i.modelDialog.Active() || i.quickModelAssign != 2 || i.cfg.ActiveModelProfile != 1 || len(s.profiles) != 1 { | |||
| 87 | + | t.Fatal("empty shortcut did not open a non-mutating picker") | |||
| 88 | + | } | |||
| 89 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEsc}) | |||
| 90 | + | if i.quickModelActivate || i.quickModelAssign != 0 || i.cfg.ActiveModelProfile != 1 || len(s.profiles) != 1 { | |||
| 91 | + | t.Fatal("cancellation changed profile state") | |||
| 92 | + | } | |||
| 93 | + | i.runSlash(context.Background(), "/profile 2") | |||
| 94 | + | selectProfileModel(t, i, "openai", "gpt-5.5") | |||
| 95 | + | if i.cfg.ActiveModelProfile != 2 || s.active != 2 || s.profiles[2].Model != "gpt-5.5" || i.modelDialog.Active() { | |||
| 96 | + | t.Fatalf("picker did not save and activate: %+v", s) | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestModelProfileDefaultSlotAndActiveSettingsEdits(t *testing.T) { | |||
| 101 | + | i, s := newProfileInteractive(t) | |||
| 102 | + | if i.cfg.ActiveModelProfile != 1 { | |||
| 103 | + | t.Fatal("missing active slot did not default to 1") | |||
| 104 | + | } | |||
| 105 | + | i.openQuickModelPicker(1) | |||
| 106 | + | i.modelDialog.reasoning = "low" | |||
| 107 | + | selectProfileModel(t, i, "openai", "gpt-5.5") | |||
| 108 | + | if s.active != 1 || s.profiles[1].Model != "gpt-5.5" || s.profiles[1].Reasoning != "low" || i.agent.Model != "gpt-5.5" || i.agent.Reasoning != "low" { | |||
| 109 | + | t.Fatalf("active settings edit did not apply and save: %+v", s) | |||
| 110 | + | } | |||
| 111 | + | fresh := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s}) | |||
| 112 | + | fresh.rend = nil | |||
| 113 | + | if fresh.cfg.ActiveModelProfile != 1 { | |||
| 114 | + | t.Fatal("fresh configuration has no default slot") | |||
| 115 | + | } | |||
| 116 | + | fresh.applyReasoningSetting("low") | |||
| 117 | + | if s.active != 1 || s.profiles[1].Model != "gpt-5.6-sol" || s.profiles[1].Reasoning != "low" { | |||
| 118 | + | t.Fatal("default slot edits not saved") | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func TestModelProfileCrossProviderCarriesTranscriptAndUsage(t *testing.T) { | |||
| 123 | + | i, s := newProfileInteractive(t) | |||
| 124 | + | msgs := []provider.Message{{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "preserve me"}}}} | |||
| 125 | + | i.agent.SetMessages(msgs) | |||
| 126 | + | i.agent.SeedCost(provider.Usage{InputTokens: 123}) | |||
| 127 | + | i.cfg.QuickModelShortcuts = []QuickModelShortcut{{Provider: "google", Model: "gemini-2.0-flash", Reasoning: "high"}} | |||
| 128 | + | var changed string | |||
| 129 | + | i.cfg.OnReasoningChanged = func(level string) { changed = level } | |||
| 130 | + | i.cfg.BuildAgentFor = func(p, m string) (*core.Agent, string, string, error) { | |||
| 131 | + | return core.NewAgent(nil, m, "", nil), p, m, nil | |||
| 132 | + | } | |||
| 133 | + | transitions := 0 | |||
| 134 | + | i.cfg.SessionTransition = func(fn func()) { transitions++; fn() } | |||
| 135 | + | i.applyQuickModelShortcut(1) | |||
| 136 | + | if transitions != 1 || i.cfg.Provider != "google" || i.cfg.Reasoning != "" || changed != "" || s.profiles[1].Reasoning != "" { | |||
| 137 | + | t.Fatalf("cross-provider state: provider=%s reasoning=%s store=%+v", i.cfg.Provider, i.cfg.Reasoning, s) | |||
| 138 | + | } | |||
| 139 | + | if !reflect.DeepEqual(i.agent.Messages(), msgs) || i.agent.Cost().InputTokens != 123 { | |||
| 140 | + | t.Fatal("lost transcript or usage") | |||
| 141 | + | } | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestModelProfileFailureDoesNotSelectOrOverwrite(t *testing.T) { | |||
| 145 | + | for _, failure := range []string{"busy", "unknown", "build", "save"} { | |||
| 146 | + | t.Run(failure, func(t *testing.T) { | |||
| 147 | + | i, s := newProfileInteractive(t) | |||
| 148 | + | i.applyQuickModelShortcut(1) | |||
| 149 | + | before := s.profiles[1] | |||
| 150 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "google", Model: "gemini-2.0-flash"}) | |||
| 151 | + | switch failure { | |||
| 152 | + | case "busy": | |||
| 153 | + | i.busy = true | |||
| 154 | + | case "unknown": | |||
| 155 | + | i.cfg.QuickModelShortcuts[1].Model = "does-not-exist" | |||
| 156 | + | case "build": | |||
| 157 | + | i.cfg.BuildAgentFor = func(string, string) (*core.Agent, string, string, error) { | |||
| 158 | + | return nil, "", "", errors.New("build failed") | |||
| 159 | + | } | |||
| 160 | + | case "save": | |||
| 161 | + | i.cfg.QuickModelShortcuts[1] = QuickModelShortcut{Provider: i.cfg.Provider, Model: i.cfg.Model, Reasoning: "low"} | |||
| 162 | + | s.err = errors.New("disk full") | |||
| 163 | + | } | |||
| 164 | + | i.applyQuickModelShortcut(2) | |||
| 165 | + | if s.active != 1 || s.profiles[1] != before || i.statusErr == "" { | |||
| 166 | + | t.Fatalf("failed switch changed persistence: %+v / %s", s, i.statusErr) | |||
| 167 | + | } | |||
| 168 | + | if failure != "save" && (i.cfg.ActiveModelProfile != 1 || i.cfg.Reasoning != "high") { | |||
| 169 | + | t.Fatal("failed switch changed live profile") | |||
| 170 | + | } | |||
| 171 | + | if failure == "save" && (i.cfg.ActiveModelProfile != 0 || !strings.Contains(i.statusErr, "model switched; profile not saved")) { | |||
| 172 | + | t.Fatal("save failure misrepresents persistence") | |||
| 173 | + | } | |||
| 174 | + | }) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func TestModelProfileSaveFailureReportsRetainedModelPersistence(t *testing.T) { | |||
| 179 | + | for _, manual := range []bool{false, true} { | |||
| 180 | + | i, s := newProfileInteractive(t) | |||
| 181 | + | i.applyQuickModelShortcut(1) | |||
| 182 | + | before := s.profiles[1] | |||
| 183 | + | i.cfg.QuickModelShortcuts = append(i.cfg.QuickModelShortcuts, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5", Reasoning: "low"}) | |||
| 184 | + | var persistedModel string | |||
| 185 | + | i.cfg.PersistModel = func(_, model string) { persistedModel = model } | |||
| 186 | + | s.err = errors.New("disk full") | |||
| 187 | + | if manual { | |||
| 188 | + | i.applyModelSelection("openai", "gpt-5.5") | |||
| 189 | + | } else { | |||
| 190 | + | i.applyQuickModelShortcut(2) | |||
| 191 | + | } | |||
| 192 | + | if persistedModel != "gpt-5.5" || i.agent.Model != persistedModel { | |||
| 193 | + | t.Fatal("live model and model/session persistence must agree") | |||
| 194 | + | } | |||
| 195 | + | if s.active != 1 || s.profiles[1] != before || i.cfg.ActiveModelProfile != 0 || i.statusOK != "" || !strings.Contains(i.statusErr, "model switched; profile not saved") { | |||
| 196 | + | t.Fatalf("failed favorite save misreported: %s", i.statusErr) | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | type legacyProfileSettingsStore struct { | |||
| 202 | + | SettingsStore | |||
| 203 | + | shortcut QuickModelShortcut | |||
| 204 | + | reasoning string | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func (s *legacyProfileSettingsStore) SetQuickModelShortcut(_ int, prov, model string) error { | |||
| 208 | + | s.shortcut = QuickModelShortcut{Provider: prov, Model: model} | |||
| 209 | + | return nil | |||
| 210 | + | } | |||
| 211 | + | func (s *legacyProfileSettingsStore) SetReasoning(level string) error { | |||
| 212 | + | s.reasoning = level | |||
| 213 | + | return nil | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func TestModelProfileLegacySettingsStoreCompatibility(t *testing.T) { | |||
| 217 | + | s := &legacyProfileSettingsStore{} | |||
| 218 | + | i := NewInteractive(InteractiveConfig{Theme: tui.Dark, Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high", SettingsStore: s}) | |||
| 219 | + | i.rend = nil | |||
| 220 | + | i.agent = core.NewAgent(nil, i.cfg.Model, "", nil) | |||
| 221 | + | if i.cfg.ActiveModelProfile != 0 { | |||
| 222 | + | t.Fatal("legacy store cannot persist an active profile") | |||
| 223 | + | } | |||
| 224 | + | i.applyReasoningSetting("low") | |||
| 225 | + | i.setQuickModelProfile(2, QuickModelShortcut{Provider: "openai", Model: "gpt-5.5"}) | |||
| 226 | + | i.applyQuickModelShortcut(2) | |||
| 227 | + | if s.reasoning != "low" || s.shortcut.Model != "gpt-5.5" || i.agent.Model != "gpt-5.5" || i.cfg.ActiveModelProfile != 0 { | |||
| 228 | + | t.Fatal("legacy reasoning/model-only settings no longer work") | |||
| 229 | + | } | |||
| 230 | + | } | |||
| 231 | + | ||||
| 232 | + | func TestModelProfileAssignmentAndReasoningSaveFailure(t *testing.T) { | |||
| 233 | + | i, s := newProfileInteractive(t) | |||
| 234 | + | i.applyQuickModelShortcut(1) | |||
| 235 | + | before := i.cfg.QuickModelShortcuts[0] | |||
| 236 | + | s.err = errors.New("read-only config") | |||
| 237 | + | i.setQuickModelProfile(1, QuickModelShortcut{}) | |||
| 238 | + | i.applyReasoningSetting("low") | |||
| 239 | + | if i.cfg.QuickModelShortcuts[0] != before || i.cfg.ActiveModelProfile != 1 || i.agent.Reasoning != "high" || i.cfg.Reasoning != "high" { | |||
| 240 | + | t.Fatal("save failure changed selection") | |||
| 241 | + | } | |||
| 242 | + | s.err = nil | |||
| 243 | + | i.setQuickModelProfile(1, QuickModelShortcut{}) | |||
| 244 | + | if i.cfg.ActiveModelProfile != 0 || s.active != 0 || s.profiles[1].Model != "" || i.agent.Model != before.Model { | |||
| 245 | + | t.Fatal("clear should detach without changing live model") | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func TestModelProfilePickerReasoningIsLocalUntilAssigned(t *testing.T) { | |||
| 250 | + | i, s := newProfileInteractive(t) | |||
| 251 | + | i.applyQuickModelShortcut(1) | |||
| 252 | + | i.openQuickModelPicker(2) | |||
| 253 | + | i.modelDialog.view = []provider.Model{{Provider: "openai", ID: "gpt-5.6-sol", Reasoning: true}} | |||
| 254 | + | i.modelDialog.cursor = 0 | |||
| 255 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyLeft}) | |||
| 256 | + | if i.cfg.Reasoning != "high" || s.profiles[1].Reasoning != "high" { | |||
| 257 | + | t.Fatal("picker changed active reasoning") | |||
| 258 | + | } | |||
| 259 | + | want := i.modelDialog.reasoning | |||
| 260 | + | i.handleKey(context.Background(), tui.Key{Kind: tui.KeyEnter}) | |||
| 261 | + | if s.profiles[2].Reasoning != want || i.cfg.ActiveModelProfile != 1 || i.cfg.Reasoning != "high" { | |||
| 262 | + | t.Fatalf("assignment=%+v reasoning=%s", s, want) | |||
| 263 | + | } | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | func TestModelProfileRestoresActiveIndicatorOnlyForMatchingSelection(t *testing.T) { | |||
| 267 | + | p := QuickModelShortcut{Provider: "openai", Model: "gpt-5.6-sol", Reasoning: "high"} | |||
| 268 | + | for _, override := range []bool{false, true} { | |||
| 269 | + | cfg := InteractiveConfig{Theme: tui.Dark, Provider: p.Provider, Model: p.Model, Reasoning: p.Reasoning, QuickModelShortcuts: []QuickModelShortcut{p}, ActiveModelProfile: 1} | |||
| 270 | + | want := 1 | |||
| 271 | + | if override { | |||
| 272 | + | cfg.Model = "gpt-5.5" | |||
| 273 | + | want = 0 | |||
| 274 | + | } | |||
| 275 | + | i := NewInteractive(cfg) | |||
| 276 | + | if i.cfg.ActiveModelProfile != want { | |||
| 277 | + | t.Fatalf("override=%v active=%d", override, i.cfg.ActiveModelProfile) | |||
| 278 | + | } | |||
| 279 | + | } | |||
| 280 | + | } | |||
| 281 | + | ||||
| 282 | + | func TestModelProfileShortcutLayouts(t *testing.T) { | |||
| 283 | + | for _, row := range []string{"123456789", "&é\"'(-è_ç"} { | |||
| 284 | + | for idx, r := range []rune(row) { | |||
| 285 | + | if got := quickModelShortcutSlot(tui.Key{Kind: tui.KeyRune, Rune: r, Ctrl: true}); got != idx+1 { | |||
| 286 | + | t.Fatalf("Ctrl+%c = %d", r, got) | |||
| 287 | + | } | |||
| 288 | + | for _, key := range []tui.Key{{Kind: tui.KeyRune, Rune: r}, {Kind: tui.KeyRune, Rune: r, Ctrl: true, Alt: true}, {Kind: tui.KeyPaste, Paste: string(r)}} { | |||
| 289 | + | if quickModelShortcutSlot(key) != 0 { | |||
| 290 | + | t.Fatalf("unexpected shortcut: %+v", key) | |||
| 291 | + | } | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func TestModelProfileCommandRejectsInvalidSlots(t *testing.T) { | |||
| 298 | + | for _, command := range []string{"/profile", "/profile 0", "/profile 10", "/profile x", "/profile 1 extra"} { | |||
| 299 | + | i, s := newProfileInteractive(t) | |||
| 300 | + | i.runSlash(context.Background(), command) | |||
| 301 | + | if !strings.Contains(i.statusErr, "usage:") || s.active != 0 { | |||
| 302 | + | t.Fatalf("%s: %s", command, i.statusErr) | |||
| 303 | + | } | |||
| 304 | + | } | |||
| 305 | + | } | |||
M packages/agent/modes/slash_suggest.go +1 -0
| @@ -52,6 +52,7 @@ var slashCatalog = []slashCommand{ | |||
| 52 | 52 | {Name: "/logout", Desc: "clear a provider's credentials"}, | |
| 53 | 53 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | |
| 54 | 54 | {Name: "/reasoning", Desc: "set the reasoning level"}, | |
| 55 | + | {Name: "/profile", Desc: "activate model + reasoning profile (/profile <1-9>)"}, | |
| 55 | 56 | {Name: "/fast", Desc: "toggle fast mode"}, | |
| 56 | 57 | {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"}, | |
| 57 | 58 | {Name: "/goal", Desc: "start or control an autonomous goal"}, | |
| @@ -52,6 +52,7 @@ var slashCatalog = []slashCommand{ | |||||
| 52 | {Name: "/logout", Desc: "clear a provider's credentials"}, | 52 | {Name: "/logout", Desc: "clear a provider's credentials"}, | ||
| 53 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | 53 | {Name: "/model", Desc: "pick a model (or /model <id>)"}, | ||
| 54 | {Name: "/reasoning", Desc: "set the reasoning level"}, | 54 | {Name: "/reasoning", Desc: "set the reasoning level"}, | ||
| 55 | + | {Name: "/profile", Desc: "activate model + reasoning profile (/profile <1-9>)"}, | |||
| 55 | {Name: "/fast", Desc: "toggle fast mode"}, | 56 | {Name: "/fast", Desc: "toggle fast mode"}, | ||
| 56 | {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"}, | 57 | {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"}, | ||
| 57 | {Name: "/goal", Desc: "start or control an autonomous goal"}, | 58 | {Name: "/goal", Desc: "start or control an autonomous goal"}, | ||
M packages/agent/settings_store.go +26 -2
| @@ -3,13 +3,15 @@ package agent | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/zut/packages/agent/modes" | |
| 6 | 7 | "github.com/bnema/zut/packages/provider" | |
| 7 | 8 | "github.com/bnema/zut/packages/tui" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | type configSettingsStore struct{} | |
| 11 | 12 | ||
| 12 | - | func (configSettingsStore) SetQuickModelShortcut(slot int, providerName, model string) error { | |
| 13 | + | // Retain the original SettingsStore contract for model-only callers. | |
| 14 | + | func (s configSettingsStore) SetQuickModelShortcut(slot int, providerName, model string) error { | |
| 13 | 15 | if slot < 1 || slot > 9 { | |
| 14 | 16 | return nil | |
| 15 | 17 | } | |
| @@ -17,12 +19,34 @@ func (configSettingsStore) SetQuickModelShortcut(slot int, providerName, model s | |||
| 17 | 19 | if err != nil { | |
| 18 | 20 | return err | |
| 19 | 21 | } | |
| 22 | + | if cfg.ActiveModelProfile == slot { | |
| 23 | + | cfg.ActiveModelProfile = 0 | |
| 24 | + | } | |
| 25 | + | return s.SetModelProfile(slot, modes.QuickModelShortcut{Provider: providerName, Model: model}, cfg.ActiveModelProfile) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (configSettingsStore) SetModelProfile(slot int, profile modes.QuickModelShortcut, activeSlot int) error { | |
| 29 | + | if slot < 1 || slot > 9 || activeSlot < 0 || activeSlot > 9 { | |
| 30 | + | return fmt.Errorf("model profile slot must be between 1 and 9 (active may be 0)") | |
| 31 | + | } | |
| 32 | + | cfg, err := LoadConfig() | |
| 33 | + | if err != nil { | |
| 34 | + | return err | |
| 35 | + | } | |
| 20 | 36 | if len(cfg.QuickModelShortcuts) < slot { | |
| 21 | 37 | next := make([]QuickModelShortcut, slot) | |
| 22 | 38 | copy(next, cfg.QuickModelShortcuts) | |
| 23 | 39 | cfg.QuickModelShortcuts = next | |
| 24 | 40 | } | |
| 25 | - | cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} | |
| 41 | + | cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: profile.Provider, Model: profile.Model, Reasoning: provider.NormalizeReasoning(profile.Reasoning)} | |
| 42 | + | cfg.ActiveModelProfile = activeSlot | |
| 43 | + | if activeSlot == slot { | |
| 44 | + | if profile.Provider == "" || profile.Model == "" { | |
| 45 | + | return fmt.Errorf("cannot activate an empty model profile") | |
| 46 | + | } | |
| 47 | + | cfg.Provider, cfg.Model = profile.Provider, profile.Model | |
| 48 | + | cfg.Reasoning = provider.NormalizeReasoning(profile.Reasoning) | |
| 49 | + | } | |
| 26 | 50 | // Trim trailing empty slots so config.json stays compact. | |
| 27 | 51 | for len(cfg.QuickModelShortcuts) > 0 { | |
| 28 | 52 | last := cfg.QuickModelShortcuts[len(cfg.QuickModelShortcuts)-1] | |
| @@ -3,13 +3,15 @@ package agent | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | 5 | ||||
| 6 | + | "github.com/bnema/zut/packages/agent/modes" | |||
| 6 | "github.com/bnema/zut/packages/provider" | 7 | "github.com/bnema/zut/packages/provider" | ||
| 7 | "github.com/bnema/zut/packages/tui" | 8 | "github.com/bnema/zut/packages/tui" | ||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | type configSettingsStore struct{} | 11 | type configSettingsStore struct{} | ||
| 11 | 12 | ||||
| 12 | - | func (configSettingsStore) SetQuickModelShortcut(slot int, providerName, model string) error { | 13 | + | // Retain the original SettingsStore contract for model-only callers. |
| 14 | + | func (s configSettingsStore) SetQuickModelShortcut(slot int, providerName, model string) error { | |||
| 13 | if slot < 1 || slot > 9 { | 15 | if slot < 1 || slot > 9 { | ||
| 14 | return nil | 16 | return nil | ||
| 15 | } | 17 | } | ||
| @@ -17,12 +19,34 @@ func (configSettingsStore) SetQuickModelShortcut(slot int, providerName, model s | |||||
| 17 | if err != nil { | 19 | if err != nil { | ||
| 18 | return err | 20 | return err | ||
| 19 | } | 21 | } | ||
| 22 | + | if cfg.ActiveModelProfile == slot { | |||
| 23 | + | cfg.ActiveModelProfile = 0 | |||
| 24 | + | } | |||
| 25 | + | return s.SetModelProfile(slot, modes.QuickModelShortcut{Provider: providerName, Model: model}, cfg.ActiveModelProfile) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func (configSettingsStore) SetModelProfile(slot int, profile modes.QuickModelShortcut, activeSlot int) error { | |||
| 29 | + | if slot < 1 || slot > 9 || activeSlot < 0 || activeSlot > 9 { | |||
| 30 | + | return fmt.Errorf("model profile slot must be between 1 and 9 (active may be 0)") | |||
| 31 | + | } | |||
| 32 | + | cfg, err := LoadConfig() | |||
| 33 | + | if err != nil { | |||
| 34 | + | return err | |||
| 35 | + | } | |||
| 20 | if len(cfg.QuickModelShortcuts) < slot { | 36 | if len(cfg.QuickModelShortcuts) < slot { | ||
| 21 | next := make([]QuickModelShortcut, slot) | 37 | next := make([]QuickModelShortcut, slot) | ||
| 22 | copy(next, cfg.QuickModelShortcuts) | 38 | copy(next, cfg.QuickModelShortcuts) | ||
| 23 | cfg.QuickModelShortcuts = next | 39 | cfg.QuickModelShortcuts = next | ||
| 24 | } | 40 | } | ||
| 25 | - | cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} | 41 | + | cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: profile.Provider, Model: profile.Model, Reasoning: provider.NormalizeReasoning(profile.Reasoning)} |
| 42 | + | cfg.ActiveModelProfile = activeSlot | |||
| 43 | + | if activeSlot == slot { | |||
| 44 | + | if profile.Provider == "" || profile.Model == "" { | |||
| 45 | + | return fmt.Errorf("cannot activate an empty model profile") | |||
| 46 | + | } | |||
| 47 | + | cfg.Provider, cfg.Model = profile.Provider, profile.Model | |||
| 48 | + | cfg.Reasoning = provider.NormalizeReasoning(profile.Reasoning) | |||
| 49 | + | } | |||
| 26 | // Trim trailing empty slots so config.json stays compact. | 50 | // Trim trailing empty slots so config.json stays compact. | ||
| 27 | for len(cfg.QuickModelShortcuts) > 0 { | 51 | for len(cfg.QuickModelShortcuts) > 0 { | ||
| 28 | last := cfg.QuickModelShortcuts[len(cfg.QuickModelShortcuts)-1] | 52 | last := cfg.QuickModelShortcuts[len(cfg.QuickModelShortcuts)-1] | ||
M packages/tui/input.go +9 -2
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "strconv" | |
| 5 | 5 | "strings" | |
| 6 | 6 | "time" | |
| 7 | + | "unicode" | |
| 7 | 8 | ) | |
| 8 | 9 | ||
| 9 | 10 | // Key is a parsed keypress. | |
| @@ -411,7 +412,10 @@ func parseCSIU(params string) (Key, bool) { | |||
| 411 | 412 | if len(parts) == 0 { | |
| 412 | 413 | return Key{}, false | |
| 413 | 414 | } | |
| 414 | - | code, err := strconv.Atoi(parts[0]) | |
| 415 | + | // Kitty may include shifted and base-layout alternatives (code:shifted:base). | |
| 416 | + | // The primary code preserves the user's layout, including AZERTY punctuation. | |
| 417 | + | primary, _, _ := strings.Cut(parts[0], ":") | |
| 418 | + | code, err := strconv.Atoi(primary) | |
| 415 | 419 | if err != nil { | |
| 416 | 420 | return Key{}, false | |
| 417 | 421 | } | |
| @@ -506,7 +510,10 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 506 | 510 | if code >= '0' && code <= '9' { | |
| 507 | 511 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 508 | 512 | } | |
| 509 | - | if !ctrl && code >= 0x20 && code <= 0x7e { | |
| 513 | + | // Only the AZERTY profile shortcuts are printable Ctrl exceptions. Other | |
| 514 | + | // Ctrl chords must remain unknown rather than becoming editor text. | |
| 515 | + | azertyProfileKey := !alt && strings.ContainsRune("&é\"'(-è_ç", rune(code)) | |
| 516 | + | if code >= 0x20 && code <= unicode.MaxRune && unicode.IsPrint(rune(code)) && (!ctrl || azertyProfileKey) { | |
| 510 | 517 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 511 | 518 | } | |
| 512 | 519 | return Key{}, false | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "strconv" | 4 | "strconv" | ||
| 5 | "strings" | 5 | "strings" | ||
| 6 | "time" | 6 | "time" | ||
| 7 | + | "unicode" | |||
| 7 | ) | 8 | ) | ||
| 8 | 9 | ||||
| 9 | // Key is a parsed keypress. | 10 | // Key is a parsed keypress. | ||
| @@ -411,7 +412,10 @@ func parseCSIU(params string) (Key, bool) { | |||||
| 411 | if len(parts) == 0 { | 412 | if len(parts) == 0 { | ||
| 412 | return Key{}, false | 413 | return Key{}, false | ||
| 413 | } | 414 | } | ||
| 414 | - | code, err := strconv.Atoi(parts[0]) | 415 | + | // Kitty may include shifted and base-layout alternatives (code:shifted:base). |
| 416 | + | // The primary code preserves the user's layout, including AZERTY punctuation. | |||
| 417 | + | primary, _, _ := strings.Cut(parts[0], ":") | |||
| 418 | + | code, err := strconv.Atoi(primary) | |||
| 415 | if err != nil { | 419 | if err != nil { | ||
| 416 | return Key{}, false | 420 | return Key{}, false | ||
| 417 | } | 421 | } | ||
| @@ -506,7 +510,10 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||||
| 506 | if code >= '0' && code <= '9' { | 510 | if code >= '0' && code <= '9' { | ||
| 507 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | 511 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | ||
| 508 | } | 512 | } | ||
| 509 | - | if !ctrl && code >= 0x20 && code <= 0x7e { | 513 | + | // Only the AZERTY profile shortcuts are printable Ctrl exceptions. Other |
| 514 | + | // Ctrl chords must remain unknown rather than becoming editor text. | |||
| 515 | + | azertyProfileKey := !alt && strings.ContainsRune("&é\"'(-è_ç", rune(code)) | |||
| 516 | + | if code >= 0x20 && code <= unicode.MaxRune && unicode.IsPrint(rune(code)) && (!ctrl || azertyProfileKey) { | |||
| 510 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | 517 | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | ||
| 511 | } | 518 | } | ||
| 512 | return Key{}, false | 519 | return Key{}, false | ||
A packages/tui/model_profiles_test.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package tui | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestReaderParsesAZERTYProfileKeys(t *testing.T) { | |
| 10 | + | for _, r := range "&é\"'(-è_ç" { | |
| 11 | + | for _, sequence := range []string{ | |
| 12 | + | fmt.Sprintf("\x1b[%d;5u", r), | |
| 13 | + | fmt.Sprintf("\x1b[%d:49:49;5u", r), | |
| 14 | + | fmt.Sprintf("\x1b[27;5;%d~", r), | |
| 15 | + | } { | |
| 16 | + | k := readKey(t, sequence) | |
| 17 | + | if k.Kind != KeyRune || k.Rune != r || !k.Ctrl { | |
| 18 | + | t.Fatalf("%q: %+v", sequence, k) | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | k := readKey(t, fmt.Sprintf("\x1b[%d;1u", r)) | |
| 22 | + | if k.Kind != KeyRune || k.Rune != r || k.Ctrl { | |
| 23 | + | t.Fatalf("unmodified %c: %+v", r, k) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestReaderKeepsUnboundEnhancedCtrlPrintablesUnknown(t *testing.T) { | |
| 29 | + | for _, r := range "[/\\=ö" { | |
| 30 | + | for _, sequence := range []string{ | |
| 31 | + | fmt.Sprintf("\x1b[%d;5u", r), | |
| 32 | + | fmt.Sprintf("\x1b[27;5;%d~", r), | |
| 33 | + | } { | |
| 34 | + | k := readKey(t, sequence) | |
| 35 | + | if k.Kind != KeyUnknown { | |
| 36 | + | t.Fatalf("Ctrl+%c parsed as %+v, want unknown", r, k) | |
| 37 | + | } | |
| 38 | + | ed := NewEditor("") | |
| 39 | + | ed.HandleKey(k) | |
| 40 | + | if ed.Value() != "" { | |
| 41 | + | t.Fatalf("Ctrl+%c inserted %q", r, ed.Value()) | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestStatusBarModelProfileIndicator(t *testing.T) { | |
| 48 | + | for _, cols := range []int{10, 25, 80, 160} { | |
| 49 | + | lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.6-sol", Reasoning: "max", ModelProfile: 9, Cols: cols}) | |
| 50 | + | if !strings.Contains(stripANSI(strings.Join(lines, "\n")), "[9]") { | |
| 51 | + | t.Fatalf("missing profile at width %d: %q", cols, lines) | |
| 52 | + | } | |
| 53 | + | for _, line := range lines { | |
| 54 | + | if visibleWidth(line) > cols { | |
| 55 | + | t.Fatalf("overflow at width %d: %q", cols, line) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| @@ -0,0 +1,59 @@ | |||||
| 1 | + | package tui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestReaderParsesAZERTYProfileKeys(t *testing.T) { | |||
| 10 | + | for _, r := range "&é\"'(-è_ç" { | |||
| 11 | + | for _, sequence := range []string{ | |||
| 12 | + | fmt.Sprintf("\x1b[%d;5u", r), | |||
| 13 | + | fmt.Sprintf("\x1b[%d:49:49;5u", r), | |||
| 14 | + | fmt.Sprintf("\x1b[27;5;%d~", r), | |||
| 15 | + | } { | |||
| 16 | + | k := readKey(t, sequence) | |||
| 17 | + | if k.Kind != KeyRune || k.Rune != r || !k.Ctrl { | |||
| 18 | + | t.Fatalf("%q: %+v", sequence, k) | |||
| 19 | + | } | |||
| 20 | + | } | |||
| 21 | + | k := readKey(t, fmt.Sprintf("\x1b[%d;1u", r)) | |||
| 22 | + | if k.Kind != KeyRune || k.Rune != r || k.Ctrl { | |||
| 23 | + | t.Fatalf("unmodified %c: %+v", r, k) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestReaderKeepsUnboundEnhancedCtrlPrintablesUnknown(t *testing.T) { | |||
| 29 | + | for _, r := range "[/\\=ö" { | |||
| 30 | + | for _, sequence := range []string{ | |||
| 31 | + | fmt.Sprintf("\x1b[%d;5u", r), | |||
| 32 | + | fmt.Sprintf("\x1b[27;5;%d~", r), | |||
| 33 | + | } { | |||
| 34 | + | k := readKey(t, sequence) | |||
| 35 | + | if k.Kind != KeyUnknown { | |||
| 36 | + | t.Fatalf("Ctrl+%c parsed as %+v, want unknown", r, k) | |||
| 37 | + | } | |||
| 38 | + | ed := NewEditor("") | |||
| 39 | + | ed.HandleKey(k) | |||
| 40 | + | if ed.Value() != "" { | |||
| 41 | + | t.Fatalf("Ctrl+%c inserted %q", r, ed.Value()) | |||
| 42 | + | } | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestStatusBarModelProfileIndicator(t *testing.T) { | |||
| 48 | + | for _, cols := range []int{10, 25, 80, 160} { | |||
| 49 | + | lines := StatusBar(StatusBarParams{Theme: Dark, Model: "gpt-5.6-sol", Reasoning: "max", ModelProfile: 9, Cols: cols}) | |||
| 50 | + | if !strings.Contains(stripANSI(strings.Join(lines, "\n")), "[9]") { | |||
| 51 | + | t.Fatalf("missing profile at width %d: %q", cols, lines) | |||
| 52 | + | } | |||
| 53 | + | for _, line := range lines { | |||
| 54 | + | if visibleWidth(line) > cols { | |||
| 55 | + | t.Fatalf("overflow at width %d: %q", cols, line) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | } | |||
M packages/tui/view.go +21 -16
| @@ -2976,19 +2976,20 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string { | |||
| 2976 | 2976 | // StatusBarParams groups the many bits of state the status bar needs. | |
| 2977 | 2977 | // Grew from a flat argument list once we settled on the layout. | |
| 2978 | 2978 | type StatusBarParams struct { | |
| 2979 | - | Theme Theme | |
| 2980 | - | Provider string | |
| 2981 | - | Model string | |
| 2982 | - | Reasoning string // "" means thinking off | |
| 2983 | - | FastMode bool // show the provider's opt-in fast tier when enabled | |
| 2984 | - | Busy bool | |
| 2985 | - | BusyPrefix string // spinner + funny line when busy | |
| 2986 | - | CWD string | |
| 2987 | - | Locked bool // sandbox on? | |
| 2988 | - | NoYolo bool // confirmation mode enabled? | |
| 2989 | - | GoalStatus string // autonomous goal lifecycle, empty when none | |
| 2990 | - | PlanCurrent int // one-based in-progress plan step; 0 hides plan progress | |
| 2991 | - | PlanTotal int | |
| 2979 | + | Theme Theme | |
| 2980 | + | Provider string | |
| 2981 | + | Model string | |
| 2982 | + | Reasoning string // "" means thinking off | |
| 2983 | + | ModelProfile int // 1–9 identifies the active model/reasoning favorite | |
| 2984 | + | FastMode bool // show the provider's opt-in fast tier when enabled | |
| 2985 | + | Busy bool | |
| 2986 | + | BusyPrefix string // spinner + funny line when busy | |
| 2987 | + | CWD string | |
| 2988 | + | Locked bool // sandbox on? | |
| 2989 | + | NoYolo bool // confirmation mode enabled? | |
| 2990 | + | GoalStatus string // autonomous goal lifecycle, empty when none | |
| 2991 | + | PlanCurrent int // one-based in-progress plan step; 0 hides plan progress | |
| 2992 | + | PlanTotal int | |
| 2992 | 2993 | ||
| 2993 | 2994 | // Cumulative session usage and cost. | |
| 2994 | 2995 | Usage provider.Usage | |
| @@ -3076,7 +3077,11 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3076 | 3077 | const pad = " " // 2 spaces | |
| 3077 | 3078 | ||
| 3078 | 3079 | reasoning := reasoningLevelLabel(p.Reasoning) | |
| 3079 | - | left := renderStatusModel(th, p.Model, reasoning) | |
| 3080 | + | modelLabel := p.Model | |
| 3081 | + | if p.ModelProfile >= 1 && p.ModelProfile <= 9 { | |
| 3082 | + | modelLabel = fmt.Sprintf("[%d] %s", p.ModelProfile, modelLabel) | |
| 3083 | + | } | |
| 3084 | + | left := renderStatusModel(th, modelLabel, reasoning) | |
| 3080 | 3085 | fastText := "" | |
| 3081 | 3086 | if p.FastMode { | |
| 3082 | 3087 | fastText = th.FGColor(th.Muted, "fast mode") | |
| @@ -3141,7 +3146,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3141 | 3146 | modelLine := pad + left | |
| 3142 | 3147 | lines := []string{busyLine} | |
| 3143 | 3148 | if visibleWidth(modelLine+pad+middle) > p.Cols { | |
| 3144 | - | lines = appendWrappedStatusLines(lines, th, pad, p.Model, reasoning, fastText, stats, p.Cols) | |
| 3149 | + | lines = appendWrappedStatusLines(lines, th, pad, modelLabel, reasoning, fastText, stats, p.Cols) | |
| 3145 | 3150 | } else { | |
| 3146 | 3151 | var infoBuilder strings.Builder | |
| 3147 | 3152 | infoBuilder.WriteString(modelLine) | |
| @@ -3163,7 +3168,7 @@ func StatusBar(p StatusBarParams) []string { | |||
| 3163 | 3168 | // into an awkward position on small widths. | |
| 3164 | 3169 | if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols { | |
| 3165 | 3170 | var lines []string | |
| 3166 | - | lines = appendWrappedStatusLines(lines, th, pad, p.Model, reasoning, fastText, stats, p.Cols) | |
| 3171 | + | lines = appendWrappedStatusLines(lines, th, pad, modelLabel, reasoning, fastText, stats, p.Cols) | |
| 3167 | 3172 | if cwd != "" { | |
| 3168 | 3173 | lines = append(lines, pad+th.FGColor(th.Muted, cwd)) | |
| 3169 | 3174 | } | |
| @@ -2976,19 +2976,20 @@ func (v *View) renderCompactionBlock(m provider.Message, width int) []string { | |||||
| 2976 | // StatusBarParams groups the many bits of state the status bar needs. | 2976 | // StatusBarParams groups the many bits of state the status bar needs. | ||
| 2977 | // Grew from a flat argument list once we settled on the layout. | 2977 | // Grew from a flat argument list once we settled on the layout. | ||
| 2978 | type StatusBarParams struct { | 2978 | type StatusBarParams struct { | ||
| 2979 | - | Theme Theme | 2979 | + | Theme Theme |
| 2980 | - | Provider string | 2980 | + | Provider string |
| 2981 | - | Model string | 2981 | + | Model string |
| 2982 | - | Reasoning string // "" means thinking off | 2982 | + | Reasoning string // "" means thinking off |
| 2983 | - | FastMode bool // show the provider's opt-in fast tier when enabled | 2983 | + | ModelProfile int // 1–9 identifies the active model/reasoning favorite |
| 2984 | - | Busy bool | 2984 | + | FastMode bool // show the provider's opt-in fast tier when enabled |
| 2985 | - | BusyPrefix string // spinner + funny line when busy | 2985 | + | Busy bool |
| 2986 | - | CWD string | 2986 | + | BusyPrefix string // spinner + funny line when busy |
| 2987 | - | Locked bool // sandbox on? | 2987 | + | CWD string |
| 2988 | - | NoYolo bool // confirmation mode enabled? | 2988 | + | Locked bool // sandbox on? |
| 2989 | - | GoalStatus string // autonomous goal lifecycle, empty when none | 2989 | + | NoYolo bool // confirmation mode enabled? |
| 2990 | - | PlanCurrent int // one-based in-progress plan step; 0 hides plan progress | 2990 | + | GoalStatus string // autonomous goal lifecycle, empty when none |
| 2991 | - | PlanTotal int | 2991 | + | PlanCurrent int // one-based in-progress plan step; 0 hides plan progress |
| 2992 | + | PlanTotal int | |||
| 2992 | 2993 | ||||
| 2993 | // Cumulative session usage and cost. | 2994 | // Cumulative session usage and cost. | ||
| 2994 | Usage provider.Usage | 2995 | Usage provider.Usage | ||
| @@ -3076,7 +3077,11 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 3076 | const pad = " " // 2 spaces | 3077 | const pad = " " // 2 spaces | ||
| 3077 | 3078 | ||||
| 3078 | reasoning := reasoningLevelLabel(p.Reasoning) | 3079 | reasoning := reasoningLevelLabel(p.Reasoning) | ||
| 3079 | - | left := renderStatusModel(th, p.Model, reasoning) | 3080 | + | modelLabel := p.Model |
| 3081 | + | if p.ModelProfile >= 1 && p.ModelProfile <= 9 { | |||
| 3082 | + | modelLabel = fmt.Sprintf("[%d] %s", p.ModelProfile, modelLabel) | |||
| 3083 | + | } | |||
| 3084 | + | left := renderStatusModel(th, modelLabel, reasoning) | |||
| 3080 | fastText := "" | 3085 | fastText := "" | ||
| 3081 | if p.FastMode { | 3086 | if p.FastMode { | ||
| 3082 | fastText = th.FGColor(th.Muted, "fast mode") | 3087 | fastText = th.FGColor(th.Muted, "fast mode") | ||
| @@ -3141,7 +3146,7 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 3141 | modelLine := pad + left | 3146 | modelLine := pad + left | ||
| 3142 | lines := []string{busyLine} | 3147 | lines := []string{busyLine} | ||
| 3143 | if visibleWidth(modelLine+pad+middle) > p.Cols { | 3148 | if visibleWidth(modelLine+pad+middle) > p.Cols { | ||
| 3144 | - | lines = appendWrappedStatusLines(lines, th, pad, p.Model, reasoning, fastText, stats, p.Cols) | 3149 | + | lines = appendWrappedStatusLines(lines, th, pad, modelLabel, reasoning, fastText, stats, p.Cols) |
| 3145 | } else { | 3150 | } else { | ||
| 3146 | var infoBuilder strings.Builder | 3151 | var infoBuilder strings.Builder | ||
| 3147 | infoBuilder.WriteString(modelLine) | 3152 | infoBuilder.WriteString(modelLine) | ||
| @@ -3163,7 +3168,7 @@ func StatusBar(p StatusBarParams) []string { | |||||
| 3163 | // into an awkward position on small widths. | 3168 | // into an awkward position on small widths. | ||
| 3164 | if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols { | 3169 | if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols { | ||
| 3165 | var lines []string | 3170 | var lines []string | ||
| 3166 | - | lines = appendWrappedStatusLines(lines, th, pad, p.Model, reasoning, fastText, stats, p.Cols) | 3171 | + | lines = appendWrappedStatusLines(lines, th, pad, modelLabel, reasoning, fastText, stats, p.Cols) |
| 3167 | if cwd != "" { | 3172 | if cwd != "" { | ||
| 3168 | lines = append(lines, pad+th.FGColor(th.Muted, cwd)) | 3173 | lines = append(lines, pad+th.FGColor(th.Muted, cwd)) | ||
| 3169 | } | 3174 | } | ||