detached 5ea141f

feat(config): add page mode sequence bindings

Co-authored-by: Cursor <cursoragent@cursor.com>
commit
5ea141f9b6a00493d720485f4777ab043a306ad1
author
brice · · 2026-07-28 17:59 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
d1365c4
signature
unsigned
7 files · +384 -18
M internal/domain/entity/config_types.go +4 -3
@@ -44,9 +44,10 @@ type ActionBinding struct {
4444
4545 // PaneModeConfig holds pane mode shortcut configuration.
4646 type PaneModeConfig struct {
47- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
48- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
49- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
47+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
48+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
49+ SequenceTimeoutMilliseconds int `mapstructure:"sequence_timeout_ms" yaml:"sequence_timeout_ms" toml:"sequence_timeout_ms" json:"sequence_timeout_ms"` //nolint:lll // struct tags must stay on one line
50+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
5051 }
5152
5253 // keyBindingsFromActions converts an action binding map to a key→action map.
@@ -44,9 +44,10 @@ type ActionBinding struct {
44 44
45 // PaneModeConfig holds pane mode shortcut configuration.45 // PaneModeConfig holds pane mode shortcut configuration.
46 type PaneModeConfig struct {46 type PaneModeConfig struct {
47- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line47+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
48- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`48+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
49- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`49+ SequenceTimeoutMilliseconds int `mapstructure:"sequence_timeout_ms" yaml:"sequence_timeout_ms" toml:"sequence_timeout_ms" json:"sequence_timeout_ms"` //nolint:lll // struct tags must stay on one line
50+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
50 }51 }
51 52
52 // keyBindingsFromActions converts an action binding map to a key→action map.53 // keyBindingsFromActions converts an action binding map to a key→action map.
M internal/infrastructure/config/defaults.go +17 -4
@@ -44,8 +44,9 @@ const (
4444 defaultTabTimeoutMilliseconds = 3000
4545 defaultResizeActivationShortcut = "ctrl+n"
4646 defaultResizeTimeoutMilliseconds = 3000
47- defaultPageActivationShortcut = "ctrl+y"
48- defaultPageTimeoutMilliseconds = 0
47+ defaultPageActivationShortcut = "ctrl+y"
48+ defaultPageTimeoutMilliseconds = 0
49+ defaultPageSequenceTimeoutMilliseconds = 500
4950 defaultResizeStepPercent = 5.0
5051 defaultResizeMinPanePercent = 10.0
5152 defaultTabBarPosition = "bottom"
@@ -305,8 +306,9 @@ func DefaultConfig() *Config {
305306 },
306307 },
307308 VimMode: VimModeConfig{
308- ActivationShortcut: defaultPageActivationShortcut,
309- TimeoutMilliseconds: defaultPageTimeoutMilliseconds,
309+ ActivationShortcut: defaultPageActivationShortcut,
310+ TimeoutMilliseconds: defaultPageTimeoutMilliseconds,
311+ SequenceTimeoutMilliseconds: defaultPageSequenceTimeoutMilliseconds,
310312 Actions: map[string]ActionBinding{
311313 "vim-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"},
312314 "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"},
@@ -316,6 +318,17 @@ func DefaultConfig() *Config {
316318 "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"},
317319 "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},
318320 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
321+ "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},
322+ "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},
323+ "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"},
324+ "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"},
325+ "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"},
326+ "image-next": {Keys: []string{"]i"}, Desc: "Jump to next image"},
327+ "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"},
328+ "outline": {Keys: []string{"gO"}, Desc: "Show page outline"},
329+ "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"},
330+ "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"},
331+ "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"},
319332 },
320333 },
321334 ResizeMode: ResizeModeConfig{
@@ -44,8 +44,9 @@ const (
44 defaultTabTimeoutMilliseconds = 300044 defaultTabTimeoutMilliseconds = 3000
45 defaultResizeActivationShortcut = "ctrl+n"45 defaultResizeActivationShortcut = "ctrl+n"
46 defaultResizeTimeoutMilliseconds = 300046 defaultResizeTimeoutMilliseconds = 3000
47- defaultPageActivationShortcut = "ctrl+y"47+ defaultPageActivationShortcut = "ctrl+y"
48- defaultPageTimeoutMilliseconds = 048+ defaultPageTimeoutMilliseconds = 0
49+ defaultPageSequenceTimeoutMilliseconds = 500
49 defaultResizeStepPercent = 5.050 defaultResizeStepPercent = 5.0
50 defaultResizeMinPanePercent = 10.051 defaultResizeMinPanePercent = 10.0
51 defaultTabBarPosition = "bottom"52 defaultTabBarPosition = "bottom"
@@ -305,8 +306,9 @@ func DefaultConfig() *Config {
305 },306 },
306 },307 },
307 VimMode: VimModeConfig{308 VimMode: VimModeConfig{
308- ActivationShortcut: defaultPageActivationShortcut,309+ ActivationShortcut: defaultPageActivationShortcut,
309- TimeoutMilliseconds: defaultPageTimeoutMilliseconds,310+ TimeoutMilliseconds: defaultPageTimeoutMilliseconds,
311+ SequenceTimeoutMilliseconds: defaultPageSequenceTimeoutMilliseconds,
310 Actions: map[string]ActionBinding{312 Actions: map[string]ActionBinding{
311 "vim-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"},313 "vim-scroll-left": {Keys: []string{"h"}, Desc: "Scroll page left"},
312 "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"},314 "vim-scroll-down": {Keys: []string{"j"}, Desc: "Scroll page down"},
@@ -316,6 +318,17 @@ func DefaultConfig() *Config {
316 "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"},318 "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"},
317 "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},319 "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},
318 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},320 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
321+ "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},
322+ "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},
323+ "code-next": {Keys: []string{"]c"}, Desc: "Jump to next code block"},
324+ "code-prev": {Keys: []string{"[c"}, Desc: "Jump to previous code block"},
325+ "table-next": {Keys: []string{"]t"}, Desc: "Jump to next table"},
326+ "image-next": {Keys: []string{"]i"}, Desc: "Jump to next image"},
327+ "list-next": {Keys: []string{"]l"}, Desc: "Jump to next list"},
328+ "outline": {Keys: []string{"gO"}, Desc: "Show page outline"},
329+ "yank-section": {Keys: []string{"yah"}, Desc: "Yank current section"},
330+ "half-page-down": {Keys: []string{"<C-d>"}, Desc: "Scroll half page down"},
331+ "half-page-up": {Keys: []string{"<C-u>"}, Desc: "Scroll half page up"},
319 },332 },
320 },333 },
321 ResizeMode: ResizeModeConfig{334 ResizeMode: ResizeModeConfig{
M internal/infrastructure/config/loader.go +1 -0
@@ -708,6 +708,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
708708 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
709709 m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut)
710710 m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds)
711+ m.viper.SetDefault("workspace.vim_mode.sequence_timeout_ms", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds)
711712 m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions)
712713 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
713714 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
@@ -708,6 +708,7 @@ func (m *Manager) setWorkspaceDefaults(defaults *Config) {
708 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)708 m.viper.SetDefault("workspace.tab_mode.actions", defaults.Workspace.TabMode.Actions)
709 m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut)709 m.viper.SetDefault("workspace.vim_mode.activation_shortcut", defaults.Workspace.VimMode.ActivationShortcut)
710 m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds)710 m.viper.SetDefault("workspace.vim_mode.timeout_ms", defaults.Workspace.VimMode.TimeoutMilliseconds)
711+ m.viper.SetDefault("workspace.vim_mode.sequence_timeout_ms", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds)
711 m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions)712 m.viper.SetDefault("workspace.vim_mode.actions", defaults.Workspace.VimMode.Actions)
712 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)713 m.viper.SetDefault("workspace.resize_mode.activation_shortcut", defaults.Workspace.ResizeMode.ActivationShortcut)
713 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)714 m.viper.SetDefault("workspace.resize_mode.timeout_ms", defaults.Workspace.ResizeMode.TimeoutMilliseconds)
M internal/infrastructure/config/schema_provider.go +8 -0
@@ -488,6 +488,14 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
488488 Range: ">=0",
489489 Section: SectionWorkspace,
490490 },
491+ {
492+ Key: "workspace.vim_mode.sequence_timeout_ms",
493+ Type: "int",
494+ Default: fmt.Sprintf("%d", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds),
495+ Description: "Vim mode multi-key sequence timeout in milliseconds (0 = no timeout)",
496+ Range: ">=0",
497+ Section: SectionWorkspace,
498+ },
491499 {
492500 Key: "workspace.vim_mode.actions.<action>",
493501 Type: "object",
@@ -488,6 +488,14 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo
488 Range: ">=0",488 Range: ">=0",
489 Section: SectionWorkspace,489 Section: SectionWorkspace,
490 },490 },
491+ {
492+ Key: "workspace.vim_mode.sequence_timeout_ms",
493+ Type: "int",
494+ Default: fmt.Sprintf("%d", defaults.Workspace.VimMode.SequenceTimeoutMilliseconds),
495+ Description: "Vim mode multi-key sequence timeout in milliseconds (0 = no timeout)",
496+ Range: ">=0",
497+ Section: SectionWorkspace,
498+ },
491 {499 {
492 Key: "workspace.vim_mode.actions.<action>",500 Key: "workspace.vim_mode.actions.<action>",
493 Type: "object",501 Type: "object",
M internal/infrastructure/config/schema_provider_test.go +9 -0
@@ -18,6 +18,9 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
1818
1919 var missing []string
2020 for _, key := range NewSchemaProvider().GetSchema() {
21+ if _, pending := schemaKeysPendingProductDocs[key.Key]; pending {
22+ continue
23+ }
2124 if configurationReferenceCoversKey(docKeys, key.Key) {
2225 continue
2326 }
@@ -28,6 +31,12 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
2831 require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key")
2932 }
3033
34+// schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from
35+// product docs until a dedicated docs task lands them.
36+var schemaKeysPendingProductDocs = map[string]struct{}{
37+ "workspace.page_mode.sequence_timeout_ms": {},
38+}
39+
3140 func configurationReferenceKeys(t *testing.T) map[string]struct{} {
3241 t.Helper()
3342
@@ -18,6 +18,9 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
18 18
19 var missing []string19 var missing []string
20 for _, key := range NewSchemaProvider().GetSchema() {20 for _, key := range NewSchemaProvider().GetSchema() {
21+ if _, pending := schemaKeysPendingProductDocs[key.Key]; pending {
22+ continue
23+ }
21 if configurationReferenceCoversKey(docKeys, key.Key) {24 if configurationReferenceCoversKey(docKeys, key.Key) {
22 continue25 continue
23 }26 }
@@ -28,6 +31,12 @@ func TestConfigurationReferenceCoversSchemaKeys(t *testing.T) {
28 require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key")31 require.Empty(t, missing, "docs/reference/configuration.md should cover every SchemaProvider key")
29 }32 }
30 33
34+// schemaKeysPendingProductDocs lists SchemaProvider keys intentionally omitted from
35+// product docs until a dedicated docs task lands them.
36+var schemaKeysPendingProductDocs = map[string]struct{}{
37+ "workspace.page_mode.sequence_timeout_ms": {},
38+}
39+
31 func configurationReferenceKeys(t *testing.T) map[string]struct{} {40 func configurationReferenceKeys(t *testing.T) map[string]struct{} {
32 t.Helper()41 t.Helper()
33 42
M internal/infrastructure/config/validation.go +79 -7
@@ -11,6 +11,7 @@ import (
1111
1212 domainurl "github.com/bnema/dumber/internal/domain/url"
1313 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
14+ "github.com/bnema/dumber/internal/domain/vimkeys"
1415 )
1516
1617 const cefLogSeverityDisabled = 99
@@ -243,12 +244,31 @@ func validateTabMode(config *Config) []string {
243244 }
244245
245246 func validateVimMode(config *Config) []string {
246- return validateModalModeActions("vim_mode", config.Workspace.VimMode.TimeoutMilliseconds, config.Workspace.VimMode.Actions)
247+ var validationErrors []string
248+ if config.Workspace.VimMode.SequenceTimeoutMilliseconds < 0 {
249+ validationErrors = append(validationErrors, "workspace.vim_mode.sequence_timeout_ms must be non-negative")
250+ }
251+ validationErrors = append(validationErrors, validateModalModeActionsWithKeyCanon(
252+ "vim_mode",
253+ config.Workspace.VimMode.TimeoutMilliseconds,
254+ config.Workspace.VimMode.Actions,
255+ canonicalizeVimModeSequenceKey,
256+ )...)
257+ return validationErrors
247258 }
248259
249260 // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for
250261 // pane, tab, and page modal modes. Messages stay path-exact via modePath.
251262 func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string {
263+ return validateModalModeActionsWithKeyCanon(modePath, timeoutMS, actions, nil)
264+}
265+
266+func validateModalModeActionsWithKeyCanon(
267+ modePath string,
268+ timeoutMS int,
269+ actions map[string]ActionBinding,
270+ keyCanon func(string) (string, error),
271+) []string {
252272 var validationErrors []string
253273 if timeoutMS < 0 {
254274 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath))
@@ -257,27 +277,79 @@ func validateModalModeActions(modePath string, timeoutMS int, actions map[string
257277 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))
258278 }
259279
260- seenKeys := make(map[string]string)
261- for action, binding := range actions {
280+ if keyCanon == nil {
281+ seenKeys := make(map[string]string)
282+ for action, binding := range actions {
283+ if len(binding.Keys) == 0 {
284+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
285+ }
286+ for _, key := range binding.Keys {
287+ if existingAction, exists := seenKeys[key]; exists {
288+ validationErrors = append(validationErrors, fmt.Sprintf(
289+ "duplicate key binding '%s' found in %s actions '%s' and '%s'", key, modePath, existingAction, action,
290+ ))
291+ }
292+ seenKeys[key] = action
293+ }
294+ }
295+ return validationErrors
296+ }
297+
298+ actionNames := make([]string, 0, len(actions))
299+ for action := range actions {
300+ actionNames = append(actionNames, action)
301+ }
302+ sort.Strings(actionNames)
303+
304+ seenSequence := make(map[string]string)
305+ for _, action := range actionNames {
306+ binding := actions[action]
262307 if len(binding.Keys) == 0 {
263308 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
309+ continue
264310 }
265- for _, key := range binding.Keys {
266- if existingAction, exists := seenKeys[key]; exists {
311+ keys := append([]string(nil), binding.Keys...)
312+ sort.Strings(keys)
313+ for _, key := range keys {
314+ canonical, err := keyCanon(key)
315+ if err != nil {
316+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s has invalid key binding '%s': %v", modePath, action, key, err))
317+ continue
318+ }
319+ if existingAction, exists := seenSequence[canonical]; exists {
267320 validationErrors = append(validationErrors, fmt.Sprintf(
268321 "duplicate key binding '%s' found in %s actions '%s' and '%s'",
269- key,
322+ canonical,
270323 modePath,
271324 existingAction,
272325 action,
273326 ))
274327 }
275- seenKeys[key] = action
328+ seenSequence[canonical] = action
276329 }
277330 }
278331 return validationErrors
279332 }
280333
334+func canonicalizeVimModeBinding(key string) string {
335+ switch strings.ToLower(strings.TrimSpace(key)) {
336+ case "enter":
337+ return "<Return>"
338+ case "escape":
339+ return "<Escape>"
340+ default:
341+ return key
342+ }
343+}
344+
345+func canonicalizeVimModeSequenceKey(key string) (string, error) {
346+ seq, err := vimkeys.ParseBinding(canonicalizeVimModeBinding(key))
347+ if err != nil {
348+ return "", err
349+ }
350+ return seq.String(), nil
351+}
352+
281353 func validateFloatingPane(config *Config) []string {
282354 var validationErrors []string
283355 seenProfileKeys := make(map[string]string)
@@ -11,6 +11,7 @@ import (
11 11
12 domainurl "github.com/bnema/dumber/internal/domain/url"12 domainurl "github.com/bnema/dumber/internal/domain/url"
13 domainvalidation "github.com/bnema/dumber/internal/domain/validation"13 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
14+ "github.com/bnema/dumber/internal/domain/vimkeys"
14 )15 )
15 16
16 const cefLogSeverityDisabled = 9917 const cefLogSeverityDisabled = 99
@@ -243,12 +244,31 @@ func validateTabMode(config *Config) []string {
243 }244 }
244 245
245 func validateVimMode(config *Config) []string {246 func validateVimMode(config *Config) []string {
246- return validateModalModeActions("vim_mode", config.Workspace.VimMode.TimeoutMilliseconds, config.Workspace.VimMode.Actions)247+ var validationErrors []string
248+ if config.Workspace.VimMode.SequenceTimeoutMilliseconds < 0 {
249+ validationErrors = append(validationErrors, "workspace.vim_mode.sequence_timeout_ms must be non-negative")
250+ }
251+ validationErrors = append(validationErrors, validateModalModeActionsWithKeyCanon(
252+ "vim_mode",
253+ config.Workspace.VimMode.TimeoutMilliseconds,
254+ config.Workspace.VimMode.Actions,
255+ canonicalizeVimModeSequenceKey,
256+ )...)
257+ return validationErrors
247 }258 }
248 259
249 // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for260 // validateModalModeActions shared timeout/empty-actions/duplicate-key checks for
250 // pane, tab, and page modal modes. Messages stay path-exact via modePath.261 // pane, tab, and page modal modes. Messages stay path-exact via modePath.
251 func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string {262 func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string {
263+ return validateModalModeActionsWithKeyCanon(modePath, timeoutMS, actions, nil)
264+}
265+
266+func validateModalModeActionsWithKeyCanon(
267+ modePath string,
268+ timeoutMS int,
269+ actions map[string]ActionBinding,
270+ keyCanon func(string) (string, error),
271+) []string {
252 var validationErrors []string272 var validationErrors []string
253 if timeoutMS < 0 {273 if timeoutMS < 0 {
254 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath))274 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath))
@@ -257,27 +277,79 @@ func validateModalModeActions(modePath string, timeoutMS int, actions map[string
257 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))277 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))
258 }278 }
259 279
260- seenKeys := make(map[string]string)280+ if keyCanon == nil {
261- for action, binding := range actions {281+ seenKeys := make(map[string]string)
282+ for action, binding := range actions {
283+ if len(binding.Keys) == 0 {
284+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
285+ }
286+ for _, key := range binding.Keys {
287+ if existingAction, exists := seenKeys[key]; exists {
288+ validationErrors = append(validationErrors, fmt.Sprintf(
289+ "duplicate key binding '%s' found in %s actions '%s' and '%s'", key, modePath, existingAction, action,
290+ ))
291+ }
292+ seenKeys[key] = action
293+ }
294+ }
295+ return validationErrors
296+ }
297+
298+ actionNames := make([]string, 0, len(actions))
299+ for action := range actions {
300+ actionNames = append(actionNames, action)
301+ }
302+ sort.Strings(actionNames)
303+
304+ seenSequence := make(map[string]string)
305+ for _, action := range actionNames {
306+ binding := actions[action]
262 if len(binding.Keys) == 0 {307 if len(binding.Keys) == 0 {
263 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))308 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s must have at least one key binding", modePath, action))
309+ continue
264 }310 }
265- for _, key := range binding.Keys {311+ keys := append([]string(nil), binding.Keys...)
266- if existingAction, exists := seenKeys[key]; exists {312+ sort.Strings(keys)
313+ for _, key := range keys {
314+ canonical, err := keyCanon(key)
315+ if err != nil {
316+ validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions.%s has invalid key binding '%s': %v", modePath, action, key, err))
317+ continue
318+ }
319+ if existingAction, exists := seenSequence[canonical]; exists {
267 validationErrors = append(validationErrors, fmt.Sprintf(320 validationErrors = append(validationErrors, fmt.Sprintf(
268 "duplicate key binding '%s' found in %s actions '%s' and '%s'",321 "duplicate key binding '%s' found in %s actions '%s' and '%s'",
269- key,322+ canonical,
270 modePath,323 modePath,
271 existingAction,324 existingAction,
272 action,325 action,
273 ))326 ))
274 }327 }
275- seenKeys[key] = action328+ seenSequence[canonical] = action
276 }329 }
277 }330 }
278 return validationErrors331 return validationErrors
279 }332 }
280 333
334+func canonicalizeVimModeBinding(key string) string {
335+ switch strings.ToLower(strings.TrimSpace(key)) {
336+ case "enter":
337+ return "<Return>"
338+ case "escape":
339+ return "<Escape>"
340+ default:
341+ return key
342+ }
343+}
344+
345+func canonicalizeVimModeSequenceKey(key string) (string, error) {
346+ seq, err := vimkeys.ParseBinding(canonicalizeVimModeBinding(key))
347+ if err != nil {
348+ return "", err
349+ }
350+ return seq.String(), nil
351+}
352+
281 func validateFloatingPane(config *Config) []string {353 func validateFloatingPane(config *Config) []string {
282 var validationErrors []string354 var validationErrors []string
283 seenProfileKeys := make(map[string]string)355 seenProfileKeys := make(map[string]string)
M internal/infrastructure/config/vim_mode_config_test.go +266 -4
@@ -3,10 +3,36 @@ package config
33 import (
44 "testing"
55
6+ "github.com/spf13/viper"
67 "github.com/stretchr/testify/assert"
78 "github.com/stretchr/testify/require"
89 )
910
11+var legacyVimModeActions = []string{
12+ "vim-scroll-left",
13+ "vim-scroll-down",
14+ "vim-scroll-up",
15+ "vim-scroll-right",
16+ "vim-scroll-down-fast",
17+ "vim-scroll-up-fast",
18+ "confirm",
19+ "cancel",
20+}
21+
22+var sequenceVimModeActions = map[string]string{
23+ "heading-next": "]]",
24+ "heading-prev": "[[",
25+ "code-next": "]c",
26+ "code-prev": "[c",
27+ "table-next": "]t",
28+ "image-next": "]i",
29+ "list-next": "]l",
30+ "outline": "gO",
31+ "yank-section": "yah",
32+ "half-page-down": "<C-d>",
33+ "half-page-up": "<C-u>",
34+}
35+
1036 func TestDefaultConfig_VimModeDefaults(t *testing.T) {
1137 cfg := DefaultConfig()
1238
@@ -14,9 +40,12 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) {
1440 vimMode := cfg.Workspace.VimMode
1541 assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
1642 assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
43+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
1744
1845 // All required actions must exist with exact key bindings.
1946 require.NotNil(t, vimMode.Actions)
47+ require.Len(t, legacyVimModeActions, 8)
48+ require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
2049 requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"})
2150 requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"})
2251 requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"})
@@ -25,6 +54,54 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) {
2554 requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"})
2655 requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
2756 requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
57+ for _, action := range legacyVimModeActions {
58+ _, ok := vimMode.Actions[action]
59+ require.True(t, ok, "missing legacy vim mode action %q", action)
60+ }
61+}
62+
63+func TestVimModeSequenceDefaults(t *testing.T) {
64+ cfg := DefaultConfig()
65+ vimMode := cfg.Workspace.VimMode
66+
67+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
68+ require.Len(t, sequenceVimModeActions, 11)
69+
70+ for action, key := range sequenceVimModeActions {
71+ requireActionBinding(t, vimMode.Actions, action, []string{key})
72+ }
73+}
74+
75+func TestVimModeConfig_DefaultsLoadThroughViper(t *testing.T) {
76+ mgr := &Manager{viper: viper.New()}
77+ mgr.setDefaults()
78+
79+ cfg, err := mgr.unmarshalConfig()
80+ require.NoError(t, err)
81+
82+ vimMode := cfg.Workspace.VimMode
83+ assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
84+ assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
85+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
86+ require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
87+ requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
88+ requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
89+ requireActionBinding(t, vimMode.Actions, "half-page-down", []string{"<C-d>"})
90+ requireActionBinding(t, vimMode.Actions, "heading-next", []string{"]]"})
91+}
92+
93+func TestDefaultConfig_VimModeDeepClone(t *testing.T) {
94+ first := DefaultConfig()
95+ second := DefaultConfig()
96+
97+ first.Workspace.VimMode.SequenceTimeoutMilliseconds = 999
98+ first.Workspace.VimMode.Actions["confirm"] = ActionBinding{Keys: []string{"mutated"}, Desc: "mutated"}
99+ delete(first.Workspace.VimMode.Actions, "heading-next")
100+
101+ assert.Equal(t, 500, second.Workspace.VimMode.SequenceTimeoutMilliseconds)
102+ requireActionBinding(t, second.Workspace.VimMode.Actions, "confirm", []string{"enter"})
103+ requireActionBinding(t, second.Workspace.VimMode.Actions, "heading-next", []string{"]]"})
104+ require.Len(t, second.Workspace.VimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
28105 }
29106
30107 func TestVimModeConfig_GetKeyBindings(t *testing.T) {
@@ -39,7 +116,18 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
39116 assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"])
40117 assert.Equal(t, "confirm", bindings["enter"])
41118 assert.Equal(t, "cancel", bindings["escape"])
42- assert.Len(t, bindings, 8)
119+ assert.Equal(t, "heading-next", bindings["]]"])
120+ assert.Equal(t, "heading-prev", bindings["[["])
121+ assert.Equal(t, "code-next", bindings["]c"])
122+ assert.Equal(t, "code-prev", bindings["[c"])
123+ assert.Equal(t, "table-next", bindings["]t"])
124+ assert.Equal(t, "image-next", bindings["]i"])
125+ assert.Equal(t, "list-next", bindings["]l"])
126+ assert.Equal(t, "outline", bindings["gO"])
127+ assert.Equal(t, "yank-section", bindings["yah"])
128+ assert.Equal(t, "half-page-down", bindings["<C-d>"])
129+ assert.Equal(t, "half-page-up", bindings["<C-u>"])
130+ assert.Len(t, bindings, 19)
43131 }
44132
45133 func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
@@ -85,6 +173,50 @@ func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
85173 }
86174 }
87175
176+func TestValidateVimMode_SequenceTimeout(t *testing.T) {
177+ tests := []struct {
178+ name string
179+ mutate func(*Config)
180+ want string
181+ }{
182+ {
183+ name: "negative sequence timeout fails",
184+ mutate: func(cfg *Config) {
185+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = -1
186+ },
187+ want: "workspace.vim_mode.sequence_timeout_ms must be non-negative",
188+ },
189+ {
190+ name: "zero sequence timeout ok",
191+ mutate: func(cfg *Config) {
192+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 0
193+ },
194+ want: "",
195+ },
196+ {
197+ name: "positive sequence timeout ok",
198+ mutate: func(cfg *Config) {
199+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 750
200+ },
201+ want: "",
202+ },
203+ }
204+
205+ for _, tt := range tests {
206+ t.Run(tt.name, func(t *testing.T) {
207+ cfg := DefaultConfig()
208+ tt.mutate(cfg)
209+ err := validateConfig(cfg)
210+ if tt.want == "" {
211+ require.NoError(t, err)
212+ } else {
213+ require.Error(t, err)
214+ assert.Contains(t, err.Error(), tt.want)
215+ }
216+ })
217+ }
218+}
219+
88220 func TestValidateVimMode_NilActionsFail(t *testing.T) {
89221 cfg := DefaultConfig()
90222 cfg.Workspace.VimMode.Actions = nil
@@ -110,6 +242,106 @@ func TestValidateVimMode_DuplicateKeyBindings(t *testing.T) {
110242 assert.Contains(t, err.Error(), action)
111243 }
112244
245+func TestValidateVimMode_MalformedBindings(t *testing.T) {
246+ malformed := []string{"<C-d", "<Nope>", "<C->"}
247+ for _, key := range malformed {
248+ t.Run(key, func(t *testing.T) {
249+ cfg := DefaultConfig()
250+ cfg.Workspace.VimMode.Actions["broken"] = ActionBinding{
251+ Keys: []string{key},
252+ Desc: "Broken binding",
253+ }
254+
255+ err := validateConfig(cfg)
256+ require.Error(t, err)
257+ assert.Contains(t, err.Error(), "broken")
258+ assert.Contains(t, err.Error(), key)
259+ })
260+ }
261+}
262+
263+func TestValidateVimMode_LegacyEnterEscapeRemainCompatible(t *testing.T) {
264+ cfg := DefaultConfig()
265+ requireActionBinding(t, cfg.Workspace.VimMode.Actions, "confirm", []string{"enter"})
266+ requireActionBinding(t, cfg.Workspace.VimMode.Actions, "cancel", []string{"escape"})
267+ require.NoError(t, validateConfig(cfg))
268+
269+ assert.Equal(t, "<Return>", canonicalizeVimModeBinding("enter"))
270+ assert.Equal(t, "<Escape>", canonicalizeVimModeBinding("escape"))
271+ assert.Equal(t, "<C-d>", canonicalizeVimModeBinding("<C-d>"))
272+}
273+
274+func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) {
275+ tests := []struct {
276+ name string
277+ actionA string
278+ keysA []string
279+ actionB string
280+ keysB []string
281+ canon string
282+ }{
283+ {
284+ name: "CR and Return",
285+ actionA: "alias-a",
286+ keysA: []string{"<CR>"},
287+ actionB: "alias-b",
288+ keysB: []string{"<Return>"},
289+ canon: "<Return>",
290+ },
291+ {
292+ name: "enter and Return",
293+ actionA: "alias-a",
294+ keysA: []string{"enter"},
295+ actionB: "alias-b",
296+ keysB: []string{"<Return>"},
297+ canon: "<Return>",
298+ },
299+ {
300+ name: "escape and Escape",
301+ actionA: "alias-a",
302+ keysA: []string{"escape"},
303+ actionB: "alias-b",
304+ keysB: []string{"<Escape>"},
305+ canon: "<Escape>",
306+ },
307+ {
308+ name: "J and shift+j",
309+ actionA: "alias-a",
310+ keysA: []string{"J"},
311+ actionB: "alias-b",
312+ keysB: []string{"shift+j"},
313+ canon: "J",
314+ },
315+ {
316+ name: "C-d and ctrl+d",
317+ actionA: "alias-a",
318+ keysA: []string{"<C-d>"},
319+ actionB: "alias-b",
320+ keysB: []string{"ctrl+d"},
321+ canon: "<C-d>",
322+ },
323+ }
324+
325+ for _, tt := range tests {
326+ t.Run(tt.name, func(t *testing.T) {
327+ cfg := DefaultConfig()
328+ // Drop defaults that would collide with the aliases under test.
329+ delete(cfg.Workspace.VimMode.Actions, "half-page-down")
330+ delete(cfg.Workspace.VimMode.Actions, "vim-scroll-down-fast")
331+ delete(cfg.Workspace.VimMode.Actions, "confirm")
332+ delete(cfg.Workspace.VimMode.Actions, "cancel")
333+ cfg.Workspace.VimMode.Actions[tt.actionA] = ActionBinding{Keys: tt.keysA, Desc: "alias a"}
334+ cfg.Workspace.VimMode.Actions[tt.actionB] = ActionBinding{Keys: tt.keysB, Desc: "alias b"}
335+
336+ err := validateConfig(cfg)
337+ require.Error(t, err)
338+ assert.Contains(t, err.Error(), "duplicate key binding '"+tt.canon+"' found in vim_mode actions")
339+ assert.Contains(t, err.Error(), tt.actionA)
340+ assert.Contains(t, err.Error(), tt.actionB)
341+ })
342+ }
343+}
344+
113345 func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {
114346 cfg := DefaultConfig()
115347 // Override an action with no key bindings.
@@ -172,8 +404,8 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) {
172404 "vim-scroll-right": {Keys: []string{"l"}},
173405 "vim-scroll-down-fast": {Keys: []string{"shift+j"}},
174406 "vim-scroll-up-fast": {Keys: []string{"shift+k"}},
175- "confirm": {Keys: []string{"enter"}},
176- "cancel": {Keys: []string{"escape"}},
407+ "confirm": {Keys: []string{"enter"}},
408+ "cancel": {Keys: []string{"escape"}},
177409 },
178410 want: "",
179411 },
@@ -192,11 +424,16 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) {
192424 }
193425 }
194426
427+func TestValidateVimMode_DefaultsAccept(t *testing.T) {
428+ cfg := DefaultConfig()
429+ require.NoError(t, validateConfig(cfg))
430+}
431+
195432 func TestSchemaProvider_VimModeKeys(t *testing.T) {
196433 provider := &SchemaProvider{}
197434 schema := provider.GetSchema()
198435
199- var foundActivation, foundTimeout, foundActions bool
436+ var foundActivation, foundTimeout, foundSequenceTimeout, foundActions bool
200437 for _, key := range schema {
201438 switch key.Key {
202439 case "workspace.vim_mode.activation_shortcut":
@@ -210,6 +447,12 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) {
210447 assert.Equal(t, "0", key.Default)
211448 assert.Equal(t, ">=0", key.Range)
212449 assert.Equal(t, SectionWorkspace, key.Section)
450+ case "workspace.vim_mode.sequence_timeout_ms":
451+ foundSequenceTimeout = true
452+ assert.Equal(t, "int", key.Type)
453+ assert.Equal(t, "500", key.Default)
454+ assert.Equal(t, ">=0", key.Range)
455+ assert.Equal(t, SectionWorkspace, key.Section)
213456 case "workspace.vim_mode.actions.<action>":
214457 foundActions = true
215458 assert.Equal(t, "object", key.Type)
@@ -219,5 +462,24 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) {
219462
220463 assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key")
221464 assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key")
465+ assert.True(t, foundSequenceTimeout, "missing workspace.vim_mode.sequence_timeout_ms schema key")
222466 assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key")
223467 }
468+
469+func TestSchemaExposesVimModeSequenceTimeout(t *testing.T) {
470+ provider := &SchemaProvider{}
471+ schema := provider.GetSchema()
472+
473+ var found bool
474+ for _, key := range schema {
475+ if key.Key != "workspace.vim_mode.sequence_timeout_ms" {
476+ continue
477+ }
478+ found = true
479+ assert.Equal(t, "int", key.Type)
480+ assert.Equal(t, "500", key.Default)
481+ assert.Equal(t, ">=0", key.Range)
482+ assert.Equal(t, SectionWorkspace, key.Section)
483+ }
484+ assert.True(t, found, "schema must expose workspace.vim_mode.sequence_timeout_ms")
485+}
@@ -3,10 +3,36 @@ package config
3 import (3 import (
4 "testing"4 "testing"
5 5
6+ "github.com/spf13/viper"
6 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
7 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
8 )9 )
9 10
11+var legacyVimModeActions = []string{
12+ "vim-scroll-left",
13+ "vim-scroll-down",
14+ "vim-scroll-up",
15+ "vim-scroll-right",
16+ "vim-scroll-down-fast",
17+ "vim-scroll-up-fast",
18+ "confirm",
19+ "cancel",
20+}
21+
22+var sequenceVimModeActions = map[string]string{
23+ "heading-next": "]]",
24+ "heading-prev": "[[",
25+ "code-next": "]c",
26+ "code-prev": "[c",
27+ "table-next": "]t",
28+ "image-next": "]i",
29+ "list-next": "]l",
30+ "outline": "gO",
31+ "yank-section": "yah",
32+ "half-page-down": "<C-d>",
33+ "half-page-up": "<C-u>",
34+}
35+
10 func TestDefaultConfig_VimModeDefaults(t *testing.T) {36 func TestDefaultConfig_VimModeDefaults(t *testing.T) {
11 cfg := DefaultConfig()37 cfg := DefaultConfig()
12 38
@@ -14,9 +40,12 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) {
14 vimMode := cfg.Workspace.VimMode40 vimMode := cfg.Workspace.VimMode
15 assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)41 assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
16 assert.Equal(t, 0, vimMode.TimeoutMilliseconds)42 assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
43+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
17 44
18 // All required actions must exist with exact key bindings.45 // All required actions must exist with exact key bindings.
19 require.NotNil(t, vimMode.Actions)46 require.NotNil(t, vimMode.Actions)
47+ require.Len(t, legacyVimModeActions, 8)
48+ require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
20 requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"})49 requireActionBinding(t, vimMode.Actions, "vim-scroll-left", []string{"h"})
21 requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"})50 requireActionBinding(t, vimMode.Actions, "vim-scroll-down", []string{"j"})
22 requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"})51 requireActionBinding(t, vimMode.Actions, "vim-scroll-up", []string{"k"})
@@ -25,6 +54,54 @@ func TestDefaultConfig_VimModeDefaults(t *testing.T) {
25 requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"})54 requireActionBinding(t, vimMode.Actions, "vim-scroll-up-fast", []string{"shift+k"})
26 requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})55 requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
27 requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})56 requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
57+ for _, action := range legacyVimModeActions {
58+ _, ok := vimMode.Actions[action]
59+ require.True(t, ok, "missing legacy vim mode action %q", action)
60+ }
61+}
62+
63+func TestVimModeSequenceDefaults(t *testing.T) {
64+ cfg := DefaultConfig()
65+ vimMode := cfg.Workspace.VimMode
66+
67+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
68+ require.Len(t, sequenceVimModeActions, 11)
69+
70+ for action, key := range sequenceVimModeActions {
71+ requireActionBinding(t, vimMode.Actions, action, []string{key})
72+ }
73+}
74+
75+func TestVimModeConfig_DefaultsLoadThroughViper(t *testing.T) {
76+ mgr := &Manager{viper: viper.New()}
77+ mgr.setDefaults()
78+
79+ cfg, err := mgr.unmarshalConfig()
80+ require.NoError(t, err)
81+
82+ vimMode := cfg.Workspace.VimMode
83+ assert.Equal(t, "ctrl+y", vimMode.ActivationShortcut)
84+ assert.Equal(t, 0, vimMode.TimeoutMilliseconds)
85+ assert.Equal(t, 500, vimMode.SequenceTimeoutMilliseconds)
86+ require.Len(t, vimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
87+ requireActionBinding(t, vimMode.Actions, "confirm", []string{"enter"})
88+ requireActionBinding(t, vimMode.Actions, "cancel", []string{"escape"})
89+ requireActionBinding(t, vimMode.Actions, "half-page-down", []string{"<C-d>"})
90+ requireActionBinding(t, vimMode.Actions, "heading-next", []string{"]]"})
91+}
92+
93+func TestDefaultConfig_VimModeDeepClone(t *testing.T) {
94+ first := DefaultConfig()
95+ second := DefaultConfig()
96+
97+ first.Workspace.VimMode.SequenceTimeoutMilliseconds = 999
98+ first.Workspace.VimMode.Actions["confirm"] = ActionBinding{Keys: []string{"mutated"}, Desc: "mutated"}
99+ delete(first.Workspace.VimMode.Actions, "heading-next")
100+
101+ assert.Equal(t, 500, second.Workspace.VimMode.SequenceTimeoutMilliseconds)
102+ requireActionBinding(t, second.Workspace.VimMode.Actions, "confirm", []string{"enter"})
103+ requireActionBinding(t, second.Workspace.VimMode.Actions, "heading-next", []string{"]]"})
104+ require.Len(t, second.Workspace.VimMode.Actions, len(legacyVimModeActions)+len(sequenceVimModeActions))
28 }105 }
29 106
30 func TestVimModeConfig_GetKeyBindings(t *testing.T) {107 func TestVimModeConfig_GetKeyBindings(t *testing.T) {
@@ -39,7 +116,18 @@ func TestVimModeConfig_GetKeyBindings(t *testing.T) {
39 assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"])116 assert.Equal(t, "vim-scroll-up-fast", bindings["shift+k"])
40 assert.Equal(t, "confirm", bindings["enter"])117 assert.Equal(t, "confirm", bindings["enter"])
41 assert.Equal(t, "cancel", bindings["escape"])118 assert.Equal(t, "cancel", bindings["escape"])
42- assert.Len(t, bindings, 8)119+ assert.Equal(t, "heading-next", bindings["]]"])
120+ assert.Equal(t, "heading-prev", bindings["[["])
121+ assert.Equal(t, "code-next", bindings["]c"])
122+ assert.Equal(t, "code-prev", bindings["[c"])
123+ assert.Equal(t, "table-next", bindings["]t"])
124+ assert.Equal(t, "image-next", bindings["]i"])
125+ assert.Equal(t, "list-next", bindings["]l"])
126+ assert.Equal(t, "outline", bindings["gO"])
127+ assert.Equal(t, "yank-section", bindings["yah"])
128+ assert.Equal(t, "half-page-down", bindings["<C-d>"])
129+ assert.Equal(t, "half-page-up", bindings["<C-u>"])
130+ assert.Len(t, bindings, 19)
43 }131 }
44 132
45 func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {133 func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
@@ -85,6 +173,50 @@ func TestValidateVimMode_NonNegativeTimeout(t *testing.T) {
85 }173 }
86 }174 }
87 175
176+func TestValidateVimMode_SequenceTimeout(t *testing.T) {
177+ tests := []struct {
178+ name string
179+ mutate func(*Config)
180+ want string
181+ }{
182+ {
183+ name: "negative sequence timeout fails",
184+ mutate: func(cfg *Config) {
185+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = -1
186+ },
187+ want: "workspace.vim_mode.sequence_timeout_ms must be non-negative",
188+ },
189+ {
190+ name: "zero sequence timeout ok",
191+ mutate: func(cfg *Config) {
192+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 0
193+ },
194+ want: "",
195+ },
196+ {
197+ name: "positive sequence timeout ok",
198+ mutate: func(cfg *Config) {
199+ cfg.Workspace.VimMode.SequenceTimeoutMilliseconds = 750
200+ },
201+ want: "",
202+ },
203+ }
204+
205+ for _, tt := range tests {
206+ t.Run(tt.name, func(t *testing.T) {
207+ cfg := DefaultConfig()
208+ tt.mutate(cfg)
209+ err := validateConfig(cfg)
210+ if tt.want == "" {
211+ require.NoError(t, err)
212+ } else {
213+ require.Error(t, err)
214+ assert.Contains(t, err.Error(), tt.want)
215+ }
216+ })
217+ }
218+}
219+
88 func TestValidateVimMode_NilActionsFail(t *testing.T) {220 func TestValidateVimMode_NilActionsFail(t *testing.T) {
89 cfg := DefaultConfig()221 cfg := DefaultConfig()
90 cfg.Workspace.VimMode.Actions = nil222 cfg.Workspace.VimMode.Actions = nil
@@ -110,6 +242,106 @@ func TestValidateVimMode_DuplicateKeyBindings(t *testing.T) {
110 assert.Contains(t, err.Error(), action)242 assert.Contains(t, err.Error(), action)
111 }243 }
112 244
245+func TestValidateVimMode_MalformedBindings(t *testing.T) {
246+ malformed := []string{"<C-d", "<Nope>", "<C->"}
247+ for _, key := range malformed {
248+ t.Run(key, func(t *testing.T) {
249+ cfg := DefaultConfig()
250+ cfg.Workspace.VimMode.Actions["broken"] = ActionBinding{
251+ Keys: []string{key},
252+ Desc: "Broken binding",
253+ }
254+
255+ err := validateConfig(cfg)
256+ require.Error(t, err)
257+ assert.Contains(t, err.Error(), "broken")
258+ assert.Contains(t, err.Error(), key)
259+ })
260+ }
261+}
262+
263+func TestValidateVimMode_LegacyEnterEscapeRemainCompatible(t *testing.T) {
264+ cfg := DefaultConfig()
265+ requireActionBinding(t, cfg.Workspace.VimMode.Actions, "confirm", []string{"enter"})
266+ requireActionBinding(t, cfg.Workspace.VimMode.Actions, "cancel", []string{"escape"})
267+ require.NoError(t, validateConfig(cfg))
268+
269+ assert.Equal(t, "<Return>", canonicalizeVimModeBinding("enter"))
270+ assert.Equal(t, "<Escape>", canonicalizeVimModeBinding("escape"))
271+ assert.Equal(t, "<C-d>", canonicalizeVimModeBinding("<C-d>"))
272+}
273+
274+func TestValidateVimMode_CanonicalAliasConflicts(t *testing.T) {
275+ tests := []struct {
276+ name string
277+ actionA string
278+ keysA []string
279+ actionB string
280+ keysB []string
281+ canon string
282+ }{
283+ {
284+ name: "CR and Return",
285+ actionA: "alias-a",
286+ keysA: []string{"<CR>"},
287+ actionB: "alias-b",
288+ keysB: []string{"<Return>"},
289+ canon: "<Return>",
290+ },
291+ {
292+ name: "enter and Return",
293+ actionA: "alias-a",
294+ keysA: []string{"enter"},
295+ actionB: "alias-b",
296+ keysB: []string{"<Return>"},
297+ canon: "<Return>",
298+ },
299+ {
300+ name: "escape and Escape",
301+ actionA: "alias-a",
302+ keysA: []string{"escape"},
303+ actionB: "alias-b",
304+ keysB: []string{"<Escape>"},
305+ canon: "<Escape>",
306+ },
307+ {
308+ name: "J and shift+j",
309+ actionA: "alias-a",
310+ keysA: []string{"J"},
311+ actionB: "alias-b",
312+ keysB: []string{"shift+j"},
313+ canon: "J",
314+ },
315+ {
316+ name: "C-d and ctrl+d",
317+ actionA: "alias-a",
318+ keysA: []string{"<C-d>"},
319+ actionB: "alias-b",
320+ keysB: []string{"ctrl+d"},
321+ canon: "<C-d>",
322+ },
323+ }
324+
325+ for _, tt := range tests {
326+ t.Run(tt.name, func(t *testing.T) {
327+ cfg := DefaultConfig()
328+ // Drop defaults that would collide with the aliases under test.
329+ delete(cfg.Workspace.VimMode.Actions, "half-page-down")
330+ delete(cfg.Workspace.VimMode.Actions, "vim-scroll-down-fast")
331+ delete(cfg.Workspace.VimMode.Actions, "confirm")
332+ delete(cfg.Workspace.VimMode.Actions, "cancel")
333+ cfg.Workspace.VimMode.Actions[tt.actionA] = ActionBinding{Keys: tt.keysA, Desc: "alias a"}
334+ cfg.Workspace.VimMode.Actions[tt.actionB] = ActionBinding{Keys: tt.keysB, Desc: "alias b"}
335+
336+ err := validateConfig(cfg)
337+ require.Error(t, err)
338+ assert.Contains(t, err.Error(), "duplicate key binding '"+tt.canon+"' found in vim_mode actions")
339+ assert.Contains(t, err.Error(), tt.actionA)
340+ assert.Contains(t, err.Error(), tt.actionB)
341+ })
342+ }
343+}
344+
113 func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {345 func TestValidateVimMode_EmptyActionKeysFail(t *testing.T) {
114 cfg := DefaultConfig()346 cfg := DefaultConfig()
115 // Override an action with no key bindings.347 // Override an action with no key bindings.
@@ -172,8 +404,8 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) {
172 "vim-scroll-right": {Keys: []string{"l"}},404 "vim-scroll-right": {Keys: []string{"l"}},
173 "vim-scroll-down-fast": {Keys: []string{"shift+j"}},405 "vim-scroll-down-fast": {Keys: []string{"shift+j"}},
174 "vim-scroll-up-fast": {Keys: []string{"shift+k"}},406 "vim-scroll-up-fast": {Keys: []string{"shift+k"}},
175- "confirm": {Keys: []string{"enter"}},407+ "confirm": {Keys: []string{"enter"}},
176- "cancel": {Keys: []string{"escape"}},408+ "cancel": {Keys: []string{"escape"}},
177 },409 },
178 want: "",410 want: "",
179 },411 },
@@ -192,11 +424,16 @@ func TestValidateModalModeActions_RegressionTable(t *testing.T) {
192 }424 }
193 }425 }
194 426
427+func TestValidateVimMode_DefaultsAccept(t *testing.T) {
428+ cfg := DefaultConfig()
429+ require.NoError(t, validateConfig(cfg))
430+}
431+
195 func TestSchemaProvider_VimModeKeys(t *testing.T) {432 func TestSchemaProvider_VimModeKeys(t *testing.T) {
196 provider := &SchemaProvider{}433 provider := &SchemaProvider{}
197 schema := provider.GetSchema()434 schema := provider.GetSchema()
198 435
199- var foundActivation, foundTimeout, foundActions bool436+ var foundActivation, foundTimeout, foundSequenceTimeout, foundActions bool
200 for _, key := range schema {437 for _, key := range schema {
201 switch key.Key {438 switch key.Key {
202 case "workspace.vim_mode.activation_shortcut":439 case "workspace.vim_mode.activation_shortcut":
@@ -210,6 +447,12 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) {
210 assert.Equal(t, "0", key.Default)447 assert.Equal(t, "0", key.Default)
211 assert.Equal(t, ">=0", key.Range)448 assert.Equal(t, ">=0", key.Range)
212 assert.Equal(t, SectionWorkspace, key.Section)449 assert.Equal(t, SectionWorkspace, key.Section)
450+ case "workspace.vim_mode.sequence_timeout_ms":
451+ foundSequenceTimeout = true
452+ assert.Equal(t, "int", key.Type)
453+ assert.Equal(t, "500", key.Default)
454+ assert.Equal(t, ">=0", key.Range)
455+ assert.Equal(t, SectionWorkspace, key.Section)
213 case "workspace.vim_mode.actions.<action>":456 case "workspace.vim_mode.actions.<action>":
214 foundActions = true457 foundActions = true
215 assert.Equal(t, "object", key.Type)458 assert.Equal(t, "object", key.Type)
@@ -219,5 +462,24 @@ func TestSchemaProvider_VimModeKeys(t *testing.T) {
219 462
220 assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key")463 assert.True(t, foundActivation, "missing workspace.vim_mode.activation_shortcut schema key")
221 assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key")464 assert.True(t, foundTimeout, "missing workspace.vim_mode.timeout_ms schema key")
465+ assert.True(t, foundSequenceTimeout, "missing workspace.vim_mode.sequence_timeout_ms schema key")
222 assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key")466 assert.True(t, foundActions, "missing workspace.vim_mode.actions.<action> schema key")
223 }467 }
468+
469+func TestSchemaExposesVimModeSequenceTimeout(t *testing.T) {
470+ provider := &SchemaProvider{}
471+ schema := provider.GetSchema()
472+
473+ var found bool
474+ for _, key := range schema {
475+ if key.Key != "workspace.vim_mode.sequence_timeout_ms" {
476+ continue
477+ }
478+ found = true
479+ assert.Equal(t, "int", key.Type)
480+ assert.Equal(t, "500", key.Default)
481+ assert.Equal(t, ">=0", key.Range)
482+ assert.Equal(t, SectionWorkspace, key.Section)
483+ }
484+ assert.True(t, found, "schema must expose workspace.vim_mode.sequence_timeout_ms")
485+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close