-
Notifications
You must be signed in to change notification settings - Fork 354
feat: user configurable tui keybindings #2415
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-blocking: The |
||
| keysOnce.Do(func() { | ||
| cachedKeys = buildKeys(userconfig.Get()) | ||
| }) | ||
|
|
||
| return cachedKeys | ||
| } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Praise: Good test coverage. Testing |
| 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()) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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]), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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., Might be better to append all quit keys: |
||
| key.WithHelp("Y", "yes"), | ||
| ), | ||
| No: key.NewBinding( | ||
|
|
||
There was a problem hiding this comment.
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:~/.config/cagent/config.yamlExample config section to document:
Also: the list of valid action names should be documented somewhere users can find it.