Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/features/tui/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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 <kbd>Ctrl</kbd>+<kbd>H</kbd> 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 <kbd>Ctrl</kbd>+<kbd>R</kbd> to enter incremental history search mode. Start typing to filter through your previous inputs. Press <kbd>Enter</kbd> to select a match, or <kbd>Escape</kbd> to cancel.
Expand Down
144 changes: 144 additions & 0 deletions pkg/tui/core/keys.go
Comment thread
joshbarrington marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
joshbarrington marked this conversation as resolved.
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{}
}
150 changes: 150 additions & 0 deletions pkg/tui/core/keys_test.go
Comment thread
joshbarrington marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 2 additions & 2 deletions pkg/tui/dialog/base.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pkg/tui/dialog/elicitation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 3 additions & 1 deletion pkg/tui/dialog/exit_confirmation.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading