Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@
</p>

<p align="center">
<img src="./assets/demo.png" alt="neocursor.nvim: Cursor's Tab predicting the rest of is_prime() as ghost text in Neovim" width="800">
<img src="./assets/demo.gif" alt="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" width="800">
</p>

<p align="center">
<sub>Typed <code>def is_prime(n):</code> — Cursor predicted the whole body.
<code>&lt;Tab&gt;</code> accepts · <code>&lt;Tab&gt;</code> again jumps to the next edit.</sub>
<sub>Renamed one field on line 3. The two call sites below are now stale.
<code>&lt;Tab&gt;</code> jumps to each one · <code>&lt;Tab&gt;</code> again accepts the rewrite.</sub>
</p>

No API key. No model to choose. No account to create. If you're already signed
Expand Down
Binary file added assets/demo-still.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file removed assets/demo.png
Binary file not shown.
100 changes: 100 additions & 0 deletions demo/README.md
Original file line number Diff line number Diff line change
@@ -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 `<Tab>`
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
`<Tab>` 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`.** Given a TTY on
stdin it starts a full UI, and the value comes back wrapped in escape codes.
It only behaves when run from a pipe, which is exactly how you test it by hand
and exactly not how a recorder runs it.
- **VHS types nothing for a single-quoted string.** Every `Type` in the tape is
double-quoted, and all shell logic lives in scripts rather than in the tape.
- **A bash `EXIT` trap also fires inside `$(...)`.** This script is mostly RPC
polls in command substitution; without a `BASHPID` guard the first poll tears
down the session it is polling.
- **tmux `-x/-y` is advisory.** Default `window-size latest` resizes to whatever
client attaches, so the window is pinned with `window-size manual`.
72 changes: 72 additions & 0 deletions demo/demo.tape
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# VHS tape for the neocursor.nvim hero GIF. Render with: ./demo/record.sh
#
# This tape only *films*. All the editing is driven by demo/drive.sh, which
# pilots a real Neovim over its RPC socket and waits on the plugin's own state
# between beats. VHS therefore never types into the editor and never needs to
# guess how long the backend will take.
#
# Geometry note: 906x520 at FontSize 16 with Padding 20 and a window bar yields
# exactly an 84x20 terminal, which is the size drive.sh pins the tmux window
# to. Changing any of these WILL reflow the code -- re-probe with `tput cols`
# before touching them.

Output "assets/demo.gif"

Set Shell "bash"
Set FontSize 16
Set Width 906
Set Height 520
Set Padding 20
Set WindowBar Colorful
Set BorderRadius 10
Set Framerate 24
Set CursorBlink false

# 1.2x. Applied at playback rather than by shortening the beats, so the pacing
# stays uniform -- typing, pauses and the Tab gestures all scale together. The
# beats themselves stay readable when drive.sh is run live in a terminal.
Set PlaybackSpeed 1.2

# GitHub-dark palette, so the GIF sits naturally in the README on either theme.
Set Theme { "name": "github-dark", "background": "#0d1117", "foreground": "#c9d1d9", "cursor": "#58a6ff", "selection": "#264f78", "black": "#484f58", "red": "#ff7b72", "green": "#3fb950", "yellow": "#d29922", "blue": "#58a6ff", "magenta": "#bc8cff", "cyan": "#39c5cf", "white": "#b1bac4", "brightBlack": "#6e7681", "brightRed": "#ffa198", "brightGreen": "#56d364", "brightYellow": "#e3b341", "brightBlue": "#79c0ff", "brightMagenta": "#d2a8ff", "brightCyan": "#56d4dd", "brightWhite": "#f0f6fc" }

# --- boot, unfilmed ---------------------------------------------------------
# Neovim's startup is not part of the story. Bring the session fully up while
# frames are hidden, so the first thing a viewer ever sees is the finished code.
#
# Every Type line below is a bare command in double quotes. VHS silently types
# NOTHING for a single-quoted string, and shell metacharacters in a tape are a
# reliable way to lose an evening -- so all the real logic lives in drive.sh and
# the tape only ever names it. record.sh guarantees the working directory.
Hide
Type "./demo/drive.sh --boot"
Enter

# Wait on the pilot's own readiness line rather than a fixed sleep. A sleep that
# is too short types the next command into a still-running boot; one that is
# long enough to be safe wastes ten seconds on every render. This also fails the
# tape loudly if boot never comes up, instead of filming an empty terminal.
Wait+Screen@30s /rpc socket up/

Type "clear"
Enter
Sleep 500ms

# Attach to the warm session. ~900ms is enough for tmux to paint the pane; the
# pilot's opening beat is sized to leave ~2.2s of readable rest after this.
Type "./demo/drive.sh --film"
Enter
Sleep 900ms
Show

# --- the take ---------------------------------------------------------------
# drive.sh holds its opening beat until this attach completes, so filming and
# the performance start together. ~15s of event-synced beats follow; the extra
# margin here is absorbed by the closing rest, not by a shell prompt.
# The beats are event-synced, so the performance is ~12-13s depending on how
# fast the machine answers RPC -- it is not a fixed length. This window has to
# clear the SLOW end of that range, or the recording stops mid-gesture with the
# final accept never shown. Overshoot is cheap: the extra frames are the same
# resting state the closing beat already holds, which is what the loop wants.
# Undershoot silently truncates the payoff.
Sleep 15s
Loading
Loading