Add quick model switch shortcuts (Ctrl+1..9) with /settings model shortcuts sub-view
- commit
- cf7ddf53228dc6286400d65461bae5efbbd0d566
- author
- patriceckhart · · 2026-06-23 04:56 UTC
- parent
- 4bec50a
- signature
- unsigned
- refs
- tag: v0.2.45
8 files · +389 -43
M README.md +3 -1
| @@ -297,12 +297,13 @@ Background subagents that run alongside your main session. Each one is a separat | |||
| 297 | 297 | ||
| 298 | 298 | ### `/settings` | |
| 299 | 299 | ||
| 300 | - | Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close. Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings: | |
| 300 | + | Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings: | |
| 301 | 301 | ||
| 302 | 302 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | |
| 303 | 303 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | |
| 304 | 304 | - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call. | |
| 305 | 305 | - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | |
| 306 | + | - **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. | |
| 306 | 307 | ||
| 307 | 308 | ### `/skills` | |
| 308 | 309 | ||
| @@ -578,6 +579,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | |||
| 578 | 579 | | `ctrl+l` | Redraw the screen. | | |
| 579 | 580 | | `ctrl+v` | Paste an image from the system clipboard through zot on macOS. Images are saved as temporary PNGs and attached to the next prompt. Other platforms currently use a no-op stub. Use your terminal/OS paste shortcut for text. | | |
| 580 | 581 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | |
| 582 | + | | `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. | | |
| 581 | 583 | | `@` | Open the file picker. Browse files and directories in the working directory. | | |
| 582 | 584 | ||
| 583 | 585 | ### File picker (`@`) | |
| @@ -297,12 +297,13 @@ Background subagents that run alongside your main session. Each one is a separat | |||||
| 297 | 297 | ||||
| 298 | ### `/settings` | 298 | ### `/settings` | ||
| 299 | 299 | ||||
| 300 | - | Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close. Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings: | 300 | + | Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` or `space` to change the selected row, `esc` to close (rows that open a sub-view, like model shortcuts, use `esc` to go back one level first). Changes are written to `$ZOT_HOME/config.json` and take effect on the next turn (no restart needed). Current settings: |
| 301 | 301 | ||||
| 302 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | 302 | - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol. | ||
| 303 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | 303 | - **auto-swarm** — let the main agent spawn background sub-agents in parallel via a built-in `swarm_spawn` tool. Off by default. When on, the tool is registered with the running agent, the system prompt gains a short addendum telling the model to delegate independent sub-tasks proactively, and zot watches every sub-agent the main agent spawns. As soon as the last sub-agent in a batch finishes its initial task, an `[auto-swarm update]` message is injected back into the chat with each agent's status / task / transcript tail, so the main agent can summarise the collective outcome. Flipping off mid-session removes the tool from the live agent and strips the addendum on the next turn — the model stops trying to delegate. See `/swarm` for the dashboard that lets you monitor, message, kill, or remove the spawned agents. | ||
| 304 | - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call. | 304 | - **thinking level** — choose reasoning for supported models: off (default; no reasoning), minimum (~1k tokens), low (~2k), medium (~8k), high (~16k), maximum (~32k). The change is persisted to `config.json` and applied to the running agent's next model call. | ||
| 305 | - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | 305 | - **color theme** — choose the built-in auto/dark/light theme or any JSON theme discovered under `$ZOT_HOME/themes` or a loaded extension. Theme files can override any subset of UI colors, syntax colors, and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zot resets to auto. See [docs/themes.md](docs/themes.md). | ||
| 306 | + | - **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. | |||
| 306 | 307 | ||||
| 307 | ### `/skills` | 308 | ### `/skills` | ||
| 308 | 309 | ||||
| @@ -578,6 +579,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | |||||
| 578 | | `ctrl+l` | Redraw the screen. | | 579 | | `ctrl+l` | Redraw the screen. | | ||
| 579 | | `ctrl+v` | Paste an image from the system clipboard through zot on macOS. Images are saved as temporary PNGs and attached to the next prompt. Other platforms currently use a no-op stub. Use your terminal/OS paste shortcut for text. | | 580 | | `ctrl+v` | Paste an image from the system clipboard through zot on macOS. Images are saved as temporary PNGs and attached to the next prompt. Other platforms currently use a no-op stub. Use your terminal/OS paste shortcut for text. | | ||
| 580 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | 581 | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | | ||
| 582 | + | | `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. | | |||
| 581 | | `@` | Open the file picker. Browse files and directories in the working directory. | | 583 | | `@` | Open the file picker. Browse files and directories in the working directory. | | ||
| 582 | 584 | ||||
| 583 | ### File picker (`@`) | 585 | ### File picker (`@`) | ||
M packages/agent/cli.go +5 -0
| @@ -931,6 +931,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 931 | 931 | }() | |
| 932 | 932 | ||
| 933 | 933 | initialCfg, _ := LoadConfig() | |
| 934 | + | quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) | |
| 935 | + | for idx, s := range initialCfg.QuickModelShortcuts { | |
| 936 | + | quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} | |
| 937 | + | } | |
| 934 | 938 | theme, _, themeErr := tui.DetectThemeWithCustom(ZotHome(), initialCfg.Theme, 80*time.Millisecond) | |
| 935 | 939 | if themeErr != nil { | |
| 936 | 940 | fmt.Fprintln(os.Stderr, "theme load:", themeErr) | |
| @@ -958,6 +962,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 958 | 962 | Theme: theme, | |
| 959 | 963 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 960 | 964 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 965 | + | QuickModelShortcuts: quickModelShortcuts, | |
| 961 | 966 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 962 | 967 | RespectGitignore: initialCfg.RespectGitignore, | |
| 963 | 968 | ThemeName: initialCfg.Theme, | |
| @@ -931,6 +931,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 931 | }() | 931 | }() | ||
| 932 | 932 | ||||
| 933 | initialCfg, _ := LoadConfig() | 933 | initialCfg, _ := LoadConfig() | ||
| 934 | + | quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) | |||
| 935 | + | for idx, s := range initialCfg.QuickModelShortcuts { | |||
| 936 | + | quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} | |||
| 937 | + | } | |||
| 934 | theme, _, themeErr := tui.DetectThemeWithCustom(ZotHome(), initialCfg.Theme, 80*time.Millisecond) | 938 | theme, _, themeErr := tui.DetectThemeWithCustom(ZotHome(), initialCfg.Theme, 80*time.Millisecond) | ||
| 935 | if themeErr != nil { | 939 | if themeErr != nil { | ||
| 936 | fmt.Fprintln(os.Stderr, "theme load:", themeErr) | 940 | fmt.Fprintln(os.Stderr, "theme load:", themeErr) | ||
| @@ -958,6 +962,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 958 | Theme: theme, | 962 | Theme: theme, | ||
| 959 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | 963 | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | ||
| 960 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | 964 | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | ||
| 965 | + | QuickModelShortcuts: quickModelShortcuts, | |||
| 961 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | 966 | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | ||
| 962 | RespectGitignore: initialCfg.RespectGitignore, | 967 | RespectGitignore: initialCfg.RespectGitignore, | ||
| 963 | ThemeName: initialCfg.Theme, | 968 | ThemeName: initialCfg.Theme, | ||
M packages/agent/config.go +10 -0
| @@ -15,6 +15,12 @@ import ( | |||
| 15 | 15 | "github.com/patriceckhart/zot/packages/provider/auth" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | + | // QuickModelShortcut is one configured keyboard shortcut slot. | |
| 19 | + | type QuickModelShortcut struct { | |
| 20 | + | Provider string `json:"provider"` | |
| 21 | + | Model string `json:"model"` | |
| 22 | + | } | |
| 23 | + | ||
| 18 | 24 | // Config is the persisted user configuration. | |
| 19 | 25 | type Config struct { | |
| 20 | 26 | Provider string `json:"provider"` | |
| @@ -23,6 +29,10 @@ type Config struct { | |||
| 23 | 29 | Temperature *float32 `json:"temperature,omitempty"` | |
| 24 | 30 | Theme string `json:"theme"` | |
| 25 | 31 | ||
| 32 | + | // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by | |
| 33 | + | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | |
| 34 | + | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | |
| 35 | + | ||
| 26 | 36 | // InlineImagesEnabled controls whether zot draws screenshots inline | |
| 27 | 37 | // when the terminal supports an image protocol. nil/missing means | |
| 28 | 38 | // auto (enabled when supported); false disables; true forces the | |
| @@ -15,6 +15,12 @@ import ( | |||||
| 15 | "github.com/patriceckhart/zot/packages/provider/auth" | 15 | "github.com/patriceckhart/zot/packages/provider/auth" | ||
| 16 | ) | 16 | ) | ||
| 17 | 17 | ||||
| 18 | + | // QuickModelShortcut is one configured keyboard shortcut slot. | |||
| 19 | + | type QuickModelShortcut struct { | |||
| 20 | + | Provider string `json:"provider"` | |||
| 21 | + | Model string `json:"model"` | |||
| 22 | + | } | |||
| 23 | + | ||||
| 18 | // Config is the persisted user configuration. | 24 | // Config is the persisted user configuration. | ||
| 19 | type Config struct { | 25 | type Config struct { | ||
| 20 | Provider string `json:"provider"` | 26 | Provider string `json:"provider"` | ||
| @@ -23,6 +29,10 @@ type Config struct { | |||||
| 23 | Temperature *float32 `json:"temperature,omitempty"` | 29 | Temperature *float32 `json:"temperature,omitempty"` | ||
| 24 | Theme string `json:"theme"` | 30 | Theme string `json:"theme"` | ||
| 25 | 31 | ||||
| 32 | + | // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by | |||
| 33 | + | // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. | |||
| 34 | + | QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` | |||
| 35 | + | ||||
| 26 | // InlineImagesEnabled controls whether zot draws screenshots inline | 36 | // InlineImagesEnabled controls whether zot draws screenshots inline | ||
| 27 | // when the terminal supports an image protocol. nil/missing means | 37 | // when the terminal supports an image protocol. nil/missing means | ||
| 28 | // auto (enabled when supported); false disables; true forces the | 38 | // auto (enabled when supported); false disables; true forces the | ||
M packages/agent/modes/interactive.go +213 -6
| @@ -6,6 +6,8 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "os" | |
| 8 | 8 | "path/filepath" | |
| 9 | + | "runtime" | |
| 10 | + | "strconv" | |
| 9 | 11 | "strings" | |
| 10 | 12 | "sync" | |
| 11 | 13 | "time" | |
| @@ -58,6 +60,12 @@ type InteractiveConfig struct { | |||
| 58 | 60 | ||
| 59 | 61 | // ThemeName mirrors the persisted config theme value. Empty means auto. | |
| 60 | 62 | ThemeName string | |
| 63 | + | ||
| 64 | + | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | |
| 65 | + | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | |
| 66 | + | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | |
| 67 | + | QuickModelShortcuts []QuickModelShortcut | |
| 68 | + | ||
| 61 | 69 | // ExtensionThemes returns themes bundled with loaded extensions. | |
| 62 | 70 | ExtensionThemes func() []tui.ThemeOption | |
| 63 | 71 | ||
| @@ -234,8 +242,15 @@ type chatCacheKey struct { | |||
| 234 | 242 | tailLimit int | |
| 235 | 243 | } | |
| 236 | 244 | ||
| 245 | + | // QuickModelShortcut is one configured quick model switch slot. | |
| 246 | + | type QuickModelShortcut struct { | |
| 247 | + | Provider string | |
| 248 | + | Model string | |
| 249 | + | } | |
| 250 | + | ||
| 237 | 251 | // SettingsStore persists user-toggleable settings surfaced by /settings. | |
| 238 | 252 | type SettingsStore interface { | |
| 253 | + | SetQuickModelShortcut(slot int, providerName, model string) error | |
| 239 | 254 | SetInlineImages(enabled bool) error | |
| 240 | 255 | SetAutoSwarm(enabled bool) error | |
| 241 | 256 | SetRecursiveFileSuggest(enabled bool) error | |
| @@ -353,6 +368,7 @@ type Interactive struct { | |||
| 353 | 368 | logoutDialog *logoutDialog | |
| 354 | 369 | telegramDialog *telegramDialog | |
| 355 | 370 | settingsDialog *settingsDialog | |
| 371 | + | quickModelAssign int | |
| 356 | 372 | telegramBridge *telegram.Bridge | |
| 357 | 373 | sessionOpsDialog *sessionOpsDialog | |
| 358 | 374 | sessionTreeDialog *sessionTreeDialog | |
| @@ -1731,11 +1747,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1731 | 1747 | if i.modelDialog.Active() { | |
| 1732 | 1748 | if k.Kind == tui.KeyCtrlC { | |
| 1733 | 1749 | i.modelDialog.Close() | |
| 1750 | + | i.quickModelAssign = 0 | |
| 1734 | 1751 | return false | |
| 1735 | 1752 | } | |
| 1736 | 1753 | act := i.modelDialog.HandleKey(k) | |
| 1754 | + | if act.Close { | |
| 1755 | + | i.quickModelAssign = 0 | |
| 1756 | + | } | |
| 1737 | 1757 | if act.Select { | |
| 1738 | - | i.applyModelSelection(act.Provider, act.Model) | |
| 1758 | + | if i.quickModelAssign > 0 { | |
| 1759 | + | i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model) | |
| 1760 | + | i.quickModelAssign = 0 | |
| 1761 | + | } else { | |
| 1762 | + | i.applyModelSelection(act.Provider, act.Model) | |
| 1763 | + | } | |
| 1739 | 1764 | } | |
| 1740 | 1765 | return false | |
| 1741 | 1766 | } | |
| @@ -1818,6 +1843,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1818 | 1843 | return false | |
| 1819 | 1844 | } | |
| 1820 | 1845 | act := i.settingsDialog.HandleKey(k) | |
| 1846 | + | if act.ModelShortcutSlot > 0 { | |
| 1847 | + | i.openQuickModelPicker(act.ModelShortcutSlot) | |
| 1848 | + | } | |
| 1821 | 1849 | if act.Toggle { | |
| 1822 | 1850 | i.applySettingChange(act) | |
| 1823 | 1851 | } | |
| @@ -1917,6 +1945,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 1917 | 1945 | return false | |
| 1918 | 1946 | } | |
| 1919 | 1947 | ||
| 1948 | + | if slot := quickModelShortcutSlot(k); slot > 0 { | |
| 1949 | + | i.applyQuickModelShortcut(slot) | |
| 1950 | + | return false | |
| 1951 | + | } | |
| 1952 | + | ||
| 1920 | 1953 | // Global keys. | |
| 1921 | 1954 | switch k.Kind { | |
| 1922 | 1955 | case tui.KeyCtrlC: | |
| @@ -2773,6 +2806,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2773 | 2806 | ||
| 2774 | 2807 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | |
| 2775 | 2808 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | |
| 2809 | + | quickItems := i.quickModelSettingItems() | |
| 2776 | 2810 | ||
| 2777 | 2811 | reasoningOptions := []settingsOption{ | |
| 2778 | 2812 | {value: "", label: "off", desc: "no reasoning"}, | |
| @@ -2820,7 +2854,7 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2820 | 2854 | } | |
| 2821 | 2855 | } | |
| 2822 | 2856 | ||
| 2823 | - | i.settingsDialog.Open([]settingsItem{ | |
| 2857 | + | items := []settingsItem{ | |
| 2824 | 2858 | { | |
| 2825 | 2859 | key: "inline_images_enabled", | |
| 2826 | 2860 | label: "render images when supported", | |
| @@ -2864,20 +2898,193 @@ func (i *Interactive) openSettingsDialog() { | |||
| 2864 | 2898 | options: themeOptions, | |
| 2865 | 2899 | choice: themeChoice, | |
| 2866 | 2900 | }, | |
| 2867 | - | }) | |
| 2901 | + | } | |
| 2902 | + | if len(quickItems) > 0 { | |
| 2903 | + | items = append(items, settingsItem{ | |
| 2904 | + | key: "quick_models", | |
| 2905 | + | label: "model shortcuts", | |
| 2906 | + | desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches", | |
| 2907 | + | children: quickItems, | |
| 2908 | + | }) | |
| 2909 | + | } | |
| 2910 | + | i.settingsDialog.Open(items) | |
| 2868 | 2911 | } | |
| 2869 | 2912 | ||
| 2870 | 2913 | func (i *Interactive) applySettingChange(act settingsAction) { | |
| 2871 | - | switch act.Key { | |
| 2872 | - | case "reasoning": | |
| 2914 | + | switch { | |
| 2915 | + | case strings.HasPrefix(act.Key, "quick_model_"): | |
| 2916 | + | i.applyQuickModelSetting(act.Key, act.StringValue) | |
| 2917 | + | case act.Key == "reasoning": | |
| 2873 | 2918 | i.applyReasoningSetting(act.StringValue) | |
| 2874 | - | case "theme": | |
| 2919 | + | case act.Key == "theme": | |
| 2875 | 2920 | i.applyThemeSetting(act.StringValue) | |
| 2876 | 2921 | default: | |
| 2877 | 2922 | i.applySettingToggle(act.Key, act.Value) | |
| 2878 | 2923 | } | |
| 2879 | 2924 | } | |
| 2880 | 2925 | ||
| 2926 | + | func (i *Interactive) quickModelSettingItems() []settingsItem { | |
| 2927 | + | if len(i.cfg.QuickModelShortcuts) < 9 { | |
| 2928 | + | next := make([]QuickModelShortcut, 9) | |
| 2929 | + | copy(next, i.cfg.QuickModelShortcuts) | |
| 2930 | + | i.cfg.QuickModelShortcuts = next | |
| 2931 | + | } | |
| 2932 | + | items := make([]settingsItem, 0, 9) | |
| 2933 | + | for slot := 1; slot <= 9; slot++ { | |
| 2934 | + | items = append(items, i.quickModelSettingItem(slot)) | |
| 2935 | + | } | |
| 2936 | + | return items | |
| 2937 | + | } | |
| 2938 | + | ||
| 2939 | + | func (i *Interactive) quickModelSettingItem(slot int) settingsItem { | |
| 2940 | + | current := QuickModelShortcut{} | |
| 2941 | + | if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot { | |
| 2942 | + | current = i.cfg.QuickModelShortcuts[slot-1] | |
| 2943 | + | } | |
| 2944 | + | hint := "not assigned" | |
| 2945 | + | if current.Provider != "" && current.Model != "" { | |
| 2946 | + | hint = current.Provider + " / " + current.Model | |
| 2947 | + | } | |
| 2948 | + | return settingsItem{ | |
| 2949 | + | key: "quick_model_" + strconv.Itoa(slot), | |
| 2950 | + | label: "model " + strconv.Itoa(slot), | |
| 2951 | + | desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.", | |
| 2952 | + | picker: true, | |
| 2953 | + | hint: hint, | |
| 2954 | + | } | |
| 2955 | + | } | |
| 2956 | + | ||
| 2957 | + | func quickModelShortcutSlot(k tui.Key) int { | |
| 2958 | + | if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' { | |
| 2959 | + | return 0 | |
| 2960 | + | } | |
| 2961 | + | if runtime.GOOS == "darwin" { | |
| 2962 | + | if !k.Super && !k.Ctrl { | |
| 2963 | + | return 0 | |
| 2964 | + | } | |
| 2965 | + | } else if !k.Ctrl { | |
| 2966 | + | return 0 | |
| 2967 | + | } | |
| 2968 | + | return int(k.Rune - '0') | |
| 2969 | + | } | |
| 2970 | + | ||
| 2971 | + | func quickModelShortcutPrefix() string { | |
| 2972 | + | return "Ctrl" | |
| 2973 | + | } | |
| 2974 | + | ||
| 2975 | + | func quickModelShortcutLabel(slot int) string { | |
| 2976 | + | return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot) | |
| 2977 | + | } | |
| 2978 | + | ||
| 2979 | + | func (i *Interactive) openQuickModelPicker(slot int) { | |
| 2980 | + | if slot < 1 || slot > 9 { | |
| 2981 | + | return | |
| 2982 | + | } | |
| 2983 | + | i.quickModelAssign = slot | |
| 2984 | + | current := i.cfg.Model | |
| 2985 | + | if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" { | |
| 2986 | + | current = i.cfg.QuickModelShortcuts[slot-1].Model | |
| 2987 | + | } | |
| 2988 | + | var loggedIn []string | |
| 2989 | + | if i.cfg.LoggedInProviders != nil { | |
| 2990 | + | loggedIn = i.cfg.LoggedInProviders() | |
| 2991 | + | } | |
| 2992 | + | i.modelDialog.Open(current, loggedIn) | |
| 2993 | + | } | |
| 2994 | + | ||
| 2995 | + | func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) { | |
| 2996 | + | i.setQuickModelShortcut(slot, providerName, model) | |
| 2997 | + | } | |
| 2998 | + | ||
| 2999 | + | func (i *Interactive) applyQuickModelShortcut(slot int) { | |
| 3000 | + | if slot < 1 || slot > 9 { | |
| 3001 | + | return | |
| 3002 | + | } | |
| 3003 | + | if i.busy { | |
| 3004 | + | i.mu.Lock() | |
| 3005 | + | i.statusErr = "cannot switch model while a turn is running" | |
| 3006 | + | i.statusOK = "" | |
| 3007 | + | i.mu.Unlock() | |
| 3008 | + | i.invalidate() | |
| 3009 | + | return | |
| 3010 | + | } | |
| 3011 | + | if len(i.cfg.QuickModelShortcuts) < slot { | |
| 3012 | + | i.mu.Lock() | |
| 3013 | + | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | |
| 3014 | + | i.statusOK = "" | |
| 3015 | + | i.mu.Unlock() | |
| 3016 | + | i.invalidate() | |
| 3017 | + | return | |
| 3018 | + | } | |
| 3019 | + | shortcut := i.cfg.QuickModelShortcuts[slot-1] | |
| 3020 | + | if shortcut.Provider == "" || shortcut.Model == "" { | |
| 3021 | + | i.mu.Lock() | |
| 3022 | + | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | |
| 3023 | + | i.statusOK = "" | |
| 3024 | + | i.mu.Unlock() | |
| 3025 | + | i.invalidate() | |
| 3026 | + | return | |
| 3027 | + | } | |
| 3028 | + | i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false) | |
| 3029 | + | i.invalidate() | |
| 3030 | + | } | |
| 3031 | + | ||
| 3032 | + | func (i *Interactive) applyQuickModelSetting(key, value string) { | |
| 3033 | + | slotText := strings.TrimPrefix(key, "quick_model_") | |
| 3034 | + | slot, err := strconv.Atoi(slotText) | |
| 3035 | + | if err != nil || slot < 1 || slot > 9 { | |
| 3036 | + | return | |
| 3037 | + | } | |
| 3038 | + | providerName, model := "", "" | |
| 3039 | + | if value != "" { | |
| 3040 | + | parts := strings.SplitN(value, "\t", 2) | |
| 3041 | + | if len(parts) == 2 { | |
| 3042 | + | providerName, model = parts[0], parts[1] | |
| 3043 | + | } | |
| 3044 | + | } | |
| 3045 | + | i.setQuickModelShortcut(slot, providerName, model) | |
| 3046 | + | } | |
| 3047 | + | ||
| 3048 | + | func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) { | |
| 3049 | + | if len(i.cfg.QuickModelShortcuts) < slot { | |
| 3050 | + | next := make([]QuickModelShortcut, slot) | |
| 3051 | + | copy(next, i.cfg.QuickModelShortcuts) | |
| 3052 | + | i.cfg.QuickModelShortcuts = next | |
| 3053 | + | } | |
| 3054 | + | i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} | |
| 3055 | + | if i.cfg.SettingsStore != nil { | |
| 3056 | + | if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil { | |
| 3057 | + | i.mu.Lock() | |
| 3058 | + | i.statusErr = "settings: " + err.Error() | |
| 3059 | + | i.mu.Unlock() | |
| 3060 | + | return | |
| 3061 | + | } | |
| 3062 | + | } | |
| 3063 | + | i.mu.Lock() | |
| 3064 | + | if model == "" { | |
| 3065 | + | i.statusOK = quickModelShortcutLabel(slot) + " cleared" | |
| 3066 | + | } else { | |
| 3067 | + | i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model | |
| 3068 | + | } | |
| 3069 | + | i.statusErr = "" | |
| 3070 | + | i.mu.Unlock() | |
| 3071 | + | i.refreshQuickModelSettingsItem(slot) | |
| 3072 | + | i.invalidate() | |
| 3073 | + | } | |
| 3074 | + | ||
| 3075 | + | func (i *Interactive) refreshQuickModelSettingsItem(slot int) { | |
| 3076 | + | if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 { | |
| 3077 | + | return | |
| 3078 | + | } | |
| 3079 | + | key := "quick_model_" + strconv.Itoa(slot) | |
| 3080 | + | for idx, it := range i.settingsDialog.items { | |
| 3081 | + | if it.key == key { | |
| 3082 | + | i.settingsDialog.items[idx] = i.quickModelSettingItem(slot) | |
| 3083 | + | return | |
| 3084 | + | } | |
| 3085 | + | } | |
| 3086 | + | } | |
| 3087 | + | ||
| 2881 | 3088 | func (i *Interactive) applySettingToggle(key string, value bool) { | |
| 2882 | 3089 | // Every setting toggle forces a full repaint at the end — same | |
| 2883 | 3090 | // effect as the user pressing Ctrl+L — so any per-setting visual | |
| @@ -6,6 +6,8 @@ import ( | |||||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | + | "runtime" | |||
| 10 | + | "strconv" | |||
| 9 | "strings" | 11 | "strings" | ||
| 10 | "sync" | 12 | "sync" | ||
| 11 | "time" | 13 | "time" | ||
| @@ -58,6 +60,12 @@ type InteractiveConfig struct { | |||||
| 58 | 60 | ||||
| 59 | // ThemeName mirrors the persisted config theme value. Empty means auto. | 61 | // ThemeName mirrors the persisted config theme value. Empty means auto. | ||
| 60 | ThemeName string | 62 | ThemeName string | ||
| 63 | + | ||||
| 64 | + | // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The | |||
| 65 | + | // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal | |||
| 66 | + | // forwards Command/Super keypresses, but Ctrl is the displayed chord. | |||
| 67 | + | QuickModelShortcuts []QuickModelShortcut | |||
| 68 | + | ||||
| 61 | // ExtensionThemes returns themes bundled with loaded extensions. | 69 | // ExtensionThemes returns themes bundled with loaded extensions. | ||
| 62 | ExtensionThemes func() []tui.ThemeOption | 70 | ExtensionThemes func() []tui.ThemeOption | ||
| 63 | 71 | ||||
| @@ -234,8 +242,15 @@ type chatCacheKey struct { | |||||
| 234 | tailLimit int | 242 | tailLimit int | ||
| 235 | } | 243 | } | ||
| 236 | 244 | ||||
| 245 | + | // QuickModelShortcut is one configured quick model switch slot. | |||
| 246 | + | type QuickModelShortcut struct { | |||
| 247 | + | Provider string | |||
| 248 | + | Model string | |||
| 249 | + | } | |||
| 250 | + | ||||
| 237 | // SettingsStore persists user-toggleable settings surfaced by /settings. | 251 | // SettingsStore persists user-toggleable settings surfaced by /settings. | ||
| 238 | type SettingsStore interface { | 252 | type SettingsStore interface { | ||
| 253 | + | SetQuickModelShortcut(slot int, providerName, model string) error | |||
| 239 | SetInlineImages(enabled bool) error | 254 | SetInlineImages(enabled bool) error | ||
| 240 | SetAutoSwarm(enabled bool) error | 255 | SetAutoSwarm(enabled bool) error | ||
| 241 | SetRecursiveFileSuggest(enabled bool) error | 256 | SetRecursiveFileSuggest(enabled bool) error | ||
| @@ -353,6 +368,7 @@ type Interactive struct { | |||||
| 353 | logoutDialog *logoutDialog | 368 | logoutDialog *logoutDialog | ||
| 354 | telegramDialog *telegramDialog | 369 | telegramDialog *telegramDialog | ||
| 355 | settingsDialog *settingsDialog | 370 | settingsDialog *settingsDialog | ||
| 371 | + | quickModelAssign int | |||
| 356 | telegramBridge *telegram.Bridge | 372 | telegramBridge *telegram.Bridge | ||
| 357 | sessionOpsDialog *sessionOpsDialog | 373 | sessionOpsDialog *sessionOpsDialog | ||
| 358 | sessionTreeDialog *sessionTreeDialog | 374 | sessionTreeDialog *sessionTreeDialog | ||
| @@ -1731,11 +1747,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1731 | if i.modelDialog.Active() { | 1747 | if i.modelDialog.Active() { | ||
| 1732 | if k.Kind == tui.KeyCtrlC { | 1748 | if k.Kind == tui.KeyCtrlC { | ||
| 1733 | i.modelDialog.Close() | 1749 | i.modelDialog.Close() | ||
| 1750 | + | i.quickModelAssign = 0 | |||
| 1734 | return false | 1751 | return false | ||
| 1735 | } | 1752 | } | ||
| 1736 | act := i.modelDialog.HandleKey(k) | 1753 | act := i.modelDialog.HandleKey(k) | ||
| 1754 | + | if act.Close { | |||
| 1755 | + | i.quickModelAssign = 0 | |||
| 1756 | + | } | |||
| 1737 | if act.Select { | 1757 | if act.Select { | ||
| 1738 | - | i.applyModelSelection(act.Provider, act.Model) | 1758 | + | if i.quickModelAssign > 0 { |
| 1759 | + | i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model) | |||
| 1760 | + | i.quickModelAssign = 0 | |||
| 1761 | + | } else { | |||
| 1762 | + | i.applyModelSelection(act.Provider, act.Model) | |||
| 1763 | + | } | |||
| 1739 | } | 1764 | } | ||
| 1740 | return false | 1765 | return false | ||
| 1741 | } | 1766 | } | ||
| @@ -1818,6 +1843,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1818 | return false | 1843 | return false | ||
| 1819 | } | 1844 | } | ||
| 1820 | act := i.settingsDialog.HandleKey(k) | 1845 | act := i.settingsDialog.HandleKey(k) | ||
| 1846 | + | if act.ModelShortcutSlot > 0 { | |||
| 1847 | + | i.openQuickModelPicker(act.ModelShortcutSlot) | |||
| 1848 | + | } | |||
| 1821 | if act.Toggle { | 1849 | if act.Toggle { | ||
| 1822 | i.applySettingChange(act) | 1850 | i.applySettingChange(act) | ||
| 1823 | } | 1851 | } | ||
| @@ -1917,6 +1945,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 1917 | return false | 1945 | return false | ||
| 1918 | } | 1946 | } | ||
| 1919 | 1947 | ||||
| 1948 | + | if slot := quickModelShortcutSlot(k); slot > 0 { | |||
| 1949 | + | i.applyQuickModelShortcut(slot) | |||
| 1950 | + | return false | |||
| 1951 | + | } | |||
| 1952 | + | ||||
| 1920 | // Global keys. | 1953 | // Global keys. | ||
| 1921 | switch k.Kind { | 1954 | switch k.Kind { | ||
| 1922 | case tui.KeyCtrlC: | 1955 | case tui.KeyCtrlC: | ||
| @@ -2773,6 +2806,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 2773 | 2806 | ||||
| 2774 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | 2807 | recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest | ||
| 2775 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | 2808 | respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore | ||
| 2809 | + | quickItems := i.quickModelSettingItems() | |||
| 2776 | 2810 | ||||
| 2777 | reasoningOptions := []settingsOption{ | 2811 | reasoningOptions := []settingsOption{ | ||
| 2778 | {value: "", label: "off", desc: "no reasoning"}, | 2812 | {value: "", label: "off", desc: "no reasoning"}, | ||
| @@ -2820,7 +2854,7 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 2820 | } | 2854 | } | ||
| 2821 | } | 2855 | } | ||
| 2822 | 2856 | ||||
| 2823 | - | i.settingsDialog.Open([]settingsItem{ | 2857 | + | items := []settingsItem{ |
| 2824 | { | 2858 | { | ||
| 2825 | key: "inline_images_enabled", | 2859 | key: "inline_images_enabled", | ||
| 2826 | label: "render images when supported", | 2860 | label: "render images when supported", | ||
| @@ -2864,20 +2898,193 @@ func (i *Interactive) openSettingsDialog() { | |||||
| 2864 | options: themeOptions, | 2898 | options: themeOptions, | ||
| 2865 | choice: themeChoice, | 2899 | choice: themeChoice, | ||
| 2866 | }, | 2900 | }, | ||
| 2867 | - | }) | 2901 | + | } |
| 2902 | + | if len(quickItems) > 0 { | |||
| 2903 | + | items = append(items, settingsItem{ | |||
| 2904 | + | key: "quick_models", | |||
| 2905 | + | label: "model shortcuts", | |||
| 2906 | + | desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches", | |||
| 2907 | + | children: quickItems, | |||
| 2908 | + | }) | |||
| 2909 | + | } | |||
| 2910 | + | i.settingsDialog.Open(items) | |||
| 2868 | } | 2911 | } | ||
| 2869 | 2912 | ||||
| 2870 | func (i *Interactive) applySettingChange(act settingsAction) { | 2913 | func (i *Interactive) applySettingChange(act settingsAction) { | ||
| 2871 | - | switch act.Key { | 2914 | + | switch { |
| 2872 | - | case "reasoning": | 2915 | + | case strings.HasPrefix(act.Key, "quick_model_"): |
| 2916 | + | i.applyQuickModelSetting(act.Key, act.StringValue) | |||
| 2917 | + | case act.Key == "reasoning": | |||
| 2873 | i.applyReasoningSetting(act.StringValue) | 2918 | i.applyReasoningSetting(act.StringValue) | ||
| 2874 | - | case "theme": | 2919 | + | case act.Key == "theme": |
| 2875 | i.applyThemeSetting(act.StringValue) | 2920 | i.applyThemeSetting(act.StringValue) | ||
| 2876 | default: | 2921 | default: | ||
| 2877 | i.applySettingToggle(act.Key, act.Value) | 2922 | i.applySettingToggle(act.Key, act.Value) | ||
| 2878 | } | 2923 | } | ||
| 2879 | } | 2924 | } | ||
| 2880 | 2925 | ||||
| 2926 | + | func (i *Interactive) quickModelSettingItems() []settingsItem { | |||
| 2927 | + | if len(i.cfg.QuickModelShortcuts) < 9 { | |||
| 2928 | + | next := make([]QuickModelShortcut, 9) | |||
| 2929 | + | copy(next, i.cfg.QuickModelShortcuts) | |||
| 2930 | + | i.cfg.QuickModelShortcuts = next | |||
| 2931 | + | } | |||
| 2932 | + | items := make([]settingsItem, 0, 9) | |||
| 2933 | + | for slot := 1; slot <= 9; slot++ { | |||
| 2934 | + | items = append(items, i.quickModelSettingItem(slot)) | |||
| 2935 | + | } | |||
| 2936 | + | return items | |||
| 2937 | + | } | |||
| 2938 | + | ||||
| 2939 | + | func (i *Interactive) quickModelSettingItem(slot int) settingsItem { | |||
| 2940 | + | current := QuickModelShortcut{} | |||
| 2941 | + | if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot { | |||
| 2942 | + | current = i.cfg.QuickModelShortcuts[slot-1] | |||
| 2943 | + | } | |||
| 2944 | + | hint := "not assigned" | |||
| 2945 | + | if current.Provider != "" && current.Model != "" { | |||
| 2946 | + | hint = current.Provider + " / " + current.Model | |||
| 2947 | + | } | |||
| 2948 | + | return settingsItem{ | |||
| 2949 | + | key: "quick_model_" + strconv.Itoa(slot), | |||
| 2950 | + | label: "model " + strconv.Itoa(slot), | |||
| 2951 | + | desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.", | |||
| 2952 | + | picker: true, | |||
| 2953 | + | hint: hint, | |||
| 2954 | + | } | |||
| 2955 | + | } | |||
| 2956 | + | ||||
| 2957 | + | func quickModelShortcutSlot(k tui.Key) int { | |||
| 2958 | + | if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' { | |||
| 2959 | + | return 0 | |||
| 2960 | + | } | |||
| 2961 | + | if runtime.GOOS == "darwin" { | |||
| 2962 | + | if !k.Super && !k.Ctrl { | |||
| 2963 | + | return 0 | |||
| 2964 | + | } | |||
| 2965 | + | } else if !k.Ctrl { | |||
| 2966 | + | return 0 | |||
| 2967 | + | } | |||
| 2968 | + | return int(k.Rune - '0') | |||
| 2969 | + | } | |||
| 2970 | + | ||||
| 2971 | + | func quickModelShortcutPrefix() string { | |||
| 2972 | + | return "Ctrl" | |||
| 2973 | + | } | |||
| 2974 | + | ||||
| 2975 | + | func quickModelShortcutLabel(slot int) string { | |||
| 2976 | + | return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot) | |||
| 2977 | + | } | |||
| 2978 | + | ||||
| 2979 | + | func (i *Interactive) openQuickModelPicker(slot int) { | |||
| 2980 | + | if slot < 1 || slot > 9 { | |||
| 2981 | + | return | |||
| 2982 | + | } | |||
| 2983 | + | i.quickModelAssign = slot | |||
| 2984 | + | current := i.cfg.Model | |||
| 2985 | + | if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" { | |||
| 2986 | + | current = i.cfg.QuickModelShortcuts[slot-1].Model | |||
| 2987 | + | } | |||
| 2988 | + | var loggedIn []string | |||
| 2989 | + | if i.cfg.LoggedInProviders != nil { | |||
| 2990 | + | loggedIn = i.cfg.LoggedInProviders() | |||
| 2991 | + | } | |||
| 2992 | + | i.modelDialog.Open(current, loggedIn) | |||
| 2993 | + | } | |||
| 2994 | + | ||||
| 2995 | + | func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) { | |||
| 2996 | + | i.setQuickModelShortcut(slot, providerName, model) | |||
| 2997 | + | } | |||
| 2998 | + | ||||
| 2999 | + | func (i *Interactive) applyQuickModelShortcut(slot int) { | |||
| 3000 | + | if slot < 1 || slot > 9 { | |||
| 3001 | + | return | |||
| 3002 | + | } | |||
| 3003 | + | if i.busy { | |||
| 3004 | + | i.mu.Lock() | |||
| 3005 | + | i.statusErr = "cannot switch model while a turn is running" | |||
| 3006 | + | i.statusOK = "" | |||
| 3007 | + | i.mu.Unlock() | |||
| 3008 | + | i.invalidate() | |||
| 3009 | + | return | |||
| 3010 | + | } | |||
| 3011 | + | if len(i.cfg.QuickModelShortcuts) < slot { | |||
| 3012 | + | i.mu.Lock() | |||
| 3013 | + | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | |||
| 3014 | + | i.statusOK = "" | |||
| 3015 | + | i.mu.Unlock() | |||
| 3016 | + | i.invalidate() | |||
| 3017 | + | return | |||
| 3018 | + | } | |||
| 3019 | + | shortcut := i.cfg.QuickModelShortcuts[slot-1] | |||
| 3020 | + | if shortcut.Provider == "" || shortcut.Model == "" { | |||
| 3021 | + | i.mu.Lock() | |||
| 3022 | + | i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" | |||
| 3023 | + | i.statusOK = "" | |||
| 3024 | + | i.mu.Unlock() | |||
| 3025 | + | i.invalidate() | |||
| 3026 | + | return | |||
| 3027 | + | } | |||
| 3028 | + | i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false) | |||
| 3029 | + | i.invalidate() | |||
| 3030 | + | } | |||
| 3031 | + | ||||
| 3032 | + | func (i *Interactive) applyQuickModelSetting(key, value string) { | |||
| 3033 | + | slotText := strings.TrimPrefix(key, "quick_model_") | |||
| 3034 | + | slot, err := strconv.Atoi(slotText) | |||
| 3035 | + | if err != nil || slot < 1 || slot > 9 { | |||
| 3036 | + | return | |||
| 3037 | + | } | |||
| 3038 | + | providerName, model := "", "" | |||
| 3039 | + | if value != "" { | |||
| 3040 | + | parts := strings.SplitN(value, "\t", 2) | |||
| 3041 | + | if len(parts) == 2 { | |||
| 3042 | + | providerName, model = parts[0], parts[1] | |||
| 3043 | + | } | |||
| 3044 | + | } | |||
| 3045 | + | i.setQuickModelShortcut(slot, providerName, model) | |||
| 3046 | + | } | |||
| 3047 | + | ||||
| 3048 | + | func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) { | |||
| 3049 | + | if len(i.cfg.QuickModelShortcuts) < slot { | |||
| 3050 | + | next := make([]QuickModelShortcut, slot) | |||
| 3051 | + | copy(next, i.cfg.QuickModelShortcuts) | |||
| 3052 | + | i.cfg.QuickModelShortcuts = next | |||
| 3053 | + | } | |||
| 3054 | + | i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} | |||
| 3055 | + | if i.cfg.SettingsStore != nil { | |||
| 3056 | + | if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil { | |||
| 3057 | + | i.mu.Lock() | |||
| 3058 | + | i.statusErr = "settings: " + err.Error() | |||
| 3059 | + | i.mu.Unlock() | |||
| 3060 | + | return | |||
| 3061 | + | } | |||
| 3062 | + | } | |||
| 3063 | + | i.mu.Lock() | |||
| 3064 | + | if model == "" { | |||
| 3065 | + | i.statusOK = quickModelShortcutLabel(slot) + " cleared" | |||
| 3066 | + | } else { | |||
| 3067 | + | i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model | |||
| 3068 | + | } | |||
| 3069 | + | i.statusErr = "" | |||
| 3070 | + | i.mu.Unlock() | |||
| 3071 | + | i.refreshQuickModelSettingsItem(slot) | |||
| 3072 | + | i.invalidate() | |||
| 3073 | + | } | |||
| 3074 | + | ||||
| 3075 | + | func (i *Interactive) refreshQuickModelSettingsItem(slot int) { | |||
| 3076 | + | if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 { | |||
| 3077 | + | return | |||
| 3078 | + | } | |||
| 3079 | + | key := "quick_model_" + strconv.Itoa(slot) | |||
| 3080 | + | for idx, it := range i.settingsDialog.items { | |||
| 3081 | + | if it.key == key { | |||
| 3082 | + | i.settingsDialog.items[idx] = i.quickModelSettingItem(slot) | |||
| 3083 | + | return | |||
| 3084 | + | } | |||
| 3085 | + | } | |||
| 3086 | + | } | |||
| 3087 | + | ||||
| 2881 | func (i *Interactive) applySettingToggle(key string, value bool) { | 3088 | func (i *Interactive) applySettingToggle(key string, value bool) { | ||
| 2882 | // Every setting toggle forces a full repaint at the end — same | 3089 | // Every setting toggle forces a full repaint at the end — same | ||
| 2883 | // effect as the user pressing Ctrl+L — so any per-setting visual | 3090 | // effect as the user pressing Ctrl+L — so any per-setting visual | ||
M packages/agent/modes/settings_dialog.go +61 -8
| @@ -10,10 +10,13 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | type settingsDialog struct { | |
| 12 | 12 | active bool | |
| 13 | + | title string | |
| 13 | 14 | items []settingsItem | |
| 14 | 15 | cursor int | |
| 15 | 16 | selecting bool | |
| 16 | 17 | optionCursor int | |
| 18 | + | parentItems []settingsItem | |
| 19 | + | parentCursor int | |
| 17 | 20 | } | |
| 18 | 21 | ||
| 19 | 22 | type settingsItem struct { | |
| @@ -22,6 +25,8 @@ type settingsItem struct { | |||
| 22 | 25 | desc string | |
| 23 | 26 | value bool | |
| 24 | 27 | options []settingsOption | |
| 28 | + | children []settingsItem | |
| 29 | + | picker bool | |
| 25 | 30 | choice int | |
| 26 | 31 | disabled bool | |
| 27 | 32 | hint string | |
| @@ -34,11 +39,12 @@ type settingsOption struct { | |||
| 34 | 39 | } | |
| 35 | 40 | ||
| 36 | 41 | type settingsAction struct { | |
| 37 | - | Toggle bool | |
| 38 | - | Key string | |
| 39 | - | Value bool | |
| 40 | - | StringValue string | |
| 41 | - | Close bool | |
| 42 | + | Toggle bool | |
| 43 | + | Key string | |
| 44 | + | Value bool | |
| 45 | + | StringValue string | |
| 46 | + | ModelShortcutSlot int | |
| 47 | + | Close bool | |
| 42 | 48 | } | |
| 43 | 49 | ||
| 44 | 50 | func newSettingsDialog() *settingsDialog { return &settingsDialog{} } | |
| @@ -47,10 +53,13 @@ func (d *settingsDialog) Open(items []settingsItem) bool { | |||
| 47 | 53 | if len(items) == 0 { | |
| 48 | 54 | return false | |
| 49 | 55 | } | |
| 56 | + | d.title = "settings" | |
| 50 | 57 | d.items = items | |
| 51 | 58 | d.cursor = 0 | |
| 52 | 59 | d.selecting = false | |
| 53 | 60 | d.optionCursor = 0 | |
| 61 | + | d.parentItems = nil | |
| 62 | + | d.parentCursor = 0 | |
| 54 | 63 | d.active = true | |
| 55 | 64 | return true | |
| 56 | 65 | } | |
| @@ -58,6 +67,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool { | |||
| 58 | 67 | func (d *settingsDialog) Close() { | |
| 59 | 68 | d.active = false | |
| 60 | 69 | d.selecting = false | |
| 70 | + | d.parentItems = nil | |
| 61 | 71 | } | |
| 62 | 72 | func (d *settingsDialog) Active() bool { return d != nil && d.active } | |
| 63 | 73 | ||
| @@ -74,7 +84,22 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction { | |||
| 74 | 84 | if d.cursor < len(d.items)-1 { | |
| 75 | 85 | d.cursor++ | |
| 76 | 86 | } | |
| 87 | + | case tui.KeyBackspace: | |
| 88 | + | if len(d.items) > 0 { | |
| 89 | + | it := d.items[d.cursor] | |
| 90 | + | if strings.HasPrefix(it.key, "quick_model_") { | |
| 91 | + | return settingsAction{Toggle: true, Key: it.key, StringValue: ""} | |
| 92 | + | } | |
| 93 | + | } | |
| 77 | 94 | case tui.KeyEsc: | |
| 95 | + | if len(d.parentItems) > 0 { | |
| 96 | + | d.items = d.parentItems | |
| 97 | + | d.cursor = d.parentCursor | |
| 98 | + | d.parentItems = nil | |
| 99 | + | d.parentCursor = 0 | |
| 100 | + | d.title = "settings" | |
| 101 | + | return settingsAction{} | |
| 102 | + | } | |
| 78 | 103 | d.Close() | |
| 79 | 104 | return settingsAction{Close: true} | |
| 80 | 105 | case tui.KeyEnter: | |
| @@ -119,6 +144,27 @@ func (d *settingsDialog) toggleCurrent() settingsAction { | |||
| 119 | 144 | if it.disabled { | |
| 120 | 145 | return settingsAction{} | |
| 121 | 146 | } | |
| 147 | + | if it.picker { | |
| 148 | + | slotText := strings.TrimPrefix(it.key, "quick_model_") | |
| 149 | + | slot := 0 | |
| 150 | + | for _, r := range slotText { | |
| 151 | + | if r < '0' || r > '9' { | |
| 152 | + | slot = 0 | |
| 153 | + | break | |
| 154 | + | } | |
| 155 | + | slot = slot*10 + int(r-'0') | |
| 156 | + | } | |
| 157 | + | return settingsAction{ModelShortcutSlot: slot} | |
| 158 | + | } | |
| 159 | + | if len(it.children) > 0 { | |
| 160 | + | d.parentItems = d.items | |
| 161 | + | d.parentCursor = d.cursor | |
| 162 | + | d.items = it.children | |
| 163 | + | d.cursor = 0 | |
| 164 | + | d.optionCursor = 0 | |
| 165 | + | d.title = "settings: " + it.label | |
| 166 | + | return settingsAction{} | |
| 167 | + | } | |
| 122 | 168 | if len(it.options) > 0 { | |
| 123 | 169 | d.optionCursor = it.choice | |
| 124 | 170 | if d.optionCursor < 0 || d.optionCursor >= len(it.options) { | |
| @@ -159,15 +205,22 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string { | |||
| 159 | 205 | return d.renderOptions(th, width) | |
| 160 | 206 | } | |
| 161 | 207 | var lines []string | |
| 162 | - | lines = append(lines, frameHeader(th, "settings", width)) | |
| 163 | - | lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:")) | |
| 208 | + | lines = append(lines, frameHeader(th, d.title, width)) | |
| 209 | + | if len(d.parentItems) > 0 { | |
| 210 | + | lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to go back:")) | |
| 211 | + | } else { | |
| 212 | + | lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:")) | |
| 213 | + | } | |
| 164 | 214 | for i, it := range d.items { | |
| 165 | 215 | box := "[ ]" | |
| 166 | 216 | if it.value { | |
| 167 | 217 | box = "[✓]" | |
| 168 | 218 | } | |
| 169 | 219 | plain := " " + box + " " + it.label | |
| 170 | - | if len(it.options) > 0 { | |
| 220 | + | if it.picker || len(it.children) > 0 { | |
| 221 | + | box = "[→]" | |
| 222 | + | plain = " " + box + " " + it.label | |
| 223 | + | } else if len(it.options) > 0 { | |
| 171 | 224 | box = "[→]" | |
| 172 | 225 | if it.choice < 0 || it.choice >= len(it.options) { | |
| 173 | 226 | it.choice = 0 | |
| @@ -10,10 +10,13 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | type settingsDialog struct { | 11 | type settingsDialog struct { | ||
| 12 | active bool | 12 | active bool | ||
| 13 | + | title string | |||
| 13 | items []settingsItem | 14 | items []settingsItem | ||
| 14 | cursor int | 15 | cursor int | ||
| 15 | selecting bool | 16 | selecting bool | ||
| 16 | optionCursor int | 17 | optionCursor int | ||
| 18 | + | parentItems []settingsItem | |||
| 19 | + | parentCursor int | |||
| 17 | } | 20 | } | ||
| 18 | 21 | ||||
| 19 | type settingsItem struct { | 22 | type settingsItem struct { | ||
| @@ -22,6 +25,8 @@ type settingsItem struct { | |||||
| 22 | desc string | 25 | desc string | ||
| 23 | value bool | 26 | value bool | ||
| 24 | options []settingsOption | 27 | options []settingsOption | ||
| 28 | + | children []settingsItem | |||
| 29 | + | picker bool | |||
| 25 | choice int | 30 | choice int | ||
| 26 | disabled bool | 31 | disabled bool | ||
| 27 | hint string | 32 | hint string | ||
| @@ -34,11 +39,12 @@ type settingsOption struct { | |||||
| 34 | } | 39 | } | ||
| 35 | 40 | ||||
| 36 | type settingsAction struct { | 41 | type settingsAction struct { | ||
| 37 | - | Toggle bool | 42 | + | Toggle bool |
| 38 | - | Key string | 43 | + | Key string |
| 39 | - | Value bool | 44 | + | Value bool |
| 40 | - | StringValue string | 45 | + | StringValue string |
| 41 | - | Close bool | 46 | + | ModelShortcutSlot int |
| 47 | + | Close bool | |||
| 42 | } | 48 | } | ||
| 43 | 49 | ||||
| 44 | func newSettingsDialog() *settingsDialog { return &settingsDialog{} } | 50 | func newSettingsDialog() *settingsDialog { return &settingsDialog{} } | ||
| @@ -47,10 +53,13 @@ func (d *settingsDialog) Open(items []settingsItem) bool { | |||||
| 47 | if len(items) == 0 { | 53 | if len(items) == 0 { | ||
| 48 | return false | 54 | return false | ||
| 49 | } | 55 | } | ||
| 56 | + | d.title = "settings" | |||
| 50 | d.items = items | 57 | d.items = items | ||
| 51 | d.cursor = 0 | 58 | d.cursor = 0 | ||
| 52 | d.selecting = false | 59 | d.selecting = false | ||
| 53 | d.optionCursor = 0 | 60 | d.optionCursor = 0 | ||
| 61 | + | d.parentItems = nil | |||
| 62 | + | d.parentCursor = 0 | |||
| 54 | d.active = true | 63 | d.active = true | ||
| 55 | return true | 64 | return true | ||
| 56 | } | 65 | } | ||
| @@ -58,6 +67,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool { | |||||
| 58 | func (d *settingsDialog) Close() { | 67 | func (d *settingsDialog) Close() { | ||
| 59 | d.active = false | 68 | d.active = false | ||
| 60 | d.selecting = false | 69 | d.selecting = false | ||
| 70 | + | d.parentItems = nil | |||
| 61 | } | 71 | } | ||
| 62 | func (d *settingsDialog) Active() bool { return d != nil && d.active } | 72 | func (d *settingsDialog) Active() bool { return d != nil && d.active } | ||
| 63 | 73 | ||||
| @@ -74,7 +84,22 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction { | |||||
| 74 | if d.cursor < len(d.items)-1 { | 84 | if d.cursor < len(d.items)-1 { | ||
| 75 | d.cursor++ | 85 | d.cursor++ | ||
| 76 | } | 86 | } | ||
| 87 | + | case tui.KeyBackspace: | |||
| 88 | + | if len(d.items) > 0 { | |||
| 89 | + | it := d.items[d.cursor] | |||
| 90 | + | if strings.HasPrefix(it.key, "quick_model_") { | |||
| 91 | + | return settingsAction{Toggle: true, Key: it.key, StringValue: ""} | |||
| 92 | + | } | |||
| 93 | + | } | |||
| 77 | case tui.KeyEsc: | 94 | case tui.KeyEsc: | ||
| 95 | + | if len(d.parentItems) > 0 { | |||
| 96 | + | d.items = d.parentItems | |||
| 97 | + | d.cursor = d.parentCursor | |||
| 98 | + | d.parentItems = nil | |||
| 99 | + | d.parentCursor = 0 | |||
| 100 | + | d.title = "settings" | |||
| 101 | + | return settingsAction{} | |||
| 102 | + | } | |||
| 78 | d.Close() | 103 | d.Close() | ||
| 79 | return settingsAction{Close: true} | 104 | return settingsAction{Close: true} | ||
| 80 | case tui.KeyEnter: | 105 | case tui.KeyEnter: | ||
| @@ -119,6 +144,27 @@ func (d *settingsDialog) toggleCurrent() settingsAction { | |||||
| 119 | if it.disabled { | 144 | if it.disabled { | ||
| 120 | return settingsAction{} | 145 | return settingsAction{} | ||
| 121 | } | 146 | } | ||
| 147 | + | if it.picker { | |||
| 148 | + | slotText := strings.TrimPrefix(it.key, "quick_model_") | |||
| 149 | + | slot := 0 | |||
| 150 | + | for _, r := range slotText { | |||
| 151 | + | if r < '0' || r > '9' { | |||
| 152 | + | slot = 0 | |||
| 153 | + | break | |||
| 154 | + | } | |||
| 155 | + | slot = slot*10 + int(r-'0') | |||
| 156 | + | } | |||
| 157 | + | return settingsAction{ModelShortcutSlot: slot} | |||
| 158 | + | } | |||
| 159 | + | if len(it.children) > 0 { | |||
| 160 | + | d.parentItems = d.items | |||
| 161 | + | d.parentCursor = d.cursor | |||
| 162 | + | d.items = it.children | |||
| 163 | + | d.cursor = 0 | |||
| 164 | + | d.optionCursor = 0 | |||
| 165 | + | d.title = "settings: " + it.label | |||
| 166 | + | return settingsAction{} | |||
| 167 | + | } | |||
| 122 | if len(it.options) > 0 { | 168 | if len(it.options) > 0 { | ||
| 123 | d.optionCursor = it.choice | 169 | d.optionCursor = it.choice | ||
| 124 | if d.optionCursor < 0 || d.optionCursor >= len(it.options) { | 170 | if d.optionCursor < 0 || d.optionCursor >= len(it.options) { | ||
| @@ -159,15 +205,22 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string { | |||||
| 159 | return d.renderOptions(th, width) | 205 | return d.renderOptions(th, width) | ||
| 160 | } | 206 | } | ||
| 161 | var lines []string | 207 | var lines []string | ||
| 162 | - | lines = append(lines, frameHeader(th, "settings", width)) | 208 | + | lines = append(lines, frameHeader(th, d.title, width)) |
| 163 | - | lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:")) | 209 | + | if len(d.parentItems) > 0 { |
| 210 | + | lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to go back:")) | |||
| 211 | + | } else { | |||
| 212 | + | lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:")) | |||
| 213 | + | } | |||
| 164 | for i, it := range d.items { | 214 | for i, it := range d.items { | ||
| 165 | box := "[ ]" | 215 | box := "[ ]" | ||
| 166 | if it.value { | 216 | if it.value { | ||
| 167 | box = "[✓]" | 217 | box = "[✓]" | ||
| 168 | } | 218 | } | ||
| 169 | plain := " " + box + " " + it.label | 219 | plain := " " + box + " " + it.label | ||
| 170 | - | if len(it.options) > 0 { | 220 | + | if it.picker || len(it.children) > 0 { |
| 221 | + | box = "[→]" | |||
| 222 | + | plain = " " + box + " " + it.label | |||
| 223 | + | } else if len(it.options) > 0 { | |||
| 171 | box = "[→]" | 224 | box = "[→]" | ||
| 172 | if it.choice < 0 || it.choice >= len(it.options) { | 225 | if it.choice < 0 || it.choice >= len(it.options) { | ||
| 173 | it.choice = 0 | 226 | it.choice = 0 | ||
M packages/agent/settings_store.go +25 -0
| @@ -4,6 +4,31 @@ import "github.com/patriceckhart/zot/packages/provider" | |||
| 4 | 4 | ||
| 5 | 5 | type configSettingsStore struct{} | |
| 6 | 6 | ||
| 7 | + | func (configSettingsStore) SetQuickModelShortcut(slot int, providerName, model string) error { | |
| 8 | + | if slot < 1 || slot > 9 { | |
| 9 | + | return nil | |
| 10 | + | } | |
| 11 | + | cfg, err := LoadConfig() | |
| 12 | + | if err != nil { | |
| 13 | + | return err | |
| 14 | + | } | |
| 15 | + | if len(cfg.QuickModelShortcuts) < slot { | |
| 16 | + | next := make([]QuickModelShortcut, slot) | |
| 17 | + | copy(next, cfg.QuickModelShortcuts) | |
| 18 | + | cfg.QuickModelShortcuts = next | |
| 19 | + | } | |
| 20 | + | cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} | |
| 21 | + | // Trim trailing empty slots so config.json stays compact. | |
| 22 | + | for len(cfg.QuickModelShortcuts) > 0 { | |
| 23 | + | last := cfg.QuickModelShortcuts[len(cfg.QuickModelShortcuts)-1] | |
| 24 | + | if last.Provider != "" || last.Model != "" { | |
| 25 | + | break | |
| 26 | + | } | |
| 27 | + | cfg.QuickModelShortcuts = cfg.QuickModelShortcuts[:len(cfg.QuickModelShortcuts)-1] | |
| 28 | + | } | |
| 29 | + | return SaveConfig(cfg) | |
| 30 | + | } | |
| 31 | + | ||
| 7 | 32 | func (configSettingsStore) SetInlineImages(enabled bool) error { | |
| 8 | 33 | cfg, err := LoadConfig() | |
| 9 | 34 | if err != nil { | |
| @@ -4,6 +4,31 @@ import "github.com/patriceckhart/zot/packages/provider" | |||||
| 4 | 4 | ||||
| 5 | type configSettingsStore struct{} | 5 | type configSettingsStore struct{} | ||
| 6 | 6 | ||||
| 7 | + | func (configSettingsStore) SetQuickModelShortcut(slot int, providerName, model string) error { | |||
| 8 | + | if slot < 1 || slot > 9 { | |||
| 9 | + | return nil | |||
| 10 | + | } | |||
| 11 | + | cfg, err := LoadConfig() | |||
| 12 | + | if err != nil { | |||
| 13 | + | return err | |||
| 14 | + | } | |||
| 15 | + | if len(cfg.QuickModelShortcuts) < slot { | |||
| 16 | + | next := make([]QuickModelShortcut, slot) | |||
| 17 | + | copy(next, cfg.QuickModelShortcuts) | |||
| 18 | + | cfg.QuickModelShortcuts = next | |||
| 19 | + | } | |||
| 20 | + | cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} | |||
| 21 | + | // Trim trailing empty slots so config.json stays compact. | |||
| 22 | + | for len(cfg.QuickModelShortcuts) > 0 { | |||
| 23 | + | last := cfg.QuickModelShortcuts[len(cfg.QuickModelShortcuts)-1] | |||
| 24 | + | if last.Provider != "" || last.Model != "" { | |||
| 25 | + | break | |||
| 26 | + | } | |||
| 27 | + | cfg.QuickModelShortcuts = cfg.QuickModelShortcuts[:len(cfg.QuickModelShortcuts)-1] | |||
| 28 | + | } | |||
| 29 | + | return SaveConfig(cfg) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 7 | func (configSettingsStore) SetInlineImages(enabled bool) error { | 32 | func (configSettingsStore) SetInlineImages(enabled bool) error { | ||
| 8 | cfg, err := LoadConfig() | 33 | cfg, err := LoadConfig() | ||
| 9 | if err != nil { | 34 | if err != nil { | ||
M packages/tui/input.go +44 -28
| @@ -14,6 +14,7 @@ type Key struct { | |||
| 14 | 14 | Ctrl bool | |
| 15 | 15 | Alt bool | |
| 16 | 16 | Shift bool | |
| 17 | + | Super bool | |
| 17 | 18 | } | |
| 18 | 19 | ||
| 19 | 20 | type KeyKind int | |
| @@ -245,7 +246,7 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { | |||
| 245 | 246 | return Key{Kind: KeyUnknown} | |
| 246 | 247 | } | |
| 247 | 248 | ||
| 248 | - | shift, alt := parseCSIModifiers(params) | |
| 249 | + | shift, alt, super := parseCSIModifiers(params) | |
| 249 | 250 | if final == 'u' { | |
| 250 | 251 | if key, ok := parseCSIU(params); ok { | |
| 251 | 252 | return key | |
| @@ -258,13 +259,13 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { | |||
| 258 | 259 | } | |
| 259 | 260 | switch final { | |
| 260 | 261 | case 'A': | |
| 261 | - | return Key{Kind: KeyUp, Alt: alt, Shift: shift} | |
| 262 | + | return Key{Kind: KeyUp, Alt: alt, Shift: shift, Super: super} | |
| 262 | 263 | case 'B': | |
| 263 | - | return Key{Kind: KeyDown, Alt: alt, Shift: shift} | |
| 264 | + | return Key{Kind: KeyDown, Alt: alt, Shift: shift, Super: super} | |
| 264 | 265 | case 'C': | |
| 265 | - | return Key{Kind: KeyRight, Alt: alt, Shift: shift} | |
| 266 | + | return Key{Kind: KeyRight, Alt: alt, Shift: shift, Super: super} | |
| 266 | 267 | case 'D': | |
| 267 | - | return Key{Kind: KeyLeft, Alt: alt, Shift: shift} | |
| 268 | + | return Key{Kind: KeyLeft, Alt: alt, Shift: shift, Super: super} | |
| 268 | 269 | case 'H': | |
| 269 | 270 | return Key{Kind: KeyHome} | |
| 270 | 271 | case 'F': | |
| @@ -287,23 +288,20 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { | |||
| 287 | 288 | return Key{Kind: KeyUnknown} | |
| 288 | 289 | } | |
| 289 | 290 | ||
| 290 | - | func parseCSIModifiers(params string) (shift, alt bool) { | |
| 291 | + | func parseCSIModifiers(params string) (shift, alt, super bool) { | |
| 291 | 292 | if params == "" { | |
| 292 | - | return false, false | |
| 293 | + | return false, false, false | |
| 293 | 294 | } | |
| 294 | 295 | i := strings.LastIndexByte(params, ';') | |
| 295 | 296 | if i < 0 || i+1 >= len(params) { | |
| 296 | - | return false, false | |
| 297 | + | return false, false, false | |
| 297 | 298 | } | |
| 298 | - | mod, err := strconv.Atoi(params[i+1:]) | |
| 299 | - | if err != nil { | |
| 300 | - | return false, false | |
| 299 | + | mod, ok := parseModifierParam(params[i+1:]) | |
| 300 | + | if !ok { | |
| 301 | + | return false, false, false | |
| 301 | 302 | } | |
| 302 | - | // Xterm-style modifier values are 1 plus a bitmask: | |
| 303 | - | // 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, 6=Shift+Ctrl, | |
| 304 | - | // 7=Alt+Ctrl, 8=Shift+Alt+Ctrl. | |
| 305 | - | bits := mod - 1 | |
| 306 | - | return bits&1 != 0, bits&2 != 0 | |
| 303 | + | shift, alt, _, super = modifierBits(mod) | |
| 304 | + | return shift, alt, super | |
| 307 | 305 | } | |
| 308 | 306 | ||
| 309 | 307 | func parseCSIU(params string) (Key, bool) { | |
| @@ -317,7 +315,9 @@ func parseCSIU(params string) (Key, bool) { | |||
| 317 | 315 | } | |
| 318 | 316 | mod := 1 | |
| 319 | 317 | if len(parts) >= 2 { | |
| 320 | - | if mod, err = strconv.Atoi(parts[1]); err != nil { | |
| 318 | + | var ok bool | |
| 319 | + | mod, ok = parseModifierParam(parts[1]) | |
| 320 | + | if !ok { | |
| 321 | 321 | return Key{}, false | |
| 322 | 322 | } | |
| 323 | 323 | } | |
| @@ -329,8 +329,8 @@ func parseModifyOtherKeys(params string) (Key, bool) { | |||
| 329 | 329 | if len(parts) != 3 || parts[0] != "27" { | |
| 330 | 330 | return Key{}, false | |
| 331 | 331 | } | |
| 332 | - | mod, err := strconv.Atoi(parts[1]) | |
| 333 | - | if err != nil { | |
| 332 | + | mod, ok := parseModifierParam(parts[1]) | |
| 333 | + | if !ok { | |
| 334 | 334 | return Key{}, false | |
| 335 | 335 | } | |
| 336 | 336 | code, err := strconv.Atoi(parts[2]) | |
| @@ -340,27 +340,40 @@ func parseModifyOtherKeys(params string) (Key, bool) { | |||
| 340 | 340 | return keyFromModifiedCode(code, mod) | |
| 341 | 341 | } | |
| 342 | 342 | ||
| 343 | - | func keyFromModifiedCode(code, mod int) (Key, bool) { | |
| 343 | + | func parseModifierParam(s string) (int, bool) { | |
| 344 | + | if i := strings.IndexByte(s, ':'); i >= 0 { | |
| 345 | + | s = s[:i] | |
| 346 | + | } | |
| 347 | + | mod, err := strconv.Atoi(s) | |
| 348 | + | if err != nil { | |
| 349 | + | return 0, false | |
| 350 | + | } | |
| 351 | + | return mod, true | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func modifierBits(mod int) (shift, alt, ctrl, super bool) { | |
| 344 | 355 | bits := mod - 1 | |
| 345 | - | shift := bits&1 != 0 | |
| 346 | - | alt := bits&2 != 0 | |
| 347 | - | ctrl := bits&4 != 0 | |
| 356 | + | return bits&1 != 0, bits&2 != 0, bits&4 != 0, bits&8 != 0 || bits&32 != 0 | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | func keyFromModifiedCode(code, mod int) (Key, bool) { | |
| 360 | + | shift, alt, ctrl, super := modifierBits(mod) | |
| 348 | 361 | // Kitty keyboard protocol (CSI ... u) reports control keys as their | |
| 349 | 362 | // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the | |
| 350 | 363 | // enhanced-mode handling these arrive as raw bytes; with it enabled | |
| 351 | 364 | // they come through here, so map them back to their dedicated keys. | |
| 352 | 365 | switch code { | |
| 353 | 366 | case 13: | |
| 354 | - | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 367 | + | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 355 | 368 | case 27: | |
| 356 | - | return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 369 | + | return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 357 | 370 | case 9: | |
| 358 | 371 | if shift { | |
| 359 | - | return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true | |
| 372 | + | return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 360 | 373 | } | |
| 361 | - | return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 374 | + | return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 362 | 375 | case 127, 8: | |
| 363 | - | return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true | |
| 376 | + | return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 364 | 377 | } | |
| 365 | 378 | if ctrl { | |
| 366 | 379 | switch code { | |
| @@ -386,6 +399,9 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 386 | 399 | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | |
| 387 | 400 | } | |
| 388 | 401 | } | |
| 402 | + | if code >= '0' && code <= '9' { | |
| 403 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |
| 404 | + | } | |
| 389 | 405 | return Key{}, false | |
| 390 | 406 | } | |
| 391 | 407 | ||
| @@ -14,6 +14,7 @@ type Key struct { | |||||
| 14 | Ctrl bool | 14 | Ctrl bool | ||
| 15 | Alt bool | 15 | Alt bool | ||
| 16 | Shift bool | 16 | Shift bool | ||
| 17 | + | Super bool | |||
| 17 | } | 18 | } | ||
| 18 | 19 | ||||
| 19 | type KeyKind int | 20 | type KeyKind int | ||
| @@ -245,7 +246,7 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { | |||||
| 245 | return Key{Kind: KeyUnknown} | 246 | return Key{Kind: KeyUnknown} | ||
| 246 | } | 247 | } | ||
| 247 | 248 | ||||
| 248 | - | shift, alt := parseCSIModifiers(params) | 249 | + | shift, alt, super := parseCSIModifiers(params) |
| 249 | if final == 'u' { | 250 | if final == 'u' { | ||
| 250 | if key, ok := parseCSIU(params); ok { | 251 | if key, ok := parseCSIU(params); ok { | ||
| 251 | return key | 252 | return key | ||
| @@ -258,13 +259,13 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { | |||||
| 258 | } | 259 | } | ||
| 259 | switch final { | 260 | switch final { | ||
| 260 | case 'A': | 261 | case 'A': | ||
| 261 | - | return Key{Kind: KeyUp, Alt: alt, Shift: shift} | 262 | + | return Key{Kind: KeyUp, Alt: alt, Shift: shift, Super: super} |
| 262 | case 'B': | 263 | case 'B': | ||
| 263 | - | return Key{Kind: KeyDown, Alt: alt, Shift: shift} | 264 | + | return Key{Kind: KeyDown, Alt: alt, Shift: shift, Super: super} |
| 264 | case 'C': | 265 | case 'C': | ||
| 265 | - | return Key{Kind: KeyRight, Alt: alt, Shift: shift} | 266 | + | return Key{Kind: KeyRight, Alt: alt, Shift: shift, Super: super} |
| 266 | case 'D': | 267 | case 'D': | ||
| 267 | - | return Key{Kind: KeyLeft, Alt: alt, Shift: shift} | 268 | + | return Key{Kind: KeyLeft, Alt: alt, Shift: shift, Super: super} |
| 268 | case 'H': | 269 | case 'H': | ||
| 269 | return Key{Kind: KeyHome} | 270 | return Key{Kind: KeyHome} | ||
| 270 | case 'F': | 271 | case 'F': | ||
| @@ -287,23 +288,20 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { | |||||
| 287 | return Key{Kind: KeyUnknown} | 288 | return Key{Kind: KeyUnknown} | ||
| 288 | } | 289 | } | ||
| 289 | 290 | ||||
| 290 | - | func parseCSIModifiers(params string) (shift, alt bool) { | 291 | + | func parseCSIModifiers(params string) (shift, alt, super bool) { |
| 291 | if params == "" { | 292 | if params == "" { | ||
| 292 | - | return false, false | 293 | + | return false, false, false |
| 293 | } | 294 | } | ||
| 294 | i := strings.LastIndexByte(params, ';') | 295 | i := strings.LastIndexByte(params, ';') | ||
| 295 | if i < 0 || i+1 >= len(params) { | 296 | if i < 0 || i+1 >= len(params) { | ||
| 296 | - | return false, false | 297 | + | return false, false, false |
| 297 | } | 298 | } | ||
| 298 | - | mod, err := strconv.Atoi(params[i+1:]) | 299 | + | mod, ok := parseModifierParam(params[i+1:]) |
| 299 | - | if err != nil { | 300 | + | if !ok { |
| 300 | - | return false, false | 301 | + | return false, false, false |
| 301 | } | 302 | } | ||
| 302 | - | // Xterm-style modifier values are 1 plus a bitmask: | 303 | + | shift, alt, _, super = modifierBits(mod) |
| 303 | - | // 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, 6=Shift+Ctrl, | 304 | + | return shift, alt, super |
| 304 | - | // 7=Alt+Ctrl, 8=Shift+Alt+Ctrl. | |||
| 305 | - | bits := mod - 1 | |||
| 306 | - | return bits&1 != 0, bits&2 != 0 | |||
| 307 | } | 305 | } | ||
| 308 | 306 | ||||
| 309 | func parseCSIU(params string) (Key, bool) { | 307 | func parseCSIU(params string) (Key, bool) { | ||
| @@ -317,7 +315,9 @@ func parseCSIU(params string) (Key, bool) { | |||||
| 317 | } | 315 | } | ||
| 318 | mod := 1 | 316 | mod := 1 | ||
| 319 | if len(parts) >= 2 { | 317 | if len(parts) >= 2 { | ||
| 320 | - | if mod, err = strconv.Atoi(parts[1]); err != nil { | 318 | + | var ok bool |
| 319 | + | mod, ok = parseModifierParam(parts[1]) | |||
| 320 | + | if !ok { | |||
| 321 | return Key{}, false | 321 | return Key{}, false | ||
| 322 | } | 322 | } | ||
| 323 | } | 323 | } | ||
| @@ -329,8 +329,8 @@ func parseModifyOtherKeys(params string) (Key, bool) { | |||||
| 329 | if len(parts) != 3 || parts[0] != "27" { | 329 | if len(parts) != 3 || parts[0] != "27" { | ||
| 330 | return Key{}, false | 330 | return Key{}, false | ||
| 331 | } | 331 | } | ||
| 332 | - | mod, err := strconv.Atoi(parts[1]) | 332 | + | mod, ok := parseModifierParam(parts[1]) |
| 333 | - | if err != nil { | 333 | + | if !ok { |
| 334 | return Key{}, false | 334 | return Key{}, false | ||
| 335 | } | 335 | } | ||
| 336 | code, err := strconv.Atoi(parts[2]) | 336 | code, err := strconv.Atoi(parts[2]) | ||
| @@ -340,27 +340,40 @@ func parseModifyOtherKeys(params string) (Key, bool) { | |||||
| 340 | return keyFromModifiedCode(code, mod) | 340 | return keyFromModifiedCode(code, mod) | ||
| 341 | } | 341 | } | ||
| 342 | 342 | ||||
| 343 | - | func keyFromModifiedCode(code, mod int) (Key, bool) { | 343 | + | func parseModifierParam(s string) (int, bool) { |
| 344 | + | if i := strings.IndexByte(s, ':'); i >= 0 { | |||
| 345 | + | s = s[:i] | |||
| 346 | + | } | |||
| 347 | + | mod, err := strconv.Atoi(s) | |||
| 348 | + | if err != nil { | |||
| 349 | + | return 0, false | |||
| 350 | + | } | |||
| 351 | + | return mod, true | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func modifierBits(mod int) (shift, alt, ctrl, super bool) { | |||
| 344 | bits := mod - 1 | 355 | bits := mod - 1 | ||
| 345 | - | shift := bits&1 != 0 | 356 | + | return bits&1 != 0, bits&2 != 0, bits&4 != 0, bits&8 != 0 || bits&32 != 0 |
| 346 | - | alt := bits&2 != 0 | 357 | + | } |
| 347 | - | ctrl := bits&4 != 0 | 358 | + | |
| 359 | + | func keyFromModifiedCode(code, mod int) (Key, bool) { | |||
| 360 | + | shift, alt, ctrl, super := modifierBits(mod) | |||
| 348 | // Kitty keyboard protocol (CSI ... u) reports control keys as their | 361 | // Kitty keyboard protocol (CSI ... u) reports control keys as their | ||
| 349 | // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the | 362 | // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the | ||
| 350 | // enhanced-mode handling these arrive as raw bytes; with it enabled | 363 | // enhanced-mode handling these arrive as raw bytes; with it enabled | ||
| 351 | // they come through here, so map them back to their dedicated keys. | 364 | // they come through here, so map them back to their dedicated keys. | ||
| 352 | switch code { | 365 | switch code { | ||
| 353 | case 13: | 366 | case 13: | ||
| 354 | - | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true | 367 | + | return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true |
| 355 | case 27: | 368 | case 27: | ||
| 356 | - | return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true | 369 | + | return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true |
| 357 | case 9: | 370 | case 9: | ||
| 358 | if shift { | 371 | if shift { | ||
| 359 | - | return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true | 372 | + | return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl, Super: super}, true |
| 360 | } | 373 | } | ||
| 361 | - | return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true | 374 | + | return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true |
| 362 | case 127, 8: | 375 | case 127, 8: | ||
| 363 | - | return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true | 376 | + | return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true |
| 364 | } | 377 | } | ||
| 365 | if ctrl { | 378 | if ctrl { | ||
| 366 | switch code { | 379 | switch code { | ||
| @@ -386,6 +399,9 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { | |||||
| 386 | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | 399 | return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true | ||
| 387 | } | 400 | } | ||
| 388 | } | 401 | } | ||
| 402 | + | if code >= '0' && code <= '9' { | |||
| 403 | + | return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true | |||
| 404 | + | } | |||
| 389 | return Key{}, false | 405 | return Key{}, false | ||
| 390 | } | 406 | } | ||
| 391 | 407 | ||||
M packages/tui/input_test.go +28 -0
| @@ -23,6 +23,34 @@ func TestReaderParsesCSIUCtrlC(t *testing.T) { | |||
| 23 | 23 | } | |
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | + | func TestReaderParsesCSIUCtrlNumber(t *testing.T) { | |
| 27 | + | k := readKey(t, "\x1b[49;5u") | |
| 28 | + | if k.Kind != KeyRune || k.Rune != '1' || !k.Ctrl { | |
| 29 | + | t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+1", k.Kind, k.Rune, k.Ctrl) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestReaderParsesCSIUSuperNumber(t *testing.T) { | |
| 34 | + | k := readKey(t, "\x1b[50;9u") | |
| 35 | + | if k.Kind != KeyRune || k.Rune != '2' || !k.Super { | |
| 36 | + | t.Fatalf("Read kind=%v rune=%q super=%v, want super+2", k.Kind, k.Rune, k.Super) | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestReaderParsesCSIUSuperNumberWithEventType(t *testing.T) { | |
| 41 | + | k := readKey(t, "\x1b[51;9:3u") | |
| 42 | + | if k.Kind != KeyRune || k.Rune != '3' || !k.Super { | |
| 43 | + | t.Fatalf("Read kind=%v rune=%q super=%v, want super+3", k.Kind, k.Rune, k.Super) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestReaderParsesCSIUHyperNumberAsSuper(t *testing.T) { | |
| 48 | + | k := readKey(t, "\x1b[52;33u") | |
| 49 | + | if k.Kind != KeyRune || k.Rune != '4' || !k.Super { | |
| 50 | + | t.Fatalf("Read kind=%v rune=%q super=%v, want hyper+4 as super", k.Kind, k.Rune, k.Super) | |
| 51 | + | } | |
| 52 | + | } | |
| 53 | + | ||
| 26 | 54 | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | |
| 27 | 55 | k := readKey(t, "\x16") | |
| 28 | 56 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | |
| @@ -23,6 +23,34 @@ func TestReaderParsesCSIUCtrlC(t *testing.T) { | |||||
| 23 | } | 23 | } | ||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | + | func TestReaderParsesCSIUCtrlNumber(t *testing.T) { | |||
| 27 | + | k := readKey(t, "\x1b[49;5u") | |||
| 28 | + | if k.Kind != KeyRune || k.Rune != '1' || !k.Ctrl { | |||
| 29 | + | t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+1", k.Kind, k.Rune, k.Ctrl) | |||
| 30 | + | } | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestReaderParsesCSIUSuperNumber(t *testing.T) { | |||
| 34 | + | k := readKey(t, "\x1b[50;9u") | |||
| 35 | + | if k.Kind != KeyRune || k.Rune != '2' || !k.Super { | |||
| 36 | + | t.Fatalf("Read kind=%v rune=%q super=%v, want super+2", k.Kind, k.Rune, k.Super) | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestReaderParsesCSIUSuperNumberWithEventType(t *testing.T) { | |||
| 41 | + | k := readKey(t, "\x1b[51;9:3u") | |||
| 42 | + | if k.Kind != KeyRune || k.Rune != '3' || !k.Super { | |||
| 43 | + | t.Fatalf("Read kind=%v rune=%q super=%v, want super+3", k.Kind, k.Rune, k.Super) | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestReaderParsesCSIUHyperNumberAsSuper(t *testing.T) { | |||
| 48 | + | k := readKey(t, "\x1b[52;33u") | |||
| 49 | + | if k.Kind != KeyRune || k.Rune != '4' || !k.Super { | |||
| 50 | + | t.Fatalf("Read kind=%v rune=%q super=%v, want hyper+4 as super", k.Kind, k.Rune, k.Super) | |||
| 51 | + | } | |||
| 52 | + | } | |||
| 53 | + | ||||
| 26 | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | 54 | func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { | ||
| 27 | k := readKey(t, "\x16") | 55 | k := readKey(t, "\x16") | ||
| 28 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | 56 | if k.Kind != KeyPasteClipboard || !k.Ctrl { | ||