git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commite5221f74dd849607418ec475e1e90d0f5152d891
parente7e6210c88
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 20:29
feat: vi mode, after qutebrowser

`browser.vi-mode` (off by default) makes the keyboard modal: normal,
insert, passthrough, hint and command modes, with qutebrowser's bindings
where the browser has the feature — j/k/h/l, Ctrl+D/U/F/B, gg/G with
counts, H/L, J/K/gt/gT/Alt+n, d/u/co, o/O/go/gO, yy/yt, p/P, i/gi, f/F/;y
hints, / ? n N search, :open/:tabopen/:q and friends.

Insert mode follows a click (a focus watcher in every frame plus a check
after each click), not a page's autofocus. Hints are drawn by the chrome
and followed with real pointer clicks, so F reuses the middle-click
background-tab route. Search is WebKit's find controller — the browser's
first find-in-page. The Servo host gets no-op stubs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                |  76 ++++-
 examples/wpe_autofill.rs |   5 +
 examples/wpe_crash.rs    |   5 +
 examples/wpe_ctx.rs      |   5 +
 examples/wpe_dark.rs     |   5 +
 examples/wpe_focus.rs    |   5 +
 examples/wpe_host.rs     |   5 +
 examples/wpe_input.rs    |   5 +
 examples/wpe_loop.rs     |   5 +
 examples/wpe_middle.rs   |   5 +
 examples/wpe_options.rs  |   5 +
 examples/wpe_paste.rs    |   5 +
 examples/wpe_select.rs   |   5 +
 examples/wpe_tabs.rs     |   5 +
 src/main.rs              | 815 ++++++++++++++++++++++++++++++++++++++++++++++-
 src/settings.rs          |   5 +
 src/vi.rs                | 741 ++++++++++++++++++++++++++++++++++++++++++
 src/webview.rs           |  22 ++
 src/wpe/host.rs          | 213 +++++++++++++
 19 files changed, 1928 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index a814f97..17cc52f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -15,7 +15,7 @@ remote at all, so check `origin/master` after committing). Read the workspace-le
 `../cce-compositor/WORKSPACE.md` first: workspace layout, the `cce-ui` toolkit, config
 conventions, and the multi-repo rules all live there.
 
-Eighteen files, ~11.4k lines. The eleven that carry the design:
+Nineteen files, ~14.8k lines. The twelve that carry the design:
 
 | file | what it owns |
 | --- | --- |
@@ -27,6 +27,7 @@ Eighteen files, ~11.4k lines. The eleven that carry the design:
 | `src/downloads.rs` | the chrome-side download pipeline (Servo has none) |
 | `src/session.rs` | open-tab persistence: the tab set survives a restart |
 | `src/settings.rs` | the per-app KDL config |
+| `src/vi.rs` | vi mode, after qutebrowser: the modes, the bindings and their parser, hint labels, `:` commands, and the page scripts |
 | `src/accounts.rs` | accounts from cce-secrets: the Secret Service worker, which entries a host earns, saving a new login, and the never-save list |
 | `src/wpe/formwatch.rs` | the page half of account autocomplete: the watcher every frame runs (fields, frame-offset relay, fill asks, sign-in capture) and the events it sends |
 | `src/raindrop/` | bookmark sync with Raindrop.io's Unsorted: the three-way merge (`mod.rs`), the REST client (`api.rs`), the worker and status line (`sync.rs`) — design in RAINDROP-SYNC.md |
@@ -540,6 +541,77 @@ notches in quick succession land exactly three notches on (190 → 760), so the
 distance a notch travels is unchanged — only its timing. (The 190 is WebKit's
 own multiplier on a precise delta; the direct path has always moved that far.)
 
+## Vi mode
+
+`browser.vi-mode` (default **off**; a toggle on cce-system-interface's Browser
+page) turns the keyboard modal, after qutebrowser. `src/vi.rs` is the
+engine-free half — modes, the `BINDINGS` table, the count/sequence parser
+(`Keys`), hint labels, `:` parsing, and the scripts; `vi_*` in `main.rs` does
+the commands; the WPE host runs the scripts (`vi_eval`, `set_vi_enabled`)
+and the find controller (`find*`). On Servo those host calls are no-ops.
+
+Modes: **normal** (keys are commands), **insert** (keys go to the page;
+Escape leaves), **passthrough** (`Ctrl+V`: *every* key goes to the page, the
+chrome's Ctrl chords included; Shift+Escape leaves), **hint** (`f`, `F`,
+`;b`, `;y`), **command** (`:`, `/`, `?`). The status line is a plate at the
+bottom-left (lifted over a bottom bar); normal mode with nothing pending
+shows nothing.
+
+The bindings are qutebrowser's where the browser has the feature: `j/k/h/l`,
+`Ctrl+D/U/F/B`, `gg`/`G` (with a count, a percent), `H`/`L`, `r`, `J`/`K`
+and `gt`/`gT`/`g0`/`g$`/`Alt+n` for tabs (`3gt` is tab 3), `d` / `u` close
+and reopen, `co` close others, `o`/`O`/`go`/`gO` the URL bar (O submits into
+a new tab — `url_new_tab`), `yy`/`yt`, `p`/`P`, `i`, `gi`, `n`/`N`, `M`
+bookmark, `Sb`/`Sh`, `gu`/`gU`. `:` knows `open [-t|-b]`, `tabopen`, `back`,
+`forward`, `reload`, `tab-close`, `tab-only`, `undo`, `buffer N`, `yank
+[title]`, `bookmarks`/`history`/`downloads`/`favorites`, and `q` (which
+quits like a window close — the tabs are kept). Points that are choices:
+
+- **The vi stage sits ahead of the chrome's Ctrl chords** in
+  `handle_key_input`, so normal mode's `Ctrl+D/U/F/B` scroll (qutebrowser)
+  instead of bookmarking or opening pages — `M` and `Sb` are the vi route
+  there. A chord vi does not bind still reaches the chrome (`Keys::takes`),
+  and Ctrl+Shift is spelled apart (`<C-S-d>`), so the favorites chord
+  survives. It stays out while the URL bar or the bookmarks search has the
+  keyboard.
+- **Unbound letters are swallowed, unbound named keys are not** (qutebrowser's
+  `forward_unbound_keys = auto`): arrows, Page Up/Down, Space, Enter and Tab
+  still reach the page. A swallowed press's release is swallowed too
+  (`vi_swallowed`, keyed case-folded so `G` released as `g` matches), even
+  across the mode change the press caused.
+- **Insert mode follows a click, not focus.** A page autofocusing its search
+  box leaves normal mode alone, so `j` still scrolls. Two signals: a focus
+  watcher in every frame (private world `cce-vi`, channel `cceVi`) reports
+  whether the focused element takes text, and a report of "yes" within
+  `VI_CLICK_WINDOW` of a page click enters insert; and after every normal-mode
+  click the chrome asks the top frame (`active_editable_js`), because clicking
+  a field that *already* had focus moves no focus and the watcher stays
+  silent. A "no" report while in insert leaves it. A *load* (not a pushState —
+  a search field rewriting the URL per keystroke must keep the keyboard), a
+  tab switch, and closing the tab all drop back to normal; a navigation also
+  forgets the click, so the next page's autofocus is not "clicked into".
+- **Hints are drawn by the chrome and followed by real clicks.** The script
+  (`hints_js`) returns visible rects in the top viewport's CSS pixels — the
+  chrome's logical pixels — descending into same-origin frames and skipping
+  anything covered at its middle; the labels are prefix-free and as short as
+  the count allows (`hint_labels`, qutebrowser's mixed-length scheme). Picking
+  one sends a pointer move, press and release at the rect's middle, so the
+  page sees a person's click: user activation, focus, even a cross-origin
+  frame under it. `F` is the same with button 2, which `decide-policy` already
+  turns into a background tab. Cross-origin frames' *contents* get no labels
+  (the script runs in the top frame). The wheel, a click, a resize or a
+  navigation takes the labels down — they would no longer match the page.
+- **Scrolling has two routes.** `j/k/h/l` go through the eased wheel model
+  (half a notch a line), aimed at the page's middle (`scroll_origin`) rather
+  than the resting pointer; `Ctrl+D/U/F/B` and `gg`/`G` are `scroll_js`,
+  exact fractions of the viewport — WebKit scales wheel deltas (the 190 for
+  76 above), so a half page by wheel would be a guess. The script scrolls
+  whatever scrolls under the view's middle, falling back to the document.
+- **Search is WebKit's find controller**: smart case, wrapping, the current
+  match from the last position; `n`/`N` step, Escape clears the highlights,
+  "Text not found" comes from `failed-to-find-text`. It is the only
+  find-in-page there is.
+
 ## Account autocomplete (cce-secrets)
 
 A login field on a page gets a list of the accounts the keyring holds for that
@@ -782,7 +854,7 @@ clipboard path as the rest of the DE.
 
 ## Not implemented yet
 
-Worth knowing before assuming a bug: no find-in-page, no zoom, no favicons, and no
+Worth knowing before assuming a bug: no find-in-page outside vi mode's `/`, no zoom, no favicons, and no
 history/URL autocomplete. (The context menu, JS dialogs and HTTP auth landed with the
 WPE backend and are Servo-only gaps now.) Account autocomplete saves new logins but
 never updates a changed password, and "never save" has no undo UI. Ctrl+Shift+O ("hand this page to
diff --git a/examples/wpe_autofill.rs b/examples/wpe_autofill.rs
index 2cf1964..fffce11 100644
--- a/examples/wpe_autofill.rs
+++ b/examples/wpe_autofill.rs
@@ -33,6 +33,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_crash.rs b/examples/wpe_crash.rs
index d6c5e12..41e5d38 100644
--- a/examples/wpe_crash.rs
+++ b/examples/wpe_crash.rs
@@ -34,6 +34,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_ctx.rs b/examples/wpe_ctx.rs
index 6387b24..e097217 100644
--- a/examples/wpe_ctx.rs
+++ b/examples/wpe_ctx.rs
@@ -15,6 +15,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_dark.rs b/examples/wpe_dark.rs
index f5eeb86..38dede8 100644
--- a/examples/wpe_dark.rs
+++ b/examples/wpe_dark.rs
@@ -15,6 +15,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_focus.rs b/examples/wpe_focus.rs
index 2e97fe7..76df616 100644
--- a/examples/wpe_focus.rs
+++ b/examples/wpe_focus.rs
@@ -26,6 +26,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_host.rs b/examples/wpe_host.rs
index 7f7b980..1fdefe3 100644
--- a/examples/wpe_host.rs
+++ b/examples/wpe_host.rs
@@ -19,6 +19,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_input.rs b/examples/wpe_input.rs
index 84f1ecf..b668f85 100644
--- a/examples/wpe_input.rs
+++ b/examples/wpe_input.rs
@@ -22,6 +22,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_loop.rs b/examples/wpe_loop.rs
index 6b01171..9f8dafb 100644
--- a/examples/wpe_loop.rs
+++ b/examples/wpe_loop.rs
@@ -26,6 +26,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_middle.rs b/examples/wpe_middle.rs
index 1355e3a..053acdf 100644
--- a/examples/wpe_middle.rs
+++ b/examples/wpe_middle.rs
@@ -27,6 +27,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_options.rs b/examples/wpe_options.rs
index 5e10cd5..9aebf39 100644
--- a/examples/wpe_options.rs
+++ b/examples/wpe_options.rs
@@ -28,6 +28,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_paste.rs b/examples/wpe_paste.rs
index 1c80894..7e44be0 100644
--- a/examples/wpe_paste.rs
+++ b/examples/wpe_paste.rs
@@ -23,6 +23,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_select.rs b/examples/wpe_select.rs
index 0161b8c..9ec0198 100644
--- a/examples/wpe_select.rs
+++ b/examples/wpe_select.rs
@@ -24,6 +24,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/examples/wpe_tabs.rs b/examples/wpe_tabs.rs
index 5ec72d5..7454580 100644
--- a/examples/wpe_tabs.rs
+++ b/examples/wpe_tabs.rs
@@ -23,6 +23,11 @@ mod pages;
 #[path = "../src/downloads.rs"]
 mod downloads;
 #[cfg(feature = "wpe")]
+// The host's vi channel and scripts.
+#[path = "../src/vi.rs"]
+#[allow(dead_code)]
+mod vi;
+
 #[path = "../src/wpe/mod.rs"]
 mod wpe;
 
diff --git a/src/main.rs b/src/main.rs
index f283325..4799e6c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,6 +15,8 @@ mod pages;
 mod raindrop;
 mod session;
 mod settings;
+/// Vi-style modal keys, after qutebrowser (`browser.vi-mode`).
+mod vi;
 /// The retired Servo backend; compiled only under `--features servo`.
 #[cfg(feature = "servo")]
 mod webview;
@@ -174,6 +176,26 @@ const BTN_H: f32 = 26.0;
 const URL_FONT: f32 = 14.0;
 /// Pixels per wheel notch when the DE reports discrete line deltas.
 const LINE_PX: f64 = 76.0;
+/// A vi `j`/`k` line, in wheel notches.
+const VI_LINE: f32 = 0.5;
+/// How soon after a click a field taking focus counts as clicked into — what
+/// separates the person entering a field from a page focusing one itself.
+const VI_CLICK_WINDOW: std::time::Duration = std::time::Duration::from_millis(1000);
+/// How long a vi status message stays up.
+const VI_MSG_TIME: std::time::Duration = std::time::Duration::from_secs(3);
+/// The vi status line: mode, pending keys, messages, the `:` line.
+const VI_LINE_H: f32 = 28.0;
+const VI_FONT: f32 = 13.0;
+/// Hint labels over the page: qutebrowser's yellow, because a label has to
+/// read over any page at all, and the chrome's dark plates vanish into a
+/// dark one.
+const HINT_H: f32 = 19.0;
+const HINT_FONT: f32 = 13.0;
+const HINT_BG: [f32; 4] = [0.98, 0.84, 0.30, 1.0];
+const HINT_RIM: [f32; 4] = [0.45, 0.34, 0.04, 1.0];
+const HINT_TEXT: [u8; 3] = [20, 18, 10];
+/// The part of a label already typed.
+const HINT_TYPED: [u8; 3] = [140, 112, 30];
 
 
 const PAGE_BG: [f32; 4] = [0.10, 0.10, 0.11, 1.0];
@@ -714,6 +736,14 @@ struct BmLayout {
     cap: usize,
 }
 
+/// What a vi script evaluation in flight was for.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ViAsk {
+    Hints,
+    FocusInput,
+    ClickCheck,
+}
+
 #[derive(Debug, Clone)]
 pub enum Message {
     /// The accounts worker answered a load: the index, or why it failed.
@@ -856,6 +886,47 @@ struct BrowserApp {
     nav_url: Option<String>,
     /// Hovered pill in the favorites strip — a repaint, like the dot.
     fav_hover: Option<usize>,
+    /// Where an eased scroll the keyboard started is aimed: the middle of
+    /// the page, not wherever the pointer happens to rest. A real wheel
+    /// event takes it back to the pointer.
+    scroll_origin: Option<(f32, f32)>,
+    /// Submitting the URL bar opens a new tab instead of loading here — vi's
+    /// `O` / `gO`.
+    url_new_tab: bool,
+    /// Recently closed tabs' addresses, newest last, for `u` / `:undo`.
+    closed_tabs: Vec<Url>,
+    /// Vi mode (`browser.vi-mode`, `src/vi.rs`). Everything below is idle
+    /// while the setting is off.
+    vi_mode: vi::Mode,
+    /// Count and keys typed toward a normal-mode binding.
+    vi_keys: vi::Keys,
+    /// Labels over the page in hint mode; empty while the page is still
+    /// being asked for them.
+    vi_hints: Vec<vi::Hint>,
+    vi_hint_kind: vi::HintKind,
+    vi_hint_typed: String,
+    /// The script evaluation whose answer is awaited, by tag; an answer
+    /// under any other tag is stale.
+    vi_pending: Option<(u32, ViAsk)>,
+    vi_tag: u32,
+    /// The `:` / `/` / `?` line.
+    vi_cmd: cce_ui::widget::LineEdit,
+    vi_prompt: vi::Prompt,
+    /// Lines submitted, `(is a search, text)`, oldest first; Up/Down walk
+    /// the ones of the open prompt's kind.
+    vi_history: Vec<(bool, String)>,
+    vi_history_at: Option<usize>,
+    /// A status message and when it goes.
+    vi_msg: Option<(String, std::time::Instant)>,
+    /// The last page click (or followed hint) — see `VI_CLICK_WINDOW`.
+    vi_click: Option<std::time::Instant>,
+    /// Keys whose press vi took, so their release is not handed to the page
+    /// unpaired — even when the press changed the mode (`i`).
+    vi_swallowed: Vec<String>,
+    /// The last search, for `n` / `N` and an empty `/`; and whether its
+    /// highlights are up.
+    vi_search: Option<String>,
+    vi_searching: bool,
 }
 
 
@@ -1096,6 +1167,7 @@ impl BrowserApp {
         self.scroll.x.jump_to(0.0);
         self.scroll.y.jump_to(0.0);
         self.scroll_sent = (0.0, 0.0);
+        self.scroll_origin = None;
     }
 
     /// Hand the engine what the glide moved since the last frame.
@@ -1127,12 +1199,8 @@ impl BrowserApp {
         // would tell the engine every frame that a gesture had just ended.
         cce_ui::widget::scroll_motion::set_scroll_phase(cce_ui::widget::ScrollPhase::Wheel);
         let s = self.scale;
-        self.host.wheel(
-            -(dx as f64) * s,
-            -(dy as f64) * s,
-            self.pointer.0 * s as f32,
-            self.pointer.1 * s as f32,
-        );
+        let (px, py) = self.scroll_origin.unwrap_or(self.pointer);
+        self.host.wheel(-(dx as f64) * s, -(dy as f64) * s, px * s as f32, py * s as f32);
         true
     }
 
@@ -1938,6 +2006,7 @@ impl BrowserApp {
     fn close_chrome(&mut self) {
         self.chrome_open = false;
         self.chrome_peek = None;
+        self.url_new_tab = false;
         // The menu hangs off a bar that is going away.
         self.bm_menu = None;
         if self.url_focused {
@@ -2305,7 +2374,12 @@ impl BrowserApp {
 
     fn navigate(&mut self) {
         if let Some(url) = parse_url_input(&self.url.text, &self.settings.search_prefix) {
-            self.host.load(url);
+            if std::mem::take(&mut self.url_new_tab) {
+                self.host.open_tab(url);
+                self.persist_session();
+            } else {
+                self.host.load(url);
+            }
             self.url_focused = false;
             self.url.selection = None;
             self.loading = true;
@@ -2329,6 +2403,15 @@ impl BrowserApp {
                 self.ac_menu = None;
             }
         }
+        if new.vi_mode != self.settings.vi_mode {
+            self.host.set_vi_enabled(new.vi_mode);
+            self.vi_set_mode(vi::Mode::Normal);
+            self.vi_msg = None;
+            if self.vi_searching {
+                self.host.find_finish();
+                self.vi_searching = false;
+            }
+        }
         self.raindrop_on.store(new.raindrop, std::sync::atomic::Ordering::SeqCst);
         if new.raindrop && !self.raindrop_started {
             raindrop::sync::spawn(self.host.bookmarks(), self.raindrop_on.clone());
@@ -2407,6 +2490,7 @@ impl BrowserApp {
         self.host.open_tab(url);
         self.url = cce_ui::widget::LineEdit::default();
         self.url_focused = true;
+        self.url_new_tab = false;
         // The focused field has to be on screen, so a new tab unfolds the
         // menu even when it was opened by chord.
         self.open_chrome();
@@ -2421,6 +2505,17 @@ impl BrowserApp {
         {
             self.opt_menu = None;
         }
+        if let Some(url) = self.host.tab(index).and_then(|t| t.url.clone()) {
+            if url.as_str() != "about:blank" {
+                self.closed_tabs.push(url);
+                if self.closed_tabs.len() > 20 {
+                    self.closed_tabs.remove(0);
+                }
+            }
+        }
+        if index == self.host.active_index() {
+            self.vi_page_changed();
+        }
         if !self.host.close_tab(index) {
             // Deliberately emptied: save the empty set so the next launch
             // starts on the homepage instead of restoring what was closed.
@@ -2436,6 +2531,9 @@ impl BrowserApp {
     fn switch_tab(&mut self, index: usize) {
         // A glide aimed at this page must not land on the next one.
         self.stop_scroll();
+        if index != self.host.active_index() {
+            self.vi_page_changed();
+        }
         // The other tab has its own fields, and may have none.
         #[cfg(feature = "wpe")]
         {
@@ -3053,6 +3151,9 @@ impl BrowserApp {
             let i = m.focused;
             return m.fields.get_mut(i).map(|(_, e)| e);
         }
+        if self.settings.vi_mode && self.vi_mode == vi::Mode::Command {
+            return Some(&mut self.vi_cmd);
+        }
         if let Some(m) = self.bm_menu.as_mut() {
             return Some(&mut m.query);
         }
@@ -3066,6 +3167,9 @@ impl BrowserApp {
         if self.modal.is_some() {
             return false;
         }
+        if self.settings.vi_mode && self.vi_mode == vi::Mode::Command {
+            return false;
+        }
         self.bm_menu.is_some()
     }
 
@@ -3079,6 +3183,7 @@ impl BrowserApp {
             cce_ui::widget::EditOutcome::Submit => self.navigate(),
             cce_ui::widget::EditOutcome::Cancel => {
                 self.url_focused = false;
+                self.url_new_tab = false;
                 self.url.selection = None;
                 self.sync_page_state();
             }
@@ -3087,6 +3192,634 @@ impl BrowserApp {
     }
 }
 
+/// A key's identity across its press and release: the logical key, case
+/// folded, since Shift can be let go between the two (`G` comes up as `g`).
+fn key_id(event: &KeyEvent) -> String {
+    format!("{:?}", event.logical_key).to_lowercase()
+}
+
+// ---- vi mode (`src/vi.rs`) ----
+impl BrowserApp {
+    /// Change mode, dropping whatever the old one had up.
+    fn vi_set_mode(&mut self, mode: vi::Mode) {
+        if mode != vi::Mode::Hint {
+            self.vi_hints.clear();
+            self.vi_hint_typed.clear();
+            if matches!(self.vi_pending, Some((_, ViAsk::Hints))) {
+                self.vi_pending = None;
+            }
+        }
+        self.vi_keys.clear();
+        self.vi_mode = mode;
+    }
+
+    /// The page went away under vi: a navigation, or another tab shown.
+    /// Insert mode and hints belonged to it; so did any search highlight.
+    fn vi_page_changed(&mut self) {
+        if matches!(self.vi_mode, vi::Mode::Insert | vi::Mode::Hint) {
+            self.vi_set_mode(vi::Mode::Normal);
+        }
+        self.vi_pending = None;
+        self.vi_searching = false;
+        // A click that navigated must not let the next page's autofocus
+        // count as clicked into.
+        self.vi_click = None;
+    }
+
+    /// Show a message on the status line for a while. The wake is needed for
+    /// the same reason as the peek's: an idle page turns no loop.
+    fn vi_say(&mut self, text: impl Into<String>) {
+        self.vi_msg = Some((text.into(), std::time::Instant::now() + VI_MSG_TIME));
+        let tx = self.sender.clone();
+        std::thread::spawn(move || {
+            std::thread::sleep(VI_MSG_TIME);
+            let _ = tx.send(Message::Spin);
+        });
+    }
+
+    /// Ask the page something; the answer arrives in `vi_drain`.
+    fn vi_ask(&mut self, ask: ViAsk, script: &str) {
+        self.vi_tag = self.vi_tag.wrapping_add(1).max(1);
+        self.vi_pending = Some((self.vi_tag, ask));
+        self.host.vi_eval(script, self.vi_tag);
+    }
+
+    /// Take in what the page side said since the last pump: script answers,
+    /// focus moves, a search's outcome, and a message's time running out.
+    /// Returns whether anything shows differently.
+    fn vi_drain(&mut self) -> bool {
+        let mut changed = false;
+        while let Some((tag, text)) = self.host.take_vi_result() {
+            let Some((want, ask)) = self.vi_pending else { continue };
+            if tag != want {
+                continue;
+            }
+            self.vi_pending = None;
+            changed = true;
+            match ask {
+                ViAsk::Hints if self.vi_mode == vi::Mode::Hint => {
+                    self.vi_hints = vi::parse_hints(&text);
+                    if self.vi_hints.is_empty() {
+                        self.vi_set_mode(vi::Mode::Normal);
+                        self.vi_say("No elements found");
+                    }
+                }
+                ViAsk::Hints => {}
+                ViAsk::FocusInput if text == "yes" => self.vi_set_mode(vi::Mode::Insert),
+                ViAsk::FocusInput => self.vi_say("No text field found"),
+                ViAsk::ClickCheck => {
+                    if text == "yes" && self.vi_mode == vi::Mode::Normal {
+                        self.vi_set_mode(vi::Mode::Insert);
+                    }
+                }
+            }
+        }
+        if let Some(editable) = self.host.take_vi_focus() {
+            if self.settings.vi_mode {
+                let clicked = self.vi_click.is_some_and(|t| t.elapsed() < VI_CLICK_WINDOW);
+                match (self.vi_mode, editable) {
+                    (vi::Mode::Normal, true) if clicked => self.vi_set_mode(vi::Mode::Insert),
+                    (vi::Mode::Insert, false) => self.vi_set_mode(vi::Mode::Normal),
+                    _ => {}
+                }
+                changed = true;
+            }
+        }
+        if self.host.take_find_result() == Some(0) {
+            let text = self.vi_search.clone().unwrap_or_default();
+            self.vi_say(format!("Text not found: {text}"));
+            changed = true;
+        }
+        if self.vi_msg.as_ref().is_some_and(|(_, at)| std::time::Instant::now() >= *at) {
+            self.vi_msg = None;
+            changed = true;
+        }
+        changed
+    }
+
+    /// Mark a press as taken, so its release is too.
+    fn vi_swallow(&mut self, event: &KeyEvent) {
+        if self.vi_swallowed.len() >= 16 {
+            self.vi_swallowed.remove(0);
+        }
+        self.vi_swallowed.push(key_id(event));
+    }
+
+    /// The vi stage of `handle_key_input`. `Some` when vi took the key —
+    /// carrying what the key did, which can be quitting — `None` to let it
+    /// go on down the funnel.
+    fn vi_key(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Option<Message>> {
+        use vi::Mode;
+        if !self.settings.vi_mode {
+            return None;
+        }
+        let owns_keyboard = matches!(self.vi_mode, Mode::Hint | Mode::Command);
+        if event.state != ElementState::Pressed {
+            let id = key_id(event);
+            if let Some(i) = self.vi_swallowed.iter().position(|k| *k == id) {
+                self.vi_swallowed.remove(i);
+                return Some(None);
+            }
+            return owns_keyboard.then_some(None);
+        }
+        // A modifier on its own means nothing to vi.
+        if matches!(
+            event.logical_key,
+            Key::Named(NamedKey::Shift | NamedKey::Control | NamedKey::Alt | NamedKey::Super)
+        ) {
+            return owns_keyboard.then_some(None);
+        }
+        match self.vi_mode {
+            Mode::Command => {
+                self.vi_swallow(event);
+                *needs_rebuild = true;
+                return Some(self.vi_cmd_key(event));
+            }
+            Mode::Hint => {
+                self.vi_swallow(event);
+                *needs_rebuild = true;
+                self.vi_hint_key(event);
+                return Some(None);
+            }
+            _ => {}
+        }
+        // A field of the chrome's own has the keyboard; vi stays out of it.
+        if self.url_focused || self.bm_menu.is_some() {
+            return None;
+        }
+        let escape = event.logical_key == Key::Named(NamedKey::Escape);
+        match self.vi_mode {
+            Mode::Passthrough => {
+                if escape && event.shift {
+                    self.vi_swallow(event);
+                    self.vi_set_mode(Mode::Normal);
+                    *needs_rebuild = true;
+                } else {
+                    self.host.key_ui(event);
+                }
+                return Some(None);
+            }
+            Mode::Insert => {
+                if !escape {
+                    return None;
+                }
+                self.vi_swallow(event);
+                self.vi_set_mode(Mode::Normal);
+                *needs_rebuild = true;
+                return Some(None);
+            }
+            _ => {}
+        }
+
+        // Normal mode. Escape clears what vi has going — pending keys, a
+        // message, a search's highlights — and only with none of that does
+        // it go on, to fold the bar or reach the page.
+        if escape {
+            let mut used = !self.vi_keys.is_empty() || self.vi_msg.take().is_some();
+            self.vi_keys.clear();
+            if self.vi_searching {
+                self.host.find_finish();
+                self.vi_searching = false;
+                used = true;
+            }
+            if !used {
+                return None;
+            }
+            self.vi_swallow(event);
+            *needs_rebuild = true;
+            return Some(None);
+        }
+        *needs_rebuild = true;
+        // Named keys are never commands: they end a sequence and go on.
+        let Some(token) = vi::token(event) else {
+            self.vi_keys.clear();
+            return None;
+        };
+        // A chord vi has no binding for is the chrome's, or the page's.
+        if (event.ctrl || event.alt) && !self.vi_keys.takes(&token) {
+            self.vi_keys.clear();
+            return None;
+        }
+        self.vi_swallow(event);
+        self.vi_msg = None;
+        match self.vi_keys.feed(&token) {
+            vi::Fed::Run(action, count) => Some(self.vi_run(action, count)),
+            vi::Fed::Pending | vi::Fed::Unbound => Some(None),
+        }
+    }
+
+    /// Do a normal-mode command.
+    fn vi_run(&mut self, action: vi::Action, count: Option<u32>) -> Option<Message> {
+        use vi::Action as A;
+        let n = count.unwrap_or(1).max(1) as usize;
+        let tabs = self.host.tab_count();
+        let cur = self.host.active_index();
+        match action {
+            A::ScrollLines(dx, dy) => self.vi_scroll_lines(dx * n as f32, dy * n as f32),
+            A::ScrollPage(f) => {
+                let js = vi::scroll_js(Some(f * n as f32), None, cce_ui::motion::enabled());
+                self.host.vi_eval(&js, 0);
+            }
+            A::Top | A::Bottom => {
+                let end = if action == A::Top { 0.0 } else { 1.0 };
+                let to = count.map_or(end, |c| c.min(100) as f32 / 100.0);
+                self.host.vi_eval(&vi::scroll_js(None, Some(to), false), 0);
+            }
+            A::Back => self.host.back(),
+            A::Forward => self.host.forward(),
+            A::Reload => self.host.reload(),
+            A::TabNext if tabs > 1 => self.switch_tab((cur + n) % tabs),
+            A::TabPrev if tabs > 1 => self.switch_tab((cur + tabs - n % tabs) % tabs),
+            A::TabGoto => match count {
+                Some(c) => return self.vi_run(A::TabFocus(c as usize), None),
+                None => return self.vi_run(A::TabNext, None),
+            },
+            A::TabFocus(i) if (1..=tabs).contains(&i) => self.switch_tab(i - 1),
+            A::TabFocus(i) => self.vi_say(format!("There is no tab {i}")),
+            A::TabFirst => self.switch_tab(0),
+            A::TabLast => self.switch_tab(tabs.saturating_sub(1)),
+            A::TabNext | A::TabPrev => {}
+            A::TabClose => return self.close_tab(cur),
+            A::TabOnly => {
+                for i in (0..tabs).rev().filter(|&i| i != cur) {
+                    self.close_tab(i);
+                }
+            }
+            A::UndoClose => match self.closed_tabs.pop() {
+                Some(url) => {
+                    self.host.open_tab(url);
+                    self.sync_page_state();
+                    self.persist_session();
+                }
+                None => self.vi_say("No closed tabs"),
+            },
+            A::Open { tab, edit } => {
+                self.open_chrome();
+                let text = if edit {
+                    self.host.url().map(|u| u.to_string()).unwrap_or_default()
+                } else {
+                    String::new()
+                };
+                self.url = cce_ui::widget::LineEdit::with_text(text);
+                self.url_focused = true;
+                self.url_new_tab = tab;
+            }
+            A::Hint(kind) => {
+                self.vi_set_mode(vi::Mode::Hint);
+                self.vi_hint_kind = kind;
+                self.vi_ask(ViAsk::Hints, &vi::hints_js(kind != vi::HintKind::Follow));
+            }
+            A::YankUrl => match self.host.url() {
+                Some(u) => {
+                    cce_ui::widget::clipboard::copy_to_clipboard(u.as_str());
+                    self.vi_say(format!("Yanked {u}"));
+                }
+                None => self.vi_say("No address to yank"),
+            },
+            A::YankTitle => match self.title.clone().filter(|t| !t.is_empty()) {
+                Some(t) => {
+                    cce_ui::widget::clipboard::copy_to_clipboard(&t);
+                    self.vi_say(format!("Yanked {t}"));
+                }
+                None => self.vi_say("No title to yank"),
+            },
+            A::Paste { tab } => {
+                let url = cce_ui::widget::clipboard::read_from_clipboard()
+                    .map(|t| t.trim().to_string())
+                    .filter(|t| !t.is_empty())
+                    .and_then(|t| parse_url_input(&t, &self.settings.search_prefix));
+                match url {
+                    Some(url) if tab => {
+                        self.host.open_tab(url);
+                        self.sync_page_state();
+                        self.persist_session();
+                    }
+                    Some(url) => {
+                        self.host.load(url);
+                        self.loading = true;
+                    }
+                    None => self.vi_say("Nothing to open in the clipboard"),
+                }
+            }
+            A::Insert => self.vi_set_mode(vi::Mode::Insert),
+            A::FocusInput => self.vi_ask(ViAsk::FocusInput, &vi::focus_input_js()),
+            A::Passthrough => self.vi_set_mode(vi::Mode::Passthrough),
+            A::Prompt(p) => {
+                self.vi_set_mode(vi::Mode::Command);
+                self.vi_prompt = p;
+                self.vi_cmd = cce_ui::widget::LineEdit::default();
+                self.vi_history_at = None;
+            }
+            A::SearchNext | A::SearchPrev => {
+                if self.vi_searching {
+                    for _ in 0..n {
+                        if action == A::SearchNext {
+                            self.host.find_next();
+                        } else {
+                            self.host.find_prev();
+                        }
+                    }
+                } else if let Some(text) = self.vi_search.clone() {
+                    // A search from before a navigation: start it again here.
+                    self.host.find(&text, action == A::SearchPrev);
+                    self.vi_searching = true;
+                } else {
+                    self.vi_say("No previous search");
+                }
+            }
+            A::Bookmark => {
+                self.host.toggle_bookmark();
+                let on = self.host.active_bookmarked();
+                self.vi_say(if on { "Bookmarked" } else { "Bookmark removed" });
+            }
+            A::Page(page) => self.open_internal_page(page),
+            A::Up | A::Root => {
+                let to = self.host.url().and_then(|u| {
+                    if action == A::Up { vi::url_up(&u) } else { vi::url_root(&u) }
+                });
+                if let Some(url) = to {
+                    self.host.load(url);
+                    self.loading = true;
+                }
+            }
+        }
+        None
+    }
+
+    /// `j`/`k`/`h`/`l`: a wheel's worth of lines through the same eased
+    /// model a notch takes, aimed at the middle of the page.
+    fn vi_scroll_lines(&mut self, dx: f32, dy: f32) {
+        let center = (self.win.0 / 2.0, self.win.1 / 2.0);
+        // Lines down are a negative wheel delta (winit: positive = up).
+        let (lx, ly) = (-dx * VI_LINE, -dy * VI_LINE);
+        if cce_ui::widget::scroll_motion::scroll_settings().smooth {
+            use cce_ui::widget::scroll_motion::Bounds;
+            self.scroll_origin = Some(center);
+            self.scroll.apply(
+                &MouseScrollDelta::LineDelta(lx, ly),
+                (LINE_PX as f32, LINE_PX as f32),
+                Bounds::UNBOUNDED,
+                Bounds::UNBOUNDED,
+            );
+        } else {
+            cce_ui::widget::scroll_motion::set_scroll_phase(cce_ui::widget::ScrollPhase::Wheel);
+            let s = self.scale;
+            self.host.wheel(
+                lx as f64 * LINE_PX * s,
+                ly as f64 * LINE_PX * s,
+                center.0 * s as f32,
+                center.1 * s as f32,
+            );
+        }
+    }
+
+    /// Keys while the labels are up: letters narrow them down, a whole label
+    /// picks, Backspace takes a letter back, Escape gives up.
+    fn vi_hint_key(&mut self, event: &KeyEvent) {
+        match &event.logical_key {
+            Key::Named(NamedKey::Escape) => self.vi_set_mode(vi::Mode::Normal),
+            Key::Named(NamedKey::Backspace) => {
+                self.vi_hint_typed.pop();
+            }
+            Key::Character(c) if !event.ctrl && !event.alt => {
+                let next = format!("{}{}", self.vi_hint_typed, c.to_lowercase());
+                // A letter no label continues with is ignored, not an error.
+                if self.vi_hints.iter().any(|h| h.label.starts_with(&next)) {
+                    let hit = self.vi_hints.iter().find(|h| h.label == next).cloned();
+                    self.vi_hint_typed = next;
+                    if let Some(h) = hit {
+                        self.vi_follow(h);
+                    }
+                }
+            }
+            _ => {}
+        }
+    }
+
+    fn vi_follow(&mut self, hint: vi::Hint) {
+        let kind = self.vi_hint_kind;
+        self.vi_set_mode(vi::Mode::Normal);
+        match kind {
+            // A real click, at the element: the page sees a person's click
+            // (popups allowed, focus moved, cross-origin frames and all), and
+            // a field picked this way is clicked into.
+            vi::HintKind::Follow => {
+                self.vi_click = Some(std::time::Instant::now());
+                self.click_page(MouseButton::Left, hint.at);
+                self.vi_ask(ViAsk::ClickCheck, &vi::active_editable_js());
+            }
+            // A middle-click: the link becomes a background tab by the same
+            // route a pointer's middle-click takes.
+            vi::HintKind::Background => self.click_page(MouseButton::Middle, hint.at),
+            vi::HintKind::Yank => match hint.href {
+                Some(href) => {
+                    cce_ui::widget::clipboard::copy_to_clipboard(&href);
+                    self.vi_say(format!("Yanked {href}"));
+                }
+                None => self.vi_say("That has no link to yank"),
+            },
+        }
+    }
+
+    /// Press and release `button` on the page at `at` (logical pixels).
+    fn click_page(&mut self, button: MouseButton, at: (f32, f32)) {
+        let s = self.scale as f32;
+        let (x, y) = (at.0 * s, at.1 * s);
+        self.host.mouse_move(x, y);
+        self.host.mouse_button_ui(button, true, x, y);
+        self.host.mouse_button_ui(button, false, x, y);
+    }
+
+    /// Keys for the `:` / `/` / `?` line. Editing is the shared `LineEdit`;
+    /// Up/Down walk earlier lines of the same kind, and Backspace on an empty
+    /// line leaves it, as in vim.
+    fn vi_cmd_key(&mut self, event: &KeyEvent) -> Option<Message> {
+        let search = self.vi_prompt != vi::Prompt::Command;
+        match event.logical_key {
+            Key::Named(NamedKey::ArrowUp | NamedKey::ArrowDown) => {
+                let up = event.logical_key == Key::Named(NamedKey::ArrowUp);
+                let past: Vec<String> = self
+                    .vi_history
+                    .iter()
+                    .filter(|(s, _)| *s == search)
+                    .map(|(_, t)| t.clone())
+                    .collect();
+                if past.is_empty() {
+                    return None;
+                }
+                let at = match (self.vi_history_at, up) {
+                    (None, true) => Some(past.len() - 1),
+                    (None, false) => None,
+                    (Some(i), true) => Some(i.saturating_sub(1)),
+                    (Some(i), false) => (i + 1 < past.len()).then_some(i + 1),
+                };
+                self.vi_history_at = at;
+                self.vi_cmd = cce_ui::widget::LineEdit::with_text(at.map_or(String::new(), |i| past[i].clone()));
+                return None;
+            }
+            Key::Named(NamedKey::Backspace) if self.vi_cmd.text.is_empty() => {
+                self.vi_set_mode(vi::Mode::Normal);
+                return None;
+            }
+            _ => {}
+        }
+        match self.vi_cmd.handle_key(event) {
+            cce_ui::widget::EditOutcome::Submit => {
+                let line = self.vi_cmd.text.clone();
+                self.vi_set_mode(vi::Mode::Normal);
+                if !line.trim().is_empty() {
+                    self.vi_history.retain(|(s, t)| !(*s == search && *t == line));
+                    self.vi_history.push((search, line.clone()));
+                    if self.vi_history.len() > 100 {
+                        self.vi_history.remove(0);
+                    }
+                }
+                self.vi_submit(&line)
+            }
+            cce_ui::widget::EditOutcome::Cancel => {
+                self.vi_set_mode(vi::Mode::Normal);
+                None
+            }
+            _ => None,
+        }
+    }
+
+    fn vi_submit(&mut self, line: &str) -> Option<Message> {
+        match self.vi_prompt {
+            vi::Prompt::Search | vi::Prompt::SearchBack => {
+                // An empty search repeats the last one, as in vim.
+                let text = Some(line.to_string()).filter(|l| !l.is_empty()).or_else(|| self.vi_search.clone());
+                if let Some(text) = text {
+                    self.host.find(&text, self.vi_prompt == vi::Prompt::SearchBack);
+                    self.vi_search = Some(text);
+                    self.vi_searching = true;
+                }
+                None
+            }
+            vi::Prompt::Command => match vi::parse_command(line) {
+                Ok(vi::Cmd::Run(action)) => self.vi_run(action, None),
+                Ok(vi::Cmd::Open { target, tab, background }) => {
+                    match parse_url_input(&target, &self.settings.search_prefix) {
+                        Some(url) if background => self.open_background_tab(url),
+                        Some(url) if tab => {
+                            self.host.open_tab(url);
+                            self.sync_page_state();
+                            self.persist_session();
+                        }
+                        Some(url) => {
+                            self.host.load(url);
+                            self.loading = true;
+                        }
+                        None => self.vi_say(format!("Cannot open {target}")),
+                    }
+                    None
+                }
+                // Like closing the window: the tabs are kept for next time.
+                Ok(vi::Cmd::Quit) => Some(Message::Quit),
+                Ok(vi::Cmd::Empty) => None,
+                Err(why) => {
+                    self.vi_say(why);
+                    None
+                }
+            },
+        }
+    }
+
+    /// Where the status line goes: the bottom-left corner, clear of the
+    /// dot (which is on the right), lifted over a bottom bar while it shows.
+    fn vi_line_rect(&self, width: f32) -> Rect {
+        let mut y = self.win.1 - bar_margin() - VI_LINE_H;
+        if self.settings.bar_position == settings::BarPosition::Bottom && self.chrome_ease() > 0.0 {
+            y = y.min(self.chrome_plate().0.y - item_gap() - VI_LINE_H);
+        }
+        let width = width.min(self.win.0 - 2.0 * bar_margin()).max(0.0);
+        Rect { x: bar_margin(), y, width, height: VI_LINE_H }
+    }
+
+    /// Hint labels over the page, and the status line: the mode, pending
+    /// keys, a message, or the `:` line being typed. Normal mode with
+    /// nothing to say draws nothing — the chrome stays a dot.
+    fn paint_vi(&mut self, pc: &mut PaintCtx, sans: &str) {
+        if !self.settings.vi_mode {
+            return;
+        }
+        if self.vi_mode == vi::Mode::Hint {
+            let typed = self.vi_hint_typed.clone();
+            let typed_w = measure_text_width(&typed, sans, HINT_FONT);
+            for h in self.vi_hints.iter().filter(|h| h.label.starts_with(&typed)) {
+                let w = measure_text_width(&h.label, sans, HINT_FONT) + 8.0;
+                let x = h.rect.0.clamp(0.0, (self.win.0 - w).max(0.0));
+                let y = h.rect.1.clamp(0.0, (self.win.1 - HINT_H).max(0.0));
+                pc.rounded_rect(
+                    Rect { x: x - 1.0, y: y - 1.0, width: w + 2.0, height: HINT_H + 2.0 },
+                    5.0,
+                    (true, true, true, true),
+                    HINT_RIM,
+                );
+                pc.rounded_rect(Rect { x, y, width: w, height: HINT_H }, 4.0, (true, true, true, true), HINT_BG);
+                let ty = cce_ui::layout::align_text_y(y, HINT_H, HINT_FONT, 0.0);
+                if !typed.is_empty() {
+                    pc.text(typed.clone(), x + 4.0, ty, HINT_FONT, HINT_TYPED);
+                }
+                pc.text(h.label[typed.len()..].to_string(), x + 4.0 + typed_w, ty, HINT_FONT, HINT_TEXT);
+            }
+        }
+
+        let material = cce_ui::scene::Material::opaque([0.13, 0.14, 0.16, 1.0]);
+        let bevel = cce_ui::layout::bevel_width().min(3.0);
+        if self.vi_mode == vi::Mode::Command {
+            let r = self.vi_line_rect((self.win.0 * 0.5).clamp(320.0, 720.0));
+            pc.plate(r, (8.0, 8.0, 8.0, 8.0), &material, bevel);
+            let sigil = self.vi_prompt.sigil().to_string();
+            let x = r.x + text_pad();
+            let ty = cce_ui::layout::align_text_y(r.y, r.height, URL_FONT, 0.0);
+            let text = self.vi_cmd.text.clone();
+            let cursor = self.vi_cmd.cursor;
+            let sel = self
+                .vi_cmd
+                .selection
+                .filter(|&(a, b)| a < b)
+                .map(|(a, b)| (self.x_of_boundary(&text, a), self.x_of_boundary(&text, b)));
+            let caret = self.x_of_boundary(&text, cursor);
+            let tx = x + measure_text_width(&sigil, sans, URL_FONT) + 3.0;
+            pc.clip(r, |pc| {
+                pc.text(sigil, x, ty, URL_FONT, [150, 190, 240]);
+                if let Some((x0, x1)) = sel {
+                    pc.quad(Rect { x: tx + x0, y: r.y + 5.0, width: x1 - x0, height: r.height - 10.0 }, SEL_BG);
+                }
+                pc.text(text, tx, ty, URL_FONT, TEXT);
+                pc.quad(
+                    Rect { x: tx + caret, y: r.y + 5.0, width: 1.0, height: r.height - 10.0 },
+                    [0.85, 0.87, 0.92, 1.0],
+                );
+            });
+            return;
+        }
+
+        let (text, color) = if let Some((msg, _)) = &self.vi_msg {
+            (msg.clone(), TEXT)
+        } else {
+            let keys = self.vi_keys.shown();
+            match self.vi_mode.label() {
+                Some(label) => (label.to_string(), [150, 190, 240]),
+                None if !keys.is_empty() => (keys, TEXT),
+                None => return,
+            }
+        };
+        let avail = self.win.0 - 2.0 * bar_margin() - 2.0 * text_pad();
+        let text = Self::fit_text(&text, sans, VI_FONT, avail);
+        let r = self.vi_line_rect(measure_text_width(&text, sans, VI_FONT) + 2.0 * text_pad());
+        pc.plate(r, (8.0, 8.0, 8.0, 8.0), &material, bevel);
+        pc.text(
+            text,
+            r.x + text_pad(),
+            cce_ui::layout::align_text_y(r.y, r.height, VI_FONT, 0.0),
+            VI_FONT,
+            color,
+        );
+    }
+}
+
 impl Application for BrowserApp {
     type Message = Message;
 
@@ -3147,6 +3880,7 @@ impl Application for BrowserApp {
         host.set_force_dark(settings.color_scheme.forces_dark());
         #[cfg(feature = "wpe")]
         host.set_accounts_enabled(settings.accounts);
+        host.set_vi_enabled(settings.vi_mode);
         let accounts = accounts::Accounts::spawn(sender.clone());
         let raindrop_on = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(settings.raindrop));
         let raindrop_started = settings.raindrop;
@@ -3205,6 +3939,25 @@ impl Application for BrowserApp {
             #[cfg(feature = "wpe")]
             ac_menu: None,
             nav_url: None,
+            scroll_origin: None,
+            url_new_tab: false,
+            closed_tabs: Vec::new(),
+            vi_mode: vi::Mode::Normal,
+            vi_keys: vi::Keys::default(),
+            vi_hints: Vec::new(),
+            vi_hint_kind: vi::HintKind::Follow,
+            vi_hint_typed: String::new(),
+            vi_pending: None,
+            vi_tag: 0,
+            vi_cmd: cce_ui::widget::LineEdit::default(),
+            vi_prompt: vi::Prompt::Command,
+            vi_history: Vec::new(),
+            vi_history_at: None,
+            vi_msg: None,
+            vi_click: None,
+            vi_swallowed: Vec::new(),
+            vi_search: None,
+            vi_searching: false,
         }
     }
 
@@ -3310,6 +4063,12 @@ impl Application for BrowserApp {
                     if now != self.nav_url {
                         self.nav_url = now;
                         self.stop_scroll();
+                        // A load, not a pushState: a search field rewriting
+                        // the address on every keystroke must not lose the
+                        // keyboard to it.
+                        if self.host.loading() {
+                            self.vi_page_changed();
+                        }
                         #[cfg(feature = "wpe")]
                         {
                             self.ac_menu = None;
@@ -3322,6 +4081,9 @@ impl Application for BrowserApp {
                     // so this is where an address change gets persisted.
                     self.persist_session();
                 }
+                if self.vi_drain() {
+                    *needs_rebuild = true;
+                }
                 // Drained after the navigation check, so a field reported in
                 // the same pump that finished the load is not thrown away
                 // with the page it arrived on.
@@ -3473,6 +4235,7 @@ impl Application for BrowserApp {
         // reach us; forget them rather than act on a stale Shift or drag.
         if !focused {
             self.shift_held = false;
+            self.vi_swallowed.clear();
             // A select's list is a transient of the page, and goes with focus
             // as a native one does.
             #[cfg(feature = "wpe")]
@@ -3504,6 +4267,9 @@ impl Application for BrowserApp {
         if self.win != (width, height) {
             self.close_opt_menu();
         }
+        if self.win != (width, height) && self.vi_mode == vi::Mode::Hint {
+            self.vi_set_mode(vi::Mode::Normal);
+        }
         self.win = (width, height);
         self.scale = scale;
         let (w, h) = self.content_px();
@@ -3647,6 +4413,14 @@ impl Application for BrowserApp {
     ) -> Option<Self::Message> {
         let pressed = state == ElementState::Pressed;
 
+        // A click ends hinting (the labels no longer match what is under
+        // them once anything moves) and the `:` line, as a click off any
+        // other field drops it.
+        if pressed && matches!(self.vi_mode, vi::Mode::Hint | vi::Mode::Command) {
+            self.vi_set_mode(vi::Mode::Normal);
+            *needs_rebuild = true;
+        }
+
         // Before any of the branches that swallow a click: a button the page
         // is holding gets its release no matter where it was let go, or the
         // engine goes on believing it is still down.
@@ -3888,6 +4662,9 @@ impl Application for BrowserApp {
                     // caret, Shift extends to it, and a drag selects.
                     self.url_entry_press = !self.url_focused;
                     let extend = self.url_focused && self.shift_held;
+                    if !self.url_focused {
+                        self.url_new_tab = false;
+                    }
                     self.url_focused = true;
                     self.url.press(at, extend);
                 } else {
@@ -3910,10 +4687,25 @@ impl Application for BrowserApp {
             MouseButton::Forward if pressed => self.host.forward(),
             _ => self.page_press(button, pressed, pos),
         }
+        if button == MouseButton::Left && self.settings.vi_mode {
+            if pressed {
+                self.vi_click = Some(std::time::Instant::now());
+            } else if self.vi_mode == vi::Mode::Normal {
+                // Clicking a field that already had focus moves no focus,
+                // so the watcher has nothing to say; ask the page instead.
+                self.vi_ask(ViAsk::ClickCheck, &vi::active_editable_js());
+            }
+        }
         None
     }
 
     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
+        self.scroll_origin = None;
+        // The labels would stay put while the page moved under them.
+        if self.vi_mode == vi::Mode::Hint {
+            self.vi_set_mode(vi::Mode::Normal);
+            *needs_rebuild = true;
+        }
         // A select's list takes the wheel: over it, it scrolls the list;
         // anywhere else it is swallowed, so the select stays under it.
         #[cfg(feature = "wpe")]
@@ -4168,6 +4960,13 @@ impl Application for BrowserApp {
             return None;
         }
 
+        // Vi mode, ahead of the chrome's chords: normal mode's Ctrl bindings
+        // (Ctrl+D/U/F/B scroll, Ctrl+V is passthrough) win over them, and
+        // passthrough hands the page even those.
+        if let Some(out) = self.vi_key(event, needs_rebuild) {
+            return out;
+        }
+
         // Tab shortcuts work regardless of URL-bar focus.
         if event.state == ElementState::Pressed && event.ctrl {
             let count = self.host.tab_count();
@@ -4266,6 +5065,7 @@ impl Application for BrowserApp {
                         }
                         "l" => {
                             self.open_chrome();
+                            self.url_new_tab = false;
                             self.url_focused = true;
                             self.select_all_url();
                             *needs_rebuild = true;
@@ -4532,6 +5332,7 @@ impl Application for BrowserApp {
             Some(2.0),
         );
 
+        self.paint_vi(&mut pc, &sans);
         self.paint_bm_menu(&mut pc, &sans);
         #[cfg(feature = "wpe")]
         self.paint_ac_menu(&mut pc, &sans);
diff --git a/src/settings.rs b/src/settings.rs
index cd2bcc5..ebcea47 100644
--- a/src/settings.rs
+++ b/src/settings.rs
@@ -96,6 +96,9 @@ pub struct Settings {
     /// (RAINDROP-SYNC.md). Off by default: it needs a token in the keyring,
     /// and it writes to an account elsewhere.
     pub raindrop: bool,
+    /// Vi-style modal keys, after qutebrowser (`src/vi.rs`). Off by default:
+    /// it changes what every letter typed at a page means.
+    pub vi_mode: bool,
     /// Window edge the utility bar floats against.
     pub bar_position: BarPosition,
     /// What pages are told to prefer.
@@ -114,6 +117,7 @@ impl Default for Settings {
             history: true,
             accounts: true,
             raindrop: false,
+            vi_mode: false,
             bar_position: BarPosition::Top,
             color_scheme: ColorScheme::Dark,
             external_browser: None,
@@ -158,6 +162,7 @@ pub fn load() -> Settings {
         history: b["history"].as_bool().unwrap_or(true),
         accounts: b["accounts"].as_bool().unwrap_or(true),
         raindrop: b["raindrop"].as_bool().unwrap_or(false),
+        vi_mode: b["vi-mode"].as_bool().unwrap_or(false),
         bar_position: BarPosition::from_key(b["bar-position"].as_str().unwrap_or("top")),
         color_scheme: ColorScheme::from_key(b["color-scheme"].as_str().unwrap_or("dark")),
         external_browser: b["external-browser"]
diff --git a/src/vi.rs b/src/vi.rs
new file mode 100644
index 0000000..7ed47e1
--- /dev/null
+++ b/src/vi.rs
@@ -0,0 +1,741 @@
+//! Vi-style modal keys, after qutebrowser.
+//!
+//! The chrome-independent half: the modes, the normal-mode bindings and the
+//! parser that turns keystrokes into them, hint labels, `:` commands, and the
+//! scripts the page side runs. `main.rs` owns what these do (the tab set, the
+//! URL bar, the drawing), and the WPE host owns how they reach the engine.
+//!
+//! Points that are choices, not accidents:
+//!
+//! * **qutebrowser's bindings, where this browser has the feature.** `J`/`K`
+//!   are next/previous tab and `d`/`u` close and reopen, as there — not
+//!   Vimium's. What the browser does not have (zoom, quickmarks, windows)
+//!   is not bound, rather than bound to nothing.
+//! * **Unbound characters are swallowed, unbound named keys are not**
+//!   (qutebrowser's `forward_unbound_keys = auto`): a stray letter must not
+//!   type into a page that happens to have focus, but arrows, Page Up/Down,
+//!   Space, Enter and Tab still reach it.
+//! * **Insert mode is entered by a click, not by focus.** A field the page
+//!   focuses on its own (a search box with `autofocus`) leaves normal mode
+//!   alone, so `j` still scrolls; a field the person clicks into — or picks
+//!   with a hint — takes the keyboard. `i` enters it explicitly.
+
+use cce_ui::widget::{Key, KeyEvent};
+
+/// What the keyboard means right now.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Mode {
+    /// Keys are commands.
+    Normal,
+    /// Keys go to the page, except Escape, which comes back to normal.
+    Insert,
+    /// Every key goes to the page, the chrome's own Ctrl chords included;
+    /// only Shift+Escape comes back.
+    Passthrough,
+    /// Labels are up over the page's clickable elements; typing one picks it.
+    Hint,
+    /// The `:` / `/` / `?` line has the keyboard.
+    Command,
+}
+
+impl Mode {
+    /// The status line's name for the mode; `None` for normal, which shows
+    /// nothing.
+    pub fn label(self) -> Option<&'static str> {
+        match self {
+            Mode::Normal | Mode::Command => None,
+            Mode::Insert => Some("-- INSERT --"),
+            Mode::Passthrough => Some("-- PASSTHROUGH --"),
+            Mode::Hint => Some("-- HINT --"),
+        }
+    }
+}
+
+/// What picking a hint does.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HintKind {
+    /// Click it, as the pointer would.
+    Follow,
+    /// Middle-click it: a link opens in a background tab.
+    Background,
+    /// Copy its link.
+    Yank,
+}
+
+/// Which line the command prompt is.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Prompt {
+    Command,
+    Search,
+    SearchBack,
+}
+
+impl Prompt {
+    pub fn sigil(self) -> char {
+        match self {
+            Prompt::Command => ':',
+            Prompt::Search => '/',
+            Prompt::SearchBack => '?',
+        }
+    }
+}
+
+/// A normal-mode command. A count, where one was typed, comes alongside.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Action {
+    /// Scroll by this many lines (a line is half a wheel notch), eased.
+    ScrollLines(f32, f32),
+    /// Scroll by this fraction of the viewport: half a page, or a page.
+    ScrollPage(f32),
+    /// To the top / the bottom; with a count, to that percent.
+    Top,
+    Bottom,
+    Back,
+    Forward,
+    Reload,
+    /// Move this many tabs (a count multiplies).
+    TabNext,
+    TabPrev,
+    /// `gt`: the next tab, or with a count, tab number `count`.
+    TabGoto,
+    TabFirst,
+    TabLast,
+    /// Tab number n, counting from 1 (`Alt+n`).
+    TabFocus(usize),
+    TabClose,
+    TabOnly,
+    UndoClose,
+    /// Focus the URL bar: empty, or holding the current address (`edit`);
+    /// submitting loads here or in a new tab.
+    Open { tab: bool, edit: bool },
+    Hint(HintKind),
+    YankUrl,
+    YankTitle,
+    /// Open the clipboard's address, here or in a new tab.
+    Paste { tab: bool },
+    Insert,
+    /// Focus the page's first text field and enter insert mode.
+    FocusInput,
+    Passthrough,
+    Prompt(Prompt),
+    SearchNext,
+    SearchPrev,
+    Bookmark,
+    /// One of the `cce://` pages.
+    Page(&'static str),
+    /// Up one path segment; to the site root.
+    Up,
+    Root,
+}
+
+/// The normal-mode bindings: key sequence -> action. A sequence is a run of
+/// tokens as [`token`] spells them: a plain character, or `<C-x>` / `<A-x>`.
+pub const BINDINGS: &[(&str, Action)] = &[
+    ("j", Action::ScrollLines(0.0, 1.0)),
+    ("k", Action::ScrollLines(0.0, -1.0)),
+    ("h", Action::ScrollLines(-1.0, 0.0)),
+    ("l", Action::ScrollLines(1.0, 0.0)),
+    ("<C-d>", Action::ScrollPage(0.5)),
+    ("<C-u>", Action::ScrollPage(-0.5)),
+    ("<C-f>", Action::ScrollPage(1.0)),
+    ("<C-b>", Action::ScrollPage(-1.0)),
+    ("gg", Action::Top),
+    ("G", Action::Bottom),
+    ("H", Action::Back),
+    ("L", Action::Forward),
+    ("r", Action::Reload),
+    ("R", Action::Reload),
+    ("J", Action::TabNext),
+    ("K", Action::TabPrev),
+    ("gt", Action::TabGoto),
+    ("gT", Action::TabPrev),
+    ("g0", Action::TabFirst),
+    ("g^", Action::TabFirst),
+    ("g$", Action::TabLast),
+    ("<A-1>", Action::TabFocus(1)),
+    ("<A-2>", Action::TabFocus(2)),
+    ("<A-3>", Action::TabFocus(3)),
+    ("<A-4>", Action::TabFocus(4)),
+    ("<A-5>", Action::TabFocus(5)),
+    ("<A-6>", Action::TabFocus(6)),
+    ("<A-7>", Action::TabFocus(7)),
+    ("<A-8>", Action::TabFocus(8)),
+    ("<A-9>", Action::TabLast),
+    ("d", Action::TabClose),
+    ("co", Action::TabOnly),
+    ("u", Action::UndoClose),
+    ("o", Action::Open { tab: false, edit: false }),
+    ("O", Action::Open { tab: true, edit: false }),
+    ("go", Action::Open { tab: false, edit: true }),
+    ("gO", Action::Open { tab: true, edit: true }),
+    ("f", Action::Hint(HintKind::Follow)),
+    ("F", Action::Hint(HintKind::Background)),
+    (";b", Action::Hint(HintKind::Background)),
+    (";y", Action::Hint(HintKind::Yank)),
+    ("yy", Action::YankUrl),
+    ("yt", Action::YankTitle),
+    ("p", Action::Paste { tab: false }),
+    ("P", Action::Paste { tab: true }),
+    ("i", Action::Insert),
+    ("gi", Action::FocusInput),
+    ("<C-v>", Action::Passthrough),
+    (":", Action::Prompt(Prompt::Command)),
+    ("/", Action::Prompt(Prompt::Search)),
+    ("?", Action::Prompt(Prompt::SearchBack)),
+    ("n", Action::SearchNext),
+    ("N", Action::SearchPrev),
+    ("M", Action::Bookmark),
+    ("Sb", Action::Page("cce://bookmarks")),
+    ("Sh", Action::Page("cce://history")),
+    ("gu", Action::Up),
+    ("gU", Action::Root),
+];
+
+/// A keystroke as the bindings spell it. `None` for keys that are not
+/// commands at all — named keys, which unbound go on to the page — and for
+/// a lone modifier.
+pub fn token(e: &KeyEvent) -> Option<String> {
+    let Key::Character(c) = &e.logical_key else { return None };
+    let ch = c.chars().next()?.to_ascii_lowercase();
+    // Shift is spelled out on a chord, so Ctrl+Shift+D (the chrome's
+    // favorite toggle) is not taken for Ctrl+D.
+    let shift = if e.shift { "S-" } else { "" };
+    if e.ctrl {
+        Some(format!("<C-{shift}{ch}>"))
+    } else if e.alt {
+        Some(format!("<A-{shift}{ch}>"))
+    } else {
+        // The logical key already carries Shift: `G`, not `g`.
+        Some(c.clone())
+    }
+}
+
+/// Whether some binding begins with these exact keys.
+fn is_prefix(keys: &str) -> bool {
+    BINDINGS.iter().any(|(k, _)| k.starts_with(keys))
+}
+
+/// What one keystroke did to the pending sequence.
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub enum Fed {
+    /// Part of a longer binding (or a count); wait for more.
+    Pending,
+    /// A whole binding, with its count if one was typed.
+    Run(Action, Option<u32>),
+    /// Bound to nothing; the sequence is dropped.
+    Unbound,
+}
+
+/// The count and keys typed so far toward a binding.
+#[derive(Debug, Default)]
+pub struct Keys {
+    count: String,
+    keys: String,
+}
+
+impl Keys {
+    pub fn feed(&mut self, token: &str) -> Fed {
+        // A count leads, and a `0` only continues one.
+        let digit = token.len() == 1 && token.as_bytes()[0].is_ascii_digit();
+        if digit && self.keys.is_empty() && (token != "0" || !self.count.is_empty()) {
+            self.count.push_str(token);
+            return Fed::Pending;
+        }
+        let candidate = format!("{}{token}", self.keys);
+        if let Some((_, action)) = BINDINGS.iter().find(|(k, _)| *k == candidate) {
+            let count = self.count.parse().ok();
+            self.clear();
+            return Fed::Run(*action, count);
+        }
+        if is_prefix(&candidate) {
+            self.keys = candidate;
+            return Fed::Pending;
+        }
+        self.clear();
+        Fed::Unbound
+    }
+
+    /// Whether `token` would be used by [`Self::feed`] rather than dropped —
+    /// how a Ctrl chord the bindings do not know goes on to the chrome.
+    pub fn takes(&self, token: &str) -> bool {
+        is_prefix(&format!("{}{token}", self.keys))
+    }
+
+    pub fn clear(&mut self) {
+        self.count.clear();
+        self.keys.clear();
+    }
+
+    pub fn is_empty(&self) -> bool {
+        self.count.is_empty() && self.keys.is_empty()
+    }
+
+    /// What is pending, for the status line.
+    pub fn shown(&self) -> String {
+        format!("{}{}", self.count, self.keys)
+    }
+}
+
+/// The characters hint labels are made of — the home row, qutebrowser's
+/// default.
+pub const HINT_CHARS: &str = "asdfghjkl";
+
+/// Labels for `n` hints: as short as `n` allows, and prefix-free, so typing
+/// a whole label is always unambiguous and never needs a confirming key.
+///
+/// Mixed lengths, the way qutebrowser does it: when `n` does not fill every
+/// label of the longest length, the spare room goes to shorter ones. Each
+/// short label takes one prefix away from the long ones, and with it
+/// `chars - 1` long labels' worth of room.
+pub fn hint_labels(n: usize, chars: &str) -> Vec<String> {
+    let chars: Vec<char> = chars.chars().collect();
+    let k = chars.len();
+    if n == 0 || k < 2 {
+        return Vec::new();
+    }
+    let mut needed = 1;
+    let mut room = k;
+    while room < n {
+        needed += 1;
+        room *= k;
+    }
+    let short = if needed > 1 { (room - n) / (k - 1) } else { 0 };
+    let spell = |mut i: usize, len: usize| -> String {
+        let mut s = vec![chars[0]; len];
+        for slot in s.iter_mut().rev() {
+            *slot = chars[i % k];
+            i /= k;
+        }
+        s.into_iter().collect()
+    };
+    let mut out = Vec::with_capacity(n);
+    for i in 0..short.min(n) {
+        out.push(spell(i, needed - 1));
+    }
+    // The prefixes after the short labels, each extended by every char.
+    let mut i = short * k;
+    while out.len() < n {
+        out.push(spell(i, needed));
+        i += 1;
+    }
+    out
+}
+
+/// One clickable element on the page, as the hint script reports it, in the
+/// chrome's logical pixels (the page's CSS pixels — see `field_rect`).
+#[derive(Debug, Clone, PartialEq)]
+pub struct Hint {
+    /// The visible part of the element.
+    pub rect: (f32, f32, f32, f32),
+    /// Where a click on it lands: the visible part's middle.
+    pub at: (f32, f32),
+    pub href: Option<String>,
+    pub label: String,
+}
+
+/// The hint script's answer, labelled. Anything malformed is dropped.
+pub fn parse_hints(json: &str) -> Vec<Hint> {
+    let Ok(serde_json::Value::Array(items)) = serde_json::from_str(json) else {
+        return Vec::new();
+    };
+    let num = |v: &serde_json::Value, k: &str| v[k].as_f64().map(|f| f as f32);
+    let mut hints: Vec<Hint> = items
+        .iter()
+        .filter_map(|v| {
+            let (x, y, w, h) = (num(v, "x")?, num(v, "y")?, num(v, "w")?, num(v, "h")?);
+            Some(Hint {
+                rect: (x, y, w, h),
+                at: (x + w / 2.0, y + h / 2.0),
+                href: v["href"].as_str().filter(|s| !s.is_empty()).map(str::to_string),
+                label: String::new(),
+            })
+        })
+        .collect();
+    let labels = hint_labels(hints.len(), HINT_CHARS);
+    for (h, l) in hints.iter_mut().zip(labels) {
+        h.label = l;
+    }
+    hints
+}
+
+/// A parsed `:` line.
+#[derive(Debug, Clone, PartialEq)]
+pub enum Cmd {
+    Run(Action),
+    /// Load what was typed: here, in a new tab, or in a background one.
+    Open { target: String, tab: bool, background: bool },
+    Quit,
+    Empty,
+}
+
+/// Parse a `:` line — qutebrowser's names, plus the short forms vim hands
+/// people's fingers (`:o`, `:t`, `:q`, `:b 3`).
+pub fn parse_command(line: &str) -> Result<Cmd, String> {
+    let line = line.trim();
+    let (name, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
+    let rest = rest.trim();
+    // `-t` / `-b` flags on `open`, as qutebrowser spells them.
+    let mut tab = false;
+    let mut background = false;
+    let mut words: Vec<&str> = Vec::new();
+    for w in rest.split_whitespace() {
+        match w {
+            "-t" | "--tab" => tab = true,
+            "-b" | "--bg" => background = true,
+            _ => words.push(w),
+        }
+    }
+    let target = words.join(" ");
+    let open = |tab: bool, background: bool| {
+        if target.is_empty() {
+            Ok(Cmd::Run(Action::Open { tab: tab || background, edit: false }))
+        } else {
+            Ok(Cmd::Open { target: target.clone(), tab, background })
+        }
+    };
+    let number = || {
+        rest.parse::<usize>()
+            .ok()
+            .filter(|n| *n > 0)
+            .ok_or_else(|| format!("{name}: needs a tab number"))
+    };
+    match name {
+        "" => Ok(Cmd::Empty),
+        "o" | "open" => open(tab, background),
+        "t" | "to" | "tabopen" | "tabnew" => open(true, background),
+        "bg" | "backgroundopen" => open(true, true),
+        "q" | "quit" | "qa" | "qall" | "wq" | "wqa" | "x" => Ok(Cmd::Quit),
+        "close" | "tab-close" | "tabclose" | "bd" | "bdelete" => Ok(Cmd::Run(Action::TabClose)),
+        "tab-only" | "only" | "tabonly" => Ok(Cmd::Run(Action::TabOnly)),
+        "undo" => Ok(Cmd::Run(Action::UndoClose)),
+        "tab-next" | "tabnext" | "tabn" | "bn" => Ok(Cmd::Run(Action::TabNext)),
+        "tab-prev" | "tabprev" | "tabp" | "tabN" | "bp" => Ok(Cmd::Run(Action::TabPrev)),
+        "tab-focus" | "buffer" | "b" => number().map(|n| Cmd::Run(Action::TabFocus(n))),
+        "back" => Ok(Cmd::Run(Action::Back)),
+        "forward" => Ok(Cmd::Run(Action::Forward)),
+        "reload" | "r" => Ok(Cmd::Run(Action::Reload)),
+        "yank" if rest.is_empty() || rest == "url" => Ok(Cmd::Run(Action::YankUrl)),
+        "yank" if rest == "title" => Ok(Cmd::Run(Action::YankTitle)),
+        "bookmark-add" | "bookmark" => Ok(Cmd::Run(Action::Bookmark)),
+        "bookmarks" => Ok(Cmd::Run(Action::Page("cce://bookmarks"))),
+        "history" => Ok(Cmd::Run(Action::Page("cce://history"))),
+        "downloads" => Ok(Cmd::Run(Action::Page("cce://downloads"))),
+        "favorites" => Ok(Cmd::Run(Action::Page("cce://favorites"))),
+        "mode-enter" if rest == "insert" => Ok(Cmd::Run(Action::Insert)),
+        "mode-enter" if rest == "passthrough" => Ok(Cmd::Run(Action::Passthrough)),
+        _ => Err(format!("Unknown command: {name}")),
+    }
+}
+
+/// Up one path segment (`gu`): `/a/b/` and `/a/b` both go to `/a/`, and the
+/// query and fragment go with the step. `None` when already at the root.
+pub fn url_up(url: &url::Url) -> Option<url::Url> {
+    let path = url.path().trim_end_matches('/');
+    if path.is_empty() && url.query().is_none() && url.fragment().is_none() {
+        return None;
+    }
+    let mut up = url.clone();
+    up.set_query(None);
+    up.set_fragment(None);
+    let parent = match path.rfind('/') {
+        Some(i) => &path[..=i],
+        None => "/",
+    };
+    up.set_path(parent);
+    Some(up)
+}
+
+/// The site root (`gU`).
+pub fn url_root(url: &url::Url) -> Option<url::Url> {
+    let mut root = url.clone();
+    root.set_path("/");
+    root.set_query(None);
+    root.set_fragment(None);
+    (root != *url).then_some(root)
+}
+
+// ---- the page side ----
+//
+// Engine-agnostic text, but only the WPE host runs it. Everything runs in a
+// private script world (`WORLD`): the DOM is shared, the page's globals are
+// not, so a page can neither see these helpers nor post on the channel the
+// chrome listens to.
+
+/// The world the scripts below run in.
+pub const WORLD: &str = "cce-vi";
+/// The channel the focus watcher reports on.
+pub const CHANNEL: &str = "cceVi";
+
+/// The shared test for "this element takes typing", as a JS prelude.
+const EDITABLE_JS: &str = r#"
+  const noText = ['button', 'submit', 'reset', 'checkbox', 'radio', 'image',
+                  'file', 'hidden', 'range', 'color'];
+  const editable = (el) => {
+    if (!el) return false;
+    if (el.isContentEditable) return true;
+    if (el.disabled || el.readOnly) return false;
+    if (el.tagName === 'TEXTAREA') return true;
+    if (el.tagName === 'INPUT') return !noText.includes((el.type || 'text').toLowerCase());
+    return false;
+  };
+"#;
+
+/// Runs in every frame and reports whether the focused element takes text,
+/// whenever focus moves. Only a document with focus speaks (every tab shares
+/// the channel; only the shown one is focused), and a frame whose focus is
+/// inside a child frame leaves it to the child.
+pub fn focus_watch_js() -> String {
+    format!(
+        r#"(() => {{
+  const h = window.webkit && window.webkit.messageHandlers;
+  if (!h || !h.cceVi) return;
+  {EDITABLE_JS}
+  let timer = 0;
+  // After the event, not in it: mid-move, activeElement is still the old one.
+  const report = () => {{
+    clearTimeout(timer);
+    timer = setTimeout(() => {{
+      if (!document.hasFocus()) return;
+      let el = document.activeElement;
+      while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
+      if (el && (el.tagName === 'IFRAME' || el.tagName === 'FRAME')) return;
+      try {{ h.cceVi.postMessage(JSON.stringify({{ t: 'focus', editable: editable(el) }})); }} catch (e) {{}}
+    }}, 0);
+  }};
+  document.addEventListener('focusin', report, true);
+  document.addEventListener('focusout', report, true);
+}})();"#
+    )
+}
+
+/// Whether what has focus now takes typing, following focus down through
+/// same-origin frames: `"yes"` or `""`. Asked after a click in normal mode,
+/// since clicking a field that already had focus moves no focus, and so
+/// tells the watcher nothing.
+pub fn active_editable_js() -> String {
+    format!(
+        r#"(() => {{
+  {EDITABLE_JS}
+  let el = document.activeElement;
+  for (;;) {{
+    while (el && el.shadowRoot && el.shadowRoot.activeElement) el = el.shadowRoot.activeElement;
+    if (!el || (el.tagName !== 'IFRAME' && el.tagName !== 'FRAME')) break;
+    try {{ const d = el.contentDocument; if (!d) return ''; el = d.activeElement; }} catch (e) {{ return ''; }}
+  }}
+  return editable(el) ? 'yes' : '';
+}})()"#
+    )
+}
+
+/// The clickable elements in view, as JSON: `[{x, y, w, h, href}]` in the
+/// top viewport's CSS pixels, clipped to what is visible. Descends into
+/// same-origin frames; a cross-origin frame's document is out of reach of a
+/// script evaluated in the top frame. `links` keeps only real links (`F`,
+/// `;y`), since only those open anything when middle-clicked or copied.
+///
+/// An element covered at its middle by something unrelated (a modal, a
+/// cookie banner) is left out: a click there would land on the cover.
+pub fn hints_js(links: bool) -> String {
+    let sel = if links {
+        "a[href], area[href]"
+    } else {
+        "a, area, button, select, textarea, summary, \
+         input:not([type=\"hidden\"]), \
+         [contenteditable]:not([contenteditable=\"false\"]), \
+         [onclick], [onmousedown], [role=\"link\"], [role=\"button\"], \
+         [role=\"tab\"], [role=\"checkbox\"], [role=\"switch\"], [role=\"option\"], \
+         [role=\"menuitem\"], [role=\"menuitemcheckbox\"], [role=\"menuitemradio\"], \
+         [role=\"treeitem\"], [aria-haspopup], [ng-click], [data-ng-click], \
+         [tabindex]:not([tabindex=\"-1\"])"
+    };
+    format!(
+        r#"(() => {{
+  const SEL = {sel:?};
+  const out = [];
+  const seen = new Set();
+  const walk = (win, ox, oy, clip) => {{
+    const doc = win.document;
+    let els;
+    try {{ els = doc.querySelectorAll(SEL); }} catch (e) {{ return; }}
+    for (const el of els) {{
+      if (seen.has(el) || el.disabled) continue;
+      const st = win.getComputedStyle(el);
+      if (st.visibility !== 'visible' || st.display === 'none') continue;
+      for (const r of el.getClientRects()) {{
+        const x0 = Math.max(r.left + ox, clip[0]), y0 = Math.max(r.top + oy, clip[1]);
+        const x1 = Math.min(r.right + ox, clip[2]), y1 = Math.min(r.bottom + oy, clip[3]);
+        if (x1 - x0 < 2 || y1 - y0 < 2) continue;
+        const hit = doc.elementFromPoint((x0 + x1) / 2 - ox, (y0 + y1) / 2 - oy);
+        if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) continue;
+        seen.add(el);
+        out.push({{ x: x0, y: y0, w: x1 - x0, h: y1 - y0, href: el.href ? String(el.href) : '' }});
+        break;
+      }}
+    }}
+    for (const f of doc.querySelectorAll('iframe, frame')) {{
+      let inner;
+      try {{ inner = f.contentWindow; if (!inner || !inner.document) continue; }} catch (e) {{ continue; }}
+      const r = f.getBoundingClientRect();
+      const fx = r.left + ox + f.clientLeft, fy = r.top + oy + f.clientTop;
+      const c = [Math.max(fx, clip[0]), Math.max(fy, clip[1]),
+                 Math.min(fx + f.clientWidth, clip[2]), Math.min(fy + f.clientHeight, clip[3])];
+      if (c[2] > c[0] && c[3] > c[1]) walk(inner, fx, fy, c);
+    }}
+  }};
+  walk(window, 0, 0, [0, 0, window.innerWidth, window.innerHeight]);
+  return JSON.stringify(out);
+}})()"#
+    )
+}
+
+/// Scroll the page's main scroller: by a fraction of the viewport, or to a
+/// fraction of its range. The scroller is whatever scrolls under the middle
+/// of the view — many pages scroll an inner element and leave the document
+/// still — falling back to the document's own. `smooth` follows the DE's
+/// animations switch.
+pub fn scroll_js(by: Option<f32>, to: Option<f32>, smooth: bool) -> String {
+    let behavior = if smooth { "smooth" } else { "instant" };
+    let by = by.map_or("null".to_string(), |f| f.to_string());
+    let to = to.map_or("null".to_string(), |f| f.to_string());
+    format!(
+        r#"(() => {{
+  const by = {by}, to = {to};
+  const scrolls = (el) => {{
+    const s = getComputedStyle(el);
+    return el.scrollHeight > el.clientHeight + 1 && /(auto|scroll|overlay)/.test(s.overflowY);
+  }};
+  const root = document.scrollingElement || document.documentElement;
+  let el = document.elementFromPoint(innerWidth / 2, innerHeight / 2);
+  while (el && el !== document.body && el !== document.documentElement && !scrolls(el)) el = el.parentElement;
+  if (!el || el === document.body || el === document.documentElement) el = root;
+  const view = el === root ? innerHeight : el.clientHeight;
+  const top = by !== null ? el.scrollTop + by * view
+                          : to * (el.scrollHeight - view);
+  el.scrollTo({{ top, behavior: '{behavior}' }});
+}})()"#
+    )
+}
+
+/// Focus the first visible text field (`gi`): `"yes"`, or `""` for none.
+pub fn focus_input_js() -> String {
+    format!(
+        r#"(() => {{
+  {EDITABLE_JS}
+  for (const el of document.querySelectorAll('input, textarea, [contenteditable]')) {{
+    if (!editable(el)) continue;
+    const r = el.getBoundingClientRect();
+    if (r.width < 2 || r.height < 2 || r.bottom < 0 || r.top > innerHeight) continue;
+    if (getComputedStyle(el).visibility !== 'visible') continue;
+    el.focus();
+    return 'yes';
+  }}
+  return '';
+}})()"#
+    )
+}
+
+/// A focus watcher report, or `None` for anything else on the channel.
+pub fn parse_focus(json: &str) -> Option<bool> {
+    let v: serde_json::Value = serde_json::from_str(json).ok()?;
+    (v["t"] == "focus").then(|| v["editable"].as_bool()).flatten()
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn feed_all(keys: &mut Keys, seq: &[&str]) -> Fed {
+        let mut last = Fed::Unbound;
+        for t in seq {
+            last = keys.feed(t);
+        }
+        last
+    }
+
+    #[test]
+    fn sequences_counts_and_dead_ends() {
+        let mut k = Keys::default();
+        assert_eq!(k.feed("j"), Fed::Run(Action::ScrollLines(0.0, 1.0), None));
+        assert_eq!(k.feed("g"), Fed::Pending);
+        assert_eq!(k.shown(), "g");
+        assert_eq!(k.feed("g"), Fed::Run(Action::Top, None));
+        assert_eq!(feed_all(&mut k, &["1", "0", "j"]), Fed::Run(Action::ScrollLines(0.0, 1.0), Some(10)));
+        // A leading 0 is not a count, and binds to nothing.
+        assert_eq!(k.feed("0"), Fed::Unbound);
+        assert_eq!(feed_all(&mut k, &["3", "g", "t"]), Fed::Run(Action::TabGoto, Some(3)));
+        assert_eq!(feed_all(&mut k, &["g", "z"]), Fed::Unbound);
+        assert!(k.is_empty());
+        assert_eq!(k.feed("<C-d>"), Fed::Run(Action::ScrollPage(0.5), None));
+        assert!(!k.takes("<C-t>"));
+        assert!(k.takes("<C-u>"));
+    }
+
+    #[test]
+    fn every_binding_is_reachable() {
+        // A binding that is a prefix of another would shadow it for good.
+        for (a, _) in BINDINGS {
+            for (b, _) in BINDINGS {
+                assert!(a == b || !b.starts_with(a), "{a:?} shadows {b:?}");
+            }
+        }
+    }
+
+    #[test]
+    fn hint_labels_are_short_unique_and_prefix_free() {
+        for n in [0, 1, 5, 9, 10, 17, 80, 81, 82, 700] {
+            let labels = hint_labels(n, HINT_CHARS);
+            assert_eq!(labels.len(), n);
+            for a in &labels {
+                for b in &labels {
+                    assert!(a == b || !b.starts_with(a.as_str()), "{a} prefixes {b} (n={n})");
+                }
+            }
+            let max = labels.iter().map(String::len).max().unwrap_or(0);
+            let need = (1..).find(|l| 9usize.pow(*l as u32) >= n.max(1)).unwrap();
+            assert_eq!(max, if n == 0 { 0 } else { need }, "n={n}");
+        }
+        // Ten hints: eight single letters, the last letter's two-letter run.
+        assert_eq!(hint_labels(10, HINT_CHARS)[..9], ["a", "s", "d", "f", "g", "h", "j", "k", "la"]);
+    }
+
+    #[test]
+    fn hints_parse_and_junk_is_dropped() {
+        let h = parse_hints(r#"[{"x":10,"y":20,"w":30,"h":10,"href":"https://a.example/"},{"x":"no"},{"x":0,"y":0,"w":4,"h":4,"href":""}]"#);
+        assert_eq!(h.len(), 2);
+        assert_eq!(h[0].at, (25.0, 25.0));
+        assert_eq!(h[0].href.as_deref(), Some("https://a.example/"));
+        assert_eq!(h[1].href, None);
+        assert_eq!((h[0].label.as_str(), h[1].label.as_str()), ("a", "s"));
+        assert!(parse_hints("not json").is_empty());
+    }
+
+    #[test]
+    fn commands_parse() {
+        assert_eq!(parse_command("o example.com"), Ok(Cmd::Open { target: "example.com".into(), tab: false, background: false }));
+        assert_eq!(parse_command("open -t rust book"), Ok(Cmd::Open { target: "rust book".into(), tab: true, background: false }));
+        assert_eq!(parse_command(" t "), Ok(Cmd::Run(Action::Open { tab: true, edit: false })));
+        assert_eq!(parse_command("b 3"), Ok(Cmd::Run(Action::TabFocus(3))));
+        assert!(parse_command("b x").is_err());
+        assert_eq!(parse_command("q"), Ok(Cmd::Quit));
+        assert_eq!(parse_command(""), Ok(Cmd::Empty));
+        assert_eq!(parse_command("frobnicate"), Err("Unknown command: frobnicate".into()));
+    }
+
+    #[test]
+    fn up_and_root() {
+        let u = |s: &str| url::Url::parse(s).unwrap();
+        assert_eq!(url_up(&u("https://a.example/x/y/?q=1")), Some(u("https://a.example/x/")));
+        assert_eq!(url_up(&u("https://a.example/x/y")), Some(u("https://a.example/x/")));
+        assert_eq!(url_up(&u("https://a.example/x")), Some(u("https://a.example/")));
+        assert_eq!(url_up(&u("https://a.example/")), None);
+        assert_eq!(url_root(&u("https://a.example/x/y#z")), Some(u("https://a.example/")));
+        assert_eq!(url_root(&u("https://a.example/")), None);
+    }
+
+    #[test]
+    fn focus_reports_parse() {
+        assert_eq!(parse_focus(r#"{"t":"focus","editable":true}"#), Some(true));
+        assert_eq!(parse_focus(r#"{"t":"focus","editable":false}"#), Some(false));
+        assert_eq!(parse_focus(r#"{"t":"other"}"#), None);
+    }
+}
diff --git a/src/webview.rs b/src/webview.rs
index 24acad2..c930825 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -806,6 +806,28 @@ impl ServoHost {
         self.mouse_button(b, pressed, x_px, y_px);
     }
 
+    // ---- vi mode ----
+    //
+    // The WPE host's surface, as no-ops: Servo has no script worlds to run
+    // the focus watcher or the hint script in, and no find controller. Keys
+    // still bind; hints, `gi`, half-page scrolls and search do nothing.
+
+    pub fn set_vi_enabled(&mut self, _on: bool) {}
+    pub fn take_vi_focus(&self) -> Option<bool> {
+        None
+    }
+    pub fn vi_eval(&self, _script: &str, _tag: u32) {}
+    pub fn take_vi_result(&self) -> Option<(u32, String)> {
+        None
+    }
+    pub fn find(&self, _text: &str, _backwards: bool) {}
+    pub fn find_next(&self) {}
+    pub fn find_prev(&self) {}
+    pub fn find_finish(&self) {}
+    pub fn take_find_result(&self) -> Option<u32> {
+        None
+    }
+
     /// A cce-ui key event, translated and forwarded. Same signature as the
     /// WPE backend's `key`.
     pub fn key_ui(&self, event: &cce_ui::widget::KeyEvent) {
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index 9479282..cf616e1 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -374,6 +374,9 @@ pub struct WebKitHost {
     held_buttons: Cell<WPEModifiers::Type>,
     /// A hang being watched to see whether it is a deadlock.
     deadlock_watch: Option<DeadlockWatch>,
+    /// The injected vi focus watcher; `None` while vi mode is off, which is
+    /// also when no page carries it.
+    vi_watcher: Option<*mut WebKitUserScript>,
 }
 
 unsafe fn cstr(s: &str) -> CString {
@@ -530,11 +533,13 @@ impl WebKitHost {
                 window_focused: false,
                 held_buttons: Cell::new(0),
                 deadlock_watch: None,
+                vi_watcher: None,
             };
             // The account watcher's channel, in its own script world. Both
             // halves are registered here, once, on the shared content
             // manager every tab is built against.
             host.register_account_channel();
+            host.register_vi_channel();
             host.open_tab(url);
             host
         }
@@ -678,6 +683,146 @@ impl WebKitHost {
         }
     }
 
+    // ---- vi mode ----
+
+    /// Listen for the vi focus watcher, in its own private world — the same
+    /// guarantee as the account channel: page script cannot post on it.
+    fn register_vi_channel(&self) {
+        unsafe {
+            let name = cstr(crate::vi::CHANNEL);
+            let world = cstr(crate::vi::WORLD);
+            if webkit_user_content_manager_register_script_message_handler(
+                self.ucm,
+                name.as_ptr(),
+                world.as_ptr(),
+            ) == 0
+            {
+                log::warn!("could not register the vi message channel");
+                return;
+            }
+            let signal = cstr(&format!("script-message-received::{}", crate::vi::CHANNEL));
+            g_signal_connect_data(
+                self.ucm as *mut _,
+                signal.as_ptr(),
+                Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(
+                    on_vi_message as *const () as usize,
+                )),
+                Rc::into_raw(self.prompts.clone()) as gpointer,
+                Some(drop_prompts_ref),
+                0,
+            );
+        }
+    }
+
+    /// Install or remove the vi focus watcher. Off, pages carry nothing.
+    pub fn set_vi_enabled(&mut self, on: bool) {
+        unsafe {
+            match (on, self.vi_watcher.take()) {
+                (true, None) => {
+                    let source = cstr(&crate::vi::focus_watch_js());
+                    let world = cstr(crate::vi::WORLD);
+                    // Every frame: the field being clicked into is often in one.
+                    let script = webkit_user_script_new_for_world(
+                        source.as_ptr(),
+                        WebKitUserContentInjectedFrames::WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
+                        WebKitUserScriptInjectionTime::WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
+                        world.as_ptr(),
+                        std::ptr::null(),
+                        std::ptr::null(),
+                    );
+                    webkit_user_content_manager_add_script(self.ucm, script);
+                    self.vi_watcher = Some(script);
+                }
+                (false, Some(script)) => {
+                    webkit_user_content_manager_remove_script(self.ucm, script);
+                    webkit_user_script_unref(script);
+                }
+                (true, Some(script)) => self.vi_watcher = Some(script),
+                (false, None) => {}
+            }
+        }
+        self.prompts.borrow_mut().vi_focus = None;
+    }
+
+    /// Whether the focused element takes text, if focus moved since last asked.
+    pub fn take_vi_focus(&self) -> Option<bool> {
+        self.prompts.borrow_mut().vi_focus.take()
+    }
+
+    /// Run `script` in the active tab's top frame, in the vi world, and queue
+    /// its result as a string under `tag` for [`Self::take_vi_result`]. A
+    /// failed script answers with an empty string, so a caller waiting on
+    /// it is never left waiting.
+    pub fn vi_eval(&self, script: &str, tag: u32) {
+        let Some(t) = self.tabs.get(self.active) else { return };
+        let ctx = Box::new((self.prompts.clone(), tag));
+        unsafe {
+            let (script, world) = (cstr(script), cstr(crate::vi::WORLD));
+            webkit_web_view_evaluate_javascript(
+                t.webview,
+                script.as_ptr(),
+                -1,
+                world.as_ptr(),
+                std::ptr::null(),
+                std::ptr::null_mut(),
+                Some(on_vi_eval),
+                Box::into_raw(ctx) as gpointer,
+            );
+        }
+    }
+
+    pub fn take_vi_result(&self) -> Option<(u32, String)> {
+        self.prompts.borrow_mut().vi_results.pop_front()
+    }
+
+    fn find_controller(&self) -> Option<*mut WebKitFindController> {
+        let t = self.tabs.get(self.active)?;
+        Some(unsafe { webkit_web_view_get_find_controller(t.webview) })
+    }
+
+    /// Find `text` in the active page, highlighting every match and
+    /// scrolling to the first. Smart case, as qutebrowser does it: case
+    /// matters only when the text has a capital in it.
+    pub fn find(&self, text: &str, backwards: bool) {
+        let Some(fc) = self.find_controller() else { return };
+        use WebKitFindOptions::*;
+        let mut opts = WEBKIT_FIND_OPTIONS_WRAP_AROUND;
+        if !text.chars().any(char::is_uppercase) {
+            opts |= WEBKIT_FIND_OPTIONS_CASE_INSENSITIVE;
+        }
+        if backwards {
+            opts |= WEBKIT_FIND_OPTIONS_BACKWARDS;
+        }
+        let c = unsafe { cstr(text) };
+        unsafe { webkit_find_controller_search(fc, c.as_ptr(), opts, 1000) };
+    }
+
+    /// The next match, in the search's own direction.
+    pub fn find_next(&self) {
+        if let Some(fc) = self.find_controller() {
+            unsafe { webkit_find_controller_search_next(fc) }
+        }
+    }
+
+    pub fn find_prev(&self) {
+        if let Some(fc) = self.find_controller() {
+            unsafe { webkit_find_controller_search_previous(fc) }
+        }
+    }
+
+    /// Drop the search and its highlights.
+    pub fn find_finish(&self) {
+        if let Some(fc) = self.find_controller() {
+            unsafe { webkit_find_controller_search_finish(fc) }
+        }
+        self.prompts.borrow_mut().find_result = None;
+    }
+
+    /// How the last search went: the match count, 0 for none.
+    pub fn take_find_result(&self) -> Option<u32> {
+        self.prompts.borrow_mut().find_result.take()
+    }
+
     fn build_webview(&self, url: &Url, state: &Rc<TabState>) -> (*mut WebKitWebView, *mut WPEView) {
         unsafe {
             let (p_display, p_ucm, p_session) = (
@@ -750,6 +895,22 @@ impl WebKitHost {
                 on_decide_policy as *const () as usize,
                 &self.prompts,
             );
+            // Find-in-page answers on the controller, not the view.
+            let fc = webkit_web_view_get_find_controller(wv);
+            for (signal, cb) in [
+                ("found-text", on_found_text as *const () as usize),
+                ("failed-to-find-text", on_failed_to_find as *const () as usize),
+            ] {
+                let name = cstr(signal);
+                g_signal_connect_data(
+                    fc as *mut _,
+                    name.as_ptr(),
+                    Some(std::mem::transmute::<usize, unsafe extern "C" fn()>(cb)),
+                    Rc::into_raw(self.prompts.clone()) as gpointer,
+                    Some(drop_prompts_ref),
+                    0,
+                );
+            }
             let (lw, lh) = self.logical_size();
             wpe_view_resized(view, lw, lh);
             wpe_view_set_visible(view, 1);
@@ -2022,6 +2183,13 @@ pub(super) struct Prompts {
     /// every one is answered exactly once: with a credential, or with
     /// nothing when it is displaced or dropped.
     fill_asks: std::collections::VecDeque<(String, *mut WebKitScriptMessageReply)>,
+    /// The vi focus watcher's latest word: whether the focused element takes
+    /// text. Only the newest matters, so a slot, not a queue.
+    vi_focus: Option<bool>,
+    /// Answers to [`WebKitHost::vi_eval`], by the caller's tag.
+    vi_results: std::collections::VecDeque<(u32, String)>,
+    /// The last find-in-page outcome: the match count, 0 for none.
+    find_result: Option<u32>,
 }
 
 /// What was under the pointer when the page asked for a context menu, read
@@ -2094,6 +2262,51 @@ unsafe fn connect_raw(
     );
 }
 
+/// A report from the vi focus watcher. Anything else is dropped.
+unsafe extern "C" fn on_vi_message(
+    _ucm: *mut WebKitUserContentManager,
+    value: *mut JSCValue,
+    data: gpointer,
+) {
+    let prompts = &*(data as *const RefCell<Prompts>);
+    let raw = jsc_value_to_string(value);
+    let Some(json) = from_cstr(raw) else { return };
+    g_free(raw as *mut _);
+    if let Some(editable) = crate::vi::parse_focus(&json) {
+        prompts.borrow_mut().vi_focus = Some(editable);
+    }
+}
+
+/// A [`WebKitHost::vi_eval`] script finished: queue what it returned.
+unsafe extern "C" fn on_vi_eval(source: *mut GObject, res: *mut GAsyncResult, data: gpointer) {
+    let ctx = Box::from_raw(data as *mut (Rc<RefCell<Prompts>>, u32));
+    let mut err: *mut GError = std::ptr::null_mut();
+    let v = webkit_web_view_evaluate_javascript_finish(source as *mut WebKitWebView, res, &mut err);
+    let mut text = String::new();
+    if !v.is_null() {
+        if jsc_value_is_string(v) != 0 {
+            let raw = jsc_value_to_string(v);
+            text = from_cstr(raw).unwrap_or_default();
+            g_free(raw as *mut _);
+        }
+        g_object_unref(v as *mut _);
+    }
+    if !err.is_null() {
+        g_error_free(err);
+    }
+    ctx.0.borrow_mut().vi_results.push_back((ctx.1, text));
+}
+
+unsafe extern "C" fn on_found_text(_fc: *mut WebKitFindController, count: guint, data: gpointer) {
+    let prompts = &*(data as *const RefCell<Prompts>);
+    prompts.borrow_mut().find_result = Some(count);
+}
+
+unsafe extern "C" fn on_failed_to_find(_fc: *mut WebKitFindController, data: gpointer) {
+    let prompts = &*(data as *const RefCell<Prompts>);
+    prompts.borrow_mut().find_result = Some(0);
+}
+
 /// A message from the account watcher. Anything that does not parse as one of
 /// its events is dropped without comment — this is a channel the chrome acts
 /// on, so it accepts only what it recognizes.