Skip to content

Commit d462006

Browse files
ThomasK33claude
andauthored
fix(terminal): stop Snacks climbing cursor on hide/show toggle (#240, #183) (#271)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 0a24f8b commit d462006

14 files changed

Lines changed: 2034 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- Push quickly-made visual selections to Claude reliably. Selections made and released faster than the selection-tracking debounce were never broadcast, and any selection was wiped shortly after leaving visual mode when Claude runs in an external terminal (the `/ide` flow) — so single-line selections in particular often never reached Claude. Selections are now flushed synchronously on visual-mode exit (from the `'<`/`'>` marks) and persist until the cursor actually moves; a single-line linewise `V` made right after a charwise selection is also no longer mis-extracted to a single character. ([#246](https://github.com/coder/claudecode.nvim/issues/246))
1616
- Diffs opened via `openDiff` no longer linger forever when they are resolved outside this Neovim or their Claude session goes away. Pending diffs are now automatically closed when the client that opened them disconnects or the integration is stopped, and `closeAllDiffTabs` now also resolves/cleans the diff module's tracked state instead of only closing windows. ([#248](https://github.com/coder/claudecode.nvim/issues/248))
1717
- Show diffs when the Claude Code terminal is the only window (no other splits). Previously `openDiff` failed with "No suitable editor window found"; now a split is created to host the diff, matching the behavior of the `openFile` tool. ([#231](https://github.com/coder/claudecode.nvim/issues/231))
18+
- Fix the "climbing cursor" in the Snacks terminal: hiding and re-showing the Claude panel no longer leaves the cursor one row above the prompt (so typed text lands on the wrong line). The Snacks provider now hides/shows without destroying the window — floats are parked via `nvim_win_set_config({hide=...})` and splits are recreated like the native provider — which preserves the cursor anchor Claude re-renders against on focus-in. Splits are fixed on all supported Neovim versions; the float fix requires Neovim >= 0.10. ([#240](https://github.com/coder/claudecode.nvim/issues/240), [#183](https://github.com/coder/claudecode.nvim/issues/183))
1819
- Work around a Neovim core bug (< 0.12.2) that fragmented large bracketed pastes into the terminal across `vim.paste` phases, making Cmd+V appear to truncate content. Added a scoped, version-gated `vim.paste` shim controlled by `terminal.fix_streamed_paste` (`"auto"` by default; no-op on Neovim >= 0.12.2). ([#161](https://github.com/coder/claudecode.nvim/issues/161))
1920

2021
## [0.3.0] - 2025-09-15
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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).
6.08 KB
Binary file not shown.

0 commit comments

Comments
 (0)