From cf7ddf53228dc6286400d65461bae5efbbd0d566 Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Tue, 23 Jun 2026 06:56:24 +0200 Subject: [PATCH] Add quick model switch shortcuts (Ctrl+1..9) with /settings model shortcuts sub-view --- README.md | 4 +- packages/agent/cli.go | 5 + packages/agent/config.go | 10 ++ packages/agent/modes/interactive.go | 219 +++++++++++++++++++++++- packages/agent/modes/settings_dialog.go | 69 +++++++- packages/agent/settings_store.go | 25 +++ packages/tui/input.go | 72 +++++--- packages/tui/input_test.go | 28 +++ 8 files changed, 389 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index 1097693..8e22609 100644 --- a/README.md +++ b/README.md @@ -297,12 +297,13 @@ Background subagents that run alongside your main session. Each one is a separat ### `/settings` -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: +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: - **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. - **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. - **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. - **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). +- **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. ### `/skills` @@ -578,6 +579,7 @@ Slash commands also work while the agent is busy. Read-only ones (`/help`, `/jum | `ctrl+l` | Redraw the screen. | | `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. | | `ctrl+o` | Expand or collapse long tool results (read, write, edit, bash outputs over ~12 lines). | +| `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. | | `@` | Open the file picker. Browse files and directories in the working directory. | ### File picker (`@`) diff --git a/packages/agent/cli.go b/packages/agent/cli.go index f21639e..fbc0781 100644 --- a/packages/agent/cli.go +++ b/packages/agent/cli.go @@ -931,6 +931,10 @@ func runInteractive(ctx context.Context, args Args, version string) error { }() initialCfg, _ := LoadConfig() + quickModelShortcuts := make([]modes.QuickModelShortcut, len(initialCfg.QuickModelShortcuts)) + for idx, s := range initialCfg.QuickModelShortcuts { + quickModelShortcuts[idx] = modes.QuickModelShortcut{Provider: s.Provider, Model: s.Model} + } theme, _, themeErr := tui.DetectThemeWithCustom(ZotHome(), initialCfg.Theme, 80*time.Millisecond) if themeErr != nil { fmt.Fprintln(os.Stderr, "theme load:", themeErr) @@ -958,6 +962,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { Theme: theme, InlineImagesEnabled: initialCfg.InlineImagesEnabled, AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, + QuickModelShortcuts: quickModelShortcuts, RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, RespectGitignore: initialCfg.RespectGitignore, ThemeName: initialCfg.Theme, diff --git a/packages/agent/config.go b/packages/agent/config.go index 60d285d..e818b61 100644 --- a/packages/agent/config.go +++ b/packages/agent/config.go @@ -15,6 +15,12 @@ import ( "github.com/patriceckhart/zot/packages/provider/auth" ) +// QuickModelShortcut is one configured keyboard shortcut slot. +type QuickModelShortcut struct { + Provider string `json:"provider"` + Model string `json:"model"` +} + // Config is the persisted user configuration. type Config struct { Provider string `json:"provider"` @@ -23,6 +29,10 @@ type Config struct { Temperature *float32 `json:"temperature,omitempty"` Theme string `json:"theme"` + // QuickModelShortcuts maps slots 1-9 to provider/model pairs used by + // Ctrl+1..9. Cmd+1..9 may also work on terminals that forward Super. + QuickModelShortcuts []QuickModelShortcut `json:"quick_model_shortcuts,omitempty"` + // InlineImagesEnabled controls whether zot draws screenshots inline // when the terminal supports an image protocol. nil/missing means // auto (enabled when supported); false disables; true forces the diff --git a/packages/agent/modes/interactive.go b/packages/agent/modes/interactive.go index 14bbc96..11a2a88 100644 --- a/packages/agent/modes/interactive.go +++ b/packages/agent/modes/interactive.go @@ -6,6 +6,8 @@ import ( "fmt" "os" "path/filepath" + "runtime" + "strconv" "strings" "sync" "time" @@ -58,6 +60,12 @@ type InteractiveConfig struct { // ThemeName mirrors the persisted config theme value. Empty means auto. ThemeName string + + // QuickModelShortcuts maps slots 1-9 to provider/model pairs. The + // shortcuts are Ctrl+1..9. Cmd+1..9 may also work when the terminal + // forwards Command/Super keypresses, but Ctrl is the displayed chord. + QuickModelShortcuts []QuickModelShortcut + // ExtensionThemes returns themes bundled with loaded extensions. ExtensionThemes func() []tui.ThemeOption @@ -234,8 +242,15 @@ type chatCacheKey struct { tailLimit int } +// QuickModelShortcut is one configured quick model switch slot. +type QuickModelShortcut struct { + Provider string + Model string +} + // SettingsStore persists user-toggleable settings surfaced by /settings. type SettingsStore interface { + SetQuickModelShortcut(slot int, providerName, model string) error SetInlineImages(enabled bool) error SetAutoSwarm(enabled bool) error SetRecursiveFileSuggest(enabled bool) error @@ -353,6 +368,7 @@ type Interactive struct { logoutDialog *logoutDialog telegramDialog *telegramDialog settingsDialog *settingsDialog + quickModelAssign int telegramBridge *telegram.Bridge sessionOpsDialog *sessionOpsDialog sessionTreeDialog *sessionTreeDialog @@ -1731,11 +1747,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { if i.modelDialog.Active() { if k.Kind == tui.KeyCtrlC { i.modelDialog.Close() + i.quickModelAssign = 0 return false } act := i.modelDialog.HandleKey(k) + if act.Close { + i.quickModelAssign = 0 + } if act.Select { - i.applyModelSelection(act.Provider, act.Model) + if i.quickModelAssign > 0 { + i.applyQuickModelSelection(i.quickModelAssign, act.Provider, act.Model) + i.quickModelAssign = 0 + } else { + i.applyModelSelection(act.Provider, act.Model) + } } return false } @@ -1818,6 +1843,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { return false } act := i.settingsDialog.HandleKey(k) + if act.ModelShortcutSlot > 0 { + i.openQuickModelPicker(act.ModelShortcutSlot) + } if act.Toggle { i.applySettingChange(act) } @@ -1917,6 +1945,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { return false } + if slot := quickModelShortcutSlot(k); slot > 0 { + i.applyQuickModelShortcut(slot) + return false + } + // Global keys. switch k.Kind { case tui.KeyCtrlC: @@ -2773,6 +2806,7 @@ func (i *Interactive) openSettingsDialog() { recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore + quickItems := i.quickModelSettingItems() reasoningOptions := []settingsOption{ {value: "", label: "off", desc: "no reasoning"}, @@ -2820,7 +2854,7 @@ func (i *Interactive) openSettingsDialog() { } } - i.settingsDialog.Open([]settingsItem{ + items := []settingsItem{ { key: "inline_images_enabled", label: "render images when supported", @@ -2864,20 +2898,193 @@ func (i *Interactive) openSettingsDialog() { options: themeOptions, choice: themeChoice, }, - }) + } + if len(quickItems) > 0 { + items = append(items, settingsItem{ + key: "quick_models", + label: "model shortcuts", + desc: "configure " + quickModelShortcutPrefix() + "+1 through " + quickModelShortcutPrefix() + "+9 quick model switches", + children: quickItems, + }) + } + i.settingsDialog.Open(items) } func (i *Interactive) applySettingChange(act settingsAction) { - switch act.Key { - case "reasoning": + switch { + case strings.HasPrefix(act.Key, "quick_model_"): + i.applyQuickModelSetting(act.Key, act.StringValue) + case act.Key == "reasoning": i.applyReasoningSetting(act.StringValue) - case "theme": + case act.Key == "theme": i.applyThemeSetting(act.StringValue) default: i.applySettingToggle(act.Key, act.Value) } } +func (i *Interactive) quickModelSettingItems() []settingsItem { + if len(i.cfg.QuickModelShortcuts) < 9 { + next := make([]QuickModelShortcut, 9) + copy(next, i.cfg.QuickModelShortcuts) + i.cfg.QuickModelShortcuts = next + } + items := make([]settingsItem, 0, 9) + for slot := 1; slot <= 9; slot++ { + items = append(items, i.quickModelSettingItem(slot)) + } + return items +} + +func (i *Interactive) quickModelSettingItem(slot int) settingsItem { + current := QuickModelShortcut{} + if slot >= 1 && len(i.cfg.QuickModelShortcuts) >= slot { + current = i.cfg.QuickModelShortcuts[slot-1] + } + hint := "not assigned" + if current.Provider != "" && current.Model != "" { + hint = current.Provider + " / " + current.Model + } + return settingsItem{ + key: "quick_model_" + strconv.Itoa(slot), + label: "model " + strconv.Itoa(slot), + desc: quickModelShortcutLabel(slot) + " switches to this model. Enter opens the /model selector, Backspace clears.", + picker: true, + hint: hint, + } +} + +func quickModelShortcutSlot(k tui.Key) int { + if k.Kind != tui.KeyRune || k.Rune < '1' || k.Rune > '9' { + return 0 + } + if runtime.GOOS == "darwin" { + if !k.Super && !k.Ctrl { + return 0 + } + } else if !k.Ctrl { + return 0 + } + return int(k.Rune - '0') +} + +func quickModelShortcutPrefix() string { + return "Ctrl" +} + +func quickModelShortcutLabel(slot int) string { + return quickModelShortcutPrefix() + "+" + strconv.Itoa(slot) +} + +func (i *Interactive) openQuickModelPicker(slot int) { + if slot < 1 || slot > 9 { + return + } + i.quickModelAssign = slot + current := i.cfg.Model + if len(i.cfg.QuickModelShortcuts) >= slot && i.cfg.QuickModelShortcuts[slot-1].Model != "" { + current = i.cfg.QuickModelShortcuts[slot-1].Model + } + var loggedIn []string + if i.cfg.LoggedInProviders != nil { + loggedIn = i.cfg.LoggedInProviders() + } + i.modelDialog.Open(current, loggedIn) +} + +func (i *Interactive) applyQuickModelSelection(slot int, providerName, model string) { + i.setQuickModelShortcut(slot, providerName, model) +} + +func (i *Interactive) applyQuickModelShortcut(slot int) { + if slot < 1 || slot > 9 { + return + } + if i.busy { + i.mu.Lock() + i.statusErr = "cannot switch model while a turn is running" + i.statusOK = "" + i.mu.Unlock() + i.invalidate() + return + } + if len(i.cfg.QuickModelShortcuts) < slot { + i.mu.Lock() + i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" + i.statusOK = "" + i.mu.Unlock() + i.invalidate() + return + } + shortcut := i.cfg.QuickModelShortcuts[slot-1] + if shortcut.Provider == "" || shortcut.Model == "" { + i.mu.Lock() + i.statusErr = quickModelShortcutLabel(slot) + " is not assigned" + i.statusOK = "" + i.mu.Unlock() + i.invalidate() + return + } + i.swapModel(shortcut.Provider, shortcut.Model, i.cfg.BuildAgentFor, false) + i.invalidate() +} + +func (i *Interactive) applyQuickModelSetting(key, value string) { + slotText := strings.TrimPrefix(key, "quick_model_") + slot, err := strconv.Atoi(slotText) + if err != nil || slot < 1 || slot > 9 { + return + } + providerName, model := "", "" + if value != "" { + parts := strings.SplitN(value, "\t", 2) + if len(parts) == 2 { + providerName, model = parts[0], parts[1] + } + } + i.setQuickModelShortcut(slot, providerName, model) +} + +func (i *Interactive) setQuickModelShortcut(slot int, providerName, model string) { + if len(i.cfg.QuickModelShortcuts) < slot { + next := make([]QuickModelShortcut, slot) + copy(next, i.cfg.QuickModelShortcuts) + i.cfg.QuickModelShortcuts = next + } + i.cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} + if i.cfg.SettingsStore != nil { + if err := i.cfg.SettingsStore.SetQuickModelShortcut(slot, providerName, model); err != nil { + i.mu.Lock() + i.statusErr = "settings: " + err.Error() + i.mu.Unlock() + return + } + } + i.mu.Lock() + if model == "" { + i.statusOK = quickModelShortcutLabel(slot) + " cleared" + } else { + i.statusOK = quickModelShortcutLabel(slot) + " set to " + providerName + " / " + model + } + i.statusErr = "" + i.mu.Unlock() + i.refreshQuickModelSettingsItem(slot) + i.invalidate() +} + +func (i *Interactive) refreshQuickModelSettingsItem(slot int) { + if i.settingsDialog == nil || !i.settingsDialog.Active() || len(i.settingsDialog.items) == 0 { + return + } + key := "quick_model_" + strconv.Itoa(slot) + for idx, it := range i.settingsDialog.items { + if it.key == key { + i.settingsDialog.items[idx] = i.quickModelSettingItem(slot) + return + } + } +} + func (i *Interactive) applySettingToggle(key string, value bool) { // Every setting toggle forces a full repaint at the end — same // effect as the user pressing Ctrl+L — so any per-setting visual diff --git a/packages/agent/modes/settings_dialog.go b/packages/agent/modes/settings_dialog.go index abdbb25..f22ca23 100644 --- a/packages/agent/modes/settings_dialog.go +++ b/packages/agent/modes/settings_dialog.go @@ -10,10 +10,13 @@ import ( type settingsDialog struct { active bool + title string items []settingsItem cursor int selecting bool optionCursor int + parentItems []settingsItem + parentCursor int } type settingsItem struct { @@ -22,6 +25,8 @@ type settingsItem struct { desc string value bool options []settingsOption + children []settingsItem + picker bool choice int disabled bool hint string @@ -34,11 +39,12 @@ type settingsOption struct { } type settingsAction struct { - Toggle bool - Key string - Value bool - StringValue string - Close bool + Toggle bool + Key string + Value bool + StringValue string + ModelShortcutSlot int + Close bool } func newSettingsDialog() *settingsDialog { return &settingsDialog{} } @@ -47,10 +53,13 @@ func (d *settingsDialog) Open(items []settingsItem) bool { if len(items) == 0 { return false } + d.title = "settings" d.items = items d.cursor = 0 d.selecting = false d.optionCursor = 0 + d.parentItems = nil + d.parentCursor = 0 d.active = true return true } @@ -58,6 +67,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool { func (d *settingsDialog) Close() { d.active = false d.selecting = false + d.parentItems = nil } func (d *settingsDialog) Active() bool { return d != nil && d.active } @@ -74,7 +84,22 @@ func (d *settingsDialog) HandleKey(k tui.Key) settingsAction { if d.cursor < len(d.items)-1 { d.cursor++ } + case tui.KeyBackspace: + if len(d.items) > 0 { + it := d.items[d.cursor] + if strings.HasPrefix(it.key, "quick_model_") { + return settingsAction{Toggle: true, Key: it.key, StringValue: ""} + } + } case tui.KeyEsc: + if len(d.parentItems) > 0 { + d.items = d.parentItems + d.cursor = d.parentCursor + d.parentItems = nil + d.parentCursor = 0 + d.title = "settings" + return settingsAction{} + } d.Close() return settingsAction{Close: true} case tui.KeyEnter: @@ -119,6 +144,27 @@ func (d *settingsDialog) toggleCurrent() settingsAction { if it.disabled { return settingsAction{} } + if it.picker { + slotText := strings.TrimPrefix(it.key, "quick_model_") + slot := 0 + for _, r := range slotText { + if r < '0' || r > '9' { + slot = 0 + break + } + slot = slot*10 + int(r-'0') + } + return settingsAction{ModelShortcutSlot: slot} + } + if len(it.children) > 0 { + d.parentItems = d.items + d.parentCursor = d.cursor + d.items = it.children + d.cursor = 0 + d.optionCursor = 0 + d.title = "settings: " + it.label + return settingsAction{} + } if len(it.options) > 0 { d.optionCursor = it.choice if d.optionCursor < 0 || d.optionCursor >= len(it.options) { @@ -159,15 +205,22 @@ func (d *settingsDialog) Render(th tui.Theme, width int) []string { return d.renderOptions(th, width) } var lines []string - lines = append(lines, frameHeader(th, "settings", width)) - lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:")) + lines = append(lines, frameHeader(th, d.title, width)) + if len(d.parentItems) > 0 { + lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to go back:")) + } else { + lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:")) + } for i, it := range d.items { box := "[ ]" if it.value { box = "[✓]" } plain := " " + box + " " + it.label - if len(it.options) > 0 { + if it.picker || len(it.children) > 0 { + box = "[→]" + plain = " " + box + " " + it.label + } else if len(it.options) > 0 { box = "[→]" if it.choice < 0 || it.choice >= len(it.options) { it.choice = 0 diff --git a/packages/agent/settings_store.go b/packages/agent/settings_store.go index cee0c25..caefca8 100644 --- a/packages/agent/settings_store.go +++ b/packages/agent/settings_store.go @@ -4,6 +4,31 @@ import "github.com/patriceckhart/zot/packages/provider" type configSettingsStore struct{} +func (configSettingsStore) SetQuickModelShortcut(slot int, providerName, model string) error { + if slot < 1 || slot > 9 { + return nil + } + cfg, err := LoadConfig() + if err != nil { + return err + } + if len(cfg.QuickModelShortcuts) < slot { + next := make([]QuickModelShortcut, slot) + copy(next, cfg.QuickModelShortcuts) + cfg.QuickModelShortcuts = next + } + cfg.QuickModelShortcuts[slot-1] = QuickModelShortcut{Provider: providerName, Model: model} + // Trim trailing empty slots so config.json stays compact. + for len(cfg.QuickModelShortcuts) > 0 { + last := cfg.QuickModelShortcuts[len(cfg.QuickModelShortcuts)-1] + if last.Provider != "" || last.Model != "" { + break + } + cfg.QuickModelShortcuts = cfg.QuickModelShortcuts[:len(cfg.QuickModelShortcuts)-1] + } + return SaveConfig(cfg) +} + func (configSettingsStore) SetInlineImages(enabled bool) error { cfg, err := LoadConfig() if err != nil { diff --git a/packages/tui/input.go b/packages/tui/input.go index 11081f2..b0fd13b 100644 --- a/packages/tui/input.go +++ b/packages/tui/input.go @@ -14,6 +14,7 @@ type Key struct { Ctrl bool Alt bool Shift bool + Super bool } type KeyKind int @@ -245,7 +246,7 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { return Key{Kind: KeyUnknown} } - shift, alt := parseCSIModifiers(params) + shift, alt, super := parseCSIModifiers(params) if final == 'u' { if key, ok := parseCSIU(params); ok { return key @@ -258,13 +259,13 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { } switch final { case 'A': - return Key{Kind: KeyUp, Alt: alt, Shift: shift} + return Key{Kind: KeyUp, Alt: alt, Shift: shift, Super: super} case 'B': - return Key{Kind: KeyDown, Alt: alt, Shift: shift} + return Key{Kind: KeyDown, Alt: alt, Shift: shift, Super: super} case 'C': - return Key{Kind: KeyRight, Alt: alt, Shift: shift} + return Key{Kind: KeyRight, Alt: alt, Shift: shift, Super: super} case 'D': - return Key{Kind: KeyLeft, Alt: alt, Shift: shift} + return Key{Kind: KeyLeft, Alt: alt, Shift: shift, Super: super} case 'H': return Key{Kind: KeyHome} case 'F': @@ -287,23 +288,20 @@ func (r *Reader) dispatchCSI(params string, final byte) Key { return Key{Kind: KeyUnknown} } -func parseCSIModifiers(params string) (shift, alt bool) { +func parseCSIModifiers(params string) (shift, alt, super bool) { if params == "" { - return false, false + return false, false, false } i := strings.LastIndexByte(params, ';') if i < 0 || i+1 >= len(params) { - return false, false + return false, false, false } - mod, err := strconv.Atoi(params[i+1:]) - if err != nil { - return false, false + mod, ok := parseModifierParam(params[i+1:]) + if !ok { + return false, false, false } - // Xterm-style modifier values are 1 plus a bitmask: - // 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, 6=Shift+Ctrl, - // 7=Alt+Ctrl, 8=Shift+Alt+Ctrl. - bits := mod - 1 - return bits&1 != 0, bits&2 != 0 + shift, alt, _, super = modifierBits(mod) + return shift, alt, super } func parseCSIU(params string) (Key, bool) { @@ -317,7 +315,9 @@ func parseCSIU(params string) (Key, bool) { } mod := 1 if len(parts) >= 2 { - if mod, err = strconv.Atoi(parts[1]); err != nil { + var ok bool + mod, ok = parseModifierParam(parts[1]) + if !ok { return Key{}, false } } @@ -329,8 +329,8 @@ func parseModifyOtherKeys(params string) (Key, bool) { if len(parts) != 3 || parts[0] != "27" { return Key{}, false } - mod, err := strconv.Atoi(parts[1]) - if err != nil { + mod, ok := parseModifierParam(parts[1]) + if !ok { return Key{}, false } code, err := strconv.Atoi(parts[2]) @@ -340,27 +340,40 @@ func parseModifyOtherKeys(params string) (Key, bool) { return keyFromModifiedCode(code, mod) } -func keyFromModifiedCode(code, mod int) (Key, bool) { +func parseModifierParam(s string) (int, bool) { + if i := strings.IndexByte(s, ':'); i >= 0 { + s = s[:i] + } + mod, err := strconv.Atoi(s) + if err != nil { + return 0, false + } + return mod, true +} + +func modifierBits(mod int) (shift, alt, ctrl, super bool) { bits := mod - 1 - shift := bits&1 != 0 - alt := bits&2 != 0 - ctrl := bits&4 != 0 + return bits&1 != 0, bits&2 != 0, bits&4 != 0, bits&8 != 0 || bits&32 != 0 +} + +func keyFromModifiedCode(code, mod int) (Key, bool) { + shift, alt, ctrl, super := modifierBits(mod) // Kitty keyboard protocol (CSI ... u) reports control keys as their // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the // enhanced-mode handling these arrive as raw bytes; with it enabled // they come through here, so map them back to their dedicated keys. switch code { case 13: - return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl}, true + return Key{Kind: KeyEnter, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true case 27: - return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl}, true + return Key{Kind: KeyEsc, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true case 9: if shift { - return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl}, true + return Key{Kind: KeyShiftTab, Alt: alt, Ctrl: ctrl, Super: super}, true } - return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl}, true + return Key{Kind: KeyTab, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true case 127, 8: - return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl}, true + return Key{Kind: KeyBackspace, Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true } if ctrl { switch code { @@ -386,6 +399,9 @@ func keyFromModifiedCode(code, mod int) (Key, bool) { return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true } } + if code >= '0' && code <= '9' { + return Key{Kind: KeyRune, Rune: rune(code), Shift: shift, Alt: alt, Ctrl: ctrl, Super: super}, true + } return Key{}, false } diff --git a/packages/tui/input_test.go b/packages/tui/input_test.go index 5853128..f7de6b0 100644 --- a/packages/tui/input_test.go +++ b/packages/tui/input_test.go @@ -23,6 +23,34 @@ func TestReaderParsesCSIUCtrlC(t *testing.T) { } } +func TestReaderParsesCSIUCtrlNumber(t *testing.T) { + k := readKey(t, "\x1b[49;5u") + if k.Kind != KeyRune || k.Rune != '1' || !k.Ctrl { + t.Fatalf("Read kind=%v rune=%q ctrl=%v, want ctrl+1", k.Kind, k.Rune, k.Ctrl) + } +} + +func TestReaderParsesCSIUSuperNumber(t *testing.T) { + k := readKey(t, "\x1b[50;9u") + if k.Kind != KeyRune || k.Rune != '2' || !k.Super { + t.Fatalf("Read kind=%v rune=%q super=%v, want super+2", k.Kind, k.Rune, k.Super) + } +} + +func TestReaderParsesCSIUSuperNumberWithEventType(t *testing.T) { + k := readKey(t, "\x1b[51;9:3u") + if k.Kind != KeyRune || k.Rune != '3' || !k.Super { + t.Fatalf("Read kind=%v rune=%q super=%v, want super+3", k.Kind, k.Rune, k.Super) + } +} + +func TestReaderParsesCSIUHyperNumberAsSuper(t *testing.T) { + k := readKey(t, "\x1b[52;33u") + if k.Kind != KeyRune || k.Rune != '4' || !k.Super { + t.Fatalf("Read kind=%v rune=%q super=%v, want hyper+4 as super", k.Kind, k.Rune, k.Super) + } +} + func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) { k := readKey(t, "\x16") if k.Kind != KeyPasteClipboard || !k.Ctrl {