detached a80868d

feat(vim): activate selected heading links

commit
a80868d069fd5a55fcf386dd89bb43837755746d
author
brice · · 2026-08-28 04:40 UTC
committer
brice · · 2026-08-28 04:52 UTC
parent
bf0756b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +252 -56
M CHANGELOG.md +1 -1
@@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file.
66
77 ### Added
88
9-- **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections.
9+- **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. Pressing `Enter` on a selected CEF heading opens its associated link before leaving Vim Mode. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections.
1010 - **Native favorites sidebar**: Added a keyboard-driven GTK sidebar toggled with `Ctrl+B`, including search, tag filtering, tag creation, favorite editing, shortcut assignment, deletion, and opening favorites in the current pane or a new split.
1111 - **WebP favicon decoding**: Added safe WebP decoding for sites that publish WebP favicons.
1212
@@ -6,7 +6,7 @@ All notable changes to this project will be documented in this file.
6 6
7 ### Added7 ### Added
8 8
9-- **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections.9+- **Vim mode for keyboard-driven page navigation**: Added configurable multi-key sequences for page scrolling, fast scrolling, page-input focus, and live heading navigation. Pressing `Enter` on a selected CEF heading opens its associated link before leaving Vim Mode. CEF accessibility data also provides the foundation for navigating structured page content such as headings, lists, paragraphs, and sections.
10 - **Native favorites sidebar**: Added a keyboard-driven GTK sidebar toggled with `Ctrl+B`, including search, tag filtering, tag creation, favorite editing, shortcut assignment, deletion, and opening favorites in the current pane or a new split.10 - **Native favorites sidebar**: Added a keyboard-driven GTK sidebar toggled with `Ctrl+B`, including search, tag filtering, tag creation, favorite editing, shortcut assignment, deletion, and opening favorites in the current pane or a new split.
11 - **WebP favicon decoding**: Added safe WebP decoding for sites that publish WebP favicons.11 - **WebP favicon decoding**: Added safe WebP decoding for sites that publish WebP favicons.
12 12
M docs/config/index.md +3 -2
@@ -449,7 +449,7 @@ desc = "Jump to previous heading"
449449
450450 [workspace.vim_mode.actions.confirm]
451451 keys = ["enter"]
452-desc = "Confirm action"
452+desc = "Open selected heading link and exit mode"
453453
454454 [workspace.vim_mode.actions.cancel]
455455 keys = ["escape"]
@@ -460,10 +460,11 @@ Notes:
460460 - Vim Mode scrolls and navigates the active webpage inside the active pane only.
461461 - `gi` focuses the next visible editable page input, starting at the first when no input is active and wrapping after the last.
462462 - `]]` and `[[` move through visible `h1`–`h6` headings and use the current Dumber theme accent for the selected-heading outline.
463+- `confirm` opens the first link inside the selected heading, or a link wrapping that heading, then exits Vim Mode. If the selected heading has no link, it only exits the mode.
463464 - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused.
464465 - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active.
465466 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.
466-- Live input, heading, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.
467+- Live input, heading, heading-link activation, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.
467468 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.
468469 - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals.
469470 - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready.
@@ -449,7 +449,7 @@ desc = "Jump to previous heading"
449 449
450 [workspace.vim_mode.actions.confirm]450 [workspace.vim_mode.actions.confirm]
451 keys = ["enter"]451 keys = ["enter"]
452-desc = "Confirm action"452+desc = "Open selected heading link and exit mode"
453 453
454 [workspace.vim_mode.actions.cancel]454 [workspace.vim_mode.actions.cancel]
455 keys = ["escape"]455 keys = ["escape"]
@@ -460,10 +460,11 @@ Notes:
460 - Vim Mode scrolls and navigates the active webpage inside the active pane only.460 - Vim Mode scrolls and navigates the active webpage inside the active pane only.
461 - `gi` focuses the next visible editable page input, starting at the first when no input is active and wrapping after the last.461 - `gi` focuses the next visible editable page input, starting at the first when no input is active and wrapping after the last.
462 - `]]` and `[[` move through visible `h1`–`h6` headings and use the current Dumber theme accent for the selected-heading outline.462 - `]]` and `[[` move through visible `h1`–`h6` headings and use the current Dumber theme accent for the selected-heading outline.
463+- `confirm` opens the first link inside the selected heading, or a link wrapping that heading, then exits Vim Mode. If the selected heading has no link, it only exits the mode.
463 - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused.464 - The default `timeout_ms = 0` means Vim Mode stays active until you exit it or focus moves into an editable/browser UI context. `Ctrl+Y` can still activate it when a page input is already focused.
464 - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active.465 - Arrow keys still use the browser engine's native page-navigation path while Vim Mode is active.
465 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.466 - `Tab` and `Shift+Tab` keep focus traversal inside the page while a page input is focused.
466-- Live input, heading, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.467+- Live input, heading, heading-link activation, and page-focus navigation currently use the CEF engine path; WebKit fallback supports the Vim scroll commands while equivalent semantic navigation is pending.
467 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.468 - Other app-level shortcuts stay suspended until Vim Mode exits, except for the Vim Mode toggle itself.
468 - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals.469 - `workspace.styling.pane_mode_color`, `workspace.styling.transition_duration`, and `workspace.styling.mode_indicator_toaster_enabled` control the pane-local Vim Mode visuals.
469 - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready.470 - Scroll execution: CEF and WebKit both use the shared `BuildPageScrollByJS` resolver when the page is ready. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. The application repeater owns held-key cadence; each engine executes one immediate scroll step per tick. Cross-origin frame contents remain best-effort. CEF may use native precision-wheel input only as a pre-frame fallback before the browser/main frame is ready.
M docs/getting-started.md +5 -4
@@ -37,10 +37,11 @@ Vim Mode stays local to the active pane and automatically leaves the mode when f
3737 3. Use `Shift+J` / `Shift+K` for faster vertical jumps
3838 4. Arrow keys continue to use the browser engine's native page navigation while Vim Mode is active
3939 5. Use `gi` to cycle through visible page inputs; use `]]` and `[[` to move through headings
40-6. `Tab` and `Shift+Tab` traverse focusable page controls while a page input is focused
41-7. Other app-level shortcuts stay suspended until you leave Vim Mode
42-8. Press `Escape` or `Enter` to leave the mode
43-9. `Ctrl+Y` can activate Vim Mode even when a page input or editor is already focused
40+6. With CEF, press `Enter` to open a link in the selected heading and leave Vim Mode; if the heading has no link, it only leaves the mode
41+7. `Tab` and `Shift+Tab` traverse focusable page controls while a page input is focused
42+8. Other app-level shortcuts stay suspended until you leave Vim Mode
43+9. Press `Escape` to leave the mode without activating the selected heading
44+10. `Ctrl+Y` can activate Vim Mode even when a page input or editor is already focused
4445
4546 > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the
4647 > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller
@@ -37,10 +37,11 @@ Vim Mode stays local to the active pane and automatically leaves the mode when f
37 3. Use `Shift+J` / `Shift+K` for faster vertical jumps37 3. Use `Shift+J` / `Shift+K` for faster vertical jumps
38 4. Arrow keys continue to use the browser engine's native page navigation while Vim Mode is active38 4. Arrow keys continue to use the browser engine's native page navigation while Vim Mode is active
39 5. Use `gi` to cycle through visible page inputs; use `]]` and `[[` to move through headings39 5. Use `gi` to cycle through visible page inputs; use `]]` and `[[` to move through headings
40-6. `Tab` and `Shift+Tab` traverse focusable page controls while a page input is focused40+6. With CEF, press `Enter` to open a link in the selected heading and leave Vim Mode; if the heading has no link, it only leaves the mode
41-7. Other app-level shortcuts stay suspended until you leave Vim Mode41+7. `Tab` and `Shift+Tab` traverse focusable page controls while a page input is focused
42-8. Press `Escape` or `Enter` to leave the mode42+8. Other app-level shortcuts stay suspended until you leave Vim Mode
43-9. `Ctrl+Y` can activate Vim Mode even when a page input or editor is already focused43+9. Press `Escape` to leave the mode without activating the selected heading
44+10. `Ctrl+Y` can activate Vim Mode even when a page input or editor is already focused
44 45
45 > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the46 > **Engine behavior**: CEF and WebKit execute Vim Mode scroll steps with the
46 > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller47 > shared `BuildPageScrollByJS` resolver (viewport-center start, nested-scroller
M docs/reference/keybindings.md +3 -3
@@ -59,7 +59,7 @@ Vim Mode is an explicit Vim-style navigation mode for the active pane only. It a
5959
6060 CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort.
6161
62-`gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, and page-focus motions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic motions yet.
62+`gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. `Enter` activates the first link inside the selected heading, or a link wrapping that heading, then leaves Vim Mode; without an associated link, it only leaves the mode. `Escape` leaves without activation. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, activation, and page-focus actions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic actions yet.
6363
6464 | Action | Keys |
6565 |--------|------|
@@ -73,8 +73,8 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th
7373 | Next heading | `]]` |
7474 | Previous heading | `[[` |
7575 | Page focus traversal | `Tab`, `Shift+Tab` |
76-| Confirm | `Enter` |
77-| Cancel | `Escape` |
76+| Activate selected heading link and exit | `Enter` |
77+| Exit without activation | `Escape` |
7878
7979 ## Resize Mode (`Ctrl+N`)
8080
@@ -59,7 +59,7 @@ Vim Mode is an explicit Vim-style navigation mode for the active pane only. It a
59 59
60 CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort.60 CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with the shared `BuildPageScrollByJS` resolver. Each step starts under the viewport center, walks up through ancestors that can move in the requested direction, and hands scrolling to the document when a nested container reaches its boundary. Cross-origin frame contents remain best-effort.
61 61
62-`gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, and page-focus motions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic motions yet.62+`gi` focuses the next visible, editable page input. With no eligible input focused it starts at the first one; repeated `gi` commands cycle through the inputs. `]]` and `[[` move through visible `h1`–`h6` headings and outline the selected heading with the current Dumber theme accent. `Enter` activates the first link inside the selected heading, or a link wrapping that heading, then leaves Vim Mode; without an associated link, it only leaves the mode. `Escape` leaves without activation. When a page input is focused, `Tab` and `Shift+Tab` keep focus traversal inside the page; traversal stops safely at the page boundaries instead of moving into the host window. These live input, heading, activation, and page-focus actions currently use the CEF engine path; the WebKit fallback supports Vim scrolling but not these semantic actions yet.
63 63
64 | Action | Keys |64 | Action | Keys |
65 |--------|------|65 |--------|------|
@@ -73,8 +73,8 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th
73 | Next heading | `]]` |73 | Next heading | `]]` |
74 | Previous heading | `[[` |74 | Previous heading | `[[` |
75 | Page focus traversal | `Tab`, `Shift+Tab` |75 | Page focus traversal | `Tab`, `Shift+Tab` |
76-| Confirm | `Enter` |76+| Activate selected heading link and exit | `Enter` |
77-| Cancel | `Escape` |77+| Exit without activation | `Escape` |
78 78
79 ## Resize Mode (`Ctrl+N`)79 ## Resize Mode (`Ctrl+N`)
80 80
M internal/application/port/webview.go +6 -0
@@ -465,6 +465,12 @@ type SemanticNavigable interface {
465465 NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error
466466 }
467467
468+// SemanticNavigationActivator is an optional WebView capability for activating
469+// the link associated with the current semantic navigation target.
470+type SemanticNavigationActivator interface {
471+ ActivateSemanticNavigationTarget(ctx context.Context) error
472+}
473+
468474 // SemanticNavigationHighlightClearer is an optional WebView capability for
469475 // removing the visual target left by semantic navigation.
470476 type SemanticNavigationHighlightClearer interface {
@@ -465,6 +465,12 @@ type SemanticNavigable interface {
465 NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error465 NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error
466 }466 }
467 467
468+// SemanticNavigationActivator is an optional WebView capability for activating
469+// the link associated with the current semantic navigation target.
470+type SemanticNavigationActivator interface {
471+ ActivateSemanticNavigationTarget(ctx context.Context) error
472+}
473+
468 // SemanticNavigationHighlightClearer is an optional WebView capability for474 // SemanticNavigationHighlightClearer is an optional WebView capability for
469 // removing the visual target left by semantic navigation.475 // removing the visual target left by semantic navigation.
470 type SemanticNavigationHighlightClearer interface {476 type SemanticNavigationHighlightClearer interface {
M internal/application/usecase/vim_navigation.go +8 -1
@@ -29,7 +29,14 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio
2929 return errors.New("vim navigation: nil webview")
3030 }
3131
32- if action == "focus-input" {
32+ switch action {
33+ case "confirm":
34+ activator, ok := wv.(port.SemanticNavigationActivator)
35+ if !ok {
36+ return errUnsupportedVimNavigationEngine
37+ }
38+ return activator.ActivateSemanticNavigationTarget(ctx)
39+ case "focus-input":
3340 focuser, ok := wv.(port.PageInputFocuser)
3441 if !ok {
3542 return errUnsupportedVimNavigationEngine
@@ -29,7 +29,14 @@ func (*VimNavigationUseCase) Execute(ctx context.Context, wv port.WebView, actio
29 return errors.New("vim navigation: nil webview")29 return errors.New("vim navigation: nil webview")
30 }30 }
31 31
32- if action == "focus-input" {32+ switch action {
33+ case "confirm":
34+ activator, ok := wv.(port.SemanticNavigationActivator)
35+ if !ok {
36+ return errUnsupportedVimNavigationEngine
37+ }
38+ return activator.ActivateSemanticNavigationTarget(ctx)
39+ case "focus-input":
33 focuser, ok := wv.(port.PageInputFocuser)40 focuser, ok := wv.(port.PageInputFocuser)
34 if !ok {41 if !ok {
35 return errUnsupportedVimNavigationEngine42 return errUnsupportedVimNavigationEngine
M internal/application/usecase/vim_navigation_test.go +8 -0
@@ -15,6 +15,7 @@ type vimNavigationWebView struct {
1515 focusNextInputCalls int
1616 pageFocusBackward []bool
1717 semanticRequests []dto.SemanticNavigationRequest
18+ activateNavigationTargetCalls int
1819 clearNavigationHighlightCalls int
1920 }
2021
@@ -31,6 +32,11 @@ func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto.
3132 return nil
3233 }
3334
35+func (wv *vimNavigationWebView) ActivateSemanticNavigationTarget(context.Context) error {
36+ wv.activateNavigationTargetCalls++
37+ return nil
38+}
39+
3440 func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error {
3541 wv.clearNavigationHighlightCalls++
3642 return nil
@@ -42,8 +48,10 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {
4248
4349 require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, ""))
4450 require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc"))
51+ require.NoError(t, uc.Execute(context.Background(), wv, "confirm", 1, ""))
4552
4653 assert.Equal(t, 1, wv.focusNextInputCalls)
54+ assert.Equal(t, 1, wv.activateNavigationTargetCalls)
4755 require.Len(t, wv.semanticRequests, 1)
4856 assert.Equal(t, dto.SemanticNavigationRequest{
4957 Target: dto.SemanticNavigationTargetHeading,
@@ -15,6 +15,7 @@ type vimNavigationWebView struct {
15 focusNextInputCalls int15 focusNextInputCalls int
16 pageFocusBackward []bool16 pageFocusBackward []bool
17 semanticRequests []dto.SemanticNavigationRequest17 semanticRequests []dto.SemanticNavigationRequest
18+ activateNavigationTargetCalls int
18 clearNavigationHighlightCalls int19 clearNavigationHighlightCalls int
19 }20 }
20 21
@@ -31,6 +32,11 @@ func (wv *vimNavigationWebView) NavigateSemantic(_ context.Context, request dto.
31 return nil32 return nil
32 }33 }
33 34
35+func (wv *vimNavigationWebView) ActivateSemanticNavigationTarget(context.Context) error {
36+ wv.activateNavigationTargetCalls++
37+ return nil
38+}
39+
34 func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error {40 func (wv *vimNavigationWebView) ClearSemanticNavigationHighlight(context.Context) error {
35 wv.clearNavigationHighlightCalls++41 wv.clearNavigationHighlightCalls++
36 return nil42 return nil
@@ -42,8 +48,10 @@ func TestVimNavigationUseCaseExecuteDispatchesConfiguredActions(t *testing.T) {
42 48
43 require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, ""))49 require.NoError(t, uc.Execute(context.Background(), wv, "focus-input", 1, ""))
44 require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc"))50 require.NoError(t, uc.Execute(context.Background(), wv, "heading-prev", 3, "#aabbcc"))
51+ require.NoError(t, uc.Execute(context.Background(), wv, "confirm", 1, ""))
45 52
46 assert.Equal(t, 1, wv.focusNextInputCalls)53 assert.Equal(t, 1, wv.focusNextInputCalls)
54+ assert.Equal(t, 1, wv.activateNavigationTargetCalls)
47 require.Len(t, wv.semanticRequests, 1)55 require.Len(t, wv.semanticRequests, 1)
48 assert.Equal(t, dto.SemanticNavigationRequest{56 assert.Equal(t, dto.SemanticNavigationRequest{
49 Target: dto.SemanticNavigationTargetHeading,57 Target: dto.SemanticNavigationTargetHeading,
M internal/infrastructure/cef/semantic_navigation.go +22 -0
@@ -32,6 +32,15 @@ func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNav
3232 return nil
3333 }
3434
35+// ActivateSemanticNavigationTarget follows the link associated with the most
36+// recent Vim semantic navigation target, if that target has one.
37+func (wv *WebView) ActivateSemanticNavigationTarget(_ context.Context) error {
38+ if wv == nil || wv.destroyed.Load() {
39+ return errDestroyed
40+ }
41+ return wv.scheduleJavaScript(activateSemanticNavigationTargetScript(wv.vimHeadingHighlightNamespace()))
42+}
43+
3544 // ClearSemanticNavigationHighlight removes the visual target left by the most
3645 // recent Vim semantic navigation in this WebView.
3746 func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error {
@@ -135,6 +144,19 @@ func headingNavigationScript(direction, count int, highlightColor, namespace str
135144 })();`
136145 }
137146
147+func activateSemanticNavigationTargetScript(namespace string) string {
148+ return `(() => {
149+ const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
150+ const target = window[stateKey];
151+ if (!(target instanceof Element) || !target.isConnected) return;
152+
153+ let link = target.closest("a[href]");
154+ if (!link) link = target.querySelector("a[href]");
155+ if (!link) return;
156+ link.click();
157+})();`
158+}
159+
138160 func clearHeadingNavigationHighlightScript(namespace string) string {
139161 return `(() => {
140162 const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
@@ -32,6 +32,15 @@ func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNav
32 return nil32 return nil
33 }33 }
34 34
35+// ActivateSemanticNavigationTarget follows the link associated with the most
36+// recent Vim semantic navigation target, if that target has one.
37+func (wv *WebView) ActivateSemanticNavigationTarget(_ context.Context) error {
38+ if wv == nil || wv.destroyed.Load() {
39+ return errDestroyed
40+ }
41+ return wv.scheduleJavaScript(activateSemanticNavigationTargetScript(wv.vimHeadingHighlightNamespace()))
42+}
43+
35 // ClearSemanticNavigationHighlight removes the visual target left by the most44 // ClearSemanticNavigationHighlight removes the visual target left by the most
36 // recent Vim semantic navigation in this WebView.45 // recent Vim semantic navigation in this WebView.
37 func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error {46 func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error {
@@ -135,6 +144,19 @@ func headingNavigationScript(direction, count int, highlightColor, namespace str
135 })();`144 })();`
136 }145 }
137 146
147+func activateSemanticNavigationTargetScript(namespace string) string {
148+ return `(() => {
149+ const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
150+ const target = window[stateKey];
151+ if (!(target instanceof Element) || !target.isConnected) return;
152+
153+ let link = target.closest("a[href]");
154+ if (!link) link = target.querySelector("a[href]");
155+ if (!link) return;
156+ link.click();
157+})();`
158+}
159+
138 func clearHeadingNavigationHighlightScript(namespace string) string {160 func clearHeadingNavigationHighlightScript(namespace string) string {
139 return `(() => {161 return `(() => {
140 const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;162 const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
M internal/infrastructure/cef/semantic_navigation_test.go +15 -0
@@ -43,6 +43,21 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin
4343 require.NoError(t, err)
4444 }
4545
46+func TestWebViewActivateSemanticNavigationTargetClicksSelectedHeadingLink(t *testing.T) {
47+ browser := cefmocks.NewMockBrowser(t)
48+ frame := cefmocks.NewMockFrame(t)
49+ browser.EXPECT().GetMainFrame().Return(frame).Once()
50+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t,
51+ "const target = window[stateKey]",
52+ "target.closest(\"a[href]\")",
53+ "target.querySelector(\"a[href]\")",
54+ "link.click()",
55+ )), "", int32(0)).Once()
56+
57+ wv := &WebView{browser: browser}
58+ require.NoError(t, wv.ActivateSemanticNavigationTarget(context.Background()))
59+}
60+
4661 func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) {
4762 browser := cefmocks.NewMockBrowser(t)
4863 frame := cefmocks.NewMockFrame(t)
@@ -43,6 +43,21 @@ func TestWebViewNavigateSemantic_HeadingExecutesStrictNavigationScript(t *testin
43 require.NoError(t, err)43 require.NoError(t, err)
44 }44 }
45 45
46+func TestWebViewActivateSemanticNavigationTargetClicksSelectedHeadingLink(t *testing.T) {
47+ browser := cefmocks.NewMockBrowser(t)
48+ frame := cefmocks.NewMockFrame(t)
49+ browser.EXPECT().GetMainFrame().Return(frame).Once()
50+ frame.EXPECT().ExecuteJavaScript(mock.MatchedBy(mockStringContaining(t,
51+ "const target = window[stateKey]",
52+ "target.closest(\"a[href]\")",
53+ "target.querySelector(\"a[href]\")",
54+ "link.click()",
55+ )), "", int32(0)).Once()
56+
57+ wv := &WebView{browser: browser}
58+ require.NoError(t, wv.ActivateSemanticNavigationTarget(context.Background()))
59+}
60+
46 func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) {61 func TestWebViewClearSemanticNavigationHighlightRemovesTargetArtifacts(t *testing.T) {
47 browser := cefmocks.NewMockBrowser(t)62 browser := cefmocks.NewMockBrowser(t)
48 frame := cefmocks.NewMockFrame(t)63 frame := cefmocks.NewMockFrame(t)
M internal/infrastructure/config/defaults.go +1 -1
@@ -316,7 +316,7 @@ func DefaultConfig() *Config {
316316 "vim-scroll-right": {Keys: []string{"l"}, Desc: "Scroll page right"},
317317 "vim-scroll-down-fast": {Keys: []string{"shift+j"}, Desc: "Scroll page down fast"},
318318 "vim-scroll-up-fast": {Keys: []string{"shift+k"}, Desc: "Scroll page up fast"},
319- "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},
319+ "confirm": {Keys: []string{"enter"}, Desc: "Open selected heading link and exit mode"},
320320 "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit mode"},
321321 "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},
322322 "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},
@@ -316,7 +316,7 @@ func DefaultConfig() *Config {
316 "vim-scroll-right": {Keys: []string{"l"}, Desc: "Scroll page right"},316 "vim-scroll-right": {Keys: []string{"l"}, Desc: "Scroll page right"},
317 "vim-scroll-down-fast": {Keys: []string{"shift+j"}, Desc: "Scroll page down fast"},317 "vim-scroll-down-fast": {Keys: []string{"shift+j"}, Desc: "Scroll page down fast"},
318 "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"},
319- "confirm": {Keys: []string{"enter"}, Desc: "Confirm action"},319+ "confirm": {Keys: []string{"enter"}, Desc: "Open selected heading link and exit mode"},
320 "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"},321 "heading-next": {Keys: []string{"]]"}, Desc: "Jump to next heading"},
322 "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},322 "heading-prev": {Keys: []string{"[["}, Desc: "Jump to previous heading"},
M internal/ui/app_vim_mode_test.go +16 -0
@@ -177,6 +177,7 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() {
177177 type semanticNavigatingWebView struct {
178178 *portmocks.MockWebView
179179 requests []dto.SemanticNavigationRequest
180+ activateNavigationTargetCalls int
180181 clearNavigationHighlightCalls int
181182 }
182183
@@ -192,6 +193,11 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request
192193 return nil
193194 }
194195
196+func (wv *semanticNavigatingWebView) ActivateSemanticNavigationTarget(context.Context) error {
197+ wv.activateNavigationTargetCalls++
198+ return nil
199+}
200+
195201 func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error {
196202 wv.clearNavigationHighlightCalls++
197203 return nil
@@ -513,6 +519,16 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
513519 }, wv.requests)
514520 }
515521
522+func TestVimMode_ConfirmActivatesSelectedHeadingLink(t *testing.T) {
523+ app, bw, paneID := newVimModeAccessibilityFixture(t)
524+ wv := newSemanticNavigatingWebView(t, 12)
525+ app.contentCoord.RegisterPopupWebView(paneID, wv)
526+
527+ app.navigateVimSequenceAction(context.Background(), bw, "confirm", 0)
528+
529+ assert.Equal(t, 1, wv.activateNavigationTargetCalls)
530+}
531+
516532 func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) {
517533 app, bw, paneID := newVimModeAccessibilityFixture(t)
518534 wv := newInputFocusingWebView(t, 6)
@@ -177,6 +177,7 @@ func (wv *accessibilityEnablingWebView) EnableAccessibility() {
177 type semanticNavigatingWebView struct {177 type semanticNavigatingWebView struct {
178 *portmocks.MockWebView178 *portmocks.MockWebView
179 requests []dto.SemanticNavigationRequest179 requests []dto.SemanticNavigationRequest
180+ activateNavigationTargetCalls int
180 clearNavigationHighlightCalls int181 clearNavigationHighlightCalls int
181 }182 }
182 183
@@ -192,6 +193,11 @@ func (wv *semanticNavigatingWebView) NavigateSemantic(_ context.Context, request
192 return nil193 return nil
193 }194 }
194 195
196+func (wv *semanticNavigatingWebView) ActivateSemanticNavigationTarget(context.Context) error {
197+ wv.activateNavigationTargetCalls++
198+ return nil
199+}
200+
195 func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error {201 func (wv *semanticNavigatingWebView) ClearSemanticNavigationHighlight(context.Context) error {
196 wv.clearNavigationHighlightCalls++202 wv.clearNavigationHighlightCalls++
197 return nil203 return nil
@@ -513,6 +519,16 @@ func TestVimMode_SequenceActionNavigatesActiveWebViewHeading(t *testing.T) {
513 }, wv.requests)519 }, wv.requests)
514 }520 }
515 521
522+func TestVimMode_ConfirmActivatesSelectedHeadingLink(t *testing.T) {
523+ app, bw, paneID := newVimModeAccessibilityFixture(t)
524+ wv := newSemanticNavigatingWebView(t, 12)
525+ app.contentCoord.RegisterPopupWebView(paneID, wv)
526+
527+ app.navigateVimSequenceAction(context.Background(), bw, "confirm", 0)
528+
529+ assert.Equal(t, 1, wv.activateNavigationTargetCalls)
530+}
531+
516 func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) {532 func TestVimMode_SequenceActionFocusesNextInput(t *testing.T) {
517 app, bw, paneID := newVimModeAccessibilityFixture(t)533 app, bw, paneID := newVimModeAccessibilityFixture(t)
518 wv := newInputFocusingWebView(t, 6)534 wv := newInputFocusingWebView(t, 6)
M internal/ui/input/keyboard.go +67 -23
@@ -407,17 +407,8 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
407407 }
408408 }
409409
410- // Escape / Enter should always leave modal modes even if the current config
411- // omits explicit confirm/cancel bindings.
412- if mode != ModeNormal && modifiers == 0 {
413- switch keyval {
414- case uint(gdk.KEY_Escape), uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
415- if mode == ModeVim {
416- h.stopVimScrollRepeat()
417- }
418- h.modal.ExitMode(h.ctx)
419- return true
420- }
410+ if h.handleGuaranteedModalExit(mode, keyval, modifiers) {
411+ return true
421412 }
422413
423414 // Page focus traversal must stay out of shortcut lookup. The callback keeps
@@ -472,6 +463,32 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
472463 return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers)
473464 }
474465
466+// handleGuaranteedModalExit preserves Escape and Enter as modal fallbacks even
467+// when the current configuration omits explicit cancel or confirm bindings.
468+func (h *KeyboardHandler) handleGuaranteedModalExit(mode Mode, keyval uint, modifiers Modifier) bool {
469+ if mode == ModeNormal || modifiers != 0 {
470+ return false
471+ }
472+
473+ switch keyval {
474+ case uint(gdk.KEY_Escape):
475+ if mode == ModeVim {
476+ h.stopVimScrollRepeat()
477+ }
478+ h.modal.ExitMode(h.ctx)
479+ return true
480+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
481+ if mode == ModeVim {
482+ h.confirmVimMode()
483+ } else {
484+ h.modal.ExitMode(h.ctx)
485+ }
486+ return true
487+ default:
488+ return false
489+ }
490+}
491+
475492 func (h *KeyboardHandler) handleShortcutLookupResult(
476493 log *zerolog.Logger,
477494 action Action,
@@ -669,6 +686,17 @@ func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers
669686 }
670687 }
671688
689+func (h *KeyboardHandler) confirmVimMode() {
690+ h.stopVimScrollRepeat()
691+ h.seq.mu.Lock()
692+ onAction := h.seq.onAction
693+ h.seq.mu.Unlock()
694+ if onAction != nil {
695+ onAction("confirm", 0)
696+ }
697+ h.modal.ExitMode(h.ctx)
698+}
699+
672700 // dispatchAction dispatches the action and handles mode-related logic.
673701 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
674702 if h.handleModeAction(action) {
@@ -817,12 +845,40 @@ func isVimScrollAction(action Action) bool {
817845 }
818846 }
819847
848+func (h *KeyboardHandler) handleVimModeAction(action Action, workspace *entity.WorkspaceConfig) bool {
849+ switch action {
850+ case ActionEnterVimMode:
851+ if h.modal.Mode() == ModeVim {
852+ h.stopVimScrollRepeat()
853+ h.modal.ExitMode(h.ctx)
854+ return true
855+ }
856+ var timeoutMS int
857+ if workspace != nil {
858+ timeoutMS = workspace.VimMode.TimeoutMilliseconds
859+ }
860+ h.modal.EnterVimMode(h.ctx, time.Duration(timeoutMS)*time.Millisecond)
861+ return true
862+ case ActionVimConfirm:
863+ if h.modal.Mode() == ModeVim {
864+ h.confirmVimMode()
865+ }
866+ return true
867+ default:
868+ return false
869+ }
870+}
871+
820872 func (h *KeyboardHandler) handleModeAction(action Action) bool {
821873 h.mu.RLock()
822874 workspace := h.workspace
823875 session := h.session
824876 h.mu.RUnlock()
825877
878+ if h.handleVimModeAction(action, workspace) {
879+ return true
880+ }
881+
826882 switch action {
827883 case ActionEnterTabMode:
828884 var ms int
@@ -856,18 +912,6 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
856912 }
857913 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
858914 return true
859- case ActionEnterVimMode:
860- if h.modal.Mode() == ModeVim {
861- h.stopVimScrollRepeat()
862- h.modal.ExitMode(h.ctx)
863- return true
864- }
865- var pgms int
866- if workspace != nil {
867- pgms = workspace.VimMode.TimeoutMilliseconds
868- }
869- h.modal.EnterVimMode(h.ctx, time.Duration(pgms)*time.Millisecond)
870- return true
871915 case ActionExitMode:
872916 if h.modal.Mode() == ModeVim {
873917 h.stopVimScrollRepeat()
@@ -407,17 +407,8 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
407 }407 }
408 }408 }
409 409
410- // Escape / Enter should always leave modal modes even if the current config410+ if h.handleGuaranteedModalExit(mode, keyval, modifiers) {
411- // omits explicit confirm/cancel bindings.411+ return true
412- if mode != ModeNormal && modifiers == 0 {
413- switch keyval {
414- case uint(gdk.KEY_Escape), uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
415- if mode == ModeVim {
416- h.stopVimScrollRepeat()
417- }
418- h.modal.ExitMode(h.ctx)
419- return true
420- }
421 }412 }
422 413
423 // Page focus traversal must stay out of shortcut lookup. The callback keeps414 // Page focus traversal must stay out of shortcut lookup. The callback keeps
@@ -472,6 +463,32 @@ func (h *KeyboardHandler) handleKeyPress(keyval, keycode uint, state gdk.Modifie
472 return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers)463 return h.handleShortcutLookupResult(log, action, found, mode, keyval, modifiers)
473 }464 }
474 465
466+// handleGuaranteedModalExit preserves Escape and Enter as modal fallbacks even
467+// when the current configuration omits explicit cancel or confirm bindings.
468+func (h *KeyboardHandler) handleGuaranteedModalExit(mode Mode, keyval uint, modifiers Modifier) bool {
469+ if mode == ModeNormal || modifiers != 0 {
470+ return false
471+ }
472+
473+ switch keyval {
474+ case uint(gdk.KEY_Escape):
475+ if mode == ModeVim {
476+ h.stopVimScrollRepeat()
477+ }
478+ h.modal.ExitMode(h.ctx)
479+ return true
480+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
481+ if mode == ModeVim {
482+ h.confirmVimMode()
483+ } else {
484+ h.modal.ExitMode(h.ctx)
485+ }
486+ return true
487+ default:
488+ return false
489+ }
490+}
491+
475 func (h *KeyboardHandler) handleShortcutLookupResult(492 func (h *KeyboardHandler) handleShortcutLookupResult(
476 log *zerolog.Logger,493 log *zerolog.Logger,
477 action Action,494 action Action,
@@ -669,6 +686,17 @@ func shouldPassthroughNativeVimModeNavigation(mode Mode, keyval uint, modifiers
669 }686 }
670 }687 }
671 688
689+func (h *KeyboardHandler) confirmVimMode() {
690+ h.stopVimScrollRepeat()
691+ h.seq.mu.Lock()
692+ onAction := h.seq.onAction
693+ h.seq.mu.Unlock()
694+ if onAction != nil {
695+ onAction("confirm", 0)
696+ }
697+ h.modal.ExitMode(h.ctx)
698+}
699+
672 // dispatchAction dispatches the action and handles mode-related logic.700 // dispatchAction dispatches the action and handles mode-related logic.
673 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {701 func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool {
674 if h.handleModeAction(action) {702 if h.handleModeAction(action) {
@@ -817,12 +845,40 @@ func isVimScrollAction(action Action) bool {
817 }845 }
818 }846 }
819 847
848+func (h *KeyboardHandler) handleVimModeAction(action Action, workspace *entity.WorkspaceConfig) bool {
849+ switch action {
850+ case ActionEnterVimMode:
851+ if h.modal.Mode() == ModeVim {
852+ h.stopVimScrollRepeat()
853+ h.modal.ExitMode(h.ctx)
854+ return true
855+ }
856+ var timeoutMS int
857+ if workspace != nil {
858+ timeoutMS = workspace.VimMode.TimeoutMilliseconds
859+ }
860+ h.modal.EnterVimMode(h.ctx, time.Duration(timeoutMS)*time.Millisecond)
861+ return true
862+ case ActionVimConfirm:
863+ if h.modal.Mode() == ModeVim {
864+ h.confirmVimMode()
865+ }
866+ return true
867+ default:
868+ return false
869+ }
870+}
871+
820 func (h *KeyboardHandler) handleModeAction(action Action) bool {872 func (h *KeyboardHandler) handleModeAction(action Action) bool {
821 h.mu.RLock()873 h.mu.RLock()
822 workspace := h.workspace874 workspace := h.workspace
823 session := h.session875 session := h.session
824 h.mu.RUnlock()876 h.mu.RUnlock()
825 877
878+ if h.handleVimModeAction(action, workspace) {
879+ return true
880+ }
881+
826 switch action {882 switch action {
827 case ActionEnterTabMode:883 case ActionEnterTabMode:
828 var ms int884 var ms int
@@ -856,18 +912,6 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
856 }912 }
857 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)913 h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
858 return true914 return true
859- case ActionEnterVimMode:
860- if h.modal.Mode() == ModeVim {
861- h.stopVimScrollRepeat()
862- h.modal.ExitMode(h.ctx)
863- return true
864- }
865- var pgms int
866- if workspace != nil {
867- pgms = workspace.VimMode.TimeoutMilliseconds
868- }
869- h.modal.EnterVimMode(h.ctx, time.Duration(pgms)*time.Millisecond)
870- return true
871 case ActionExitMode:915 case ActionExitMode:
872 if h.modal.Mode() == ModeVim {916 if h.modal.Mode() == ModeVim {
873 h.stopVimScrollRepeat()917 h.stopVimScrollRepeat()
M internal/ui/input/keyboard_test.go +57 -16
@@ -580,8 +580,11 @@ func TestHandleKeyPress_VimModeEscapeExits(t *testing.T) {
580580 }
581581
582582 h := NewKeyboardHandler(ctx, workspace, newTestSession())
583- h.SetOnAction(func(ctx context.Context, action Action) error {
584- return nil
583+ var confirmCalls int
584+ h.SetOnSequenceAction(func(action string, _ int) {
585+ if action == "confirm" {
586+ confirmCalls++
587+ }
585588 })
586589
587590 // Enter vim mode
@@ -598,36 +601,74 @@ func TestHandleKeyPress_VimModeEscapeExits(t *testing.T) {
598601 if h.Mode() != ModeNormal {
599602 t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode())
600603 }
604+ if confirmCalls != 0 {
605+ t.Fatalf("confirm calls after Escape = %d, want 0", confirmCalls)
606+ }
607+}
608+
609+func TestHandleKeyPress_VimModeEnterConfirmsAndExits(t *testing.T) {
610+ for _, tt := range []struct {
611+ name string
612+ keyval uint
613+ }{
614+ {name: "return", keyval: uint(gdk.KEY_Return)},
615+ {name: "keypad enter", keyval: uint(gdk.KEY_KP_Enter)},
616+ } {
617+ t.Run(tt.name, func(t *testing.T) {
618+ ctx := context.Background()
619+ workspace := newTestWorkspace()
620+ workspace.VimMode = entity.VimModeConfig{
621+ ActivationShortcut: "ctrl+y",
622+ Actions: map[string]entity.ActionBinding{
623+ "confirm": {Keys: []string{"enter"}},
624+ },
625+ }
626+
627+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
628+ var confirmed bool
629+ h.SetOnSequenceAction(func(action string, count int) {
630+ confirmed = action == "confirm" && count == 0
631+ })
632+ enterVimMode(t, h)
633+
634+ if !h.handleKeyPress(tt.keyval, 0, 0) {
635+ t.Fatal("Enter should be consumed")
636+ }
637+ if h.Mode() != ModeNormal {
638+ t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode())
639+ }
640+ if !confirmed {
641+ t.Fatal("Enter should dispatch Vim confirm before exiting")
642+ }
643+ })
644+ }
601645 }
602646
603-func TestHandleKeyPress_VimModeEnterExits(t *testing.T) {
647+func TestHandleKeyPress_VimModeConfiguredConfirmActivatesAndExits(t *testing.T) {
604648 ctx := context.Background()
605649 workspace := newTestWorkspace()
606650 workspace.VimMode = entity.VimModeConfig{
607651 ActivationShortcut: "ctrl+y",
608652 Actions: map[string]entity.ActionBinding{
609- "confirm": {Keys: []string{"enter"}},
653+ "confirm": {Keys: []string{"x"}},
610654 },
611655 }
612656
613657 h := NewKeyboardHandler(ctx, workspace, newTestSession())
614- h.SetOnAction(func(ctx context.Context, action Action) error {
615- return nil
658+ var confirmed bool
659+ h.SetOnSequenceAction(func(action string, count int) {
660+ confirmed = action == "confirm" && count == 0
616661 })
662+ enterVimMode(t, h)
617663
618- // Enter vim mode
619- h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)
620- if h.Mode() != ModeVim {
621- t.Fatal("failed to enter vim mode")
664+ if !h.handleKeyPress(uint('x'), 0, 0) {
665+ t.Fatal("configured confirm should be consumed")
622666 }
623-
624- // Enter should exit vim mode
625- result := h.handleKeyPress(uint(gdk.KEY_Return), 0, 0)
626- if !result {
627- t.Fatal("Enter should be consumed")
667+ if !confirmed {
668+ t.Fatal("configured confirm should dispatch Vim confirm")
628669 }
629670 if h.Mode() != ModeNormal {
630- t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode())
671+ t.Fatalf("mode after configured confirm = %v, want ModeNormal", h.Mode())
631672 }
632673 }
633674
@@ -580,8 +580,11 @@ func TestHandleKeyPress_VimModeEscapeExits(t *testing.T) {
580 }580 }
581 581
582 h := NewKeyboardHandler(ctx, workspace, newTestSession())582 h := NewKeyboardHandler(ctx, workspace, newTestSession())
583- h.SetOnAction(func(ctx context.Context, action Action) error {583+ var confirmCalls int
584- return nil584+ h.SetOnSequenceAction(func(action string, _ int) {
585+ if action == "confirm" {
586+ confirmCalls++
587+ }
585 })588 })
586 589
587 // Enter vim mode590 // Enter vim mode
@@ -598,36 +601,74 @@ func TestHandleKeyPress_VimModeEscapeExits(t *testing.T) {
598 if h.Mode() != ModeNormal {601 if h.Mode() != ModeNormal {
599 t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode())602 t.Fatalf("mode after Escape = %v, want ModeNormal", h.Mode())
600 }603 }
604+ if confirmCalls != 0 {
605+ t.Fatalf("confirm calls after Escape = %d, want 0", confirmCalls)
606+ }
607+}
608+
609+func TestHandleKeyPress_VimModeEnterConfirmsAndExits(t *testing.T) {
610+ for _, tt := range []struct {
611+ name string
612+ keyval uint
613+ }{
614+ {name: "return", keyval: uint(gdk.KEY_Return)},
615+ {name: "keypad enter", keyval: uint(gdk.KEY_KP_Enter)},
616+ } {
617+ t.Run(tt.name, func(t *testing.T) {
618+ ctx := context.Background()
619+ workspace := newTestWorkspace()
620+ workspace.VimMode = entity.VimModeConfig{
621+ ActivationShortcut: "ctrl+y",
622+ Actions: map[string]entity.ActionBinding{
623+ "confirm": {Keys: []string{"enter"}},
624+ },
625+ }
626+
627+ h := NewKeyboardHandler(ctx, workspace, newTestSession())
628+ var confirmed bool
629+ h.SetOnSequenceAction(func(action string, count int) {
630+ confirmed = action == "confirm" && count == 0
631+ })
632+ enterVimMode(t, h)
633+
634+ if !h.handleKeyPress(tt.keyval, 0, 0) {
635+ t.Fatal("Enter should be consumed")
636+ }
637+ if h.Mode() != ModeNormal {
638+ t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode())
639+ }
640+ if !confirmed {
641+ t.Fatal("Enter should dispatch Vim confirm before exiting")
642+ }
643+ })
644+ }
601 }645 }
602 646
603-func TestHandleKeyPress_VimModeEnterExits(t *testing.T) {647+func TestHandleKeyPress_VimModeConfiguredConfirmActivatesAndExits(t *testing.T) {
604 ctx := context.Background()648 ctx := context.Background()
605 workspace := newTestWorkspace()649 workspace := newTestWorkspace()
606 workspace.VimMode = entity.VimModeConfig{650 workspace.VimMode = entity.VimModeConfig{
607 ActivationShortcut: "ctrl+y",651 ActivationShortcut: "ctrl+y",
608 Actions: map[string]entity.ActionBinding{652 Actions: map[string]entity.ActionBinding{
609- "confirm": {Keys: []string{"enter"}},653+ "confirm": {Keys: []string{"x"}},
610 },654 },
611 }655 }
612 656
613 h := NewKeyboardHandler(ctx, workspace, newTestSession())657 h := NewKeyboardHandler(ctx, workspace, newTestSession())
614- h.SetOnAction(func(ctx context.Context, action Action) error {658+ var confirmed bool
615- return nil659+ h.SetOnSequenceAction(func(action string, count int) {
660+ confirmed = action == "confirm" && count == 0
616 })661 })
662+ enterVimMode(t, h)
617 663
618- // Enter vim mode664+ if !h.handleKeyPress(uint('x'), 0, 0) {
619- h.handleKeyPress(uint('y'), 0, gdk.ControlMaskValue)665+ t.Fatal("configured confirm should be consumed")
620- if h.Mode() != ModeVim {
621- t.Fatal("failed to enter vim mode")
622 }666 }
623-667+ if !confirmed {
624- // Enter should exit vim mode668+ t.Fatal("configured confirm should dispatch Vim confirm")
625- result := h.handleKeyPress(uint(gdk.KEY_Return), 0, 0)
626- if !result {
627- t.Fatal("Enter should be consumed")
628 }669 }
629 if h.Mode() != ModeNormal {670 if h.Mode() != ModeNormal {
630- t.Fatalf("mode after Enter = %v, want ModeNormal", h.Mode())671+ t.Fatalf("mode after configured confirm = %v, want ModeNormal", h.Mode())
631 }672 }
632 }673 }
633 674
M internal/ui/input/shortcuts.go +9 -1
@@ -206,6 +206,7 @@ const (
206206
207207 // Vim mode actions
208208 ActionEnterVimMode Action = "enter_vim_mode"
209+ ActionVimConfirm Action = "vim_confirm"
209210 ActionVimScrollLeft Action = "vim_scroll_left"
210211 ActionVimScrollDown Action = "vim_scroll_down"
211212 ActionVimScrollUp Action = "vim_scroll_up"
@@ -589,7 +590,7 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
589590 var registered, parseErrors, unknownActions int
590591 for key, configAction := range bindings {
591592 if binding, ok := ParseKeyString(key); ok {
592- if action := mapConfigAction(configAction); action != "" {
593+ if action := mapModeConfigAction(configAction, mode); action != "" {
593594 dest[binding] = action
594595 registered++
595596 log.Trace().
@@ -760,6 +761,13 @@ func ParseFloatingProfileAction(action Action) (string, bool) {
760761 return target.URL, true
761762 }
762763
764+func mapModeConfigAction(configAction, mode string) Action {
765+ if mode == "vim" && configAction == "confirm" {
766+ return ActionVimConfirm
767+ }
768+ return mapConfigAction(configAction)
769+}
770+
763771 // mapConfigAction maps config action names to Action constants.
764772 func mapConfigAction(configAction string) Action {
765773 if configAction == "cancel" || configAction == "confirm" {
@@ -206,6 +206,7 @@ const (
206 206
207 // Vim mode actions207 // Vim mode actions
208 ActionEnterVimMode Action = "enter_vim_mode"208 ActionEnterVimMode Action = "enter_vim_mode"
209+ ActionVimConfirm Action = "vim_confirm"
209 ActionVimScrollLeft Action = "vim_scroll_left"210 ActionVimScrollLeft Action = "vim_scroll_left"
210 ActionVimScrollDown Action = "vim_scroll_down"211 ActionVimScrollDown Action = "vim_scroll_down"
211 ActionVimScrollUp Action = "vim_scroll_up"212 ActionVimScrollUp Action = "vim_scroll_up"
@@ -589,7 +590,7 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
589 var registered, parseErrors, unknownActions int590 var registered, parseErrors, unknownActions int
590 for key, configAction := range bindings {591 for key, configAction := range bindings {
591 if binding, ok := ParseKeyString(key); ok {592 if binding, ok := ParseKeyString(key); ok {
592- if action := mapConfigAction(configAction); action != "" {593+ if action := mapModeConfigAction(configAction, mode); action != "" {
593 dest[binding] = action594 dest[binding] = action
594 registered++595 registered++
595 log.Trace().596 log.Trace().
@@ -760,6 +761,13 @@ func ParseFloatingProfileAction(action Action) (string, bool) {
760 return target.URL, true761 return target.URL, true
761 }762 }
762 763
764+func mapModeConfigAction(configAction, mode string) Action {
765+ if mode == "vim" && configAction == "confirm" {
766+ return ActionVimConfirm
767+ }
768+ return mapConfigAction(configAction)
769+}
770+
763 // mapConfigAction maps config action names to Action constants.771 // mapConfigAction maps config action names to Action constants.
764 func mapConfigAction(configAction string) Action {772 func mapConfigAction(configAction string) Action {
765 if configAction == "cancel" || configAction == "confirm" {773 if configAction == "cancel" || configAction == "confirm" {
M internal/ui/input/vimkeys_dispatch.go +6 -2
@@ -272,7 +272,9 @@ func (h *KeyboardHandler) feedVimModeSequence(keyval uint, state gdk.ModifierTyp
272272 if onPending != nil {
273273 onPending("")
274274 }
275- if onAction != nil {
275+ if action == "confirm" {
276+ h.confirmVimMode()
277+ } else if onAction != nil {
276278 onAction(action, count)
277279 }
278280 log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete")
@@ -343,7 +345,9 @@ func (h *KeyboardHandler) resolveAmbiguity(gen uint64) {
343345 if onPending != nil {
344346 onPending("")
345347 }
346- if onAction != nil {
348+ if action == "confirm" {
349+ h.confirmVimMode()
350+ } else if onAction != nil {
347351 onAction(action, count)
348352 }
349353 log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved")
@@ -272,7 +272,9 @@ func (h *KeyboardHandler) feedVimModeSequence(keyval uint, state gdk.ModifierTyp
272 if onPending != nil {272 if onPending != nil {
273 onPending("")273 onPending("")
274 }274 }
275- if onAction != nil {275+ if action == "confirm" {
276+ h.confirmVimMode()
277+ } else if onAction != nil {
276 onAction(action, count)278 onAction(action, count)
277 }279 }
278 log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete")280 log.Debug().Str("action", action).Int("count", count).Msg("vim mode sequence complete")
@@ -343,7 +345,9 @@ func (h *KeyboardHandler) resolveAmbiguity(gen uint64) {
343 if onPending != nil {345 if onPending != nil {
344 onPending("")346 onPending("")
345 }347 }
346- if onAction != nil {348+ if action == "confirm" {
349+ h.confirmVimMode()
350+ } else if onAction != nil {
347 onAction(action, count)351 onAction(action, count)
348 }352 }
349 log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved")353 log.Debug().Str("action", action).Int("count", count).Msg("vim mode ambiguity resolved")
M internal/ui/input/vimkeys_dispatch_test.go +25 -2
@@ -275,8 +275,8 @@ func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) {
275275 }
276276
277277 enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
278- if action, found := set.VimMode[enterBinding]; !found || action != ActionExitMode {
279- t.Fatalf("legacy enter missing from VimMode: found=%v action=%q", found, action)
278+ if action, found := set.VimMode[enterBinding]; !found || action != ActionVimConfirm {
279+ t.Fatalf("legacy Vim confirm missing from VimMode: found=%v action=%q", found, action)
280280 }
281281
282282 escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
@@ -360,6 +360,29 @@ func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) {
360360 }
361361 }
362362
363+func TestFeedVimModeSequence_ConfiguredConfirmActivatesAndExits(t *testing.T) {
364+ ctx := context.Background()
365+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
366+ "confirm": {Keys: []string{"zz"}},
367+ })
368+ h := NewKeyboardHandler(ctx, ws, newTestSession())
369+ actions := captureSequenceActions(h)
370+ enterVimMode(t, h)
371+
372+ if !h.handleKeyPress(uint('z'), 0, 0) {
373+ t.Fatal("configured confirm prefix should be consumed")
374+ }
375+ if !h.handleKeyPress(uint('z'), 0, 0) {
376+ t.Fatal("configured confirm completion should be consumed")
377+ }
378+ if len(*actions) != 1 || (*actions)[0].action != "confirm" || (*actions)[0].count != 0 {
379+ t.Fatalf("actions = %#v, want confirm count 0", *actions)
380+ }
381+ if h.Mode() != ModeNormal {
382+ t.Fatalf("mode after configured confirm sequence = %v, want ModeNormal", h.Mode())
383+ }
384+}
385+
363386 func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
364387 ctx := context.Background()
365388 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
@@ -275,8 +275,8 @@ func assertLegacyVimModeBindings(t *testing.T, set *ShortcutSet) {
275 }275 }
276 276
277 enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}277 enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
278- if action, found := set.VimMode[enterBinding]; !found || action != ActionExitMode {278+ if action, found := set.VimMode[enterBinding]; !found || action != ActionVimConfirm {
279- t.Fatalf("legacy enter missing from VimMode: found=%v action=%q", found, action)279+ t.Fatalf("legacy Vim confirm missing from VimMode: found=%v action=%q", found, action)
280 }280 }
281 281
282 escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}282 escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
@@ -360,6 +360,29 @@ func TestFeedVimModeSequence_CompleteDoubleBracket(t *testing.T) {
360 }360 }
361 }361 }
362 362
363+func TestFeedVimModeSequence_ConfiguredConfirmActivatesAndExits(t *testing.T) {
364+ ctx := context.Background()
365+ ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{
366+ "confirm": {Keys: []string{"zz"}},
367+ })
368+ h := NewKeyboardHandler(ctx, ws, newTestSession())
369+ actions := captureSequenceActions(h)
370+ enterVimMode(t, h)
371+
372+ if !h.handleKeyPress(uint('z'), 0, 0) {
373+ t.Fatal("configured confirm prefix should be consumed")
374+ }
375+ if !h.handleKeyPress(uint('z'), 0, 0) {
376+ t.Fatal("configured confirm completion should be consumed")
377+ }
378+ if len(*actions) != 1 || (*actions)[0].action != "confirm" || (*actions)[0].count != 0 {
379+ t.Fatalf("actions = %#v, want confirm count 0", *actions)
380+ }
381+ if h.Mode() != ModeNormal {
382+ t.Fatalf("mode after configured confirm sequence = %v, want ModeNormal", h.Mode())
383+ }
384+}
385+
363 func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {386 func TestFeedVimModeSequence_PendingYankNotifications(t *testing.T) {
364 ctx := context.Background()387 ctx := context.Background()
365 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{388 ws := vimModeSequenceWorkspace(map[string]entity.ActionBinding{

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