- TypeScript 97.2%
- CSS 1.4%
- Swift 0.8%
- JavaScript 0.3%
- HTML 0.3%
* 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 |
||
|---|---|---|
| .github/workflows | ||
| assets | ||
| docs | ||
| scripts | ||
| src | ||
| test | ||
| xcode/vimplug | ||
| .editorconfig | ||
| .gitignore | ||
| LICENSE | ||
| options.html | ||
| package.json | ||
| playwright.config.ts | ||
| pnpm-lock.yaml | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
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.
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:
- Settings > Advanced > check "Show features for web developers".
- 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.
- 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.
- 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. - Modifier combos pass through unless explicitly bound. Cmd+K, Cmd+I and Cmd+B stay with the page.
- A disabled host never activates the engine. Chrome excludes it from injection outright; Safari loads a fail-closed bootstrap that confirms the host first.
- 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.