Compare commits

...

4 commits

Author SHA1 Message Date
patriceckhart
b325477870 Update zot welcome tagline
Some checks failed
ci / test (windows-latest) (push) Has been cancelled
ci / test (macos-latest) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
2026-06-24 07:33:11 +02:00
mi-skam
198b8cd284 Add opencode-go/qwen3.7-plus to built-in catalog
Some checks are pending
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
Adds Qwen3.7 Plus for both opencode-go and opencode providers,
following the same pattern as qwen3.5-plus and qwen3.6-plus.
Context window is 1M tokens per the OpenRouter model card.
2026-06-23 12:47:08 +02:00
patriceckhart
cf7ddf5322 Add quick model switch shortcuts (Ctrl+1..9) with /settings model shortcuts sub-view
Some checks are pending
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
2026-06-23 06:56:24 +02:00
patriceckhart
4bec50ae9c fix(swarm): inherit host model provider for auto-spawn
Some checks are pending
ci / test (macos-latest) (push) Waiting to run
ci / test (ubuntu-latest) (push) Waiting to run
ci / test (windows-latest) (push) Waiting to run
2026-06-22 17:39:21 +02:00
13 changed files with 547 additions and 65 deletions

View file

@ -297,12 +297,13 @@ Background subagents that run alongside your main session. Each one is a separat
### `/settings` ### `/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. - **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. - **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. - **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). - **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` ### `/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+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+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+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. | | `@` | Open the file picker. Browse files and directories in the working directory. |
### File picker (`@`) ### File picker (`@`)

View file

@ -340,7 +340,7 @@ func PrintHelp(version string) {
if useColor { if useColor {
headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("i'm zot. yet another coding agent harness.")) headline = th.AccentBar(th.Assistant) + assistant(tui.Bold("i'm zot. yet another coding agent harness."))
} else { } else {
headline = "i'm zot. yet another coding agent harness." headline = "zot. yet another coding agent harness."
} }
fmt.Fprintln(os.Stderr, headline) fmt.Fprintln(os.Stderr, headline)
fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands.")) fmt.Fprintln(os.Stderr, muted("ask anything, or type /help inside the tui to see commands."))

View file

@ -484,9 +484,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
return reg return reg
} }
reg["swarm_spawn"] = &tools.SwarmSpawnTool{ reg["swarm_spawn"] = &tools.SwarmSpawnTool{
Swarm: swarmMgr, Swarm: swarmMgr,
Enabled: AutoSwarmEnabled, Enabled: AutoSwarmEnabled,
OnSpawned: onSpawnedSwarm, DefaultModel: func() string { return r.Model },
DefaultProvider: func() string { return r.Provider },
OnSpawned: onSpawnedSwarm,
} }
return reg return reg
} }
@ -929,6 +931,10 @@ func runInteractive(ctx context.Context, args Args, version string) error {
}() }()
initialCfg, _ := LoadConfig() 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) theme, _, themeErr := tui.DetectThemeWithCustom(ZotHome(), initialCfg.Theme, 80*time.Millisecond)
if themeErr != nil { if themeErr != nil {
fmt.Fprintln(os.Stderr, "theme load:", themeErr) fmt.Fprintln(os.Stderr, "theme load:", themeErr)
@ -956,6 +962,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
Theme: theme, Theme: theme,
InlineImagesEnabled: initialCfg.InlineImagesEnabled, InlineImagesEnabled: initialCfg.InlineImagesEnabled,
AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
QuickModelShortcuts: quickModelShortcuts,
RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
RespectGitignore: initialCfg.RespectGitignore, RespectGitignore: initialCfg.RespectGitignore,
ThemeName: initialCfg.Theme, ThemeName: initialCfg.Theme,

View file

@ -15,6 +15,12 @@ import (
"github.com/patriceckhart/zot/packages/provider/auth" "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. // Config is the persisted user configuration.
type Config struct { type Config struct {
Provider string `json:"provider"` Provider string `json:"provider"`
@ -23,6 +29,10 @@ type Config struct {
Temperature *float32 `json:"temperature,omitempty"` Temperature *float32 `json:"temperature,omitempty"`
Theme string `json:"theme"` 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 // InlineImagesEnabled controls whether zot draws screenshots inline
// when the terminal supports an image protocol. nil/missing means // when the terminal supports an image protocol. nil/missing means
// auto (enabled when supported); false disables; true forces the // auto (enabled when supported); false disables; true forces the

View file

@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -58,6 +60,12 @@ type InteractiveConfig struct {
// ThemeName mirrors the persisted config theme value. Empty means auto. // ThemeName mirrors the persisted config theme value. Empty means auto.
ThemeName string 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 returns themes bundled with loaded extensions.
ExtensionThemes func() []tui.ThemeOption ExtensionThemes func() []tui.ThemeOption
@ -234,8 +242,15 @@ type chatCacheKey struct {
tailLimit int 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. // SettingsStore persists user-toggleable settings surfaced by /settings.
type SettingsStore interface { type SettingsStore interface {
SetQuickModelShortcut(slot int, providerName, model string) error
SetInlineImages(enabled bool) error SetInlineImages(enabled bool) error
SetAutoSwarm(enabled bool) error SetAutoSwarm(enabled bool) error
SetRecursiveFileSuggest(enabled bool) error SetRecursiveFileSuggest(enabled bool) error
@ -353,6 +368,7 @@ type Interactive struct {
logoutDialog *logoutDialog logoutDialog *logoutDialog
telegramDialog *telegramDialog telegramDialog *telegramDialog
settingsDialog *settingsDialog settingsDialog *settingsDialog
quickModelAssign int
telegramBridge *telegram.Bridge telegramBridge *telegram.Bridge
sessionOpsDialog *sessionOpsDialog sessionOpsDialog *sessionOpsDialog
sessionTreeDialog *sessionTreeDialog sessionTreeDialog *sessionTreeDialog
@ -508,6 +524,9 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
i.view.TailLimit = initialResumeTailLimit i.view.TailLimit = initialResumeTailLimit
} }
} }
if cfg.AutoSwarmEnabled != nil && *cfg.AutoSwarmEnabled {
i.applyAutoSwarmTool(true)
}
return i return i
} }
@ -1728,11 +1747,20 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
if i.modelDialog.Active() { if i.modelDialog.Active() {
if k.Kind == tui.KeyCtrlC { if k.Kind == tui.KeyCtrlC {
i.modelDialog.Close() i.modelDialog.Close()
i.quickModelAssign = 0
return false return false
} }
act := i.modelDialog.HandleKey(k) act := i.modelDialog.HandleKey(k)
if act.Close {
i.quickModelAssign = 0
}
if act.Select { 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 return false
} }
@ -1815,6 +1843,9 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
return false return false
} }
act := i.settingsDialog.HandleKey(k) act := i.settingsDialog.HandleKey(k)
if act.ModelShortcutSlot > 0 {
i.openQuickModelPicker(act.ModelShortcutSlot)
}
if act.Toggle { if act.Toggle {
i.applySettingChange(act) i.applySettingChange(act)
} }
@ -1914,6 +1945,11 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) {
return false return false
} }
if slot := quickModelShortcutSlot(k); slot > 0 {
i.applyQuickModelShortcut(slot)
return false
}
// Global keys. // Global keys.
switch k.Kind { switch k.Kind {
case tui.KeyCtrlC: case tui.KeyCtrlC:
@ -2770,6 +2806,7 @@ func (i *Interactive) openSettingsDialog() {
recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest recursiveFiles := i.cfg.RecursiveFileSuggest != nil && *i.cfg.RecursiveFileSuggest
respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore respectGitignore := i.cfg.RespectGitignore == nil || *i.cfg.RespectGitignore
quickItems := i.quickModelSettingItems()
reasoningOptions := []settingsOption{ reasoningOptions := []settingsOption{
{value: "", label: "off", desc: "no reasoning"}, {value: "", label: "off", desc: "no reasoning"},
@ -2817,7 +2854,7 @@ func (i *Interactive) openSettingsDialog() {
} }
} }
i.settingsDialog.Open([]settingsItem{ items := []settingsItem{
{ {
key: "inline_images_enabled", key: "inline_images_enabled",
label: "render images when supported", label: "render images when supported",
@ -2861,20 +2898,193 @@ func (i *Interactive) openSettingsDialog() {
options: themeOptions, options: themeOptions,
choice: themeChoice, 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) { func (i *Interactive) applySettingChange(act settingsAction) {
switch act.Key { switch {
case "reasoning": case strings.HasPrefix(act.Key, "quick_model_"):
i.applyQuickModelSetting(act.Key, act.StringValue)
case act.Key == "reasoning":
i.applyReasoningSetting(act.StringValue) i.applyReasoningSetting(act.StringValue)
case "theme": case act.Key == "theme":
i.applyThemeSetting(act.StringValue) i.applyThemeSetting(act.StringValue)
default: default:
i.applySettingToggle(act.Key, act.Value) 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) { func (i *Interactive) applySettingToggle(key string, value bool) {
// Every setting toggle forces a full repaint at the end — same // Every setting toggle forces a full repaint at the end — same
// effect as the user pressing Ctrl+L — so any per-setting visual // effect as the user pressing Ctrl+L — so any per-setting visual
@ -4175,9 +4385,10 @@ func (i *Interactive) swapModel(prov, model string, builder func(string, string)
// to invalidate. // to invalidate.
i.mu.Unlock() i.mu.Unlock()
// The new agent was built off the base tool registry, so any // The new agent was built off the base tool registry, so any
// dynamically-registered tools (telegram_send_*) need to be // dynamically-registered tools need to be reattached. The apply
// reattached. applyTelegramTools is a no-op when the bridge is // helpers are no-ops when their feature is inactive, so the
// idle so the cross-provider path still works on a vanilla setup. // cross-provider path still works on a vanilla setup.
i.applyAutoSwarmTool(i.autoSwarmEnabled())
i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
if i.cfg.PersistModel != nil { if i.cfg.PersistModel != nil {
i.cfg.PersistModel(p, md) i.cfg.PersistModel(p, md)
@ -4206,6 +4417,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
i.statusErr = "" i.statusErr = ""
i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method i.statusOK = "logged in to " + ev.Provider + " via " + ev.Method
i.mu.Unlock() i.mu.Unlock()
i.applyAutoSwarmTool(i.autoSwarmEnabled())
i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active()) i.applyTelegramTools(i.telegramBridge != nil && i.telegramBridge.Active())
i.dialog.ShowResult(true, "") i.dialog.ShowResult(true, "")
} }
@ -5269,6 +5481,10 @@ func (i *Interactive) applyAutoSwarmSystemPrompt(active bool) {
// when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools' // when /settings -> auto-swarm is enabled. Mirrors applyTelegramTools'
// snapshot+mutate pattern so extension tools and /reload-ext additions // snapshot+mutate pattern so extension tools and /reload-ext additions
// survive a toggle. // survive a toggle.
func (i *Interactive) autoSwarmEnabled() bool {
return i.cfg.AutoSwarmEnabled != nil && *i.cfg.AutoSwarmEnabled
}
func (i *Interactive) applyAutoSwarmTool(active bool) { func (i *Interactive) applyAutoSwarmTool(active bool) {
if i.agent == nil { if i.agent == nil {
return return
@ -5283,9 +5499,11 @@ func (i *Interactive) applyAutoSwarmTool(active bool) {
} }
if active && i.cfg.Swarm != nil { if active && i.cfg.Swarm != nil {
next["swarm_spawn"] = &tools.SwarmSpawnTool{ next["swarm_spawn"] = &tools.SwarmSpawnTool{
Swarm: i.cfg.Swarm, Swarm: i.cfg.Swarm,
Enabled: func() bool { return true }, Enabled: func() bool { return true },
OnSpawned: i.trackSwarmAgent, DefaultModel: func() string { return i.cfg.Model },
DefaultProvider: func() string { return i.cfg.Provider },
OnSpawned: i.trackSwarmAgent,
} }
} }
i.agent.SetTools(next) i.agent.SetTools(next)

View file

@ -10,10 +10,13 @@ import (
type settingsDialog struct { type settingsDialog struct {
active bool active bool
title string
items []settingsItem items []settingsItem
cursor int cursor int
selecting bool selecting bool
optionCursor int optionCursor int
parentItems []settingsItem
parentCursor int
} }
type settingsItem struct { type settingsItem struct {
@ -22,6 +25,8 @@ type settingsItem struct {
desc string desc string
value bool value bool
options []settingsOption options []settingsOption
children []settingsItem
picker bool
choice int choice int
disabled bool disabled bool
hint string hint string
@ -34,11 +39,12 @@ type settingsOption struct {
} }
type settingsAction struct { type settingsAction struct {
Toggle bool Toggle bool
Key string Key string
Value bool Value bool
StringValue string StringValue string
Close bool ModelShortcutSlot int
Close bool
} }
func newSettingsDialog() *settingsDialog { return &settingsDialog{} } func newSettingsDialog() *settingsDialog { return &settingsDialog{} }
@ -47,10 +53,13 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
if len(items) == 0 { if len(items) == 0 {
return false return false
} }
d.title = "settings"
d.items = items d.items = items
d.cursor = 0 d.cursor = 0
d.selecting = false d.selecting = false
d.optionCursor = 0 d.optionCursor = 0
d.parentItems = nil
d.parentCursor = 0
d.active = true d.active = true
return true return true
} }
@ -58,6 +67,7 @@ func (d *settingsDialog) Open(items []settingsItem) bool {
func (d *settingsDialog) Close() { func (d *settingsDialog) Close() {
d.active = false d.active = false
d.selecting = false d.selecting = false
d.parentItems = nil
} }
func (d *settingsDialog) Active() bool { return d != nil && d.active } 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 { if d.cursor < len(d.items)-1 {
d.cursor++ 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: 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() d.Close()
return settingsAction{Close: true} return settingsAction{Close: true}
case tui.KeyEnter: case tui.KeyEnter:
@ -119,6 +144,27 @@ func (d *settingsDialog) toggleCurrent() settingsAction {
if it.disabled { if it.disabled {
return settingsAction{} 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 { if len(it.options) > 0 {
d.optionCursor = it.choice d.optionCursor = it.choice
if d.optionCursor < 0 || d.optionCursor >= len(it.options) { 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) return d.renderOptions(th, width)
} }
var lines []string var lines []string
lines = append(lines, frameHeader(th, "settings", width)) lines = append(lines, frameHeader(th, d.title, width))
lines = append(lines, th.FG256(th.Muted, "change with enter/space, esc to close:")) 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 { for i, it := range d.items {
box := "[ ]" box := "[ ]"
if it.value { if it.value {
box = "[✓]" box = "[✓]"
} }
plain := " " + box + " " + it.label 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 = "[→]" box = "[→]"
if it.choice < 0 || it.choice >= len(it.options) { if it.choice < 0 || it.choice >= len(it.options) {
it.choice = 0 it.choice = 0

View file

@ -10,9 +10,9 @@ import "github.com/patriceckhart/zot/packages/tui"
// the moment zot starts. After welcomeVersionDuration the caller // the moment zot starts. After welcomeVersionDuration the caller
// flips showVersion off and the headline reverts to plain text. // flips showVersion off and the headline reverts to plain text.
func welcomeBanner(th tui.Theme, version string, showVersion bool) []string { func welcomeBanner(th tui.Theme, version string, showVersion bool) []string {
text := "i'm zot. yet another coding agent harness." text := "zot. yet another coding agent harness."
if showVersion && version != "" { if showVersion && version != "" {
text = "i'm zot (" + version + "). yet another coding agent harness." text = "zot (" + version + "). yet another coding agent harness."
} }
headline := th.AccentBar(th.Assistant) + th.FG256(th.Assistant, tui.Bold(text)) headline := th.AccentBar(th.Assistant) + th.FG256(th.Assistant, tui.Bold(text))
return []string{ return []string{

View file

@ -4,6 +4,31 @@ import "github.com/patriceckhart/zot/packages/provider"
type configSettingsStore struct{} 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 { func (configSettingsStore) SetInlineImages(enabled bool) error {
cfg, err := LoadConfig() cfg, err := LoadConfig()
if err != nil { if err != nil {

View file

@ -31,6 +31,13 @@ type SwarmSpawnTool struct {
// is treated as disabled. // is treated as disabled.
Enabled func() bool Enabled func() bool
// DefaultModel and DefaultProvider return the host agent's resolved
// model and provider. They are used when the tool call omits both
// fields, so auto-swarm follows the same auth route as the user sees
// in the parent session.
DefaultModel func() string
DefaultProvider func() string
// OnSpawned, if set, is called after every successful spawn with // OnSpawned, if set, is called after every successful spawn with
// the new agent + the task it was started with. Used by the // the new agent + the task it was started with. Used by the
// interactive host to track agents and surface a summary back // interactive host to track agents and surface a summary back
@ -53,11 +60,11 @@ const swarmSpawnSchema = `{
}, },
"model": { "model": {
"type": "string", "type": "string",
"description": "Optional model id to pin the sub-agent to (e.g. \"claude-sonnet-4-5\", \"gpt-5\"). Defaults to the host's current model." "description": "Optional model id to pin the sub-agent to. Normally omit both model and provider so the sub-agent inherits the host session's resolved provider/model/auth route. Do not infer provider from model name. If you override this, also provide provider."
}, },
"provider": { "provider": {
"type": "string", "type": "string",
"description": "Optional provider id (e.g. \"anthropic\", \"openai\"). Usually paired with model." "description": "Optional provider id. Normally omit both model and provider so the sub-agent inherits the host session. If you override this, also provide model. Note: openai means public OpenAI API-key auth; openai-codex means ChatGPT/Codex subscription auth."
} }
}, },
"required": ["task"] "required": ["task"]
@ -85,10 +92,24 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
return toolErr("swarm_spawn: task is required"), nil return toolErr("swarm_spawn: task is required"), nil
} }
model := strings.TrimSpace(a.Model)
providerID := strings.TrimSpace(a.Provider)
if (model == "") != (providerID == "") {
return toolErr("swarm_spawn: omit both model/provider to inherit the host, or provide both explicitly"), nil
}
if model == "" && providerID == "" {
if t.DefaultModel != nil {
model = strings.TrimSpace(t.DefaultModel())
}
if t.DefaultProvider != nil {
providerID = strings.TrimSpace(t.DefaultProvider())
}
}
agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{ agent, err := t.Swarm.SpawnReq(ctx, swarm.SpawnRequest{
Task: task, Task: task,
Model: strings.TrimSpace(a.Model), Model: model,
Provider: strings.TrimSpace(a.Provider), Provider: providerID,
}) })
if err != nil { if err != nil {
return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err) return core.ToolResult{}, fmt.Errorf("swarm_spawn: %w", err)
@ -100,11 +121,11 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID) fmt.Fprintf(&sb, "spawned sub-agent %s\n", agent.ID)
fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200)) fmt.Fprintf(&sb, "task: %s\n", truncateTask(task, 200))
if a.Model != "" { if model != "" {
fmt.Fprintf(&sb, "model: %s\n", a.Model) fmt.Fprintf(&sb, "model: %s\n", model)
} }
if a.Provider != "" { if providerID != "" {
fmt.Fprintf(&sb, "provider: %s\n", a.Provider) fmt.Fprintf(&sb, "provider: %s\n", providerID)
} }
sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ") sb.WriteString("\nThe sub-agent is running in the background. Use /swarm in the TUI to monitor it. ")
sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.") sb.WriteString("This conversation continues immediately; do not wait for the sub-agent to finish before working on the next thing.")
@ -113,8 +134,8 @@ func (t *SwarmSpawnTool) Execute(ctx context.Context, raw json.RawMessage, progr
Details: map[string]any{ Details: map[string]any{
"agent_id": agent.ID, "agent_id": agent.ID,
"task": task, "task": task,
"model": a.Model, "model": model,
"provider": a.Provider, "provider": providerID,
}, },
}, nil }, nil
} }

View file

@ -0,0 +1,100 @@
package tools
import (
"context"
"encoding/json"
"path/filepath"
"strings"
"testing"
"github.com/patriceckhart/zot/packages/agent/swarm"
"github.com/patriceckhart/zot/packages/provider"
)
type noopSwarmRunner struct{}
func (noopSwarmRunner) Run(context.Context, swarm.Sink) error { return nil }
func newTestSwarm(t *testing.T) *swarm.Swarm {
t.Helper()
root := t.TempDir()
return swarm.New(swarm.Config{
Root: filepath.Join(root, "swarm"),
RepoRoot: root,
NewRunner: func(*swarm.Agent) swarm.Runner {
return noopSwarmRunner{}
},
})
}
func TestSwarmSpawnInheritsHostModelAndProviderWhenOmitted(t *testing.T) {
tool := &SwarmSpawnTool{
Swarm: newTestSwarm(t),
Enabled: func() bool { return true },
DefaultModel: func() string { return "gpt-5" },
DefaultProvider: func() string { return "openai-codex" },
}
res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs"}`), nil)
if err != nil {
t.Fatal(err)
}
if res.IsError {
t.Fatalf("unexpected tool error: %s", textResult(res.Content))
}
details, ok := res.Details.(map[string]any)
if !ok {
t.Fatalf("details type = %T, want map[string]any", res.Details)
}
if got := details["model"]; got != "gpt-5" {
t.Fatalf("model detail = %v, want gpt-5", got)
}
if got := details["provider"]; got != "openai-codex" {
t.Fatalf("provider detail = %v, want openai-codex", got)
}
text := textResult(res.Content)
if !strings.Contains(text, "model: gpt-5") || !strings.Contains(text, "provider: openai-codex") {
t.Fatalf("result text missing inherited model/provider:\n%s", text)
}
agents := tool.Swarm.List()
if len(agents) != 1 {
t.Fatalf("spawned agents = %d, want 1", len(agents))
}
if agents[0].Model != "gpt-5" || agents[0].Provider != "openai-codex" {
t.Fatalf("agent model/provider = %q/%q, want gpt-5/openai-codex", agents[0].Model, agents[0].Provider)
}
}
func TestSwarmSpawnRejectsPartialModelProviderOverride(t *testing.T) {
tool := &SwarmSpawnTool{
Swarm: newTestSwarm(t),
Enabled: func() bool { return true },
DefaultModel: func() string { return "gpt-5" },
DefaultProvider: func() string { return "openai-codex" },
}
res, err := tool.Execute(context.Background(), json.RawMessage(`{"task":"research docs","provider":"openai"}`), nil)
if err != nil {
t.Fatal(err)
}
if !res.IsError {
t.Fatalf("expected partial override to fail")
}
if got := textResult(res.Content); !strings.Contains(got, "omit both model/provider") {
t.Fatalf("error text = %q", got)
}
if got := len(tool.Swarm.List()); got != 0 {
t.Fatalf("spawned agents = %d, want 0", got)
}
}
func textResult(content []provider.Content) string {
if len(content) == 0 {
return ""
}
if tb, ok := content[0].(provider.TextBlock); ok {
return tb.Text
}
return ""
}

View file

@ -310,6 +310,7 @@ var builtinCatalog = []Model{
{Provider: "opencode-go", ID: "minimax-m2.7", DisplayName: "MiniMax M2.7", ContextWindow: 204800, MaxOutput: 131072, Reasoning: true, PriceInput: 0.3, PriceOutput: 1.2, PriceCacheRead: 0.06, BaseURL: "https://opencode.ai/zen/go/v1"}, {Provider: "opencode-go", ID: "minimax-m2.7", DisplayName: "MiniMax M2.7", ContextWindow: 204800, MaxOutput: 131072, Reasoning: true, PriceInput: 0.3, PriceOutput: 1.2, PriceCacheRead: 0.06, BaseURL: "https://opencode.ai/zen/go/v1"},
{Provider: "opencode-go", ID: "qwen3.5-plus", DisplayName: "Qwen3.5 Plus", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, PriceCacheWrite: 0.25, BaseURL: "https://opencode.ai/zen/go/v1"}, {Provider: "opencode-go", ID: "qwen3.5-plus", DisplayName: "Qwen3.5 Plus", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, PriceCacheWrite: 0.25, BaseURL: "https://opencode.ai/zen/go/v1"},
{Provider: "opencode-go", ID: "qwen3.6-plus", DisplayName: "Qwen3.6 Plus", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.5, PriceOutput: 3, PriceCacheRead: 0.05, PriceCacheWrite: 0.625, BaseURL: "https://opencode.ai/zen/go/v1"}, {Provider: "opencode-go", ID: "qwen3.6-plus", DisplayName: "Qwen3.6 Plus", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.5, PriceOutput: 3, PriceCacheRead: 0.05, PriceCacheWrite: 0.625, BaseURL: "https://opencode.ai/zen/go/v1"},
{Provider: "opencode-go", ID: "qwen3.7-plus", DisplayName: "Qwen3.7 Plus", ContextWindow: 1000000, MaxOutput: 65536, Reasoning: true, PriceInput: 0.32, PriceOutput: 1.28, PriceCacheRead: 0.064, PriceCacheWrite: 0.4, BaseURL: "https://opencode.ai/zen/go/v1"},
// openrouter: discovered live via DiscoverOpenRouter, none baked in. // openrouter: discovered live via DiscoverOpenRouter, none baked in.
// ----- together ----- // ----- together -----
{Provider: "together", ID: "MiniMaxAI/MiniMax-M2.5", DisplayName: "MiniMax-M2.5", ContextWindow: 204800, MaxOutput: 131072, Reasoning: true, PriceInput: 0.3, PriceOutput: 1.2, PriceCacheRead: 0.06, BaseURL: "https://api.together.ai/v1"}, {Provider: "together", ID: "MiniMaxAI/MiniMax-M2.5", DisplayName: "MiniMax-M2.5", ContextWindow: 204800, MaxOutput: 131072, Reasoning: true, PriceInput: 0.3, PriceOutput: 1.2, PriceCacheRead: 0.06, BaseURL: "https://api.together.ai/v1"},
@ -677,4 +678,5 @@ var builtinCatalog = []Model{
{Provider: "opencode", ID: "nemotron-3-super-free", DisplayName: "Nemotron 3 Super Free", ContextWindow: 204800, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://opencode.ai/zen/v1"}, {Provider: "opencode", ID: "nemotron-3-super-free", DisplayName: "Nemotron 3 Super Free", ContextWindow: 204800, MaxOutput: 128000, Reasoning: true, PriceInput: 0, PriceOutput: 0, PriceCacheRead: 0, BaseURL: "https://opencode.ai/zen/v1"},
{Provider: "opencode", ID: "qwen3.5-plus", DisplayName: "Qwen3.5 Plus", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, PriceCacheWrite: 0.25, BaseURL: "https://opencode.ai/zen"}, {Provider: "opencode", ID: "qwen3.5-plus", DisplayName: "Qwen3.5 Plus", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.2, PriceOutput: 1.2, PriceCacheRead: 0.02, PriceCacheWrite: 0.25, BaseURL: "https://opencode.ai/zen"},
{Provider: "opencode", ID: "qwen3.6-plus", DisplayName: "Qwen3.6 Plus", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.5, PriceOutput: 3, PriceCacheRead: 0.05, PriceCacheWrite: 0.625, BaseURL: "https://opencode.ai/zen"}, {Provider: "opencode", ID: "qwen3.6-plus", DisplayName: "Qwen3.6 Plus", ContextWindow: 262144, MaxOutput: 65536, Reasoning: true, PriceInput: 0.5, PriceOutput: 3, PriceCacheRead: 0.05, PriceCacheWrite: 0.625, BaseURL: "https://opencode.ai/zen"},
{Provider: "opencode", ID: "qwen3.7-plus", DisplayName: "Qwen3.7 Plus", ContextWindow: 1000000, MaxOutput: 65536, Reasoning: true, PriceInput: 0.32, PriceOutput: 1.28, PriceCacheRead: 0.064, PriceCacheWrite: 0.4, BaseURL: "https://opencode.ai/zen"},
} }

View file

@ -14,6 +14,7 @@ type Key struct {
Ctrl bool Ctrl bool
Alt bool Alt bool
Shift bool Shift bool
Super bool
} }
type KeyKind int type KeyKind int
@ -245,7 +246,7 @@ func (r *Reader) dispatchCSI(params string, final byte) Key {
return Key{Kind: KeyUnknown} return Key{Kind: KeyUnknown}
} }
shift, alt := parseCSIModifiers(params) shift, alt, super := parseCSIModifiers(params)
if final == 'u' { if final == 'u' {
if key, ok := parseCSIU(params); ok { if key, ok := parseCSIU(params); ok {
return key return key
@ -258,13 +259,13 @@ func (r *Reader) dispatchCSI(params string, final byte) Key {
} }
switch final { switch final {
case 'A': case 'A':
return Key{Kind: KeyUp, Alt: alt, Shift: shift} return Key{Kind: KeyUp, Alt: alt, Shift: shift, Super: super}
case 'B': case 'B':
return Key{Kind: KeyDown, Alt: alt, Shift: shift} return Key{Kind: KeyDown, Alt: alt, Shift: shift, Super: super}
case 'C': case 'C':
return Key{Kind: KeyRight, Alt: alt, Shift: shift} return Key{Kind: KeyRight, Alt: alt, Shift: shift, Super: super}
case 'D': case 'D':
return Key{Kind: KeyLeft, Alt: alt, Shift: shift} return Key{Kind: KeyLeft, Alt: alt, Shift: shift, Super: super}
case 'H': case 'H':
return Key{Kind: KeyHome} return Key{Kind: KeyHome}
case 'F': case 'F':
@ -287,23 +288,20 @@ func (r *Reader) dispatchCSI(params string, final byte) Key {
return Key{Kind: KeyUnknown} return Key{Kind: KeyUnknown}
} }
func parseCSIModifiers(params string) (shift, alt bool) { func parseCSIModifiers(params string) (shift, alt, super bool) {
if params == "" { if params == "" {
return false, false return false, false, false
} }
i := strings.LastIndexByte(params, ';') i := strings.LastIndexByte(params, ';')
if i < 0 || i+1 >= len(params) { if i < 0 || i+1 >= len(params) {
return false, false return false, false, false
} }
mod, err := strconv.Atoi(params[i+1:]) mod, ok := parseModifierParam(params[i+1:])
if err != nil { if !ok {
return false, false return false, false, false
} }
// Xterm-style modifier values are 1 plus a bitmask: shift, alt, _, super = modifierBits(mod)
// 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, 6=Shift+Ctrl, return shift, alt, super
// 7=Alt+Ctrl, 8=Shift+Alt+Ctrl.
bits := mod - 1
return bits&1 != 0, bits&2 != 0
} }
func parseCSIU(params string) (Key, bool) { func parseCSIU(params string) (Key, bool) {
@ -317,7 +315,9 @@ func parseCSIU(params string) (Key, bool) {
} }
mod := 1 mod := 1
if len(parts) >= 2 { 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 return Key{}, false
} }
} }
@ -329,8 +329,8 @@ func parseModifyOtherKeys(params string) (Key, bool) {
if len(parts) != 3 || parts[0] != "27" { if len(parts) != 3 || parts[0] != "27" {
return Key{}, false return Key{}, false
} }
mod, err := strconv.Atoi(parts[1]) mod, ok := parseModifierParam(parts[1])
if err != nil { if !ok {
return Key{}, false return Key{}, false
} }
code, err := strconv.Atoi(parts[2]) code, err := strconv.Atoi(parts[2])
@ -340,27 +340,40 @@ func parseModifyOtherKeys(params string) (Key, bool) {
return keyFromModifiedCode(code, mod) 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 bits := mod - 1
shift := bits&1 != 0 return bits&1 != 0, bits&2 != 0, bits&4 != 0, bits&8 != 0 || bits&32 != 0
alt := bits&2 != 0 }
ctrl := bits&4 != 0
func keyFromModifiedCode(code, mod int) (Key, bool) {
shift, alt, ctrl, super := modifierBits(mod)
// Kitty keyboard protocol (CSI ... u) reports control keys as their // Kitty keyboard protocol (CSI ... u) reports control keys as their
// codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the // codepoints: Esc=27, Enter=13, Tab=9, Backspace=127. Without the
// enhanced-mode handling these arrive as raw bytes; with it enabled // enhanced-mode handling these arrive as raw bytes; with it enabled
// they come through here, so map them back to their dedicated keys. // they come through here, so map them back to their dedicated keys.
switch code { switch code {
case 13: 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: 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: case 9:
if shift { 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: 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 { if ctrl {
switch code { switch code {
@ -386,6 +399,9 @@ func keyFromModifiedCode(code, mod int) (Key, bool) {
return Key{Kind: KeyPasteClipboard, Shift: shift, Alt: alt, Ctrl: true}, true 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 return Key{}, false
} }

View file

@ -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) { func TestReaderParsesRawCtrlVAsClipboardPaste(t *testing.T) {
k := readKey(t, "\x16") k := readKey(t, "\x16")
if k.Kind != KeyPasteClipboard || !k.Ctrl { if k.Kind != KeyPasteClipboard || !k.Ctrl {