|
| 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. |
0 commit comments