mirror of
https://github.com/patriceckhart/zot.git
synced 2026-06-27 22:06:31 +02:00
Add quick model switch shortcuts (Ctrl+1..9) with /settings model shortcuts sub-view
This commit is contained in:
parent
4bec50ae9c
commit
cf7ddf5322
8 changed files with 389 additions and 43 deletions
|
|
@ -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 (`@`)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue