detached cf7ddf5

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
297297
298298 ### `/settings`
299299
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:
301301
302302 - **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.
303303 - **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.
304304 - **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.
305305 - **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.
306307
307308 ### `/skills`
308309
@@ -578,6 +579,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum
578579 | `ctrl+l` | Redraw the screen. |
579580 | `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. |
580581 | `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. |
581583 | `@` | Open the file picker. Browse files and directories in the working directory. |
582584
583585 ### 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 {
931931 }()
932932
933933 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+ }
934938 theme, _, themeErr := tui.DetectThemeWithCustom(ZotHome(), initialCfg.Theme, 80*time.Millisecond)
935939 if themeErr != nil {
936940 fmt.Fprintln(os.Stderr, "theme load:", themeErr)
@@ -958,6 +962,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
958962 Theme: theme,
959963 InlineImagesEnabled: initialCfg.InlineImagesEnabled,
960964 AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
965+ QuickModelShortcuts: quickModelShortcuts,
961966 RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
962967 RespectGitignore: initialCfg.RespectGitignore,
963968 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 (
1515 "github.com/patriceckhart/zot/packages/provider/auth"
1616 )
1717
18+// QuickModelShortcut is one configured keyboard shortcut slot.
19+type QuickModelShortcut struct {
20+ Provider string `json:"provider"`
21+ Model string `json:"model"`
22+}
23+
1824 // Config is the persisted user configuration.
1925 type Config struct {
2026 Provider string `json:"provider"`
@@ -23,6 +29,10 @@ type Config struct {
2329 Temperature *float32 `json:"temperature,omitempty"`
2430 Theme string `json:"theme"`
2531
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+
2636 // InlineImagesEnabled controls whether zot draws screenshots inline
2737 // when the terminal supports an image protocol. nil/missing means
2838 // 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 inline36 // InlineImagesEnabled controls whether zot draws screenshots inline
27 // when the terminal supports an image protocol. nil/missing means37 // when the terminal supports an image protocol. nil/missing means
28 // auto (enabled when supported); false disables; true forces the38 // auto (enabled when supported); false disables; true forces the
M packages/agent/modes/interactive.go +213 -6
@@ -6,6 +6,8 @@ import (
66 "fmt"
77 "os"
88 "path/filepath"
9+ "runtime"
10+ "strconv"
911 "strings"
1012 "sync"
1113 "time"
@@ -58,6 +60,12 @@ type InteractiveConfig struct {
5860
5961 // ThemeName mirrors the persisted config theme value. Empty means auto.
6062 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+
6169 // ExtensionThemes returns themes bundled with loaded extensions.
6270 ExtensionThemes func() []tui.ThemeOption
6371
@@ -234,8 +242,15 @@ type chatCacheKey struct {
234242 tailLimit int
235243 }
236244
245+// QuickModelShortcut is one configured quick model switch slot.
246+type QuickModelShortcut struct {
247+ Provider string
248+ Model string
249+}
250+
237251 // SettingsStore persists user-toggleable settings surfaced by /settings.
238252 type SettingsStore interface {
253+ SetQuickModelShortcut(slot int, providerName, model string) error
239254 SetInlineImages(enabled bool) error
240255 SetAutoSwarm(enabled bool) error
241256 SetRecursiveFileSuggest(enabled bool) error
@@ -353,6 +368,7 @@ type Interactive struct {
353368 logoutDialog *logoutDialog
354369 telegramDialog *telegramDialog
355370 settingsDialog *settingsDialog
371+ quickModelAssign int
356372 telegramBridge *telegram.Bridge
357373 sessionOpsDialog *sessionOpsDialog
358374 sessionTreeDialog *sessionTreeDialog
@@ -1731,11 +1747,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
17311747 if i.modelDialog.Active() {
17321748 if k.Kind == tui.KeyCtrlC {
17331749 i.modelDialog.Close()
1750+ i.quickModelAssign = 0
17341751 return false
17351752 }
17361753 act := i.modelDialog.HandleKey(k)
1754+ if act.Close {
1755+ i.quickModelAssign = 0
1756+ }
17371757 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+ }
17391764 }
17401765 return false
17411766 }
@@ -1818,6 +1843,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
18181843 return false
18191844 }
18201845 act := i.settingsDialog.HandleKey(k)
1846+ if act.ModelShortcutSlot > 0 {
1847+ i.openQuickModelPicker(act.ModelShortcutSlot)
1848+ }
18211849 if act.Toggle {
18221850 i.applySettingChange(act)
18231851 }
@@ -1917,6 +1945,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
19171945 return false
19181946 }
19191947
1948+ if slot := quickModelShortcutSlot(k); slot > 0 {
1949+ i.applyQuickModelShortcut(slot)
1950+ return false
1951+ }
1952+
19201953 // Global keys.
19211954 switch k.Kind {
19221955 case tui.KeyCtrlC:
@@ -2773,6 +2806,7 @@ func (i *Interactive) openSettingsDialog() {
27732806
27742807 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
27752808 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
2809+ quickItems := i.quickModelSettingItems()
27762810
27772811 reasoningOptions := []settingsOption{
27782812 {value: "", label: "off", desc: "no reasoning"},
@@ -2820,7 +2854,7 @@ func (i *Interactive) openSettingsDialog() {
28202854 }
28212855 }
28222856
2823- i.settingsDialog.Open([]settingsItem{
2857+ items := []settingsItem{
28242858 {
28252859 key: "inline_images_enabled",
28262860 label: "render images when supported",
@@ -2864,20 +2898,193 @@ func (i *Interactive) openSettingsDialog() {
28642898 options: themeOptions,
28652899 choice: themeChoice,
28662900 },
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)
28682911 }
28692912
28702913 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":
28732918 i.applyReasoningSetting(act.StringValue)
2874- case "theme":
2919+ case act.Key == "theme":
28752920 i.applyThemeSetting(act.StringValue)
28762921 default:
28772922 i.applySettingToggle(act.Key, act.Value)
28782923 }
28792924 }
28802925
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+
28813088 func (i *Interactive) applySettingToggle(key string, value bool) {
28823089 // Every setting toggle forces a full repaint at the end — same
28833090 // 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 string62 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.ThemeOption70 ExtensionThemes func() []tui.ThemeOption
63 71
@@ -234,8 +242,15 @@ type chatCacheKey struct {
234 tailLimit int242 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) error254 SetInlineImages(enabled bool) error
240 SetAutoSwarm(enabled bool) error255 SetAutoSwarm(enabled bool) error
241 SetRecursiveFileSuggest(enabled bool) error256 SetRecursiveFileSuggest(enabled bool) error
@@ -353,6 +368,7 @@ type Interactive struct {
353 logoutDialog *logoutDialog368 logoutDialog *logoutDialog
354 telegramDialog *telegramDialog369 telegramDialog *telegramDialog
355 settingsDialog *settingsDialog370 settingsDialog *settingsDialog
371+ quickModelAssign int
356 telegramBridge *telegram.Bridge372 telegramBridge *telegram.Bridge
357 sessionOpsDialog *sessionOpsDialog373 sessionOpsDialog *sessionOpsDialog
358 sessionTreeDialog *sessionTreeDialog374 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 false1751 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 false1765 return false
1741 }1766 }
@@ -1818,6 +1843,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
1818 return false1843 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 false1945 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.RecursiveFileSuggest2807 recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
2775 respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore2808 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 — same3089 // Every setting toggle forces a full repaint at the end — same
2883 // effect as the user pressing Ctrl+L — so any per-setting visual3090 // 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 (
1010
1111 type settingsDialog struct {
1212 active bool
13+ title string
1314 items []settingsItem
1415 cursor int
1516 selecting bool
1617 optionCursor int
18+ parentItems []settingsItem
19+ parentCursor int
1720 }
1821
1922 type settingsItem struct {
@@ -22,6 +25,8 @@ type settingsItem struct {
2225 desc string
2326 value bool
2427 options []settingsOption
28+ children []settingsItem
29+ picker bool
2530 choice int
2631 disabled bool
2732 hint string
@@ -34,11 +39,12 @@ type settingsOption struct {
3439 }
3540
3641 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
4248 }
4349
4450 func newSettingsDialog() *settingsDialog { return &settingsDialog{} }
@@ -47,10 +53,13 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
4753 if len(items) == 0 {
4854 return false
4955 }
56+ d.title = "settings"
5057 d.items = items
5158 d.cursor = 0
5259 d.selecting = false
5360 d.optionCursor = 0
61+ d.parentItems = nil
62+ d.parentCursor = 0
5463 d.active = true
5564 return true
5665 }
@@ -58,6 +67,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
5867 func (d *settingsDialog) Close() {
5968 d.active = false
6069 d.selecting = false
70+ d.parentItems = nil
6171 }
6272 func (d *settingsDialog) Active() bool { return d != nil && d.active }
6373
@@ -74,7 +84,22 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction {
7484 if d.cursor < len(d.items)-1 {
7585 d.cursor++
7686 }
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+ }
7794 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+ }
78103 d.Close()
79104 return settingsAction{Close: true}
80105 case tui.KeyEnter:
@@ -119,6 +144,27 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
119144 if it.disabled {
120145 return settingsAction{}
121146 }
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+ }
122168 if len(it.options) > 0 {
123169 d.optionCursor = it.choice
124170 if d.optionCursor < 0 || d.optionCursor >= len(it.options) {
@@ -159,15 +205,22 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string {
159205 return d.renderOptions(th, width)
160206 }
161207 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+ }
164214 for i, it := range d.items {
165215 box := "[ ]"
166216 if it.value {
167217 box = "[✓]"
168218 }
169219 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 {
171224 box = "[→]"
172225 if it.choice < 0 || it.choice >= len(it.options) {
173226 it.choice = 0
@@ -10,10 +10,13 @@ import (
10 10
11 type settingsDialog struct {11 type settingsDialog struct {
12 active bool12 active bool
13+ title string
13 items []settingsItem14 items []settingsItem
14 cursor int15 cursor int
15 selecting bool16 selecting bool
16 optionCursor int17 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 string25 desc string
23 value bool26 value bool
24 options []settingsOption27 options []settingsOption
28+ children []settingsItem
29+ picker bool
25 choice int30 choice int
26 disabled bool31 disabled bool
27 hint string32 hint string
@@ -34,11 +39,12 @@ type settingsOption struct {
34 }39 }
35 40
36 type settingsAction struct {41 type settingsAction struct {
37- Toggle bool42+ Toggle bool
38- Key string43+ Key string
39- Value bool44+ Value bool
40- StringValue string45+ StringValue string
41- Close bool46+ 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 false54 return false
49 }55 }
56+ d.title = "settings"
50 d.items = items57 d.items = items
51 d.cursor = 058 d.cursor = 0
52 d.selecting = false59 d.selecting = false
53 d.optionCursor = 060 d.optionCursor = 0
61+ d.parentItems = nil
62+ d.parentCursor = 0
54 d.active = true63 d.active = true
55 return true64 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 = false68 d.active = false
60 d.selecting = false69 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.choice169 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 []string207 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.label219 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 = 0226 it.choice = 0
M packages/agent/settings_store.go +25 -0
@@ -4,6 +4,31 @@ import "github.com/patriceckhart/zot/packages/provider"
44
55 type configSettingsStore struct{}
66
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+
732 func (configSettingsStore) SetInlineImages(enabled bool) error {
833 cfg, err := LoadConfig()
934 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 {
1414 Ctrl bool
1515 Alt bool
1616 Shift bool
17+ Super bool
1718 }
1819
1920 type KeyKind int
@@ -245,7 +246,7 @@ func (r *Reader) dispatchCSI(params string, final byte) Key {
245246 return Key{Kind: KeyUnknown}
246247 }
247248
248- shift, alt := parseCSIModifiers(params)
249+ shift, alt, super := parseCSIModifiers(params)
249250 if final == 'u' {
250251 if key, ok := parseCSIU(params); ok {
251252 return key
@@ -258,13 +259,13 @@ func (r *Reader) dispatchCSI(params string, final byte) Key {
258259 }
259260 switch final {
260261 case 'A':
261- return Key{Kind: KeyUp, Alt: alt, Shift: shift}
262+ return Key{Kind: KeyUp, Alt: alt, Shift: shift, Super: super}
262263 case 'B':
263- return Key{Kind: KeyDown, Alt: alt, Shift: shift}
264+ return Key{Kind: KeyDown, Alt: alt, Shift: shift, Super: super}
264265 case 'C':
265- return Key{Kind: KeyRight, Alt: alt, Shift: shift}
266+ return Key{Kind: KeyRight, Alt: alt, Shift: shift, Super: super}
266267 case 'D':
267- return Key{Kind: KeyLeft, Alt: alt, Shift: shift}
268+ return Key{Kind: KeyLeft, Alt: alt, Shift: shift, Super: super}
268269 case 'H':
269270 return Key{Kind: KeyHome}
270271 case 'F':
@@ -287,23 +288,20 @@ func (r *Reader) dispatchCSI(params string, final byte) Key {
287288 return Key{Kind: KeyUnknown}
288289 }
289290
290-func parseCSIModifiers(params string) (shift, alt bool) {
291+func parseCSIModifiers(params string) (shift, alt, super bool) {
291292 if params == "" {
292- return false, false
293+ return false, false, false
293294 }
294295 i := strings.LastIndexByte(params, ';')
295296 if i < 0 || i+1 >= len(params) {
296- return false, false
297+ return false, false, false
297298 }
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
301302 }
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
307305 }
308306
309307 func parseCSIU(params string) (Key, bool) {
@@ -317,7 +315,9 @@ func parseCSIU(params string) (Key, bool) {
317315 }
318316 mod := 1
319317 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 {
321321 return Key{}, false
322322 }
323323 }
@@ -329,8 +329,8 @@ func parseModifyOtherKeys(params string) (Key, bool) {
329329 if len(parts) != 3 || parts[0] != "27" {
330330 return Key{}, false
331331 }
332- mod, err := strconv.Atoi(parts[1])
333- if err != nil {
332+ mod, ok := parseModifierParam(parts[1])
333+ if !ok {
334334 return Key{}, false
335335 }
336336 code, err := strconv.Atoi(parts[2])
@@ -340,27 +340,40 @@ func parseModifyOtherKeys(params string) (Key, bool) {
340340 return keyFromModifiedCode(code, mod)
341341 }
342342
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) {
344355 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)
348361 // Kitty keyboard protocol (CSI ... u) reports control keys as their
349362 // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the
350363 // enhanced-mode handling these arrive as raw bytes; with it enabled
351364 // they come through here, so map them back to their dedicated keys.
352365 switch code {
353366 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
355368 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
357370 case 9:
358371 if shift {
359- return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true
372+ return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl, Super: super}, true
360373 }
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
362375 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
364377 }
365378 if ctrl {
366379 switch code {
@@ -386,6 +399,9 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
386399 return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true
387400 }
388401 }
402+ if code >= '0' && code <= '9' {
403+ return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true
404+ }
389405 return Key{}, false
390406 }
391407
@@ -14,6 +14,7 @@ type Key struct {
14 Ctrl bool14 Ctrl bool
15 Alt bool15 Alt bool
16 Shift bool16 Shift bool
17+ Super bool
17 }18 }
18 19
19 type KeyKind int20 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 key252 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, false293+ 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, false297+ 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, false301+ 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 := 1316 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{}, false321 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{}, false330 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{}, false334 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 - 1355 bits := mod - 1
345- shift := bits&1 != 0356+ return bits&1 != 0, bits&2 != 0, bits&4 != 0, bits&8 != 0 || bits&32 != 0
346- alt := bits&2 != 0357+}
347- ctrl := bits&4 != 0358+
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 their361 // Kitty keyboard protocol (CSI ... u) reports control keys as their
349 // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the362 // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the
350 // enhanced-mode handling these arrive as raw bytes; with it enabled363 // 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}, true367+ 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}, true369+ 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}, true372+ return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl, Super: super}, true
360 }373 }
361- return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true374+ 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}, true376+ 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}, true399 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{}, false405 return Key{}, false
390 }406 }
391 407
M packages/tui/input_test.go +28 -0
@@ -23,6 +23,34 @@ func TestReaderParsesCSIUCtrlC(t *testing.T) {
2323 }
2424 }
2525
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+
2654 func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
2755 k := readKey(t, "\x16")
2856 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 {

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