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.