A browser Vim tutor, inspired by vim-hero's short-lesson format — small lessons of 1–4 keys, each with one practice
challenge — that goes deeper: registers, macros, ex commands, regex, windows, quickfix, and the
Neovim plugins people actually run. The lesson plan is in CURRICULUM.md.
npm install
npm run dev # http://localhost:5317
npm test # engine + every lesson's reference solution
npm run build # typecheck + production bundle in dist/src/vim/— a Vim engine written from scratch in TypeScript (modes, operators, text objects, registers, macros, dot-repeat, undo, Vim regex, ex commands, splits, quickfix, folds) plus plugin emulations insrc/vim/plugins/.src/lessons/— lesson content (sections/*.tsx), the challenge runtime, and the validator. Authoring guide:docs/LESSONS.md; plugin API:docs/PLUGINS.md.src/components/— the UI.
npm run check:nvim (needs nvim on PATH) replays lessons and ad-hoc cases in nvim --clean --headless:
ok N bad M— every plain-text round's reference solution must leave the goal text in Neovim. This is the gate: the script fails whenbadis not 0.extended: ok N bad M— the engine's own result for each round against Neovim's: cursor (Neovim defaults,nostartofline), the unnamed register's text and type, any register the goal names, and the text/cursor after follow-up probes (x, pluspwhen the register is non-empty) fed after the solution.cases: ok N bad M— the same engine-vs-Neovim comparison forscripts/nvimcheck/cases.json, a list of{id, text, keys, cursor?, name?, options?}repros that are not lesson rounds. Append to it freely.
All three sections gate the run (strict by default); NVIMCHECK_STRICT=0 npm run check:nvim relaxes it to the text gate only.
Tests check the text; node scripts/look.mjs checks the screen. It plays a lesson in headless
Chromium (starting vite on 5317 if it is not up) and writes screenshots of the practice panel
plus an index.md to look/<lesson>/ (gitignored), so a person or an agent can read them:
node scripts/look.mjs visual-lines: each round's opening screen, each step, the live hints, and the results, playing the lesson's own solutions.--keys "|||jyy3j2dd\"0p|": play your own keys per round (Vim notation,|between rounds, empty = the solution), e.g. to see what the coach says about a route that skips the lesson's key. Fewer entries than rounds stops after the last one with a shot of the screen as it stands (--keys "f=cl=": round 1 half typed).--rounds 2,5: shots for those rounds only.--out dir: somewhere else.--sweep: every round's opening screen in every lesson, tolook/sweep/.
Keys go through the editor's own keydown handler. In dev builds window.__vimchi exposes the
lessons, parseKeys and the live session for it. When the app carries the cursor over from the
last round, the harness puts it back at the round's start (and scroll) before playing a solution,
since solutions start there; your own --keys are played from wherever the cursor was carried.
Browsers reserve Ctrl-W/N/T/Q. The practice editor maps Alt-W/N/T/Q to them, and its "full screen" button uses the Keyboard Lock API to capture the real keys (Chromium).
The Go backend in server/ handles GitHub sign-in, syncs lesson runs to SQLite,
and serves the built SPA. It needs Go 1.26+ and no cgo.
cd server
cp .env.example .env # fill in GITHUB_CLIENT_ID / GITHUB_CLIENT_SECRET
set -a; . ./.env; set +a
go run ./cmd/vimchi- Dev: run
npm run devalongside it; Vite proxies/apiand/authto:8080. Set the GitHub OAuth app's callback to$VIMCHI_BASE_URL/auth/github/callback(the Vite origin). - Prod:
npm run build, then run the server withVIMCHI_STATICpointing atdist/,VIMCHI_BASE_URLset to the public origin andVIMCHI_SECURE_COOKIES=1if TLS terminates at a proxy.
Tests: cd server && go test ./...
The frontend also works on its own as a static site (dist/): without the server, progress
stays in the browser's localStorage and sign-in is unavailable.
The only instance is the public one, https://vimchi.dev, on a DigitalOcean droplet: see
deploy/do/README.md. deploy/do/deploy.sh builds the image here (Dockerfile builds the SPA
with node and the server as a static Go binary into one image), ships it over SSH, restarts and
smoke-tests. GET /healthz is the uptime probe (200 {"ok":true} when SQLite answers).
docker/docker-compose.yml + docker/up.sh run the same image locally on the
nginx-proxy-manager_default network with SQLite bind-mounted at var/ (no host port). The
tailnet instance that used them (vimchi.nrsil.io) was torn down on 2026-09-30; they remain as a
local/staging recipe. up.sh injects VIMCHI_GITHUB_CLIENT_ID / VIMCHI_GITHUB_CLIENT_SECRET
from keys when they exist; without them the tutor runs with sign-in disabled.
The plugin lessons emulate the default keymaps (LazyVim's where the starters differ, such as
mini.surround on gsa / gsd / gsr; flash.nvim is on in every lesson) of mini.surround, mini.ai and
nvim-treesitter-textobjects, flash.nvim, telescope.nvim, snacks.explorer and neo-tree, gitsigns.nvim, lazygit,
grug-far.nvim and a LuaSnip/blink.cmp-style completion and snippet flow; their code is not
included. Fonts (IBM Plex Sans, JetBrains Mono, Space Grotesk) load from Google Fonts under
the SIL Open Font License.
Challenge files (src/challenges/corpus/) are short excerpts from these repos, used under their
licenses, attributed per file, with their license texts shipped at /THIRD-PARTY-NOTICES.txt
(public/THIRD-PARTY-NOTICES.txt): TheAlgorithms/Go (MIT), TheAlgorithms/TypeScript (MIT), charmbracelet/lipgloss (MIT), echasnovski/mini.nvim (MIT), google/uuid (BSD-3-Clause), lewis6991/gitsigns.nvim (MIT), stevearc/oil.nvim (MIT), unjs/ufo (MIT).
MIT — see LICENSE.
