diff --git a/docs/features/tui/index.md b/docs/features/tui/index.md
index ad6a6b36f..4a53f0024 100644
--- a/docs/features/tui/index.md
+++ b/docs/features/tui/index.md
@@ -147,6 +147,8 @@ Customize session titles to make them more meaningful and easier to find. By def
## Keyboard Shortcuts
+The table below lists the default keybindings. These shortcuts can be overridden via `~/.config/cagent/config.yaml`.
+
| Shortcut | Action |
| ---------- | ----------------------------------------------- |
| Ctrl+K | Open command palette |
@@ -167,9 +169,43 @@ Customize session titles to make them more meaningful and easier to find. By def
| Escape | Cancel current operation |
| Enter | Send message (or newline with Shift+Enter) |
| Up/Down | Navigate message history |
+| Ctrl+C | Quit |
Press Ctrl+H to view the complete list of all available keyboard shortcuts.
+### Custom Keybindings
+
+You can override the default keyboard shortcuts by specifying `keybindings` in your `~/.config/cagent/config.yaml` file under the `settings` block.
+
+For each action you wish to remap, provide the action name and a list of key combinations (using Bubbles key format, e.g. `ctrl+q`, `f2`).
+
+**Example Configuration:**
+
+```yaml
+settings:
+ keybindings:
+ - action: "quit"
+ keys: ["ctrl+q"]
+ - action: "commands"
+ keys: ["f2", "ctrl+k"]
+```
+
+**Valid Action Names:**
+
+* `quit`
+* `switch_focus`
+* `commands`
+* `help`
+* `toggle_yolo`
+* `toggle_hide_tool_results`
+* `cycle_agent`
+* `model_picker`
+* `clear_queue`
+* `suspend`
+* `toggle_sidebar`
+* `edit_external`
+* `history_search`
+
## History Search
Press Ctrl+R to enter incremental history search mode. Start typing to filter through your previous inputs. Press Enter to select a match, or Escape to cancel.
diff --git a/pkg/tui/core/keys.go b/pkg/tui/core/keys.go
new file mode 100644
index 000000000..02e463694
--- /dev/null
+++ b/pkg/tui/core/keys.go
@@ -0,0 +1,144 @@
+package core
+
+import (
+ "log/slog"
+ "strings"
+ "sync"
+
+ "charm.land/bubbles/v2/key"
+
+ "github.com/docker/docker-agent/pkg/userconfig"
+)
+
+// KeyMap contains global keybindings used across the TUI
+type KeyMap struct {
+ Quit key.Binding
+ SwitchFocus key.Binding
+ Commands key.Binding
+ Help key.Binding
+ ToggleYolo key.Binding
+ ToggleHideToolResults key.Binding
+ CycleAgent key.Binding
+ ModelPicker key.Binding
+ ClearQueue key.Binding
+ Suspend key.Binding
+ ToggleSidebar key.Binding
+ EditExternal key.Binding
+ HistorySearch key.Binding
+}
+
+var (
+ cachedKeys KeyMap
+ keysOnce sync.Once
+)
+
+// DefaultKeyMap returns the default keybindings
+func DefaultKeyMap() KeyMap {
+ return KeyMap{
+ Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")),
+ SwitchFocus: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch focus")),
+ Commands: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "commands")),
+ Help: key.NewBinding(key.WithKeys("ctrl+h", "f1", "ctrl+?"), key.WithHelp("ctrl+h", "help")),
+ ToggleYolo: key.NewBinding(key.WithKeys("ctrl+y"), key.WithHelp("ctrl+y", "toggle yolo mode")),
+ ToggleHideToolResults: key.NewBinding(key.WithKeys("ctrl+o"), key.WithHelp("ctrl+o", "toggle hide tool results")),
+ CycleAgent: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "cycle agent")),
+ ModelPicker: key.NewBinding(key.WithKeys("ctrl+m"), key.WithHelp("ctrl+m", "model picker")),
+ ClearQueue: key.NewBinding(key.WithKeys("ctrl+x"), key.WithHelp("ctrl+x", "clear queue")),
+ Suspend: key.NewBinding(key.WithKeys("ctrl+z"), key.WithHelp("ctrl+z", "suspend")),
+ ToggleSidebar: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle sidebar")),
+ EditExternal: key.NewBinding(key.WithKeys("ctrl+g"), key.WithHelp("ctrl+g", "edit in external editor")),
+ HistorySearch: key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl+r", "history search")),
+ }
+}
+
+type keyField struct {
+ binding *key.Binding
+ help string
+}
+
+func validateKeys(keys []string, action string, boundKeys map[string]string) []string {
+ var validKeys []string
+ for _, k := range keys {
+ kStr := strings.TrimSpace(k)
+ if kStr == "" || strings.Contains(kStr, " ") {
+ slog.Warn("Invalid key string ignored", "action", action, "key", k)
+ continue
+ }
+
+ if existingAction, exists := boundKeys[kStr]; exists {
+ slog.Warn("Keybinding conflict detected", "key", kStr, "action", action, "conflicts_with", existingAction)
+ } else {
+ boundKeys[kStr] = action
+ }
+
+ validKeys = append(validKeys, kStr)
+ }
+ return validKeys
+}
+
+// applyUserKeybindings loops through user-defined keybindings and overrides the defaults.
+// Basic string validation and key conflict detection is applied, any issues are logged.
+func applyUserKeybindings(bindings []userconfig.Keybinding, actionMap map[string]keyField) {
+ boundKeys := make(map[string]string)
+
+ for _, b := range bindings {
+ if len(b.Keys) == 0 {
+ slog.Warn("Keybinding ignored: no keys specified", "action", b.Action)
+ continue
+ }
+
+ if f, ok := actionMap[b.Action]; ok {
+ validKeys := validateKeys(b.Keys, b.Action, boundKeys)
+
+ if len(validKeys) > 0 {
+ *f.binding = key.NewBinding(key.WithKeys(validKeys...), key.WithHelp(validKeys[0], f.help))
+ }
+ } else {
+ slog.Warn("Unrecognized keybinding action", "action", b.Action)
+ }
+ }
+}
+
+// buildKeys merges user config overrides with the defaults to produce a KeyMap.
+// This is separated from GetKeys() to allow testing with mock settings.
+func buildKeys(settings *userconfig.Settings) KeyMap {
+ keys := DefaultKeyMap()
+
+ if settings != nil && settings.Keybindings != nil {
+ actionMap := map[string]keyField{
+ "quit": {&keys.Quit, "quit"},
+ "switch_focus": {&keys.SwitchFocus, "switch focus"},
+ "commands": {&keys.Commands, "commands"},
+ "help": {&keys.Help, "help"},
+ "toggle_yolo": {&keys.ToggleYolo, "toggle yolo mode"},
+ "toggle_hide_tool_results": {&keys.ToggleHideToolResults, "toggle hide tool results"},
+ "cycle_agent": {&keys.CycleAgent, "cycle agent"},
+ "model_picker": {&keys.ModelPicker, "model picker"},
+ "clear_queue": {&keys.ClearQueue, "clear queue"},
+ "suspend": {&keys.Suspend, "suspend"},
+ "toggle_sidebar": {&keys.ToggleSidebar, "toggle sidebar"},
+ "edit_external": {&keys.EditExternal, "edit in external editor"},
+ "history_search": {&keys.HistorySearch, "history search"},
+ }
+
+ applyUserKeybindings(*settings.Keybindings, actionMap)
+ }
+
+ return keys
+}
+
+// GetKeys returns the current keybindings, merging user config overrides with defaults.
+// The result is cached after the first call.
+func GetKeys() KeyMap {
+ keysOnce.Do(func() {
+ cachedKeys = buildKeys(userconfig.Get())
+ })
+
+ return cachedKeys
+}
+
+// ResetKeys clears the cached keybindings, allowing them to be reloaded.
+// This is primarily useful for testing or future hot-reload support.
+func ResetKeys() {
+ keysOnce = sync.Once{}
+}
diff --git a/pkg/tui/core/keys_test.go b/pkg/tui/core/keys_test.go
new file mode 100644
index 000000000..ef0779d38
--- /dev/null
+++ b/pkg/tui/core/keys_test.go
@@ -0,0 +1,150 @@
+package core
+
+import (
+ "testing"
+
+ "charm.land/bubbles/v2/key"
+ "github.com/goccy/go-yaml"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/docker/docker-agent/pkg/userconfig"
+)
+
+func TestBuildKeys_Defaults(t *testing.T) {
+ keys := buildKeys(nil)
+
+ // Verify defaults
+ assert.Equal(t, []string{"ctrl+c"}, keys.Quit.Keys())
+ assert.Equal(t, []string{"tab"}, keys.SwitchFocus.Keys())
+ assert.Equal(t, []string{"ctrl+k"}, keys.Commands.Keys())
+ assert.Equal(t, []string{"ctrl+h", "f1", "ctrl+?"}, keys.Help.Keys())
+ assert.Equal(t, []string{"ctrl+y"}, keys.ToggleYolo.Keys())
+ assert.Equal(t, []string{"ctrl+o"}, keys.ToggleHideToolResults.Keys())
+ assert.Equal(t, []string{"ctrl+s"}, keys.CycleAgent.Keys())
+ assert.Equal(t, []string{"ctrl+m"}, keys.ModelPicker.Keys())
+ assert.Equal(t, []string{"ctrl+x"}, keys.ClearQueue.Keys())
+ assert.Equal(t, []string{"ctrl+z"}, keys.Suspend.Keys())
+ assert.Equal(t, []string{"ctrl+b"}, keys.ToggleSidebar.Keys())
+ assert.Equal(t, []string{"ctrl+g"}, keys.EditExternal.Keys())
+ assert.Equal(t, []string{"ctrl+r"}, keys.HistorySearch.Keys())
+}
+
+func TestBuildKeys_Overrides(t *testing.T) {
+ settings := &userconfig.Settings{
+ Keybindings: &[]userconfig.Keybinding{
+ {Action: "quit", Keys: []string{"ctrl+q"}},
+ {Action: "switch_focus", Keys: []string{"ctrl+t"}},
+ {Action: "commands", Keys: []string{"f2", "ctrl+k"}},
+ {Action: "unknown_action", Keys: []string{"ctrl+u"}}, // Should be ignored
+ },
+ }
+
+ keys := buildKeys(settings)
+
+ // Verify overrides
+ assert.Equal(t, []string{"ctrl+q"}, keys.Quit.Keys())
+ assert.Equal(t, []string{"ctrl+t"}, keys.SwitchFocus.Keys())
+
+ // Verify arrays are maintained
+ assert.Equal(t, []string{"f2", "ctrl+k"}, keys.Commands.Keys())
+
+ // Verify defaults are preserved where not overridden
+ assert.Equal(t, []string{"ctrl+h", "f1", "ctrl+?"}, keys.Help.Keys())
+ assert.Equal(t, []string{"ctrl+y"}, keys.ToggleYolo.Keys())
+ assert.Equal(t, []string{"ctrl+o"}, keys.ToggleHideToolResults.Keys())
+ assert.Equal(t, []string{"ctrl+s"}, keys.CycleAgent.Keys())
+ assert.Equal(t, []string{"ctrl+m"}, keys.ModelPicker.Keys())
+ assert.Equal(t, []string{"ctrl+x"}, keys.ClearQueue.Keys())
+ assert.Equal(t, []string{"ctrl+z"}, keys.Suspend.Keys())
+ assert.Equal(t, []string{"ctrl+b"}, keys.ToggleSidebar.Keys())
+ assert.Equal(t, []string{"ctrl+g"}, keys.EditExternal.Keys())
+ assert.Equal(t, []string{"ctrl+r"}, keys.HistorySearch.Keys())
+}
+
+func TestBuildKeys_EmptySettings(t *testing.T) {
+ settings := &userconfig.Settings{}
+ keys := buildKeys(settings)
+
+ // Verify defaults
+ assert.Equal(t, []string{"ctrl+c"}, keys.Quit.Keys())
+ assert.Equal(t, []string{"tab"}, keys.SwitchFocus.Keys())
+}
+
+func TestBuildKeys_EmptyKey(t *testing.T) {
+ settings := &userconfig.Settings{
+ Keybindings: &[]userconfig.Keybinding{
+ {Action: "quit", Keys: []string{}}, // Should be ignored
+ },
+ }
+ keys := buildKeys(settings)
+
+ // Verify defaults remain
+ assert.Equal(t, []string{"ctrl+c"}, keys.Quit.Keys())
+}
+
+func TestBuildKeys_InvalidKeysAndConflicts(t *testing.T) {
+ settings := &userconfig.Settings{
+ Keybindings: &[]userconfig.Keybinding{
+ {Action: "quit", Keys: []string{"ctrl+q", " ", ""}}, // spaces and empty should be ignored
+ {Action: "suspend", Keys: []string{"ctrl+q"}}, // conflict with quit
+ },
+ }
+
+ keys := buildKeys(settings)
+
+ // Valid keys should still be applied
+ assert.Equal(t, []string{"ctrl+q"}, keys.Quit.Keys())
+ assert.Equal(t, []string{"ctrl+q"}, keys.Suspend.Keys())
+}
+
+func TestBuildKeys_FromYAML(t *testing.T) {
+ yamlConfig := `
+settings:
+ keybindings:
+ - action: "quit"
+ keys: ["ctrl+q"]
+ - action: "commands"
+ keys: ["f2", "ctrl+k"]
+ - action: "history_search"
+ keys: ["ctrl+f"]
+`
+
+ var config userconfig.Config
+ err := yaml.Unmarshal([]byte(yamlConfig), &config)
+ require.NoError(t, err)
+
+ keys := buildKeys(config.Settings)
+
+ // Verify the keys loaded correctly from the YAML unmarshal
+ assert.Equal(t, []string{"ctrl+q"}, keys.Quit.Keys())
+ assert.Equal(t, []string{"f2", "ctrl+k"}, keys.Commands.Keys())
+ assert.Equal(t, []string{"ctrl+f"}, keys.HistorySearch.Keys())
+
+ // Verify defaults are preserved for missing YAML fields
+ assert.Equal(t, []string{"tab"}, keys.SwitchFocus.Keys())
+ assert.Equal(t, []string{"ctrl+h", "f1", "ctrl+?"}, keys.Help.Keys())
+}
+
+func TestResetKeys(t *testing.T) {
+ // Call GetKeys to initialize sync.Once
+ _ = GetKeys()
+
+ // Keep a copy of original to restore later
+ originalCached := cachedKeys
+
+ // Modify cachedKeys to a bogus value
+ cachedKeys.Quit = key.NewBinding(key.WithKeys("bogus"))
+
+ // Calling GetKeys again should still return the bogus value because sync.Once isn't reset
+ assert.Equal(t, []string{"bogus"}, GetKeys().Quit.Keys())
+
+ // Reset keys
+ ResetKeys()
+
+ // Calling GetKeys now should re-initialize from default/config
+ assert.NotEqual(t, []string{"bogus"}, GetKeys().Quit.Keys())
+
+ // Clean up
+ cachedKeys = originalCached
+}
diff --git a/pkg/tui/dialog/base.go b/pkg/tui/dialog/base.go
index 68a4d8933..3a66fae0f 100644
--- a/pkg/tui/dialog/base.go
+++ b/pkg/tui/dialog/base.go
@@ -153,9 +153,9 @@ func RenderHelpKeys(contentWidth int, bindings ...string) string {
return styles.BaseStyle.Width(contentWidth).Align(lipgloss.Center).Render(strings.Join(parts, " "))
}
-// HandleQuit checks for ctrl+c and returns tea.Quit if matched.
+// HandleQuit checks for the quit key and returns tea.Quit if matched.
func HandleQuit(msg tea.KeyPressMsg) tea.Cmd {
- if msg.String() == "ctrl+c" {
+ if key.Matches(msg, core.GetKeys().Quit) {
return tea.Quit
}
return nil
diff --git a/pkg/tui/dialog/elicitation.go b/pkg/tui/dialog/elicitation.go
index e7144de2e..3b80e1ab1 100644
--- a/pkg/tui/dialog/elicitation.go
+++ b/pkg/tui/dialog/elicitation.go
@@ -16,6 +16,7 @@ import (
"github.com/docker/docker-agent/pkg/tools"
"github.com/docker/docker-agent/pkg/tui/components/markdown"
"github.com/docker/docker-agent/pkg/tui/components/scrollview"
+ "github.com/docker/docker-agent/pkg/tui/core"
"github.com/docker/docker-agent/pkg/tui/core/layout"
"github.com/docker/docker-agent/pkg/tui/styles"
)
@@ -180,6 +181,9 @@ func (d *ElicitationDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
func (d *ElicitationDialog) handleKeyPress(msg tea.KeyPressMsg) (layout.Model, tea.Cmd) {
switch {
+ case key.Matches(msg, core.GetKeys().Quit):
+ cmd := d.close(tools.ElicitationActionDecline, nil)
+ return d, tea.Sequence(cmd, tea.Quit)
case key.Matches(msg, d.keyMap.Space) && !d.isTextInputField() && !d.hasFreeFormInput():
// Space cycles forward through options, same as down arrow
d.moveSelection(1)
diff --git a/pkg/tui/dialog/exit_confirmation.go b/pkg/tui/dialog/exit_confirmation.go
index ec589e30b..d3d3f7ad1 100644
--- a/pkg/tui/dialog/exit_confirmation.go
+++ b/pkg/tui/dialog/exit_confirmation.go
@@ -19,9 +19,11 @@ type exitConfirmationKeyMap struct {
}
func defaultExitConfirmationKeyMap() exitConfirmationKeyMap {
+ yesKeys := append([]string{"y", "Y"}, core.GetKeys().Quit.Keys()...)
+
return exitConfirmationKeyMap{
Yes: key.NewBinding(
- key.WithKeys("y", "Y", "ctrl+c"),
+ key.WithKeys(yesKeys...),
key.WithHelp("Y", "yes"),
),
No: key.NewBinding(
diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go
index 08691c88f..bec209039 100644
--- a/pkg/tui/tui.go
+++ b/pkg/tui/tui.go
@@ -1581,64 +1581,32 @@ func (m *appModel) Help() help.KeyMap {
// AllBindings returns ALL available key bindings for the help dialog (comprehensive list).
func (m *appModel) AllBindings() []key.Binding {
- quitBinding := key.NewBinding(
- key.WithKeys("ctrl+c"),
- key.WithHelp("Ctrl+c", "quit"),
- )
+ keys := core.GetKeys()
+ quitBinding := keys.Quit
if m.leanMode {
return []key.Binding{quitBinding}
}
- tabBinding := key.NewBinding(
- key.WithKeys("tab"),
- key.WithHelp("Tab", "switch focus"),
- )
+ tabBinding := keys.SwitchFocus
bindings := []key.Binding{quitBinding, tabBinding}
bindings = append(bindings, m.tabBar.Bindings()...)
// Additional global shortcuts
bindings = append(bindings,
- key.NewBinding(
- key.WithKeys("ctrl+k"),
- key.WithHelp("Ctrl+k", "commands"),
- ),
- key.NewBinding(
- key.WithKeys("ctrl+h"),
- key.WithHelp("Ctrl+h", "help"),
- ),
- key.NewBinding(
- key.WithKeys("ctrl+y"),
- key.WithHelp("Ctrl+y", "toggle yolo mode"),
- ),
- key.NewBinding(
- key.WithKeys("ctrl+o"),
- key.WithHelp("Ctrl+o", "toggle hide tool results"),
- ),
- key.NewBinding(
- key.WithKeys("ctrl+s"),
- key.WithHelp("Ctrl+s", "cycle agent"),
- ),
- key.NewBinding(
- key.WithKeys("ctrl+m"),
- key.WithHelp("Ctrl+m", "model picker"),
- ),
- key.NewBinding(
- key.WithKeys("ctrl+x"),
- key.WithHelp("Ctrl+x", "clear queue"),
- ),
- key.NewBinding(
- key.WithKeys("ctrl+z"),
- key.WithHelp("Ctrl+z", "suspend"),
- ),
+ keys.Commands,
+ keys.Help,
+ keys.ToggleYolo,
+ keys.ToggleHideToolResults,
+ keys.CycleAgent,
+ keys.ModelPicker,
+ keys.ClearQueue,
+ keys.Suspend,
)
if !m.leanMode {
- bindings = append(bindings, key.NewBinding(
- key.WithKeys("ctrl+b"),
- key.WithHelp("Ctrl+b", "toggle sidebar"),
- ))
+ bindings = append(bindings, keys.ToggleSidebar)
}
// Show newline help based on keyboard enhancement support
@@ -1658,15 +1626,12 @@ func (m *appModel) AllBindings() []key.Binding {
bindings = append(bindings, m.chatPage.Bindings()...)
} else {
editorName := editorname.FromEnv(os.Getenv("VISUAL"), os.Getenv("EDITOR"))
+ editExternal := keys.EditExternal
+ editExternal.SetHelp(editExternal.Keys()[0], "edit in "+editorName)
+
bindings = append(bindings,
- key.NewBinding(
- key.WithKeys("ctrl+g"),
- key.WithHelp("Ctrl+g", "edit in "+editorName),
- ),
- key.NewBinding(
- key.WithKeys("ctrl+r"),
- key.WithHelp("Ctrl+r", "history search"),
- ),
+ editExternal,
+ keys.HistorySearch,
)
}
return bindings
@@ -1678,19 +1643,20 @@ func (m *appModel) Bindings() []key.Binding {
all := m.AllBindings()
// Define which keys should appear in the status bar
+ keys := core.GetKeys()
statusBarKeys := map[string]bool{
- "ctrl+c": true, // quit
- "tab": true, // switch focus
- "ctrl+t": true, // new tab (from tabBar)
- "ctrl+w": true, // close tab (from tabBar)
- "ctrl+p": true, // prev tab (from tabBar)
- "ctrl+n": true, // next tab (from tabBar)
- "ctrl+k": true, // commands
- "ctrl+h": true, // help
- "shift+enter": true, // newline
- "ctrl+j": true, // newline fallback
- "ctrl+g": true, // edit in external editor (editor context)
- "ctrl+r": true, // history search (editor context)
+ keys.Quit.Keys()[0]: true, // quit
+ keys.SwitchFocus.Keys()[0]: true, // switch focus
+ "ctrl+t": true, // new tab (from tabBar)
+ "ctrl+w": true, // close tab (from tabBar)
+ "ctrl+p": true, // prev tab (from tabBar)
+ "ctrl+n": true, // next tab (from tabBar)
+ keys.Commands.Keys()[0]: true, // commands
+ keys.Help.Keys()[0]: true, // help
+ "shift+enter": true, // newline
+ "ctrl+j": true, // newline fallback
+ keys.EditExternal.Keys()[0]: true, // edit in external editor (editor context)
+ keys.HistorySearch.Keys()[0]: true, // history search (editor context)
// Content panel bindings (↑↓, c, e, d) are always included
"up": true,
"down": true,
@@ -1779,32 +1745,38 @@ func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
// Global keyboard shortcuts (active even during history search)
+ keys := core.GetKeys()
switch {
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+z"))):
+ case key.Matches(msg, keys.Quit):
+ return m, core.CmdHandler(dialog.OpenDialogMsg{
+ Model: dialog.NewExitConfirmationDialog(),
+ })
+
+ case key.Matches(msg, keys.Suspend):
return m, tea.Suspend
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+k"))):
+ case key.Matches(msg, keys.Commands):
categories := m.commandCategories()
return m, core.CmdHandler(dialog.OpenDialogMsg{
Model: dialog.NewCommandPaletteDialog(categories),
})
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+y"))):
+ case key.Matches(msg, keys.ToggleYolo):
return m, core.CmdHandler(messages.ToggleYoloMsg{})
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+o"))):
+ case key.Matches(msg, keys.ToggleHideToolResults):
return m, core.CmdHandler(messages.ToggleHideToolResultsMsg{})
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+s"))):
+ case key.Matches(msg, keys.CycleAgent):
return m.handleCycleAgent()
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+m"))):
+ case key.Matches(msg, keys.ModelPicker):
return m.handleOpenModelPicker()
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+x"))):
+ case key.Matches(msg, keys.ClearQueue):
return m, core.CmdHandler(messages.ClearQueueMsg{})
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+h", "f1", "ctrl+?"))):
+ case key.Matches(msg, keys.Help):
// Show contextual help dialog with ALL available key bindings
return m, core.CmdHandler(dialog.OpenDialogMsg{
Model: dialog.NewHelpDialog(m.AllBindings()),
@@ -1817,10 +1789,10 @@ func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
switch {
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+g"))):
+ case key.Matches(msg, keys.EditExternal):
return m.openExternalEditor()
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+r"))):
+ case key.Matches(msg, keys.HistorySearch):
if m.focusedPanel == PanelEditor && !m.editor.IsRecording() {
model, cmd := m.editor.EnterHistorySearch()
m.editor = model.(editor.Editor)
@@ -1828,14 +1800,14 @@ func (m *appModel) handleKeyPress(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
}
// Toggle sidebar (propagates to content view regardless of focus)
- case key.Matches(msg, key.NewBinding(key.WithKeys("ctrl+b"))):
+ case key.Matches(msg, keys.ToggleSidebar):
if m.leanMode {
return m, nil
}
return m.forwardChat(msg)
// Focus switching: Tab key toggles between content and editor
- case key.Matches(msg, key.NewBinding(key.WithKeys("tab"))):
+ case key.Matches(msg, keys.SwitchFocus):
return m.switchFocus()
// Esc: cancel stream (works regardless of focus)
diff --git a/pkg/userconfig/userconfig.go b/pkg/userconfig/userconfig.go
index f42575f55..6c906c970 100644
--- a/pkg/userconfig/userconfig.go
+++ b/pkg/userconfig/userconfig.go
@@ -67,6 +67,13 @@ type Settings struct {
// and agents. These act as user-wide defaults; session-level and agent-level
// permissions override them.
Permissions *latest.PermissionsConfig `yaml:"permissions,omitempty"`
+ // Keybindings allows users to configure custom keybindings for TUI actions.
+ Keybindings *[]Keybinding `yaml:"keybindings,omitempty"`
+}
+
+type Keybinding struct {
+ Action string `yaml:"action"`
+ Keys []string `yaml:"keys"`
}
// DefaultTabTitleMaxLength is the default maximum tab title length when not configured.