detached 6990358

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 UnverifiedB5690EEEBB952194 The 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
326326 | `/login` | Log in via API key or subscription (opens a dialog). |
327327 | `/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. |
328328 | `/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). |
329330 | `/reasoning` | Set the reasoning level for subsequent model calls. |
330331 | `/fast` | Toggle fast mode for subsequent model calls. |
331332 | `/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
468469 - **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`.
469470 - **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`.
470471 - **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.
472500
473501 ### `/skills`
474502
@@ -942,7 +970,7 @@ Slash commands also work while the agent is busy. Non-destructive ones (`/help`,
942970 | `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. |
943971 | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash, create_worktree, grep, lsp, and web_search outputs over ~12 lines). |
944972 | `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. |
946974 | `@` | Open the file picker. Browse files and directories in the working directory. |
947975
948976 ### 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 {
494494 // hard error (used by print/json modes).
495495 func Resolve(args Args, requireCred bool) (Resolved, error) {
496496 cfg, cfgErr := LoadConfig()
497+ profileSlot := cfg.applyActiveModelProfile()
498+ repairConfig := false
497499 // Programmatic callers historically supplied Tools without the parser's
498500 // provenance bit. Treat a non-empty list as explicit before a named profile
499501 // can contribute its defaults.
@@ -534,7 +536,7 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
534536 // Reset the saved config so this doesn't keep happening.
535537 cfg.Provider = provName
536538 cfg.Model = ""
537- _ = SaveConfig(cfg)
539+ repairConfig = true
538540 }
539541
540542 var (
@@ -702,13 +704,27 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
702704 model, fm.ID)
703705 if args.Model == "" && cfg.Model == model {
704706 cfg.Model = fm.ID
705- _ = SaveConfig(cfg)
707+ repairConfig = true
706708 }
707709 resolvedModel = fm
708710 model = fm.ID
709711 }
710712 }
711713
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+
712728 explicitBaseURL := args.BaseURL != "" || (resolvedModel.Source == "user" && resolvedModel.BaseURL != "")
713729
714730 // 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's499 // Programmatic callers historically supplied Tools without the parser's
498 // provenance bit. Treat a non-empty list as explicit before a named profile500 // 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 = provName537 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.ID706 cfg.Model = fm.ID
705- _ = SaveConfig(cfg)707+ repairConfig = true
706 }708 }
707 resolvedModel = fm709 resolvedModel = fm
708 model = fm.ID710 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 the730 // 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,
14891489
14901490 // ---- interactive mode: opens the TUI even without credentials ----
14911491
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+
14921501 func runInteractive(ctx context.Context, args Args, version string) (runErr error) {
14931502 initialCfg, _ := LoadConfig()
14941503 if initialCfg.Goals.MaxTokenBudget != nil && *initialCfg.Goals.MaxTokenBudget == 0 {
@@ -1499,6 +1508,12 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
14991508 if err != nil {
15001509 return err
15011510 }
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+ }
15021517
15031518 authStore := AuthStoreFor()
15041519 mgr := auth.NewManager(authStore)
@@ -2515,10 +2530,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
25152530 }
25162531
25172532 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)
25222534 themeEnv := strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_THEME")))
25232535 if themeEnv != "" && themeEnv != "auto" && themeEnv != "dark" && themeEnv != "light" {
25242536 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
25892601 GoalMaxTokenBudget: initialCfg.Goals.MaxTokenBudget,
25902602 JailByDefault: initialCfg.JailByDefault,
25912603 QuickModelShortcuts: quickModelShortcuts,
2604+ ActiveModelProfile: initialCfg.ActiveModelProfile,
25922605 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
25932606 RespectGitignore: initialCfg.RespectGitignore,
25942607 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 err1509 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.FastMode2532 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 (
1818
1919 // QuickModelShortcut is one configured keyboard shortcut slot.
2020 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"`
2324 }
2425
2526 // SubagentsConfig contains the resident manager policy. max_concurrent limits
@@ -69,6 +70,7 @@ type Config struct {
6970 // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by
7071 // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super.
7172 QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"`
73+ ActiveModelProfile int `json:"active_model_profile,omitempty"`
7274
7375 // InlineImagesEnabled controls whether zut draws screenshots inline
7476 // when the terminal supports an image protocol. nil/missing means
@@ -321,6 +323,35 @@ func LoadConfig() (Config, error) {
321323 return c, nil
322324 }
323325
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+
324355 func validateSubagentConfig(cfg SubagentsConfig) error {
325356 values := []struct {
326357 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 limits26 // 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 by70 // 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 inline75 // InlineImagesEnabled controls whether zut draws screenshots inline
74 // when the terminal supports an image protocol. nil/missing means76 // when the terminal supports an image protocol. nil/missing means
@@ -321,6 +323,35 @@ func LoadConfig() (Config, error) {
321 return c, nil323 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 string357 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{
2323 {"ctrl+l", "redraw the screen"},
2424 {"ctrl+o", "expand / collapse long tool results"},
2525 {"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"},
2627 {"pgup / pgdn", "scroll the chat one page up / down"},
2728 {"up / down", "move in input, browse prompt history at top edge, scroll chat when empty"},
2829 }
@@ -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 {
175175 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal
176176 // forwards Command/Super keypresses, but Ctrl is the displayed chord.
177177 QuickModelShortcuts []QuickModelShortcut
178+ ActiveModelProfile int // 0 means no active profile; otherwise 1–9
178179
179180 // ExtensionThemes returns themes bundled with loaded extensions.
180181 ExtensionThemes func() []tui.ThemeOption
@@ -480,8 +481,9 @@ type chatCacheKey struct {
480481
481482 // QuickModelShortcut is one configured quick model switch slot.
482483 type QuickModelShortcut struct {
483- Provider string
484- Model string
484+ Provider string
485+ Model string
486+ Reasoning string
485487 }
486488
487489 type extensionStatus struct {
@@ -511,6 +513,12 @@ type SettingsStore interface {
511513 SetTheme(name string) error
512514 }
513515
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+
514522 type terminalAlertsSettingsStore interface {
515523 SetTerminalAlertsEnabled(enabled bool) error
516524 }
@@ -833,6 +841,7 @@ type Interactive struct {
833841 settingsDialog *settingsDialog
834842 floatingPane tui.FloatingPane
835843 quickModelAssign int
844+ quickModelActivate bool // picker was opened by an empty-slot shortcut
836845 telegramBridge *telegram.Bridge
837846 sessionOpsDialog *sessionOpsDialog
838847 sessionTreeDialog *sessionTreeDialog
@@ -980,6 +989,21 @@ type startupPreResult struct {
980989
981990 // NewInteractive constructs an Interactive from cfg.
982991 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+ }
9831007 renderer := tui.NewRenderer(cfg.Terminal)
9841008 renderer.SetTheme(cfg.Theme)
9851009 startupAgentName := ""
@@ -175,6 +175,7 @@ type InteractiveConfig struct {
175 // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal175 // 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 []QuickModelShortcut177 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.ThemeOption181 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 string484+ Provider string
484- Model string485+ 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) error513 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) error523 SetTerminalAlertsEnabled(enabled bool) error
516 }524 }
@@ -833,6 +841,7 @@ type Interactive struct {
833 settingsDialog *settingsDialog841 settingsDialog *settingsDialog
834 floatingPane tui.FloatingPane842 floatingPane tui.FloatingPane
835 quickModelAssign int843 quickModelAssign int
844+ quickModelActivate bool // picker was opened by an empty-slot shortcut
836 telegramBridge *telegram.Bridge845 telegramBridge *telegram.Bridge
837 sessionOpsDialog *sessionOpsDialog846 sessionOpsDialog *sessionOpsDialog
838 sessionTreeDialog *sessionTreeDialog847 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) {
299299 i.statusOK = ""
300300 i.mu.Unlock()
301301 }
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'))
302310 case "/reasoning":
303311 i.openReasoningDialog()
304312 case "/fast":
@@ -1101,7 +1109,11 @@ func (i *Interactive) applyRescueModelSelection(prov, model string) {
11011109 func (i *Interactive) swapModel(prov, model string, builder func(string, string) (*core.Agent, string, string, error), rescue bool) {
11021110 var replaced bool
11031111 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+ }
11051117 }
11061118 if i.cfg.SessionTransition != nil {
11071119 i.cfg.SessionTransition(swap)
@@ -1112,16 +1124,19 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
11121124 i.resetCompactHandoff()
11131125 }
11141126 }
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) {
11161131 if model == "" {
1117- return false
1132+ return false, false
11181133 }
11191134 m, err := provider.FindModel(prov, model)
11201135 if err != nil {
11211136 i.mu.Lock()
11221137 i.statusErr = err.Error()
11231138 i.mu.Unlock()
1124- return false
1139+ return false, false
11251140 }
11261141 // Same provider AND not a rescue retry: just swap the model on
11271142 // the existing agent. Mixed-API providers dispatch from model metadata,
@@ -1137,13 +1152,13 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
11371152 if i.cfg.PersistModel != nil {
11381153 i.cfg.PersistModel(i.cfg.Provider, m.ID)
11391154 }
1140- return false
1155+ return false, true
11411156 }
11421157 if builder == nil {
11431158 i.mu.Lock()
11441159 i.statusErr = "cannot switch provider: no builder configured"
11451160 i.mu.Unlock()
1146- return false
1161+ return false, false
11471162 }
11481163 // Snapshot the current transcript and cumulative usage BEFORE we
11491164 // 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
11611176 i.mu.Lock()
11621177 i.statusErr = err.Error()
11631178 i.mu.Unlock()
1164- return false
1179+ return false, false
11651180 }
11661181
11671182 // 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
12041219 if i.cfg.PersistModel != nil {
12051220 i.cfg.PersistModel(p, md)
12061221 }
1207- return true
1222+ return true, true
12081223 }
12091224 func (i *Interactive) handleAuthEvent(ev auth.Event) {
12101225 switch ev.Kind {
@@ -1234,6 +1249,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
12341249 i.agent = ag
12351250 i.cfg.Provider = prov
12361251 i.cfg.Model = model
1252+ i.cfg.detachMismatchedModelProfile()
12371253 i.statusErr = ""
12381254 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
12391255 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 bool1110 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 false1132+ 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 false1139+ return false, false
1125 }1140 }
1126 // Same provider AND not a rescue retry: just swap the model on1141 // 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 false1155+ 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 false1161+ return false, false
1147 }1162 }
1148 // Snapshot the current transcript and cumulative usage BEFORE we1163 // Snapshot the current transcript and cumulative usage BEFORE we
1149 // build the replacement agent so we can hand them off. Without1164 // 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 false1179+ return false, false
1165 }1180 }
1166 1181
1167 // Replay the transcript and seed the cost on the freshly-built1182 // 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 true1222+ 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 = ag1249 i.agent = ag
1235 i.cfg.Provider = prov1250 i.cfg.Provider = prov
1236 i.cfg.Model = model1251 i.cfg.Model = model
1252+ i.cfg.detachMismatchedModelProfile()
1237 i.statusErr = ""1253 i.statusErr = ""
1238 i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method1254 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
105105 i.agent = ag
106106 i.cfg.Provider = providerName
107107 i.cfg.Model = model
108+ i.cfg.detachMismatchedModelProfile()
108109 i.view.Messages = filterHiddenTranscriptMessages(ag.Messages())
109110 i.cumUsage = ag.Cost()
110111 last := ag.LastTurnUsage()
@@ -192,6 +193,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
192193 }
193194 i.cfg.Provider = provider
194195 i.cfg.Model = model
196+ i.cfg.detachMismatchedModelProfile()
195197 titleCancel := i.titleCancel
196198 i.titleCancel = nil
197199 i.titleVersion++
@@ -105,6 +105,7 @@ func (i *Interactive) ApplySessionAgentWithCompactHandoff(ag *core.Agent, provid
105 i.agent = ag105 i.agent = ag
106 i.cfg.Provider = providerName106 i.cfg.Provider = providerName
107 i.cfg.Model = model107 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 = provider194 i.cfg.Provider = provider
194 i.cfg.Model = model195 i.cfg.Model = model
196+ i.cfg.detachMismatchedModelProfile()
195 titleCancel := i.titleCancel197 titleCancel := i.titleCancel
196 i.titleCancel = nil198 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) {
159159 if k.Kind == tui.KeyCtrlC {
160160 i.modelDialog.Close()
161161 i.quickModelAssign = 0
162+ i.quickModelActivate = false
162163 return false
163164 }
164165 act := i.modelDialog.HandleKey(k)
165166 if act.Close {
166167 i.quickModelAssign = 0
168+ i.quickModelActivate = false
167169 }
168- if act.ReasoningChanged {
170+ if act.ReasoningChanged && i.quickModelAssign == 0 {
169171 i.applyReasoningSetting(act.Reasoning)
170172 }
171173 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 = 0161 i.quickModelAssign = 0
162+ i.quickModelActivate = false
162 return false163 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 = 0167 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) {
7373 if !slices.Equal(changed, []string{"xhigh", "max", ""}) {
7474 t.Fatalf("reasoning callbacks = %q", changed)
7575 }
76- if i.statusOK != "reasoning level off" {
76+ if i.statusOK != "reasoning level off — profile 1 updated" {
7777 t.Fatalf("reasoning status = %q", i.statusOK)
7878 }
7979 }
@@ -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() {
673673 Provider: i.cfg.Provider,
674674 Model: i.cfg.Model,
675675 Reasoning: i.cfg.Reasoning,
676+ ModelProfile: i.cfg.ActiveModelProfile,
676677 FastMode: i.cfg.FastMode != nil && *i.cfg.FastMode,
677678 Busy: i.busy,
678679 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() {
408408 if len(quickItems) > 0 {
409409 items = append(items, settingsItem{
410410 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",
413413 children: quickItems,
414414 })
415415 }
@@ -479,12 +479,15 @@ func (i *Interactive) quickModelSettingItem(slot int) settingsItem {
479479 }
480480 hint := "not assigned"
481481 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)"
483486 }
484487 return settingsItem{
485488 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.",
488491 picker: true,
489492 hint: hint,
490493 }
@@ -493,7 +496,7 @@ func (i *Interactive) compactModeEnabled() bool {
493496 return i.cfg.CompactMode != nil && *i.cfg.CompactMode
494497 }
495498 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 {
497500 return 0
498501 }
499502 if runtime.GOOS == "darwin" {
@@ -503,7 +506,17 @@ func quickModelShortcutSlot(k tui.Key) int {
503506 } else if !k.Ctrl {
504507 return 0
505508 }
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
507520 }
508521 func quickModelShortcutPrefix() string {
509522 return "Ctrl"
@@ -516,18 +529,26 @@ func (i *Interactive) openQuickModelPicker(slot int) {
516529 return
517530 }
518531 i.quickModelAssign = slot
519- current := i.cfg.Model
532+ i.quickModelActivate = false
533+ current, reasoning := i.cfg.Model, i.cfg.Reasoning
520534 if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" {
521535 current = i.cfg.QuickModelShortcuts[slot-1].Model
536+ reasoning = i.cfg.QuickModelShortcuts[slot-1].Reasoning
522537 }
523538 var loggedIn []string
524539 if i.cfg.LoggedInProviders != nil {
525540 loggedIn = i.cfg.LoggedInProviders()
526541 }
527- i.modelDialog.Open(current, loggedIn, i.cfg.Reasoning)
542+ i.modelDialog.Open(current, loggedIn, reasoning)
528543 }
529544 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
531552 }
532553 func (i *Interactive) applyQuickModelShortcut(slot int) {
533554 if slot < 1 || slot > 9 {
@@ -541,25 +562,21 @@ func (i *Interactive) applyQuickModelShortcut(slot int) {
541562 i.invalidate()
542563 return
543564 }
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]
551568 }
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
554572 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)
557575 i.mu.Unlock()
558576 i.invalidate()
559577 return
560578 }
561- i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false)
562- i.invalidate()
579+ i.activateModelProfile(slot, p)
563580 }
564581 func (i *Interactive) applyQuickModelSetting(key, value string) {
565582 slotText := strings.TrimPrefix(key, "quick_model_")
@@ -577,30 +594,7 @@ func (i *Interactive) applyQuickModelSetting(key, value string) {
577594 i.setQuickModelShortcut(slot, providerName, model)
578595 }
579596 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})
604598 }
605599 func (i *Interactive) refreshQuickModelSettingsItem(slot int) {
606600 if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 {
@@ -1258,8 +1252,12 @@ func (i *Interactive) applyReasoningSetting(level string) {
12581252 i.invalidate()
12591253 }()
12601254 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 {
12631261 if err := i.cfg.SettingsStore.SetReasoning(level); err != nil {
12641262 i.mu.Lock()
12651263 i.statusErr = "settings: " + err.Error()
@@ -1267,18 +1265,16 @@ func (i *Interactive) applyReasoningSetting(level string) {
12671265 return
12681266 }
12691267 }
1270- if i.cfg.OnReasoningChanged != nil {
1271- i.cfg.OnReasoningChanged(level)
1272- }
1268+ i.setLiveReasoning(level)
12731269 i.mu.Lock()
1274- if i.agent != nil {
1275- i.agent.Reasoning = level
1276- }
12771270 label := level
12781271 if label == "" {
12791272 label = "off"
12801273 }
12811274 i.statusOK = "reasoning level " + label
1275+ if i.cfg.ActiveModelProfile > 0 {
1276+ i.statusOK += fmt.Sprintf(" — profile %d updated", i.cfg.ActiveModelProfile)
1277+ }
12821278 i.statusErr = ""
12831279 i.mu.Unlock()
12841280 }
@@ -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.Model482+ 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.CompactMode496 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 0500 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 0507 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 return529 return
517 }530 }
518 i.quickModelAssign = slot531 i.quickModelAssign = slot
519- current := i.cfg.Model532+ 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].Model535 current = i.cfg.QuickModelShortcuts[slot-1].Model
536+ reasoning = i.cfg.QuickModelShortcuts[slot-1].Reasoning
522 }537 }
523 var loggedIn []string538 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 return563 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 return577 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 = level1255+ 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 return1265 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 := level1270 label := level
1278 if label == "" {1271 if label == "" {
1279 label = "off"1272 label = "off"
1280 }1273 }
1281 i.statusOK = "reasoning level " + label1274 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{
5252 {Name: "/logout", Desc: "clear a provider's credentials"},
5353 {Name: "/model", Desc: "pick a model (or /model <id>)"},
5454 {Name: "/reasoning", Desc: "set the reasoning level"},
55+ {Name: "/profile", Desc: "activate model + reasoning profile (/profile <1-9>)"},
5556 {Name: "/fast", Desc: "toggle fast mode"},
5657 {Name: "/orchestrator", Desc: "toggle automatic subagent orchestration"},
5758 {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
33 import (
44 "fmt"
55
6+ "github.com/bnema/zut/packages/agent/modes"
67 "github.com/bnema/zut/packages/provider"
78 "github.com/bnema/zut/packages/tui"
89 )
910
1011 type configSettingsStore struct{}
1112
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 {
1315 if slot < 1 || slot > 9 {
1416 return nil
1517 }
@@ -17,12 +19,34 @@ func (configSettingsStore) SetQuickModelShortcut(slot int, providerName, model s
1719 if err != nil {
1820 return err
1921 }
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+ }
2036 if len(cfg.QuickModelShortcuts) < slot {
2137 next := make([]QuickModelShortcut, slot)
2238 copy(next, cfg.QuickModelShortcuts)
2339 cfg.QuickModelShortcuts = next
2440 }
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+ }
2650 // Trim trailing empty slots so config.json stays compact.
2751 for len(cfg.QuickModelShortcuts) > 0 {
2852 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 nil16 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 err20 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 = next39 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 (
44 "strconv"
55 "strings"
66 "time"
7+ "unicode"
78 )
89
910 // Key is a parsed keypress.
@@ -411,7 +412,10 @@ func parseCSIU(params string) (Key, bool) {
411412 if len(parts) == 0 {
412413 return Key{}, false
413414 }
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)
415419 if err != nil {
416420 return Key{}, false
417421 }
@@ -506,7 +510,10 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
506510 if code >= '0' && code <= '9' {
507511 return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
508512 }
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) {
510517 return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
511518 }
512519 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{}, false413 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{}, false420 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}, true511 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}, true517 return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
511 }518 }
512 return Key{}, false519 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 {
29762976 // StatusBarParams groups the many bits of state the status bar needs.
29772977 // Grew from a flat argument list once we settled on the layout.
29782978 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
29922993
29932994 // Cumulative session usage and cost.
29942995 Usage provider.Usage
@@ -3076,7 +3077,11 @@ func StatusBar(p StatusBarParams) []string {
30763077 const pad = " " // 2 spaces
30773078
30783079 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)
30803085 fastText := ""
30813086 if p.FastMode {
30823087 fastText = th.FGColor(th.Muted, "fast mode")
@@ -3141,7 +3146,7 @@ func StatusBar(p StatusBarParams) []string {
31413146 modelLine := pad + left
31423147 lines := []string{busyLine}
31433148 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)
31453150 } else {
31463151 var infoBuilder strings.Builder
31473152 infoBuilder.WriteString(modelLine)
@@ -3163,7 +3168,7 @@ func StatusBar(p StatusBarParams) []string {
31633168 // into an awkward position on small widths.
31643169 if p.Cols > 0 && p.BusyPrefix == "" && visibleWidth(primary) > p.Cols {
31653170 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)
31673172 if cwd != "" {
31683173 lines = append(lines, pad+th.FGColor(th.Muted, cwd))
31693174 }
@@ -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 Theme2979+ Theme Theme
2980- Provider string2980+ Provider string
2981- Model string2981+ Model string
2982- Reasoning string // "" means thinking off2982+ Reasoning string // "" means thinking off
2983- FastMode bool // show the provider's opt-in fast tier when enabled2983+ ModelProfile int // 1–9 identifies the active model/reasoning favorite
2984- Busy bool2984+ FastMode bool // show the provider's opt-in fast tier when enabled
2985- BusyPrefix string // spinner + funny line when busy2985+ Busy bool
2986- CWD string2986+ 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 none2989+ NoYolo bool // confirmation mode enabled?
2990- PlanCurrent int // one-based in-progress plan step; 0 hides plan progress2990+ GoalStatus string // autonomous goal lifecycle, empty when none
2991- PlanTotal int2991+ 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.Usage2995 Usage provider.Usage
@@ -3076,7 +3077,11 @@ func StatusBar(p StatusBarParams) []string {
3076 const pad = " " // 2 spaces3077 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 + left3146 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.Builder3151 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 []string3170 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 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close