Keyboard-driven browser control for Safari and Chrome.
  • TypeScript 97.2%
  • CSS 1.4%
  • Swift 0.8%
  • JavaScript 0.3%
  • HTML 0.3%
Find a file
小明 c29f1e2f11
Fix hints under strict CSP and tighten the config model (#24)
* fix(hints): style the UI under a strict CSP and wait for a full label

Pages with a style-src CSP dropped the injected <style>, leaving hints unstyled. Constructable stylesheets are not blocked. A label prefix also fired as soon as it was the only match, so a truncated label set triggered the wrong element.

* feat(build): add opt-in Developer ID signing and a latency benchmark

* refactor(config): limit bindings to normal mode and fix held-scroll catch-up

* chore: prepare 0.0.17 release
2026-10-27 16:23:42 +08:00
.github/workflows build: attach prebuilt Safari and Chrome builds to each release 2026-10-07 09:47:21 +08:00
assets feat: add icons, gg/G motions, shorter hint labels and pointer-aware clicks 2026-09-08 09:26:03 +08:00
docs Fix hints under strict CSP and tighten the config model (#24) 2026-10-27 16:23:42 +08:00
scripts Fix hints under strict CSP and tighten the config model (#24) 2026-10-27 16:23:42 +08:00
src Fix hints under strict CSP and tighten the config model (#24) 2026-10-27 16:23:42 +08:00
test Fix hints under strict CSP and tighten the config model (#24) 2026-10-27 16:23:42 +08:00
xcode/vimplug Fix hints under strict CSP and tighten the config model (#24) 2026-10-27 16:23:42 +08:00
.editorconfig chore: scaffold project 2026-09-07 19:59:06 +08:00
.gitignore test: add invariant regressions against built artifact 2026-09-07 20:18:48 +08:00
LICENSE chore: prepare 0.0.2 release 2026-09-08 09:46:18 +08:00
options.html feat: add vite build with size budget 2026-09-07 20:00:19 +08:00
package.json Fix hints under strict CSP and tighten the config model (#24) 2026-10-27 16:23:42 +08:00
playwright.config.ts Fix hints under strict CSP and tighten the config model (#24) 2026-10-27 16:23:42 +08:00
pnpm-lock.yaml test: add invariant regressions against built artifact 2026-09-07 20:18:48 +08:00
README.md Fix hints under strict CSP and tighten the config model (#24) 2026-10-27 16:23:42 +08:00
tsconfig.json test: add invariant regressions against built artifact 2026-09-07 20:18:48 +08:00
vite.config.ts feat: add icons, gg/G motions, shorter hint labels and pointer-aware clicks 2026-09-08 09:26:03 +08:00

vimplug

Keyboard-driven browser control for Safari and Chrome.

Scroll, follow links, switch tabs, search a page and select text without leaving the home row. Normal-mode actions are rebindable, and the configuration is one block of text you can read, version and carry between machines.

The settings page, listing each action beside the key that runs it

Why this exists

Vimium is excellent and does not run on Safari. Vimkey did, but it is closed source and no longer maintained: its issue tracker holds years of unanswered reports, several of them about the extension eating keystrokes meant for the page.

vimplug is a rewrite, not a fork — the original source was never published. It reaches key parity with vimkey, adds what Vimium users expect, and treats the reports vimkey never answered as the specification for what must not go wrong.

Install

Every release carries a build for Safari and one for Chrome and Edge. Neither is signed and neither updates itself: a signed Safari app needs an Apple Developer ID at $99 a year (#10), and vimplug is not in a browser store yet. To update, install the new release the same way.

Safari. Needs Safari 16.4+ (macOS 13.3+).

curl -L https://github.com/m1ngsama/vimplug/releases/latest/download/vimplug-safari.zip -o /tmp/vimplug-safari.zip
rm -rf /Applications/vimplug.app
ditto -x -k /tmp/vimplug-safari.zip /Applications
open /Applications/vimplug.app

Opening the app once is what puts vimplug in Safari's extension list. If you download the zip with a browser instead, macOS quarantines it and refuses to open the app until you run xattr -dr com.apple.quarantine /Applications/vimplug.app.

Then, in Safari:

  1. Settings > Advanced > check "Show features for web developers".
  2. Settings > Developer > check "Allow unsigned extensions". This resets every time Safari quits, and an unsigned build needs it ticked again at the next launch. Building the app with your own Apple Development certificate removes this step on your Mac.
  3. Settings > Extensions > enable vimplug, and set site access to "Always Allow on Every Website". Without site access the extension silently does nothing, which looks exactly like a broken build.

docs/SAFARI.md covers the rest, including what Safari withholds and what signing would change.

Chrome or Edge. Download vimplug-chrome.zip and unzip it into a folder you will keep. Open chrome://extensions, turn on developer mode, click "Load unpacked" and pick that folder.

From source. Needs Node 26 and pnpm; the Safari app also needs Xcode.

pnpm install
pnpm build:app   # dist/vimplug.app
pnpm build       # dist/chrome

Keys

Default normal-mode bindings. Actions are rebindable; ? shows the bindings actually in force. Controls within hint, visual, insert and command modes are fixed.

Keys Action
j k h l Scroll; hold to keep going
d u Scroll half a page
gg G Top, bottom
50% A percentage of the way down, given as a count
0 $ Left edge, right edge
f Hint clickable elements
F Hint, opening links in a new tab
<A-f> Hint, opening several links in background tabs
gh Hint, hovering instead of clicking
o O Open a URL or search here, in a new tab
ge gE Edit the page URL and open it here, in a new tab
T Search open tabs
t New tab
b B Search bookmarks, opening here, in a new tab
yf Copy a link URL by hint
J K Previous, next tab; also gT gt
^ Back to the tab you were just on
< > Move the tab left, right
W Move the tab to a new window
<A-p> <A-m> Pin, mute the tab
zi zo z0 Zoom in, out, reset
g0 g$ First, last tab
H L Back, forward
r R Reload, reload bypassing the cache
x X Close, reopen tab
yt Duplicate tab
gs View the page source
yy yl ym Copy the page URL, title, Markdown link
p P Open the clipboard URL here, in a new tab
gi Focus the first text field
gu gU Up one URL level, site root
[[ ]] Previous page, next page
gf gF Focus an iframe by hint, return focus to the page
i Suspend vimplug until Esc
- = m Media volume down, up, mute
/ n N Find in page, next, previous
* # Find the selected text, forward, backward
M ` Set a mark, jump to it; `` returns to where the last jump started
v Visual mode
: Run any action by name
. Repeat the last action
? Keyboard help
<Esc> Leave the current mode

Hint and visual mode also leave on Ctrl, for keyboards with Caps Lock remapped to Control.

m keeps vimkey's mute, so marks use M and ` rather than vim's m.

Most keys take a count: 5j scrolls five steps, 3K moves three tabs on, 2gu climbs two URL levels. A bare 0 stays a binding of its own, so a count cannot start with it.

Some actions have no default key and run from : until you map them: scrolling a full page, closing the other tabs or those to the left or right, and sending the next key to the page.

A lowercase mark belongs to its page. An uppercase mark is global: jumping to it switches to the tab showing that page, or reopens it.

T lists tabs most recently used first. ]] and [[ follow the page's own next and previous links, and fall back to a page= or /page/ number in the URL.

In the find box, Up and Down recall earlier queries, and a query containing \r is a regular expression: v\d+\r finds v12. n and N find the last query again after a reload.

o searches your open tabs, bookmarks and history alongside plain URLs and searches. What you typed is always one of the rows, so Enter never has to guess between navigating and searching.

Scrolling first tries a scrollable ancestor of the focused element, then the last clicked element, then the viewport center, before falling back to the document. This lets j work in apps whose page does not itself scroll.

Safari does not grant extensions access to history, bookmarks or closed tabs, so there o searches open tabs only and b finds nothing. gs is Chrome only, and W on Safari reopens the page in the new window instead of moving the tab. An action the browser cannot run says so. X reopens the last 25 tabs closed since Safari started, at their URL but without their back history. Everything else behaves the same on both browsers.

While hints are showing, characters that are not a hint label narrow the hints by link text. The last candidate left fires once you pause, or at once on Enter, and Backspace takes back a character. Typing a label in capitals, with Shift held, opens the link in a background tab.

Hints cover links inside same-origin iframes and open shadow roots, and a checkbox or radio button the page hides is hinted through its label. gh sends hover events, which opens menus built with script but not ones that rely on CSS :hover.

In visual mode h j k l w b 0 $ extend the selection and take a count, V selects the line, o moves the cursor to the other end, ( ) { } move by sentence and paragraph, y copies, p and P open the selection here or in a new tab, and <Esc> cancels.

Clicking the toolbar button turns vimplug off for the site you are on, and on again. The button reads off where it is disabled.

/ searches as you type and counts the matches. <CR> commits the search: the panel closes, the current match is selected so v starts from it, and n and N step through the rest. <Esc> cancels instead, putting the page back where it was before the search moved it. Once a search is committed, <Esc> in normal mode clears the highlights, the way :noh does. Queries are smartcase: /error ignores case, /Error does not.

Keys pressed while an input method is composing belong to the input method, never to vimplug, so typing a word in Chinese, Japanese or Korean cannot fire a command.

<Esc> in a text field leaves the field, so the next key is a command again. A field with its popup open, and a code editor, keep <Esc> for themselves.

Configuration

Safari. Settings > Extensions > vimplug > Settings. Chrome. Right-click the toolbar button > Options, or find vimplug on chrome://extensions.

The settings page has two views over one configuration. Keys lists every action with its key; click a key and press the one you want. Text is the same configuration as text. The Keys view edits that text in place through the parser's source spans, so your comments and layout survive a rebind.

map <C-d> scrollHalfDown

map <C-[> escape
map <Esc>  escape

site youtube.com {
  unmap j
  unmap k
}

site mail.google.com {
  disable
}

set hintChars = "fjdkslaghrueiwo"
set keyMatching = physical

keyMatching = physical (the default) matches the physical key position, so bindings survive a Chinese IME or a Dvorak layout. Set it to logical to match the produced character instead.

Where two site blocks match one host, the more specific pattern wins; ties go to whichever appears last.

Colours

set theme = gruvbox-dark recolours everything vimplug draws. Built in: default-dark, default-light, gruvbox-dark, gruvbox-light, nord, catppuccin-mocha, catppuccin-latte, tokyo-night, solarized-dark, solarized-light, and system, which picks a default from your desktop setting.

Light and dark are separate schemes rather than a system setting. Hints are drawn on other people's pages and have to stand out against the page, not against your desktop.

Any single colour can be overridden on top of a scheme, and a scheme can be set for one site alone:

set theme = nord
set themeAccent = "#ff5f5f"

site github.com {
  set theme = gruvbox-light
}

The Text view exports the configuration to a file and reads one back, which is how it moves between machines. Stored configurations carry a version and are migrated on read, so an older file keeps working.

Engine invariants

Four properties hold by construction and are covered by regression tests. Changing any of them is a behaviour change, not a refactor.

  1. Insert mode listens for <Esc> and nothing else. The mode machine is the only thing that attaches the full listener, so every other key typed in a text field goes straight to the page.
  2. Modifier combos pass through unless explicitly bound. Cmd+K, Cmd+I and Cmd+B stay with the page.
  3. A disabled host never activates the engine. Chrome excludes it from injection outright; Safari loads a fail-closed bootstrap that confirms the host first.
  4. One engine per frame. Saving settings re-registers content scripts and can deliver the engine to a loading page twice, which would double every keystroke.

Hints and overlays render inside a shadow root and find highlights use the CSS Custom Highlight API, so nothing the engine draws touches the page's own markup or styles.

Development

pnpm check         # tsc --noEmit
pnpm test          # unit tests, no build step
pnpm build         # produces dist/chrome and dist/safari
pnpm build:app     # builds dist/safari, then the Safari app at dist/vimplug.app
pnpm test:engine   # mode semantics against dist/safari, in WebKit and Chromium
pnpm test:e2e      # builds, then drives the artifact in a real Chromium
pnpm sweep         # runs the engine over real sites; needs the network
pnpm benchmark     # frame-observed latency in Playwright WebKit; needs build:safari; JSON in OS temp
pnpm icons         # regenerates the icon set

pnpm benchmark --suite held --samples 5 --warmup 1 compares held j/k on idle pages and with three 80 ms main-thread blocks after an 800 ms cruise warmup. The default --suite latency keeps the first-scroll, hint-host and find-query metrics. Both use the existing dist/safari/content.js without rebuilding; --engine chromium selects Chromium. JSON records raw callback observations and environment, revision, artifact and fixture provenance in an OS temporary directory, or a new file given by --output /tmp/result.json. Held steps and callback gaps do not measure actual paint or real Safari. See --help.

Unit tests run on Node's built-in test runner straight from TypeScript, so there is no test framework and no compile step. Every browser test exercises a build artifact, never a dev server.

Safari cannot be driven by Playwright, and safaridriver cannot load extensions at all. But Safari is WebKit, so pnpm test:engine injects dist/safari/content.js into Playwright's WebKit behind a small chrome.* shim. That covers everything the engine does with the DOM, focus, event order and input methods. It cannot cover the extension plumbing — registerContentScripts, the disabled-site bootstrap, the background worker, the toolbar button — which stays manual on Safari and automated on Chrome via pnpm test:e2e.

pnpm sweep runs the engine over pages nobody here wrote: GitHub, MDN, Wikipedia, Hacker News. Fixtures cover what we thought of, and one pass over real sites has found defects the whole fixture suite missed — site shortcuts firing on what is typed into our own panel, matches running together across unrelated elements, matches in hidden menus. It is not a CI job: it needs the network, and someone else's redesign would turn it red for no reason. Run it before a release.

content.js must stay under 30KB gzip. pnpm build fails if it does not.

License

GPL-3.0-or-later. See LICENSE.

vimkey is closed source, which is the reason this project exists; a copyleft license keeps any fork of vimplug from ending up the same way.