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
108 changes: 108 additions & 0 deletions pkg/tui/core/keys.go
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: The TUI docs (docs/features/tui/index.md) have a "Keyboard Shortcuts" table with hardcoded key names. This needs to either:

  1. Mention that these are the defaults and can be overridden via ~/.config/cagent/config.yaml
  2. Add a section documenting the keybinding configuration format

Example config section to document:

settings:
  keybindings:
    - action: "quit"
      keys: ["ctrl+q"]
    - action: "commands"
      keys: ["f2", "ctrl+k"]

Also: the list of valid action names should be documented somewhere users can find it.

Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package core

import (
"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")),
}
}

// 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 {
for _, b := range *settings.Keybindings {
if len(b.Keys) == 0 {
continue
}

usrKeys := b.Keys
keyName := usrKeys[0]

switch b.Action {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: Consider adding basic validation here — at minimum, log a warning for unrecognized b.Action values so users know they have a typo in their config. Currently, unknown actions are silently ignored.

Also worth considering for a follow-up: conflict detection (two actions bound to the same key) and invalid key string validation.

case "quit":
keys.Quit = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "quit"))
case "switch_focus":
keys.SwitchFocus = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "switch focus"))
case "commands":
keys.Commands = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "commands"))
case "help":
keys.Help = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "help"))
case "toggle_yolo":
keys.ToggleYolo = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "toggle yolo mode"))
case "toggle_hide_tool_results":
keys.ToggleHideToolResults = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "toggle hide tool results"))
case "cycle_agent":
keys.CycleAgent = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "cycle agent"))
case "model_picker":
keys.ModelPicker = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "model picker"))
case "clear_queue":
keys.ClearQueue = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "clear queue"))
case "suspend":
keys.Suspend = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "suspend"))
case "toggle_sidebar":
keys.ToggleSidebar = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "toggle sidebar"))
case "edit_external":
keys.EditExternal = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "edit in external editor"))
case "history_search":
keys.HistorySearch = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "history search"))
}
}
}

return keys
Comment on lines +62 to +97
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: The repetitive switch/case could be reduced with a map-based lookup. Something like:

type keyField struct {
    binding *key.Binding
    help    string
}

actionMap := map[string]keyField{
    "quit":        {&keys.Quit, "quit"},
    "switch_focus": {&keys.SwitchFocus, "switch focus"},
    // ...
}

for _, b := range *settings.Keybindings {
    if len(b.Keys) == 0 { continue }
    if f, ok := actionMap[b.Action]; ok {
        *f.binding = key.NewBinding(key.WithKeys(b.Keys...), key.WithHelp(b.Keys[0], f.help))
    }
}

This would also make it trivial to add validation/logging for unknown actions.

}

// GetKeys returns the current keybindings, merging user config overrides with defaults.
// The result is cached after the first call.
func GetKeys() KeyMap {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking: The sync.Once caching means keybindings can never be reloaded without restarting the application. This is fine for a first implementation, but consider adding a ResetKeys() function (even if unexported, useful for tests) or a comment noting this limitation for future hot-reload support.

keysOnce.Do(func() {
cachedKeys = buildKeys(userconfig.Get())
})

return cachedKeys
}
111 changes: 111 additions & 0 deletions pkg/tui/core/keys_test.go
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise: Good test coverage. Testing buildKeys directly (bypassing the sync.Once cache) is the right approach, and the YAML round-trip test is a nice touch for ensuring the config format works end-to-end.

Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package core

import (
"testing"

"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.Keybindings{
{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.Keybindings{
{Action: "quit", Keys: []string{}}, // Should be ignored
},
}
keys := buildKeys(settings)

// Verify defaults remain
assert.Equal(t, []string{"ctrl+c"}, keys.Quit.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())
}
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
2 changes: 1 addition & 1 deletion pkg/tui/dialog/exit_confirmation.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ type exitConfirmationKeyMap struct {
func defaultExitConfirmationKeyMap() exitConfirmationKeyMap {
return exitConfirmationKeyMap{
Yes: key.NewBinding(
key.WithKeys("y", "Y", "ctrl+c"),
key.WithKeys("y", "Y", core.GetKeys().Quit.Keys()[0]),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: What happens if the user configures quit with multiple keys (e.g., keys: ["ctrl+q", "ctrl+c"])? Only Keys()[0] is used here, so pressing the second configured key won't trigger "yes" in the confirmation dialog. Is that intentional?

Might be better to append all quit keys: key.WithKeys(append([]string{"y", "Y"}, core.GetKeys().Quit.Keys()...)...)

key.WithHelp("Y", "yes"),
),
No: key.NewBinding(
Expand Down
Loading
Loading