Skip to content

Commit ac8aadf

Browse files
allow user to set tui keybindings in config file
1 parent c4c635f commit ac8aadf

File tree

7 files changed

+276
-81
lines changed

7 files changed

+276
-81
lines changed

pkg/tui/core/keys.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
package core
2+
3+
import (
4+
"sync"
5+
6+
"charm.land/bubbles/v2/key"
7+
8+
"github.com/docker/docker-agent/pkg/userconfig"
9+
)
10+
11+
// KeyMap contains global keybindings used across the TUI
12+
type KeyMap struct {
13+
Quit key.Binding
14+
SwitchFocus key.Binding
15+
Commands key.Binding
16+
Help key.Binding
17+
ToggleYolo key.Binding
18+
ToggleHideToolResults key.Binding
19+
CycleAgent key.Binding
20+
ModelPicker key.Binding
21+
ClearQueue key.Binding
22+
Suspend key.Binding
23+
ToggleSidebar key.Binding
24+
EditExternal key.Binding
25+
HistorySearch key.Binding
26+
}
27+
28+
var (
29+
cachedKeys KeyMap
30+
keysOnce sync.Once
31+
)
32+
33+
// DefaultKeyMap returns the default keybindings
34+
func DefaultKeyMap() KeyMap {
35+
return KeyMap{
36+
Quit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("ctrl+c", "quit")),
37+
SwitchFocus: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "switch focus")),
38+
Commands: key.NewBinding(key.WithKeys("ctrl+k"), key.WithHelp("ctrl+k", "commands")),
39+
Help: key.NewBinding(key.WithKeys("ctrl+h", "f1", "ctrl+?"), key.WithHelp("ctrl+h", "help")),
40+
ToggleYolo: key.NewBinding(key.WithKeys("ctrl+y"), key.WithHelp("ctrl+y", "toggle yolo mode")),
41+
ToggleHideToolResults: key.NewBinding(key.WithKeys("ctrl+o"), key.WithHelp("ctrl+o", "toggle hide tool results")),
42+
CycleAgent: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "cycle agent")),
43+
ModelPicker: key.NewBinding(key.WithKeys("ctrl+m"), key.WithHelp("ctrl+m", "model picker")),
44+
ClearQueue: key.NewBinding(key.WithKeys("ctrl+x"), key.WithHelp("ctrl+x", "clear queue")),
45+
Suspend: key.NewBinding(key.WithKeys("ctrl+z"), key.WithHelp("ctrl+z", "suspend")),
46+
ToggleSidebar: key.NewBinding(key.WithKeys("ctrl+b"), key.WithHelp("ctrl+b", "toggle sidebar")),
47+
EditExternal: key.NewBinding(key.WithKeys("ctrl+g"), key.WithHelp("ctrl+g", "edit in external editor")),
48+
HistorySearch: key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl+r", "history search")),
49+
}
50+
}
51+
52+
// buildKeys merges user config overrides with the defaults to produce a KeyMap.
53+
// This is separated from GetKeys() to allow testing with mock settings.
54+
func buildKeys(settings *userconfig.Settings) KeyMap {
55+
keys := DefaultKeyMap()
56+
57+
if settings != nil && settings.Keybindings != nil {
58+
for _, b := range *settings.Keybindings {
59+
if len(b.Keys) == 0 {
60+
continue
61+
}
62+
63+
usrKeys := b.Keys
64+
keyName := usrKeys[0]
65+
66+
switch b.Action {
67+
case "quit":
68+
keys.Quit = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "quit"))
69+
case "switch_focus":
70+
keys.SwitchFocus = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "switch focus"))
71+
case "commands":
72+
keys.Commands = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "commands"))
73+
case "help":
74+
keys.Help = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "help"))
75+
case "toggle_yolo":
76+
keys.ToggleYolo = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "toggle yolo mode"))
77+
case "toggle_hide_tool_results":
78+
keys.ToggleHideToolResults = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "toggle hide tool results"))
79+
case "cycle_agent":
80+
keys.CycleAgent = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "cycle agent"))
81+
case "model_picker":
82+
keys.ModelPicker = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "model picker"))
83+
case "clear_queue":
84+
keys.ClearQueue = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "clear queue"))
85+
case "suspend":
86+
keys.Suspend = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "suspend"))
87+
case "toggle_sidebar":
88+
keys.ToggleSidebar = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "toggle sidebar"))
89+
case "edit_external":
90+
keys.EditExternal = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "edit in external editor"))
91+
case "history_search":
92+
keys.HistorySearch = key.NewBinding(key.WithKeys(usrKeys...), key.WithHelp(keyName, "history search"))
93+
}
94+
}
95+
}
96+
97+
return keys
98+
}
99+
100+
// GetKeys returns the current keybindings, merging user config overrides with defaults.
101+
// The result is cached after the first call.
102+
func GetKeys() KeyMap {
103+
keysOnce.Do(func() {
104+
cachedKeys = buildKeys(userconfig.Get())
105+
})
106+
107+
return cachedKeys
108+
}

pkg/tui/core/keys_test.go

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package core
2+
3+
import (
4+
"testing"
5+
6+
"github.com/goccy/go-yaml"
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
10+
"github.com/docker/docker-agent/pkg/userconfig"
11+
)
12+
13+
func TestBuildKeys_Defaults(t *testing.T) {
14+
keys := buildKeys(nil)
15+
16+
// Verify defaults
17+
assert.Equal(t, []string{"ctrl+c"}, keys.Quit.Keys())
18+
assert.Equal(t, []string{"tab"}, keys.SwitchFocus.Keys())
19+
assert.Equal(t, []string{"ctrl+k"}, keys.Commands.Keys())
20+
assert.Equal(t, []string{"ctrl+h", "f1", "ctrl+?"}, keys.Help.Keys())
21+
assert.Equal(t, []string{"ctrl+y"}, keys.ToggleYolo.Keys())
22+
assert.Equal(t, []string{"ctrl+o"}, keys.ToggleHideToolResults.Keys())
23+
assert.Equal(t, []string{"ctrl+s"}, keys.CycleAgent.Keys())
24+
assert.Equal(t, []string{"ctrl+m"}, keys.ModelPicker.Keys())
25+
assert.Equal(t, []string{"ctrl+x"}, keys.ClearQueue.Keys())
26+
assert.Equal(t, []string{"ctrl+z"}, keys.Suspend.Keys())
27+
assert.Equal(t, []string{"ctrl+b"}, keys.ToggleSidebar.Keys())
28+
assert.Equal(t, []string{"ctrl+g"}, keys.EditExternal.Keys())
29+
assert.Equal(t, []string{"ctrl+r"}, keys.HistorySearch.Keys())
30+
}
31+
32+
func TestBuildKeys_Overrides(t *testing.T) {
33+
settings := &userconfig.Settings{
34+
Keybindings: &[]userconfig.Keybindings{
35+
{Action: "quit", Keys: []string{"ctrl+q"}},
36+
{Action: "switch_focus", Keys: []string{"ctrl+t"}},
37+
{Action: "commands", Keys: []string{"f2", "ctrl+k"}},
38+
{Action: "unknown_action", Keys: []string{"ctrl+u"}}, // Should be ignored
39+
},
40+
}
41+
42+
keys := buildKeys(settings)
43+
44+
// Verify overrides
45+
assert.Equal(t, []string{"ctrl+q"}, keys.Quit.Keys())
46+
assert.Equal(t, []string{"ctrl+t"}, keys.SwitchFocus.Keys())
47+
48+
// Verify arrays are maintained
49+
assert.Equal(t, []string{"f2", "ctrl+k"}, keys.Commands.Keys())
50+
51+
// Verify defaults are preserved where not overridden
52+
assert.Equal(t, []string{"ctrl+h", "f1", "ctrl+?"}, keys.Help.Keys())
53+
assert.Equal(t, []string{"ctrl+y"}, keys.ToggleYolo.Keys())
54+
assert.Equal(t, []string{"ctrl+o"}, keys.ToggleHideToolResults.Keys())
55+
assert.Equal(t, []string{"ctrl+s"}, keys.CycleAgent.Keys())
56+
assert.Equal(t, []string{"ctrl+m"}, keys.ModelPicker.Keys())
57+
assert.Equal(t, []string{"ctrl+x"}, keys.ClearQueue.Keys())
58+
assert.Equal(t, []string{"ctrl+z"}, keys.Suspend.Keys())
59+
assert.Equal(t, []string{"ctrl+b"}, keys.ToggleSidebar.Keys())
60+
assert.Equal(t, []string{"ctrl+g"}, keys.EditExternal.Keys())
61+
assert.Equal(t, []string{"ctrl+r"}, keys.HistorySearch.Keys())
62+
}
63+
64+
func TestBuildKeys_EmptySettings(t *testing.T) {
65+
settings := &userconfig.Settings{}
66+
keys := buildKeys(settings)
67+
68+
// Verify defaults
69+
assert.Equal(t, []string{"ctrl+c"}, keys.Quit.Keys())
70+
assert.Equal(t, []string{"tab"}, keys.SwitchFocus.Keys())
71+
}
72+
73+
func TestBuildKeys_EmptyKey(t *testing.T) {
74+
settings := &userconfig.Settings{
75+
Keybindings: &[]userconfig.Keybindings{
76+
{Action: "quit", Keys: []string{}}, // Should be ignored
77+
},
78+
}
79+
keys := buildKeys(settings)
80+
81+
// Verify defaults remain
82+
assert.Equal(t, []string{"ctrl+c"}, keys.Quit.Keys())
83+
}
84+
85+
func TestBuildKeys_FromYAML(t *testing.T) {
86+
yamlConfig := `
87+
settings:
88+
keybindings:
89+
- action: "quit"
90+
keys: ["ctrl+q"]
91+
- action: "commands"
92+
keys: ["f2", "ctrl+k"]
93+
- action: "history_search"
94+
keys: ["ctrl+f"]
95+
`
96+
97+
var config userconfig.Config
98+
err := yaml.Unmarshal([]byte(yamlConfig), &config)
99+
require.NoError(t, err)
100+
101+
keys := buildKeys(config.Settings)
102+
103+
// Verify the keys loaded correctly from the YAML unmarshal
104+
assert.Equal(t, []string{"ctrl+q"}, keys.Quit.Keys())
105+
assert.Equal(t, []string{"f2", "ctrl+k"}, keys.Commands.Keys())
106+
assert.Equal(t, []string{"ctrl+f"}, keys.HistorySearch.Keys())
107+
108+
// Verify defaults are preserved for missing YAML fields
109+
assert.Equal(t, []string{"tab"}, keys.SwitchFocus.Keys())
110+
assert.Equal(t, []string{"ctrl+h", "f1", "ctrl+?"}, keys.Help.Keys())
111+
}

pkg/tui/dialog/base.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -144,9 +144,9 @@ func RenderHelpKeys(contentWidth int, bindings ...string) string {
144144
return styles.BaseStyle.Width(contentWidth).Align(lipgloss.Center).Render(strings.Join(parts, " "))
145145
}
146146

147-
// HandleQuit checks for ctrl+c and returns tea.Quit if matched.
147+
// HandleQuit checks for the quit key and returns tea.Quit if matched.
148148
func HandleQuit(msg tea.KeyPressMsg) tea.Cmd {
149-
if msg.String() == "ctrl+c" {
149+
if key.Matches(msg, core.GetKeys().Quit) {
150150
return tea.Quit
151151
}
152152
return nil

pkg/tui/dialog/elicitation.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515

1616
"github.com/docker/docker-agent/pkg/tools"
1717
"github.com/docker/docker-agent/pkg/tui/components/markdown"
18+
"github.com/docker/docker-agent/pkg/tui/core"
1819
"github.com/docker/docker-agent/pkg/tui/core/layout"
1920
"github.com/docker/docker-agent/pkg/tui/styles"
2021
)
@@ -145,7 +146,7 @@ func (d *ElicitationDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) {
145146
}
146147
return d, nil
147148
case tea.KeyPressMsg:
148-
if msg.String() == "ctrl+c" {
149+
if key.Matches(msg, core.GetKeys().Quit) {
149150
cmd := d.close(tools.ElicitationActionDecline, nil)
150151
return d, tea.Sequence(cmd, tea.Quit)
151152
}

pkg/tui/dialog/exit_confirmation.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ type exitConfirmationKeyMap struct {
2121
func defaultExitConfirmationKeyMap() exitConfirmationKeyMap {
2222
return exitConfirmationKeyMap{
2323
Yes: key.NewBinding(
24-
key.WithKeys("y", "Y", "ctrl+c"),
24+
key.WithKeys("y", "Y", core.GetKeys().Quit.Keys()[0]),
2525
key.WithHelp("Y", "yes"),
2626
),
2727
No: key.NewBinding(

0 commit comments

Comments
 (0)