diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index fcdcaf3..39a2785 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -55,3 +55,18 @@ jobs:
env:
NEOCURSOR_SPEC_NO_HINTS: "1"
run: nvim --headless -u NONE -c "luafile test/flow_spec.lua"
+
+ # assets/demo.gif is generated by demo/drive.sh piloting a real Neovim.
+ # Running that pilot headlessly here means a change to the tab flow fails
+ # CI instead of silently leaving a GIF in the README that shows behavior
+ # the plugin no longer has. Linux only: this needs tmux, and the flow
+ # itself is already covered on all three platforms above.
+ - name: demo harness still drives the full flow (keeps the README GIF honest)
+ if: runner.os == 'Linux'
+ env:
+ DEMO_ATTACH: "0"
+ run: |
+ if ! command -v tmux >/dev/null 2>&1; then
+ sudo apt-get update -qq && sudo apt-get install -y -qq tmux
+ fi
+ ./demo/drive.sh
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 24dca4f..6784b6f 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -42,6 +42,7 @@ sidecar.py stdio bridge: Neovim JSON ⇄ Cursor StreamCpp (Connect/protob
cursor_paths.py platform path resolution — also runnable standalone as a diagnostic
docs/ installation variants, full config reference, troubleshooting
+demo/ the recording harness that generates assets/demo.gif
test/ specs; all of these run in CI on macOS, Linux and Windows
poc/ protocol spikes kept for reference; never loaded at runtime
```
@@ -81,6 +82,10 @@ nvim --headless -u NONE -c "luafile test/hints_spec.lua"
# the same behavioral suite with hint chrome disabled — chrome must never
# change behavior, so these assertions must pass identically
NEOCURSOR_SPEC_NO_HINTS=1 nvim --headless -u NONE -c "luafile test/flow_spec.lua"
+
+# the demo harness, headless: pilots a real Neovim through jump → accept →
+# jump → accept and fails if any beat lands wrong (needs tmux)
+DEMO_ATTACH=0 ./demo/drive.sh
```
None of them require you to be signed into Cursor; they synthesize an install
@@ -94,6 +99,30 @@ Every push and PR runs the full suite on `ubuntu-latest`, `windows-latest` and
`macos-latest`. Platform matters here more than in most plugins: neocursor reads
Cursor's session off disk, and that path moves per OS.
+The demo harness also runs headlessly on Linux. It is there so that a change to
+the tab flow breaks the build rather than quietly leaving a README GIF that
+shows behavior the plugin no longer has.
+
+---
+
+## Regenerating the demo GIF
+
+`assets/demo.gif` is a build artifact, not a hand-recorded file. Rebuild it with:
+
+```sh
+./demo/record.sh # needs vhs, tmux, neovim, python3
+```
+
+The recording uses a canned backend, so it works without a Cursor subscription
+and produces essentially the same output every run — which is what makes it
+reviewable in a pull request instead of an opaque binary.
+
+Change what happens on screen in `demo/drive.sh` (keystrokes and pacing) or
+`demo/scenario.py` (the code being edited). Do not change the terminal geometry
+casually: the tape's `906x520` is chosen to yield exactly the 84x20 pane
+`drive.sh` pins tmux to, and anything else reflows the code. `demo/README.md`
+explains the design and the traps in more detail.
+
---
## Pull requests
diff --git a/README.md b/README.md
index 56908d1..08fdd81 100644
--- a/README.md
+++ b/README.md
@@ -16,12 +16,12 @@
-
+
- Typed def is_prime(n): — Cursor predicted the whole body.
- <Tab> accepts · <Tab> again jumps to the next edit.
+ Renamed one field on line 3. The two call sites below are now stale.
+ <Tab> jumps to each one · <Tab> again accepts the rewrite.
No API key. No model to choose. No account to create. If you're already signed
diff --git a/assets/demo-still.png b/assets/demo-still.png
new file mode 100644
index 0000000..f8afd92
Binary files /dev/null and b/assets/demo-still.png differ
diff --git a/assets/demo.gif b/assets/demo.gif
new file mode 100644
index 0000000..e441a45
Binary files /dev/null and b/assets/demo.gif differ
diff --git a/assets/demo.png b/assets/demo.png
deleted file mode 100644
index 0d53ec1..0000000
Binary files a/assets/demo.png and /dev/null differ
diff --git a/demo/README.md b/demo/README.md
new file mode 100644
index 0000000..239f25b
--- /dev/null
+++ b/demo/README.md
@@ -0,0 +1,100 @@
+# Demo recording harness
+
+Everything that produces `assets/demo.gif` (the README hero) and
+`assets/demo-still.png` (a poster frame for contexts that cannot animate —
+GitHub's social card, link unfurls, slides). Regenerate both with:
+
+```bash
+./demo/record.sh
+```
+
+## Why it is built this way
+
+A demo GIF is documentation, and documentation that nobody can rebuild rots.
+The usual approach — screen-record yourself once and drag the file in — leaves a
+binary in the repo that cannot be reviewed, corrected, or re-shot when the UI
+changes. Everything here is aimed at making the recording a build artifact
+instead: pinned inputs, scripted keystrokes, one command.
+
+Two decisions carry most of that weight.
+
+**The backend is canned.** `sidecar.py` speaks the same JSON-lines protocol as
+the real sidecar but always returns the same two edits. The live Cursor backend
+varies in latency and wording, which would make every take different and the
+result impossible to review in a pull request. It also means recording works
+without a Cursor subscription.
+
+**The keystrokes are event-synchronised, not timed.** `drive.sh` pilots a real
+Neovim over its RPC socket and polls the plugin's own predicates —
+`has_suggestion()`, the cursor line, the buffer text — so each beat starts the
+instant the previous one lands. A recording built on `sleep` looks fine on the
+machine it was made on and silently breaks everywhere slower, pressing ``
+before the ghost text exists.
+
+## The pieces
+
+| File | Role |
+| --- | --- |
+| `scenario.py` | The code on screen. Copied to a scratch dir; never edited in place. |
+| `init.lua` | Hermetic Neovim config — no user plugins, fixed colours and geometry. |
+| `sidecar.py` | Scripted stand-in for Cursor's backend. |
+| `drive.sh` | Pilots Neovim; owns the keystrokes and the pacing. |
+| `demo.tape` | VHS tape. Only films — it never types into the editor. |
+| `record.sh` | Entry point: preflight, record, optimise, report size. |
+
+## The scenario
+
+`self.retries` is renamed to `self.max_retries` on line 3. Two call sites, 5 and
+10 lines away, are now stale. The developer types one word and then presses
+`` four times: jump, accept, jump, accept.
+
+This case is chosen deliberately over a plain completion demo. Completion looks
+like every other AI plugin; the cursor jump is the part that does not exist
+elsewhere, so the scenario is built to need it twice.
+
+## Tuning
+
+| Variable | Default | Effect |
+| --- | --- | --- |
+| `DEMO_SPEED` | `1.0` | Scales every dramatic pause. `1.5` for a slower read. |
+| `DEMO_COLS` / `DEMO_ROWS` | `84` / `20` | tmux pane size. Must match the tape geometry. |
+| `DEMO_ATTACH` | `1` | `0` runs the beats headlessly — the CI smoke test. |
+| `DEMO_BUDGET_KB` | `3500` | Warn above this output size. |
+| `DEMO_POSTER_AT` | `7.2` | Timestamp the poster frame is taken from. |
+
+Playback speed is set in the tape (`Set PlaybackSpeed`), not here — it scales
+typing and pauses together, whereas `DEMO_SPEED` stretches only the pauses and
+is meant for watching the pilot live.
+
+Geometry is the one thing that cannot be changed casually. `906x520` at
+`FontSize 16` with `Padding 20` and a window bar yields exactly an 84x20
+terminal, which is what `drive.sh` pins tmux to. Change any of them and the code
+reflows. Re-probe before touching it:
+
+```bash
+# inside a tape: Type "tput cols > /tmp/probe.txt; tput lines >> /tmp/probe.txt"
+```
+
+## Checking it without recording
+
+```bash
+DEMO_ATTACH=0 ./demo/drive.sh # drives the flow, prints one line per beat
+```
+
+This is a genuine behavioural test — it fails loudly if a suggestion never
+arrives or a jump lands on the wrong line — and it is much faster than a full
+render.
+
+## Gotchas that cost real time
+
+- **`nvim --remote-expr` needs `--headless` and `/dev/null 2>&1; then TMO=(timeout 3)
+elif command -v gtimeout >/dev/null 2>&1; then TMO=(gtimeout 3)
+else TMO=(); fi
+
+# --headless and /dev/null /dev/null '; send '0'; send 'fr'; beat 0.5
+ send 'cw'; beat 0.25
+ type_human 'max_retries'
+
+ # Beat 2 -- the plugin now knows both call sites are stale.
+ await 'suggestion arrives' 1 "pred has_suggestion" 8000
+ beat 1.4
+
+ # Beat 3 -- Tab #1 teleports to the first broken call site, 5 lines down.
+ send ''
+ await 'jumped to call site L8' 8 'cur_line' 4000
+ beat 1.2
+
+ # Beat 4 -- Tab #2 accepts the rewrite there.
+ send ''
+ await 'call site L8 rewritten' 1 'has_word 8' 4000
+ beat 1.2
+
+ # Beat 5 -- Tab #3 teleports to the second call site, 5 lines further.
+ send ''
+ await 'jumped to call site L13' 13 'cur_line' 4000
+ beat 1.2
+
+ # Beat 6 -- Tab #4 accepts the final rewrite. The refactor is complete; the
+ # developer typed one word to get here.
+ send ''
+ await 'call site L13 rewritten' 1 'has_word 13' 4000
+
+ send ''
+ # Beat 7 -- rest on the finished state so all three lines can be seen to
+ # agree, and so the loop has a clean resting frame to return to.
+ beat 2.0
+ printf 'demo drive complete\n'
+}
+
+boot() {
+ rm -rf "$WORK" && mkdir -p "$WORK"
+ cp "$ROOT/demo/scenario.py" "$WORK/config.py" # never mutate the repo copy
+ rm -f "$SOCK" "$LOG"
+ tmux kill-session -t "$SESSION" 2>/dev/null || true
+ tmux new-session -d -s "$SESSION" -x "$COLS" -y "$ROWS" \
+ "nvim --listen $SOCK -u $ROOT/demo/init.lua $WORK/config.py"
+
+ # `-x/-y` alone is advisory: tmux's default `window-size latest` resizes the
+ # window to whatever client attaches, so the recorder's own terminal would
+ # silently change the frame and reflow the code. Pin it.
+ tmux set-option -t "$SESSION" window-size manual 2>/dev/null || true
+ tmux resize-window -t "$SESSION" -x "$COLS" -y "$ROWS" 2>/dev/null || true
+
+ # tmux's status bar would otherwise brand every frame of the GIF with a
+ # hostname and a clock -- which also makes the recording non-reproducible.
+ tmux set-option -t "$SESSION" status off 2>/dev/null || true
+
+ # Block until the editor actually answers, so a recorder that starts filming
+ # right after this call never catches a blank or half-painted frame.
+ await 'nvim rpc socket up' 2 "rpc '1+1'" 15000
+}
+
+# bash runs an EXIT trap inside command-substitution subshells too, and this
+# script is almost entirely $(...) RPC polls. Without the BASHPID guard the
+# very first poll would tear down the session it is polling.
+MAIN_PID=$$
+cleanup() {
+ [ "${BASHPID:-$$}" = "$MAIN_PID" ] || return 0
+ tmux kill-session -t "$SESSION" 2>/dev/null || true
+ rm -f "$SOCK"
+}
+
+case "$MODE" in
+ --boot)
+ # Leave the session running for a subsequent --film; no cleanup trap.
+ boot
+ ;;
+ --film)
+ # Session is already warm. Hold the pilot until a client is actually
+ # attached: starting on a timer instead means any delay in attaching eats
+ # the opening beat, and the recording opens midway through the typing with
+ # the "before" state never shown.
+ #
+ # Deliberately does NOT tear the session down at the end -- if attach
+ # exits, the recorder films the shell prompt underneath. The last frame
+ # must stay on the finished code. record.sh cleans up afterwards.
+ (
+ until [ "$(tmux display -t "$SESSION" -p '#{session_attached}' 2>/dev/null)" = "1" ]; do
+ sleep 0.05
+ done
+ # Attach only tells us tmux has a client -- it says nothing about whether
+ # the recorder has resumed capturing frames, and that resumption has
+ # drifted by up to ~3s between runs. Hold the pristine frame through that
+ # window: if the performance starts first, the recording opens midway
+ # through the typing and the viewer never sees the original line, which is
+ # the entire setup for the rename.
+ beat 2.0
+ beats
+ ) >"$LOG" 2>&1 &
+ exec tmux attach -t "$SESSION"
+ ;;
+ all)
+ trap cleanup EXIT
+ boot
+ if [ "$ATTACH" = "0" ]; then
+ beats 2>&1 | tee "$LOG"
+ exit "${PIPESTATUS[0]}"
+ fi
+ ( beats >"$LOG" 2>&1; tmux kill-session -t "$SESSION" 2>/dev/null ) &
+ exec tmux attach -t "$SESSION"
+ ;;
+ *)
+ printf 'usage: %s [--boot|--film]\n' "$0" >&2
+ exit 2
+ ;;
+esac
diff --git a/demo/init.lua b/demo/init.lua
new file mode 100644
index 0000000..c99fac9
--- /dev/null
+++ b/demo/init.lua
@@ -0,0 +1,55 @@
+-- Pinned, hermetic Neovim config for the demo recording.
+-- Deliberately loads NOTHING but neocursor: no colorscheme plugins, no
+-- statusline, no user config. The recording must look identical on any
+-- machine and in CI, so every visual knob is set explicitly here.
+local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h")
+
+vim.opt.rtp:prepend(root)
+vim.opt.swapfile = false
+vim.opt.shada = ""
+vim.opt.more = false
+vim.opt.showcmd = false
+vim.opt.showmode = false
+vim.opt.ruler = false
+vim.opt.laststatus = 0 -- no statusline: the code is the whole frame
+vim.opt.cmdheight = 0 -- reclaim the last row (nvim 0.8+)
+vim.opt.number = true
+vim.opt.numberwidth = 4
+vim.opt.signcolumn = "no"
+vim.opt.fillchars = { eob = " " } -- hide the ~ tildes past end-of-buffer
+vim.opt.scrolloff = 99 -- keep content vertically centred
+vim.opt.shiftwidth = 4
+vim.opt.expandtab = true
+vim.opt.termguicolors = true
+
+vim.cmd("syntax on")
+vim.cmd("colorscheme habamax") -- ships with nvim; dark, high-contrast, stable
+
+-- Readable on both GitHub light and dark backgrounds.
+vim.api.nvim_set_hl(0, "Normal", { fg = "#c9d1d9", bg = "#0d1117" })
+vim.api.nvim_set_hl(0, "LineNr", { fg = "#484f58", bg = "#0d1117" })
+vim.api.nvim_set_hl(0, "CursorLineNr", { fg = "#c9d1d9", bg = "#0d1117" })
+vim.api.nvim_set_hl(0, "EndOfBuffer", { fg = "#0d1117", bg = "#0d1117" })
+
+-- The demo backend: a scripted stand-in for Cursor's servers so the recording
+-- is byte-identical every run. Swap DEMO_SIDECAR=real to film the live backend.
+local sidecar = os.getenv("NEOCURSOR_DEMO_SIDECAR") or (root .. "/demo/sidecar.py")
+local py = vim.fn.executable("python3") == 1 and "python3" or "python"
+
+-- setup() announces itself with an INFO vim.notify. With cmdheight=0 there is
+-- no room to display it, so Neovim raises a hit-enter prompt -- which stops
+-- answering RPC and hangs the driver before a single frame is filmed.
+-- Drop INFO chatter, but deliberately let WARN/ERROR through: if the sidecar
+-- fails to launch the recording SHOULD visibly break rather than quietly film
+-- an editor that does nothing.
+local real_notify = vim.notify
+vim.notify = function(msg, level, opts)
+ if (level or vim.log.levels.INFO) <= vim.log.levels.INFO then return end
+ return real_notify(msg, level, opts)
+end
+
+require("neocursor").setup({
+ debounce = 60,
+ show_hints = true, -- the hint chrome IS the narration in a silent GIF
+ sidecar_cmd = { py, sidecar },
+})
diff --git a/demo/record.sh b/demo/record.sh
new file mode 100755
index 0000000..5e2dc49
--- /dev/null
+++ b/demo/record.sh
@@ -0,0 +1,88 @@
+#!/usr/bin/env bash
+# Render the hero GIF. One command, reproducible on any machine:
+#
+# ./demo/record.sh
+#
+# Everything that determines what ends up on screen is pinned in version
+# control -- the Neovim config (demo/init.lua), the backend responses
+# (demo/sidecar.py), the keystrokes and their synchronisation (demo/drive.sh),
+# and the terminal geometry (demo/demo.tape). Re-running this should produce
+# essentially the same GIF, which is what makes the demo reviewable in a PR
+# rather than a mystery binary someone once dragged in.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$ROOT"
+export NEOCURSOR_ROOT="$ROOT"
+
+OUT="assets/demo.gif"
+BUDGET_KB="${DEMO_BUDGET_KB:-3500}" # GitHub renders fine well past this;
+ # beyond it the README feels sluggish.
+
+missing=()
+for tool in vhs tmux nvim python3; do
+ command -v "$tool" >/dev/null 2>&1 || missing+=("$tool")
+done
+if [ ${#missing[@]} -gt 0 ]; then
+ printf 'missing required tool(s): %s\n' "${missing[*]}" >&2
+ printf 'install with: brew install vhs tmux neovim\n' >&2
+ exit 1
+fi
+
+# A leftover session from an aborted run would be attached to instead of a
+# fresh one, silently filming a half-finished buffer.
+tmux kill-session -t "${DEMO_SESSION:-neocursor-demo}" 2>/dev/null || true
+
+mkdir -p assets
+rm -f "$OUT"
+
+printf '==> recording (about 40s)\n'
+vhs demo/demo.tape
+
+# drive.sh --film deliberately leaves the session alive so the recording ends
+# on the finished code rather than on a shell prompt. Reap it here.
+tmux kill-session -t "${DEMO_SESSION:-neocursor-demo}" 2>/dev/null || true
+rm -f "${DEMO_SOCK:-/tmp/neocursor-demo.sock}"
+
+if [ ! -f "$OUT" ]; then
+ printf 'vhs produced no output; see the errors above\n' >&2
+ exit 1
+fi
+
+size_kb() { echo $(( ( $(wc -c < "$1") + 1023 ) / 1024 )); }
+raw_kb=$(size_kb "$OUT")
+printf '==> raw: %s KB\n' "$raw_kb"
+
+# Lossless-only optimisation. `--lossy` shaves a lot more off, but it stipples
+# antialiased glyph edges, and this GIF is almost entirely small text -- the
+# thing a viewer is being asked to read.
+if command -v gifsicle >/dev/null 2>&1; then
+ gifsicle -O3 --careful "$OUT" -o "$OUT.opt" 2>/dev/null && mv "$OUT.opt" "$OUT"
+ printf '==> optimised: %s KB (was %s KB)\n' "$(size_kb "$OUT")" "$raw_kb"
+else
+ printf '==> gifsicle not found; skipping optimisation\n'
+fi
+
+# A still for the places that cannot animate: GitHub's social preview card, link
+# unfurls, slides. Generated here rather than by hand so it can never drift out
+# of sync with the GIF it is supposed to represent.
+POSTER="assets/demo-still.png"
+POSTER_AT="${DEMO_POSTER_AT:-7.2}" # mid-refactor: one call site fixed, one
+ # still stale with the jump hint showing
+if command -v ffmpeg >/dev/null 2>&1; then
+ if ffmpeg -y -ss "$POSTER_AT" -i "$OUT" -frames:v 1 "$POSTER" 2>/dev/null; then
+ printf '==> poster: %s (t=%ss)\n' "$POSTER" "$POSTER_AT"
+ else
+ printf '!! poster frame failed; %s may be stale\n' "$POSTER" >&2
+ fi
+else
+ printf '==> ffmpeg not found; skipping poster frame\n'
+fi
+
+final_kb=$(size_kb "$OUT")
+if [ "$final_kb" -gt "$BUDGET_KB" ]; then
+ printf '!! %s KB exceeds the %s KB budget -- consider trimming a beat\n' \
+ "$final_kb" "$BUDGET_KB" >&2
+fi
+
+printf '==> wrote %s (%s KB)\n' "$OUT" "$final_kb"
diff --git a/demo/scenario.py b/demo/scenario.py
new file mode 100644
index 0000000..adffe70
--- /dev/null
+++ b/demo/scenario.py
@@ -0,0 +1,13 @@
+class Config:
+ def __init__(self):
+ self.retries = 3
+ self.timeout = 30
+
+
+def connect(cfg):
+ for attempt in range(cfg.retries):
+ dial(cfg.timeout)
+
+
+def report(cfg):
+ print(f"retries={cfg.retries}")
diff --git a/demo/sidecar.py b/demo/sidecar.py
new file mode 100644
index 0000000..e6138dd
--- /dev/null
+++ b/demo/sidecar.py
@@ -0,0 +1,70 @@
+#!/usr/bin/env python3
+"""Scripted stand-in for Cursor's backend, used only to record the demo GIF.
+
+Speaks the exact same JSON-lines protocol as the real sidecar (see
+test/fake_sidecar.py), so init.lua's job plumbing is exercised unmodified.
+Using a canned backend makes the recording byte-identical on every run and in
+CI — the real backend's latency and phrasing vary, which would make the GIF
+irreproducible and the demo un-reviewable.
+
+Scenario (demo/scenario.py): the developer renames `self.retries` to
+`self.max_retries` on line 3. The two call sites that now break live 5 and 10
+lines away — exactly the case where next-edit prediction beats a plain LSP
+rename, because each site needs a different surrounding rewrite.
+
+ L8 for attempt in range(cfg.retries): -> cfg.max_retries
+ L13 print(f"retries={cfg.retries}") -> cfg.max_retries
+
+Yielding the Tab rhythm: jump -> accept -> jump -> accept.
+"""
+import json
+import os
+import sys
+import time
+
+# Set NEOCURSOR_DEMO_LOG to capture the real request shape while iterating.
+LOG = os.getenv("NEOCURSOR_DEMO_LOG")
+
+# A deliberate, constant think-time. Instant replies look fake and give the
+# viewer no beat to register the ghost text; the real backend sits near this.
+LATENCY_S = 0.25
+
+CHAIN = [
+ {
+ "text": " for attempt in range(cfg.max_retries):",
+ "range": {"start": 8, "endInclusive": 8},
+ },
+ {
+ "text": ' print(f"retries={cfg.max_retries}")',
+ "range": {"start": 13, "endInclusive": 13},
+ },
+]
+
+print("ready", file=sys.stderr, flush=True)
+
+for line in sys.stdin:
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ req = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+
+ if LOG:
+ with open(LOG, "a") as fh:
+ fh.write(line + "\n")
+
+ time.sleep(LATENCY_S)
+
+ res = {
+ "id": req.get("id"),
+ "text": CHAIN[0]["text"],
+ "range": CHAIN[0]["range"],
+ "edits": CHAIN,
+ # Once the chain is spent, point back at the field the developer just
+ # renamed. The pill lands near the top of the frame, so the last beat
+ # visually rhymes with the first and the GIF loops without a jolt.
+ "prediction": {"path": req.get("path"), "line": 3},
+ }
+ print(json.dumps(res), flush=True)