detached 2bc6356

feat(vim): show visual and hint sub-mode in the mode indicator

The Vim Mode toast reads VIM MODE · VISUAL or VIM MODE · HINTS while a page interaction owns the keys, and visual selection is painted with the theme accent.
commit
2bc63567e163e305922b12be78d359330919740e
author
brice · · 2026-09-26 13:47 UTC
committer
brice · · 2026-10-10 08:27 UTC
parent
a94e48a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +63 -17
M docs/reference/keybindings.md +1 -1
@@ -66,7 +66,7 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th
6666
6767 Link hints label the visible targets in the viewport; type a label to pick one, `Backspace` to undo a letter, and `Escape` to close the hints and stay in Vim Mode. `f` follows a link or activates a control, `F` opens a link in a new pane, and `yf` copies a link URL.
6868
69-`v` starts a visual selection from the current page selection, or from the first visible text. Motions extend it: `h/j/k/l`, `w/b/e`, `0`/`^`/`$`, `(`/`)` for sentences, `{`/`}` for paragraphs, `gg`/`G` for the document edges, and a count prefix such as `3w`. `o` swaps the selection ends, `y` or `Enter` copies and ends the selection, and `v` or `Escape` cancels it.
69+`v` starts a visual selection from the current page selection, or from the first visible text. The mode indicator reads `VIM MODE · VISUAL` (or `· HINTS` while hints are shown) and the selection uses the theme accent. Motions extend it: `h/j/k/l`, `w/b/e`, `0`/`^`/`$`, `(`/`)` for sentences, `{`/`}` for paragraphs, `gg`/`G` for the document edges, and a count prefix such as `3w`. `o` swaps the selection ends, `y` or `Enter` copies and ends the selection, and `v` or `Escape` cancels it.
7070
7171 Text-object yanks copy one block without selecting it: `yah` copies the current section (the heading nearest the top of the viewport through the next heading of the same or a higher level), and `yap`, `yac`, `yat`, and `yal` copy the first visible paragraph, code block, table, or list. The copied block flashes with the theme accent. Hints, visual selection, and yanks use the CEF engine path.
7272
@@ -66,7 +66,7 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th
66 66
67 Link hints label the visible targets in the viewport; type a label to pick one, `Backspace` to undo a letter, and `Escape` to close the hints and stay in Vim Mode. `f` follows a link or activates a control, `F` opens a link in a new pane, and `yf` copies a link URL.67 Link hints label the visible targets in the viewport; type a label to pick one, `Backspace` to undo a letter, and `Escape` to close the hints and stay in Vim Mode. `f` follows a link or activates a control, `F` opens a link in a new pane, and `yf` copies a link URL.
68 68
69-`v` starts a visual selection from the current page selection, or from the first visible text. Motions extend it: `h/j/k/l`, `w/b/e`, `0`/`^`/`$`, `(`/`)` for sentences, `{`/`}` for paragraphs, `gg`/`G` for the document edges, and a count prefix such as `3w`. `o` swaps the selection ends, `y` or `Enter` copies and ends the selection, and `v` or `Escape` cancels it.69+`v` starts a visual selection from the current page selection, or from the first visible text. The mode indicator reads `VIM MODE · VISUAL` (or `· HINTS` while hints are shown) and the selection uses the theme accent. Motions extend it: `h/j/k/l`, `w/b/e`, `0`/`^`/`$`, `(`/`)` for sentences, `{`/`}` for paragraphs, `gg`/`G` for the document edges, and a count prefix such as `3w`. `o` swaps the selection ends, `y` or `Enter` copies and ends the selection, and `v` or `Escape` cancels it.
70 70
71 Text-object yanks copy one block without selecting it: `yah` copies the current section (the heading nearest the top of the viewport through the next heading of the same or a higher level), and `yap`, `yac`, `yat`, and `yal` copy the first visible paragraph, code block, table, or list. The copied block flashes with the theme accent. Hints, visual selection, and yanks use the CEF engine path.71 Text-object yanks copy one block without selecting it: `yah` copies the current section (the heading nearest the top of the viewport through the next heading of the same or a higher level), and `yap`, `yac`, `yat`, and `yal` copy the first visible paragraph, code block, table, or list. The copied block flashes with the theme accent. Hints, visual selection, and yanks use the CEF engine path.
72 72
M internal/infrastructure/cef/vim_page_runtime.js +13 -2
@@ -206,7 +206,17 @@
206206 return null;
207207 }
208208
209- function startVisual() {
209+ // Visual mode paints the selection with the accent so it stands out even on
210+ // pages that restyle ::selection; the style is removed when visual ends.
211+ function styleVisual(color) {
212+ const style = document.createElement("style");
213+ style.setAttribute("data-dumber-vim-visual", "");
214+ style.textContent = "::selection { background: " + color + " !important; color: #111 !important; }";
215+ document.documentElement.appendChild(style);
216+ state.root = style;
217+ }
218+
219+ function startVisual(color) {
210220 const selection = window.getSelection();
211221 if (!selection) return false;
212222 if (selection.rangeCount === 0 || selection.isCollapsed) {
@@ -217,6 +227,7 @@
217227 }
218228 state.count = "";
219229 state.pendingG = false;
230+ styleVisual(color);
220231 return true;
221232 }
222233
@@ -350,7 +361,7 @@
350361 return;
351362 }
352363 state = { kind: request.kind, token };
353- const started = request.kind === "visual" ? startVisual() : startHints(request.kind, color);
364+ const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
354365 if (!started) finish({ type: "end" });
355366 },
356367 key(token, key) {
@@ -206,7 +206,17 @@
206 return null;206 return null;
207 }207 }
208 208
209- function startVisual() {209+ // Visual mode paints the selection with the accent so it stands out even on
210+ // pages that restyle ::selection; the style is removed when visual ends.
211+ function styleVisual(color) {
212+ const style = document.createElement("style");
213+ style.setAttribute("data-dumber-vim-visual", "");
214+ style.textContent = "::selection { background: " + color + " !important; color: #111 !important; }";
215+ document.documentElement.appendChild(style);
216+ state.root = style;
217+ }
218+
219+ function startVisual(color) {
210 const selection = window.getSelection();220 const selection = window.getSelection();
211 if (!selection) return false;221 if (!selection) return false;
212 if (selection.rangeCount === 0 || selection.isCollapsed) {222 if (selection.rangeCount === 0 || selection.isCollapsed) {
@@ -217,6 +227,7 @@
217 }227 }
218 state.count = "";228 state.count = "";
219 state.pendingG = false;229 state.pendingG = false;
230+ styleVisual(color);
220 return true;231 return true;
221 }232 }
222 233
@@ -350,7 +361,7 @@
350 return;361 return;
351 }362 }
352 state = { kind: request.kind, token };363 state = { kind: request.kind, token };
353- const started = request.kind === "visual" ? startVisual() : startHints(request.kind, color);364+ const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
354 if (!started) finish({ type: "end" });365 if (!started) finish({ type: "end" });
355 },366 },
356 key(token, key) {367 key(token, key) {
M internal/ui/app.go +26 -4
@@ -2230,7 +2230,7 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
22302230 return
22312231 }
22322232 if outcome.CapturePageKeys {
2233- a.captureVimPageKeys(ctx, bw, wv)
2233+ a.captureVimPageKeys(ctx, bw, wv, vimPageInteractionLabel(action))
22342234 }
22352235 if bw != nil && (action == "heading-next" || action == "heading-prev") {
22362236 if _, ok := wv.(port.SemanticNavigable); ok {
@@ -2248,11 +2248,12 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
22482248
22492249 // captureVimPageKeys routes this window's Vim Mode keys to the in-page
22502250 // interaction running in wv until the page reports its end.
2251-func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView) {
2251+func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, label string) {
22522252 if bw == nil || bw.keyboardHandler == nil || wv == nil {
22532253 return
22542254 }
22552255 bw.vimPageInteractionWebView = wv
2256+ a.showVimModeToast(ctx, bw, label)
22562257 bw.keyboardHandler.SetPageKeyCapture(func(key string) {
22572258 if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {
22582259 logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")
@@ -2263,7 +2264,7 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port
22632264
22642265 // handleVimPageInteractionEnded releases key capture when the page reports
22652266 // that its hint or visual interaction finished.
2266-func (a *App) handleVimPageInteractionEnded(paneID entity.PaneID) {
2267+func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) {
22672268 bw := a.browserWindowForAnyPane(paneID)
22682269 if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil {
22692270 return
@@ -2274,6 +2275,24 @@ func (a *App) handleVimPageInteractionEnded(paneID entity.PaneID) {
22742275 bw.vimPageInteractionWebView = nil
22752276 if bw.keyboardHandler != nil {
22762277 bw.keyboardHandler.ClearPageKeyCapture()
2278+ if bw.keyboardHandler.Mode() == input.ModeVim {
2279+ a.showVimModeToast(ctx, bw, "")
2280+ }
2281+ }
2282+}
2283+
2284+// vimPageInteractionLabel names the key-capturing sub-mode shown in the Vim
2285+// Mode indicator so users can tell hints or visual selection are active.
2286+func vimPageInteractionLabel(action string) string {
2287+ switch action {
2288+ case "visual":
2289+ return "VISUAL"
2290+ case "hint-follow-new":
2291+ return "HINTS · NEW PANE"
2292+ case "hint-yank-url":
2293+ return "HINTS · YANK URL"
2294+ default:
2295+ return "HINTS"
22772296 }
22782297 }
22792298
@@ -2290,6 +2309,9 @@ func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow) {
22902309 if wv == nil {
22912310 return
22922311 }
2312+ if bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModeVim {
2313+ a.showVimModeToast(ctx, bw, "")
2314+ }
22932315 if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil {
22942316 logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction")
22952317 }
@@ -3477,7 +3499,7 @@ func (a *App) initCoordinators(ctx context.Context) {
34773499 a.handlePageEditableFocusChanged(ctx, paneID, editable)
34783500 })
34793501 a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) {
3480- a.handleVimPageInteractionEnded(paneID)
3502+ a.handleVimPageInteractionEnded(ctx, paneID)
34813503 })
34823504
34833505 // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
@@ -2230,7 +2230,7 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
2230 return2230 return
2231 }2231 }
2232 if outcome.CapturePageKeys {2232 if outcome.CapturePageKeys {
2233- a.captureVimPageKeys(ctx, bw, wv)2233+ a.captureVimPageKeys(ctx, bw, wv, vimPageInteractionLabel(action))
2234 }2234 }
2235 if bw != nil && (action == "heading-next" || action == "heading-prev") {2235 if bw != nil && (action == "heading-next" || action == "heading-prev") {
2236 if _, ok := wv.(port.SemanticNavigable); ok {2236 if _, ok := wv.(port.SemanticNavigable); ok {
@@ -2248,11 +2248,12 @@ func (a *App) navigateVimSequenceAction(ctx context.Context, bw *browserWindow,
2248 2248
2249 // captureVimPageKeys routes this window's Vim Mode keys to the in-page2249 // captureVimPageKeys routes this window's Vim Mode keys to the in-page
2250 // interaction running in wv until the page reports its end.2250 // interaction running in wv until the page reports its end.
2251-func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView) {2251+func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port.WebView, label string) {
2252 if bw == nil || bw.keyboardHandler == nil || wv == nil {2252 if bw == nil || bw.keyboardHandler == nil || wv == nil {
2253 return2253 return
2254 }2254 }
2255 bw.vimPageInteractionWebView = wv2255 bw.vimPageInteractionWebView = wv
2256+ a.showVimModeToast(ctx, bw, label)
2256 bw.keyboardHandler.SetPageKeyCapture(func(key string) {2257 bw.keyboardHandler.SetPageKeyCapture(func(key string) {
2257 if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {2258 if err := a.vimNavigationUseCase().SendPageKey(ctx, wv, key); err != nil {
2258 logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")2259 logging.FromContext(ctx).Debug().Err(err).Msg("vim page key forwarding failed")
@@ -2263,7 +2264,7 @@ func (a *App) captureVimPageKeys(ctx context.Context, bw *browserWindow, wv port
2263 2264
2264 // handleVimPageInteractionEnded releases key capture when the page reports2265 // handleVimPageInteractionEnded releases key capture when the page reports
2265 // that its hint or visual interaction finished.2266 // that its hint or visual interaction finished.
2266-func (a *App) handleVimPageInteractionEnded(paneID entity.PaneID) {2267+func (a *App) handleVimPageInteractionEnded(ctx context.Context, paneID entity.PaneID) {
2267 bw := a.browserWindowForAnyPane(paneID)2268 bw := a.browserWindowForAnyPane(paneID)
2268 if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil {2269 if bw == nil || bw.vimPageInteractionWebView == nil || a.contentCoord == nil {
2269 return2270 return
@@ -2274,6 +2275,24 @@ func (a *App) handleVimPageInteractionEnded(paneID entity.PaneID) {
2274 bw.vimPageInteractionWebView = nil2275 bw.vimPageInteractionWebView = nil
2275 if bw.keyboardHandler != nil {2276 if bw.keyboardHandler != nil {
2276 bw.keyboardHandler.ClearPageKeyCapture()2277 bw.keyboardHandler.ClearPageKeyCapture()
2278+ if bw.keyboardHandler.Mode() == input.ModeVim {
2279+ a.showVimModeToast(ctx, bw, "")
2280+ }
2281+ }
2282+}
2283+
2284+// vimPageInteractionLabel names the key-capturing sub-mode shown in the Vim
2285+// Mode indicator so users can tell hints or visual selection are active.
2286+func vimPageInteractionLabel(action string) string {
2287+ switch action {
2288+ case "visual":
2289+ return "VISUAL"
2290+ case "hint-follow-new":
2291+ return "HINTS · NEW PANE"
2292+ case "hint-yank-url":
2293+ return "HINTS · YANK URL"
2294+ default:
2295+ return "HINTS"
2277 }2296 }
2278 }2297 }
2279 2298
@@ -2290,6 +2309,9 @@ func (a *App) endVimPageInteraction(ctx context.Context, bw *browserWindow) {
2290 if wv == nil {2309 if wv == nil {
2291 return2310 return
2292 }2311 }
2312+ if bw.keyboardHandler != nil && bw.keyboardHandler.Mode() == input.ModeVim {
2313+ a.showVimModeToast(ctx, bw, "")
2314+ }
2293 if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil {2315 if err := a.vimNavigationUseCase().CancelPageInteraction(ctx, wv); err != nil {
2294 logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction")2316 logging.FromContext(ctx).Debug().Err(err).Msg("failed to cancel vim page interaction")
2295 }2317 }
@@ -3477,7 +3499,7 @@ func (a *App) initCoordinators(ctx context.Context) {
3477 a.handlePageEditableFocusChanged(ctx, paneID, editable)3499 a.handlePageEditableFocusChanged(ctx, paneID, editable)
3478 })3500 })
3479 a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) {3501 a.contentCoord.SetOnVimPageInteractionEnded(func(paneID entity.PaneID) {
3480- a.handleVimPageInteractionEnded(paneID)3502+ a.handleVimPageInteractionEnded(ctx, paneID)
3481 })3503 })
3482 3504
3483 // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.3505 // JS dialogs are shown in the owning pane's overlay, wherever that pane lives.
M internal/ui/mode_frame_test.go +7 -0
@@ -156,3 +156,10 @@ func TestShouldStartLinger(t *testing.T) {
156156 require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect))
157157 require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect))
158158 }
159+
160+func TestVimPageInteractionLabel(t *testing.T) {
161+ require.Equal(t, "VISUAL", vimPageInteractionLabel("visual"))
162+ require.Equal(t, "HINTS", vimPageInteractionLabel("hint-follow"))
163+ require.Equal(t, "HINTS · NEW PANE", vimPageInteractionLabel("hint-follow-new"))
164+ require.Equal(t, "HINTS · YANK URL", vimPageInteractionLabel("hint-yank-url"))
165+}
@@ -156,3 +156,10 @@ func TestShouldStartLinger(t *testing.T) {
156 require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect))156 require.False(t, shouldStartLinger(true, true, true, true, 15, 25, rect))
157 require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect))157 require.False(t, shouldStartLinger(false, false, true, true, 15, 25, rect))
158 }158 }
159+
160+func TestVimPageInteractionLabel(t *testing.T) {
161+ require.Equal(t, "VISUAL", vimPageInteractionLabel("visual"))
162+ require.Equal(t, "HINTS", vimPageInteractionLabel("hint-follow"))
163+ require.Equal(t, "HINTS · NEW PANE", vimPageInteractionLabel("hint-follow-new"))
164+ require.Equal(t, "HINTS · YANK URL", vimPageInteractionLabel("hint-yank-url"))
165+}
M internal/ui/vim_mode.go +16 -10
@@ -323,25 +323,31 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin
323323 if bw.modeToaster == nil {
324324 return
325325 }
326- log := logging.FromContext(ctx)
326+
327+ a.showVimModeToast(ctx, bw, pending)
328+ logging.FromContext(ctx).Debug().
329+ Str("window_id", bw.id).
330+ Str("pending", pending).
331+ Msg("vim mode pending toaster updated")
332+}
333+
334+// showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the
335+// per-window mode toaster, honoring the indicator toggle.
336+func (a *App) showVimModeToast(ctx context.Context, bw *browserWindow, detail string) {
337+ if bw == nil || bw.modeToaster == nil {
338+ return
339+ }
327340 if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
328341 bw.modeToaster.Hide()
329- log.Debug().Str("window_id", bw.id).Msg("vim mode pending toaster hidden (disabled)")
330342 return
331343 }
332-
333344 text := input.ModeVim.DisplayName()
334- if pending != "" {
335- text = text + " · " + pending
345+ if detail != "" {
346+ text = text + " · " + detail
336347 }
337348 bw.modeToaster.Show(ctx, text, component.ToastInfo,
338349 component.WithDuration(0),
339350 component.WithPosition(component.ToastPositionBottomLeft),
340351 component.WithModeClass(getModeToastClass(input.ModeVim)),
341352 )
342- log.Debug().
343- Str("window_id", bw.id).
344- Str("pending", pending).
345- Str("toast_text", text).
346- Msg("vim mode pending toaster updated")
347353 }
@@ -323,25 +323,31 @@ func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pendin
323 if bw.modeToaster == nil {323 if bw.modeToaster == nil {
324 return324 return
325 }325 }
326- log := logging.FromContext(ctx)326+
327+ a.showVimModeToast(ctx, bw, pending)
328+ logging.FromContext(ctx).Debug().
329+ Str("window_id", bw.id).
330+ Str("pending", pending).
331+ Msg("vim mode pending toaster updated")
332+}
333+
334+// showVimModeToast shows "VIM MODE" with an optional " · detail" suffix on the
335+// per-window mode toaster, honoring the indicator toggle.
336+func (a *App) showVimModeToast(ctx context.Context, bw *browserWindow, detail string) {
337+ if bw == nil || bw.modeToaster == nil {
338+ return
339+ }
327 if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {340 if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled {
328 bw.modeToaster.Hide()341 bw.modeToaster.Hide()
329- log.Debug().Str("window_id", bw.id).Msg("vim mode pending toaster hidden (disabled)")
330 return342 return
331 }343 }
332-
333 text := input.ModeVim.DisplayName()344 text := input.ModeVim.DisplayName()
334- if pending != "" {345+ if detail != "" {
335- text = text + " · " + pending346+ text = text + " · " + detail
336 }347 }
337 bw.modeToaster.Show(ctx, text, component.ToastInfo,348 bw.modeToaster.Show(ctx, text, component.ToastInfo,
338 component.WithDuration(0),349 component.WithDuration(0),
339 component.WithPosition(component.ToastPositionBottomLeft),350 component.WithPosition(component.ToastPositionBottomLeft),
340 component.WithModeClass(getModeToastClass(input.ModeVim)),351 component.WithModeClass(getModeToastClass(input.ModeVim)),
341 )352 )
342- log.Debug().
343- Str("window_id", bw.id).
344- Str("pending", pending).
345- Str("toast_text", text).
346- Msg("vim mode pending toaster updated")
347 }353 }

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