Skip to content

Commit 907d41e

Browse files
ThomasK33claude
andauthored
fix(terminal): work around Neovim <0.12.2 terminal paste fragmentation (#252)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent c403636 commit 907d41e

10 files changed

Lines changed: 997 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## [Unreleased]
4+
5+
### Bug Fixes
6+
7+
- 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))
8+
39
## [0.3.0] - 2025-09-15
410

511
### Features

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,10 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
264264
provider = "auto", -- "auto", "snacks", "native", "external", "none", or custom provider table
265265
auto_close = true,
266266
snacks_win_opts = {}, -- Opts to pass to `Snacks.terminal.open()` - see Floating Window section below
267+
-- Work around a Neovim core bug (< 0.12.2) that fragments large pastes into
268+
-- the terminal, making Cmd+V appear to truncate ([#161]). true | false | "auto"
269+
-- ("auto", the default, enables it only on affected Neovim versions).
270+
fix_streamed_paste = "auto",
267271

268272
-- Provider-specific options
269273
provider_opts = {

fixtures/paste-repro/README.md

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
# paste-repro — triage & reproduction for issue #161
2+
3+
**Issue:** [#161](https://github.com/coder/claudecode.nvim/issues/161) — "Pasting text with
4+
Cmd+V in the Claude Code terminal truncates content, while right-click paste works."
5+
6+
## TL;DR verdict
7+
8+
This is **not a claudecode.nvim bug**. It is an upstream **Neovim core** bug in the default
9+
terminal paste path ([neovim/neovim#39110](https://github.com/neovim/neovim/issues/39110)),
10+
fixed by [PR #39152](https://github.com/neovim/neovim/pull/39152) (milestone **0.12.2**) and
11+
backport #39174. The plugin never touches the paste path, so it inherits whatever the running
12+
Neovim build does.
13+
14+
A large bracketed paste into a `:terminal` buffer is streamed through `vim.paste(lines, phase)`
15+
in phases (1→2→…→3). On **affected builds**, each streamed phase is independently wrapped in its
16+
own `ESC[200~ … ESC[201~` bracketed-paste markers, so one logical paste reaches the inner program
17+
(Claude) as **N separate paste events**. Claude renders N `[Pasted text #k]` placeholders with
18+
phase-boundary characters leaking between them — which the user perceives as truncation.
19+
20+
| Neovim build | bracketed-paste segments seen by inner PTY | verdict |
21+
| ---------------------------- | ------------------------------------------ | --------------- |
22+
| 0.11.5 (reporter's version) | **6** | 🐛 fragmented |
23+
| 0.11.6 (commenter's version) | **6** | 🐛 fragmented |
24+
| 0.12.2 (fix present) | **1** | ✅ single paste |
25+
| 0.11.6 + workaround | **1** | ✅ single paste |
26+
27+
(Counts are for a 120-line / ~6 KB payload; the number of segments scales with paste size.)
28+
29+
## Why it is size/timing dependent
30+
31+
The fragmentation is driven by **TUI input read chunking** (~1 KB reads). A paste small enough to
32+
land in a single read arrives as one non-streamed `phase == -1` call → one segment → no bug.
33+
A larger paste spans multiple reads → streamed phases 1/2/…/3 → on an affected build, one segment
34+
per phase. This is why short pastes look fine and large pastes "truncate", and why there is no
35+
fixed line threshold.
36+
37+
## Root-cause chain (verified)
38+
39+
1. Emulator (WezTerm/Ghostty/iTerm2) wraps the clipboard in `ESC[200~ … ESC[201~` and writes it
40+
to Neovim's PTY as one logical paste.
41+
2. Neovim's TUI streams it through `vim.paste(lines, phase)``runtime/lua/vim/_core/editor.lua`
42+
(historically `runtime/lua/vim/_editor.lua`). For a terminal buffer the handler runs
43+
`nvim_put(lines, 'c', false, true)` **once per phase** (editor.lua:185-186).
44+
3. Each `nvim_put` → C `do_put` (`register.c`) → `terminal_paste` (`terminal.c`), which wraps the
45+
write in bracketed-paste markers **iff** the inner program enabled DECSET 2004.
46+
4. **The defect:** before #39152, `terminal_paste` emitted start/end markers _unconditionally on
47+
every call_, so N phases ⇒ N bracketed segments. The fix added a `streamed_paste` flag (managed
48+
across phases by `nvim_paste` in `api/vim.c`) so the whole stream gets exactly one marker pair.
49+
The fix is at the **C layer**; the Lua `vim.paste` in 0.12.2 still does a per-phase `nvim_put`.
50+
51+
`claudecode.nvim` does **not** override `vim.paste`, set paste keymaps, or call
52+
`nvim_chan_send`/`nvim_paste` (native.lua uses plain `vim.fn.termopen`; snacks.lua delegates to
53+
`Snacks.terminal.open`). It neither causes nor currently mitigates the bug.
54+
55+
### Why right-click may differ from Cmd+V (unverified)
56+
57+
Plausibly, right-click paste in some emulators injects clipboard bytes **directly into the inner
58+
PTY**, bypassing Neovim's `vim.paste` streaming entirely, so it arrives as one clean bracketed
59+
paste. Cmd+V is intercepted by the TUI and routed through the streamed phases. This is
60+
emulator-/keybinding-dependent and was not confirmed in a controlled test.
61+
62+
## How this fixture proves it
63+
64+
`claude` requires auth and hides its paste handling, so this fixture replaces it with
65+
[`observer.py`](./observer.py): a tiny program that enables bracketed paste (DECSET 2004) and logs
66+
exactly how many `ESC[200~`/`ESC[201~` segments the inner PTY receives. **Segment count is the
67+
signal** (`start_markers`/`end_markers` in the log's `TOTAL` line): `>1` = bug, `1` = correct.
68+
The observer is wired in as `terminal_cmd`, so pastes flow through the _real_ plugin terminal path.
69+
70+
## Reproduce it
71+
72+
The bug only appears on an affected Neovim (`< 0.12.2`). You do **not** need to change your
73+
installed/active Neovim — `mise exec neovim@<ver> -- nvim …` runs an old build ephemerally. mise
74+
keeps versions side-by-side in its own cache and only switches the active one via `mise use`, so
75+
this never touches your default Neovim. (`mise exec neovim@<ver> -- nvim` resolves the managed tool
76+
directly, so it also isn't shadowed by other version managers on your `PATH`.) `agent-repro.sh`
77+
does this for you via `NVIM_VERSION`.
78+
79+
### A. Automated (agent-tty) — deterministic, no manual steps
80+
81+
From the repo root:
82+
83+
```bash
84+
NVIM_VERSION=0.11.7 fixtures/paste-repro/agent-repro.sh # affected: also 0.11.5/0.11.6/0.12.0/0.12.1
85+
```
86+
87+
Expected on an affected version:
88+
89+
```
90+
default TOTAL ... start_markers=6 end_markers=6 => BUG (fragmented)
91+
with-workaround TOTAL ... start_markers=1 end_markers=1 => OK (single paste)
92+
```
93+
94+
Re-run with `NVIM_VERSION=0.12.2` and both rows report `OK` — demonstrating the version fix.
95+
The script drives a real Neovim TUI in an isolated agent-tty session, auto-opens the plugin's
96+
Claude terminal (`PASTE_REPRO_AUTOOPEN=1`), pastes via bracketed paste, and reports segments.
97+
(`NVIM_VERSION` resolves the binary through mise without touching your active Neovim; pass an
98+
explicit `NVIM_BIN=/path/to/nvim` instead if you prefer.)
99+
100+
### B. Manual (interactive)
101+
102+
The `vv` alias calls bare `nvim` (subject to whatever's first on `PATH`), so launch Neovim directly
103+
through `mise exec` instead — it runs the managed build unambiguously:
104+
105+
```bash
106+
cd fixtures && \
107+
NVIM_APPNAME=paste-repro XDG_CONFIG_HOME="$PWD" \
108+
mise exec neovim@0.11.7 -- nvim
109+
```
110+
111+
Then inside Neovim:
112+
113+
1. `<leader>ac` opens the Claude terminal (running `observer.py`); you'll see `OBSERVER READY`.
114+
2. Copy 100+ lines to the system clipboard and paste with **Cmd+V** (bracketed paste).
115+
3. Inspect the observer log (path shown by `<leader>al`, default
116+
`:echo stdpath('cache')`/`claudecode-paste-observer.log`): a `start_markers` > 1 in the `TOTAL`
117+
line is the bug. Set `APPLY_PASTE_FIX=1` before launching to verify the workaround collapses it
118+
to 1.
119+
120+
### C. Pure-Neovim isolation (no plugin) — proves it's core, not the plugin
121+
122+
```bash
123+
mise exec neovim@0.11.7 -- nvim --clean \
124+
-c "terminal python3 $PWD/fixtures/paste-repro/observer.py /tmp/obs.log" -c startinsert
125+
# paste 100+ lines, then: grep TOTAL /tmp/obs.log -> start_markers=6 on 0.11.7, =1 on 0.12.2
126+
```
127+
128+
## The workaround (and its edge cases)
129+
130+
The community workaround (huiyu + kyleawayan, in the issue thread) overrides `vim.paste` for
131+
terminal buffers to coalesce streamed phases into a single `phase == -1` replay (toggle in this
132+
fixture with `APPLY_PASTE_FIX=1`):
133+
134+
- Coalescing to `phase == -1` → one `nvim_put` → one bracketed segment → one `[Pasted text]`.
135+
- kyleawayan's refinement re-glues the mid-line chunk seam
136+
(`chunks[#chunks] = chunks[#chunks] .. lines[1]`); without it, every chunk boundary injects a
137+
spurious newline (because `lines` is a `readfile()`-style split with delimiters dropped).
138+
- **Residual edge case** (now verified safe): a chunk boundary landing exactly on a source newline
139+
does _not_ drop a newline — a `readfile()`-style split of newline-terminated text yields a
140+
trailing empty element, so the seam becomes a harmless empty-string concat. This was confirmed
141+
byte-for-byte against fixed-Neovim output for newline-on-boundary, consecutive blank lines, no
142+
trailing newline, CRLF, and a 20 KB multi-boundary paste.
143+
144+
## Status / fix
145+
146+
This is **shipped** in the plugin as of the change that added this fixture:
147+
148+
- **Real fix:** upgrade Neovim to **0.12.2+**.
149+
- **Plugin shim (default `"auto"`):** `lua/claudecode/terminal/paste_fix.lua` installs a scoped,
150+
version-gated, cooperative `vim.paste` override (config: `terminal.fix_streamed_paste`). Unlike
151+
the community snippet it is **scoped to the plugin's own managed terminal buffer** (not all
152+
`:terminal` buffers), is a **no-op on Neovim >= 0.12.2**, and delegates to any pre-existing
153+
`vim.paste`. Verified end-to-end: on 0.11.7 the shipped shim collapses a 20 KB paste from 6
154+
segments to 1, byte-identical to 0.12.2. Set `fix_streamed_paste = false` to opt out, or `true`
155+
to force it on. Unit tests: `tests/unit/terminal/paste_fix_spec.lua`.
156+
157+
This fixture sets `terminal.fix_streamed_paste = false` by default so it reproduces the **raw** bug
158+
(`APPLY_PASTE_FIX` toggles the _standalone_ community override, kept for isolating the workaround
159+
independently of the plugin). To instead exercise the **shipped plugin fix** end-to-end, launch
160+
with `PASTE_REPRO_PLUGIN_FIX=auto` (or `=true`).
161+
162+
## Files
163+
164+
- `init.lua` — fixture config (native provider; `terminal_cmd` → observer). Env toggles:
165+
`APPLY_PASTE_FIX` (standalone workaround), `PASTE_REPRO_PLUGIN_FIX` (the plugin's own
166+
`fix_streamed_paste`, default off here), `PASTE_REPRO_AUTOOPEN`, `PASTE_OBSERVER_LOG`.
167+
- `observer.py` — bracketed-paste segment counter (the measurement instrument).
168+
- `agent-repro.sh` — self-contained automated reproduction via agent-tty.
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
#!/usr/bin/env bash
2+
#
3+
# Self-contained, deterministic reproduction of claudecode.nvim issue #161
4+
# ("Cmd+V paste truncates content in the Claude Code terminal") using agent-tty.
5+
#
6+
# It drives a real Neovim TUI inside an agent-tty session, opens the plugin's
7+
# native Claude terminal (which here runs observer.py instead of `claude`), pastes
8+
# a large payload via bracketed paste, and reports how many ESC[200~/ESC[201~
9+
# bracketed-paste SEGMENTS the inner PTY received:
10+
#
11+
# * > 1 segment => BUG reproduced (Claude would render N separate [Pasted text #k]
12+
# placeholders => perceived truncation)
13+
# * 1 segment => correct (one logical paste)
14+
#
15+
# Requirements: agent-tty, python3, and one or more Neovim builds.
16+
# The bug is version-dependent (fixed upstream by neovim/neovim#39152, in 0.12.2):
17+
# * Neovim 0.11.x / 0.12.0 / 0.12.1 -> reproduces (N segments)
18+
# * Neovim 0.12.2+ -> single segment
19+
#
20+
# Usage:
21+
# ./agent-repro.sh # uses `nvim` on PATH
22+
# NVIM_VERSION=0.11.7 ./agent-repro.sh # run an old Neovim via mise (recommended)
23+
# NVIM_BIN=/path/to/nvim ./agent-repro.sh
24+
# LINES=300 ./agent-repro.sh # bigger payload
25+
#
26+
# NVIM_VERSION resolves the binary through mise. mise installs versions
27+
# side-by-side under its own cache, so this NEVER changes your active/default
28+
# Neovim (that only changes via `mise use`). To run one off without this script:
29+
# mise exec neovim@0.11.7 -- nvim ...
30+
set -uo pipefail
31+
32+
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
33+
FIX_DIR="$(dirname "$HERE")" # .../fixtures
34+
LINES="${LINES:-120}"
35+
export _ZO_DOCTOR=0
36+
37+
# Resolve how to launch Neovim (used both verbatim and embedded in a `bash -lc`):
38+
# explicit NVIM_BIN > NVIM_VERSION (via `mise exec`, side-by-side/ephemeral) > `nvim` on PATH.
39+
# `mise exec neovim@X -- nvim` resolves the managed tool directly, so it is not affected by other
40+
# nvim managers on PATH and never changes your active Neovim.
41+
if [ -n "${NVIM_BIN:-}" ]; then
42+
NVIM="$NVIM_BIN"
43+
elif [ -n "${NVIM_VERSION:-}" ]; then
44+
command -v mise >/dev/null || {
45+
echo "ERROR: NVIM_VERSION set but mise not found on PATH"
46+
exit 1
47+
}
48+
mise install "neovim@$NVIM_VERSION" >/dev/null 2>&1 || true
49+
NVIM="mise exec neovim@$NVIM_VERSION -- nvim"
50+
else
51+
NVIM="nvim"
52+
fi
53+
54+
command -v agent-tty >/dev/null || {
55+
echo "ERROR: agent-tty not found on PATH"
56+
exit 1
57+
}
58+
command -v python3 >/dev/null || {
59+
echo "ERROR: python3 not found on PATH"
60+
exit 1
61+
}
62+
# $NVIM may be a path, "nvim", or "mise exec neovim@X -- nvim", so smoke-test it
63+
# (unquoted, to word-split the launcher) rather than checking for an executable file.
64+
# shellcheck disable=SC2086
65+
$NVIM --version >/dev/null 2>&1 || {
66+
echo "ERROR: could not run Neovim ('$NVIM'); set NVIM_BIN or NVIM_VERSION"
67+
exit 1
68+
}
69+
70+
WORK="$(mktemp -d)"
71+
AGENT_HOME="$WORK/atty-home"
72+
mkdir -p "$AGENT_HOME"
73+
PAYLOAD="$WORK/payload.txt"
74+
trap 'agent-tty --home "$AGENT_HOME" gc --json >/dev/null 2>&1; rm -rf "$WORK"' EXIT
75+
76+
# A multi-line payload big enough to span several TUI input reads (~1 KB chunks).
77+
python3 - "$PAYLOAD" "$LINES" <<'PY'
78+
import sys
79+
path, n = sys.argv[1], int(sys.argv[2])
80+
open(path, "w").write("\n".join("L%03d: the quick brown fox jumps over the lazy dog" % i for i in range(1, n+1)) + "\n")
81+
PY
82+
echo "Payload: $LINES lines, $(wc -c <"$PAYLOAD") bytes"
83+
# shellcheck disable=SC2086
84+
echo "Neovim: $($NVIM --version | head -1)"
85+
echo
86+
87+
run() { # run <apply_fix 0|1> <label>
88+
local fix="$1" label="$2"
89+
local log="$WORK/observer_$label.log"
90+
rm -f "$log" "$log.raw"
91+
local a=(agent-tty --home "$AGENT_HOME")
92+
local sid
93+
sid="$("${a[@]}" create --json --cols 110 --rows 32 -- \
94+
bash -lc "cd '$FIX_DIR' && PASTE_OBSERVER_LOG='$log' APPLY_PASTE_FIX='$fix' PASTE_REPRO_AUTOOPEN=1 NVIM_APPNAME=paste-repro XDG_CONFIG_HOME='$FIX_DIR' $NVIM --clean -u '$FIX_DIR/paste-repro/init.lua'" |
95+
python3 -c "import json,sys;print(json.load(sys.stdin)['result']['sessionId'])")"
96+
"${a[@]}" wait "$sid" --text 'OBSERVER READY' --timeout-ms 15000 --json >/dev/null 2>&1
97+
"${a[@]}" paste "$sid" "$(cat "$PAYLOAD")" --json >/dev/null 2>&1
98+
"${a[@]}" wait "$sid" --screen-stable-ms 1000 --timeout-ms 8000 --json >/dev/null 2>&1
99+
"${a[@]}" type "$sid" '<<QUIT>>' --json >/dev/null 2>&1
100+
"${a[@]}" wait "$sid" --screen-stable-ms 700 --timeout-ms 6000 --json >/dev/null 2>&1
101+
local total
102+
total="$(grep -E '^TOTAL' "$log" 2>/dev/null || echo 'NO LOG (terminal did not open)')"
103+
local segs
104+
segs="$(sed -n 's/.*start_markers=\([0-9]*\).*/\1/p' <<<"$total")"
105+
local verdict="?"
106+
[ "${segs:-0}" = "1" ] && verdict="OK (single paste)"
107+
[ "${segs:-0}" -gt 1 ] 2>/dev/null && verdict="BUG (fragmented)"
108+
printf ' %-28s %s => %s\n' "$label" "$total" "$verdict"
109+
"${a[@]}" send-keys "$sid" "C-\\" "C-n" --json >/dev/null 2>&1
110+
"${a[@]}" type "$sid" ':qa!' --json >/dev/null 2>&1
111+
"${a[@]}" send-keys "$sid" Enter --json >/dev/null 2>&1
112+
sleep 0.3
113+
"${a[@]}" destroy "$sid" --json >/dev/null 2>&1
114+
}
115+
116+
echo "Result (bracketed-paste segments seen by the inner PTY):"
117+
run 0 "default"
118+
run 1 "with-workaround"
119+
echo
120+
echo "Interpretation: on an affected Neovim (<= 0.11.x / 0.12.1) 'default' shows >1"
121+
echo "segment (bug); 'with-workaround' shows 1. On Neovim 0.12.2+ both show 1."

0 commit comments

Comments
 (0)