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 @@

- neocursor.nvim: Cursor's Tab predicting the rest of is_prime() as ghost text in Neovim + neocursor.nvim in Neovim: renaming self.retries to self.max_retries, then pressing Tab to jump to each stale call site and accept the rewritten line

- 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)