|
| 1 | +# cursor-toggle-repro — triage & reproduction for #240 / #183 |
| 2 | + |
| 3 | +**Issues:** |
| 4 | +[#240](https://github.com/coder/claudecode.nvim/issues/240) — "When re-opening the |
| 5 | +Claude side panel, the cursor is one line higher than it should be" (vertical split). |
| 6 | +[#183](https://github.com/coder/claudecode.nvim/issues/183) — "Input cursor in |
| 7 | +floating mode moves upwards every time I toggle" (float). **Same root cause.** |
| 8 | + |
| 9 | +## TL;DR verdict |
| 10 | + |
| 11 | +With the **Snacks** terminal provider (LazyVim's default), hiding and re-showing the |
| 12 | +Claude window leaves the terminal cursor **one row above** Claude's `❯` input prompt, |
| 13 | +so typed text lands on the wrong line and the prompt box visibly corrupts. The |
| 14 | +plugin's **native** provider does **not** show this. |
| 15 | + |
| 16 | +The popular community fixes (RasmusN's fork, mwojick's gist) attribute the bug to a |
| 17 | +**`SIGWINCH`/pty resize** when Snacks destroys the window. **That is not what happens |
| 18 | +here** — instrumenting the inner PTY shows **zero `SIGWINCH`** on toggle. The real |
| 19 | +chain is: |
| 20 | + |
| 21 | +1. Snacks hides the panel by **closing the window** (`nvim_win_close(win, true)`) and |
| 22 | + re-shows it by **recreating** it — for both splits and floats. Chain in |
| 23 | + `snacks.nvim/lua/snacks/win.lua`: `Win:hide` (619) → `Win:close({buf=false})` (542, |
| 24 | + `nvim_win_close` at 562) → `Win:show` (819) → `open_win`. Snacks has **no |
| 25 | + hide-without-close** option. The native provider also closes/reopens, but does not |
| 26 | + trigger the drift. |
| 27 | +2. On hide Neovim sends **focus-out** (`ESC[O` / `CSI O`) and on show **focus-in** |
| 28 | + (`ESC[I` / `CSI I`) to the child, because Claude enables focus reporting |
| 29 | + (DECSET `?1004`). (Confirmed in Neovim source: `terminal_focus()` → |
| 30 | + `vterm_state_focus_in/out()` → bytes to the child via `term_output_callback`.) |
| 31 | +3. Claude Code (built on **Ink**, which redraws **relative** to the cursor) re-renders |
| 32 | + its TUI on focus-in. After the window was destroyed/recreated, its cursor anchor is |
| 33 | + off by one, so the relative redraw lands one row too high — and keeps climbing. |
| 34 | + |
| 35 | +Two facts pin the layers: |
| 36 | + |
| 37 | +- **Focus change alone does not drift.** Moving Neovim focus editor↔terminal _without_ |
| 38 | + hiding the window never drifts. The window destroy+recreate is a required co-factor. |
| 39 | +- **Absolute-positioning programs do not drift; only Claude does.** _Measured_ |
| 40 | + (`box-delta-check.sh`): a synthetic TUI that redraws with absolute cursor moves |
| 41 | + (`CSI row;col H`) keeps its cursor on its `>` prompt row (cursorRow=35) across every |
| 42 | + toggle — zero drift — under the identical Snacks float churn that moves real Claude by |
| 43 | + one row. This rules out a Neovim PTY/window coordinate bug and pins the drift to |
| 44 | + Claude's **cursor-relative Ink repaint**. Consistent with the community report that |
| 45 | + **downgrading Claude to `2.0.76` makes it disappear**. So this is substantially a |
| 46 | + **Claude-CLI-side** rendering behavior that Snacks' window churn exposes; the plugin |
| 47 | + can only _work around_ it. |
| 48 | +- **"Destroy/recreate" is not the _sole_ discriminator — _how_ Snacks recreates is.** |
| 49 | + The native provider _also_ closes the window on hide (`nvim_win_close`, |
| 50 | + `native.lua:193`) and creates a _new_ window on show (`vsplit` + `nvim_win_set_buf`, |
| 51 | + `native.lua:227-232`) reusing the same terminal buffer — yet it does **not** drift. |
| 52 | + Snacks re-shows a float via `nvim_open_win` (`win.lua:733`), which is what resets the |
| 53 | + cursor/scroll anchor. The snacks-only A/B (close+recreate → delta 1; config-hide → |
| 54 | + delta 0) isolates the recreate within the snacks float; native shows a _different_ |
| 55 | + recreate path is immune. (RasmusN's fork also `nvim_win_set_cursor`-scrolls to bottom |
| 56 | + and defers `startinsert`, hinting the new float window's scroll/cursor view on re-show |
| 57 | + is the proximate anchor shift.) |
| 58 | + |
| 59 | +| layer | role | |
| 60 | +| ------------------------ | ------------------------------------------------------------------------------------------------ | |
| 61 | +| Claude CLI ≥ 2.1.x (Ink) | re-renders relative-to-cursor on focus-in → the actual climb; older 2.0.76 did not | |
| 62 | +| Snacks provider | hide=close-window, show=recreate-window → disturbs the cursor anchor that Claude redraws against | |
| 63 | +| Neovim | forwards focus-out/in to the child on window hide/show (no resize) | |
| 64 | + |
| 65 | +Each link in this chain was re-checked against primary sources (the Neovim 0.12.2 |
| 66 | +binary's terminal/focus source, the pinned snacks.nvim source, and api.txt) by an |
| 67 | +independent adversarial pass; all held. The one thing the community fixes get wrong is |
| 68 | +the _cause_ (they say `SIGWINCH`); their _mechanism_ (stop destroying the window) is |
| 69 | +right anyway, because it preserves the cursor anchor. |
| 70 | + |
| 71 | +## Reproduce it |
| 72 | + |
| 73 | +### A. Automated (agent-tty) |
| 74 | + |
| 75 | +```bash |
| 76 | +fixtures/cursor-toggle-repro/agent-repro.sh |
| 77 | +``` |
| 78 | + |
| 79 | +- **PART A (no auth):** runs `box.py` (a synthetic TUI that enables focus reporting and |
| 80 | + logs every byte + `SIGWINCH`) as the terminal command and toggles the window. Expected: |
| 81 | + `SIGWINCH events on toggle: 0`, with `FOCUS_IN`/`FOCUS_OUT` on every cycle — proving |
| 82 | + the trigger is focus churn, not a resize. |
| 83 | +- **PART B (needs a logged-in `claude`):** runs the real Claude CLI under both providers |
| 84 | + and prints the cursor-vs-prompt `delta` after each toggle: |
| 85 | + |
| 86 | + ``` |
| 87 | + -- provider=snacks -- |
| 88 | + baseline: cursorRow=9 promptRow=9 delta=0 |
| 89 | + after toggle 1: cursorRow=8 promptRow=9 delta=1 <- BUG (cursor one row above ❯) |
| 90 | + -- provider=native -- |
| 91 | + baseline: cursorRow=9 promptRow=9 delta=0 |
| 92 | + after toggle 1: cursorRow=9 promptRow=9 delta=0 <- fine |
| 93 | + ``` |
| 94 | + |
| 95 | +#### #183 float, measured this session (Claude 2.1.168, nvim 0.12.2, current `main`) |
| 96 | + |
| 97 | +```text |
| 98 | +$ ./float-repro.sh # the bug |
| 99 | +== provider=snacks (float) == |
| 100 | + baseline: delta=0 |
| 101 | + after toggle 1..5: delta=1 <- cursor one row ABOVE ❯ on every toggle |
| 102 | + final: typed "ZZZQ" rendered as "──ZZZQ" ON THE BOX TOP BORDER (row 9), ❯ on row 10 |
| 103 | +== provider=native (float) == |
| 104 | + baseline..toggle 5: delta=0 <- fine; "ZZZQ" rendered as "❯ ZZZQ" |
| 105 | +
|
| 106 | +$ ./float-fix-probe.sh # the candidate fix (config-hide) |
| 107 | +== provider=snacks (float) + CONFIG-HIDE == |
| 108 | + baseline..toggle 5: delta=0 <- FIXED; "ZZZQ" rendered as "❯ ZZZQ" |
| 109 | +
|
| 110 | +$ ./box-float-check.sh # instrument: not a resize |
| 111 | + SIGWINCH events on toggle: 0 FOCUS_IN: 4 FOCUS_OUT: 4 (4 cycles) |
| 112 | +
|
| 113 | +$ ./box-delta-check.sh # control: absolute-positioning TUI is immune |
| 114 | +== box.py (absolute CSI row;col H) under snacks float == |
| 115 | + baseline..toggle 3: cursorRow=35 (stable) — cursor stays ON its "> " prompt row, NO drift |
| 116 | +``` |
| 117 | + |
| 118 | +The snacks-vs-config-hide A/B holds the focus flow identical (move to editor → hide → |
| 119 | +re-show+focus); the only difference is whether the window is **destroyed** or **kept**, so |
| 120 | +the destroy/recreate is the trigger. `box-float-check.sh` confirms the toggle is **not** a |
| 121 | +pty resize. (Note: in this automated flow the snacks/float drift stabilizes at delta=1 — |
| 122 | +each toggle re-introduces a 1-row error rather than climbing unbounded; the user-visible |
| 123 | +symptom, "typed text lands on the wrong line after a toggle," is the same.) |
| 124 | + |
| 125 | +### B. Manual (interactive) |
| 126 | + |
| 127 | +```bash |
| 128 | +cd fixtures && NVIM_APPNAME=cursor-toggle-repro XDG_CONFIG_HOME="$PWD" \ |
| 129 | + mise exec -- nvim cursor-toggle-repro/sample.txt |
| 130 | +``` |
| 131 | + |
| 132 | +1. `<leader>ac` opens the Claude terminal (Snacks split). |
| 133 | +2. `<C-\><C-n>` then `<C-w>h` to the editor; `<leader>ac` to hide, `<leader>ac` to show. |
| 134 | +3. The `❯` prompt is drawn where it was, but the cursor (and anything you type) is now one |
| 135 | + row higher. Toggle again to see it worsen / corrupt the box. |
| 136 | + |
| 137 | +Env knobs (see `init.lua`): `CURSOR_REPRO_PROVIDER` (`snacks`|`native`), |
| 138 | +`CURSOR_REPRO_POSITION` (`right`|`float` = #183), `CURSOR_REPRO_CMD` (run `box.py` |
| 139 | +instead of `claude`), `CURSOR_REPRO_BORDER`. `:ReproCursorInfo` / `:ReproWinDiag` dump |
| 140 | +geometry to `$CURSOR_REPRO_LOG`. |
| 141 | + |
| 142 | +## Fixes & workarounds |
| 143 | + |
| 144 | +> Validated here = measured to keep `delta=0` across toggles with real Claude. |
| 145 | +
|
| 146 | +1. **Config-hide (validated for floats — fixes #183).** Hide/show via |
| 147 | + `nvim_win_set_config(win, {hide=true/false})` instead of closing+recreating the |
| 148 | + window. Keeping the window object alive preserves the grid + cursor anchor, so Claude's |
| 149 | + focus-in redraw stays aligned. This is what RasmusN's fork and mwojick's "parking |
| 150 | + float" do (their _stated_ reason — avoiding `SIGWINCH` — is wrong, but the fix works for |
| 151 | + a different reason: it preserves the anchor). **Caveat:** `{hide=true}` does **not** |
| 152 | + visually hide a _non-floating split_ in Neovim 0.12.2 (the window stays visible), so this |
| 153 | + path is a clean fix for **floats only**. Re-confirmed this session on Claude 2.1.168 via |
| 154 | + `float-fix-probe.sh` (delta stays 0 across 5 toggles; typed text lands after `❯`). |
| 155 | + **Plugin-integration caveat:** a config-hidden window is still `nvim_win_is_valid()==true`, |
| 156 | + so the snacks provider's `simple_toggle`/`focus_toggle` visibility checks |
| 157 | + (`terminal:win_valid()`) would treat it as still-visible. A real plugin fix must gate on |
| 158 | + `nvim_win_get_config(win).hide` (what the fixture's `:ReproConfigHideToggle` does) or track |
| 159 | + hidden state, and must manage the window directly rather than via `terminal:toggle()`. |
| 160 | + |
| 161 | +2. **Use the native provider (workaround for #240 split users, today).** |
| 162 | + `terminal = { provider = "native" }` — does not drift. Loses Snacks' float/UI niceties. |
| 163 | + |
| 164 | +3. **Downgrade Claude CLI to `2.0.76` (workaround).** Confirms the bug is in Claude's |
| 165 | + newer focus-driven redraw; not a long-term fix. |
| 166 | + |
| 167 | +4. **Upstream (the real fix):** Claude Code's focus-in re-render should not depend on a |
| 168 | + cursor anchor that can move; this is the layer that regressed between 2.0.76 and 2.1.x. |
| 169 | + |
| 170 | +**What did NOT work:** `start_insert=false` + scroll-to-bottom + deferred `startinsert` |
| 171 | +(RasmusN's split-side change) — still `delta=1` here. Setting `border="none"` (matching the |
| 172 | +native row count) — still `delta=1`. So neither the insert timing nor the 1-row height |
| 173 | +difference is the cause. |
| 174 | + |
| 175 | +## Files |
| 176 | + |
| 177 | +- `init.lua` — fixture config (Snacks provider; loads the local plugin + snacks via rtp). |
| 178 | + Also defines `:ReproConfigHideToggle` / `<leader>ah`, the candidate-fix probe that |
| 179 | + hides the float via `nvim_win_set_config{hide=…}` instead of closing the window. |
| 180 | +- `box.py` — synthetic TUI / instrument: enables focus reporting, logs input bytes + SIGWINCH. |
| 181 | +- `sample.txt` — filler content for the "main editor" window. |
| 182 | +- `agent-repro.sh` — self-contained automated reproduction for the **split** (#240): |
| 183 | + PART A (box.py, no-auth) + PART B (real Claude, snacks vs native). |
| 184 | +- `float-repro.sh` — **#183-specific** automated reproduction: Snacks `position="float"`, |
| 185 | + real Claude, hides+re-shows N times and prints the cursor-vs-`❯` delta. snacks→delta 1, |
| 186 | + native→delta 0; ends by typing `ZZZQ` to show where input lands. |
| 187 | +- `float-fix-probe.sh` — validates the candidate fix: same float harness but toggles via |
| 188 | + `<leader>ah` (config-hide). Measures whether delta stays 0. |
| 189 | +- `box-float-check.sh` — instrument refresh on the float: counts SIGWINCH vs focus |
| 190 | + events across snacks close+recreate toggles (proves 0 SIGWINCH, focus churn present). |
0 commit comments