Skip to content

Commit cb225ef

Browse files
ThomasK33claude
andcommitted
fix(terminal): harden paste shim per code review (#161)
Address findings from a max-effort review of the streamed-paste shim: - Fixture repro was self-defeating: the plugin's own fix_streamed_paste="auto" coalesced the paste on exactly the affected Neovim versions, so the agent-repro.sh "default" run could never observe the bug. The fixture now sets fix_streamed_paste=false by default (raw repro), with PASTE_REPRO_PLUGIN_FIX to opt into exercising the shipped fix. - Harden the hot path: is_managed_terminal() now pcall-guards get_active_terminal_bufnr() (provider resolution could throw and break pasting editor-wide) and caches the lazily-required terminal module instead of pcall(require) on every phase. - Latch the managed-terminal decision once per stream at phase 1 and reuse it for phases 2/3. This resolves the provider only once per paste (not per phase) and keeps buffered content intact if focus changes mid-stream (previously a mid-stream focus change stranded phases 1/2 and mis-delegated phase 3). - Make the config dynamic: an `enabled` flag is checked in the override, so a later setup() with fix_streamed_paste=false disables it without needing to uninstall/restore the global vim.paste. - is_affected_version: guard v.minor like v.patch ((v.minor or 0)) to avoid a nil comparison on a malformed version table. - Simplify _accumulate to a single append loop. - Tests: add phase-2 (1->2->3) coverage, a mid-stream focus-change case, and a dynamic-disable case (26 unit tests). Skipped (noted, not applied): switching the version gate to vim.fn.has() (the explicit, tested vim.version() check avoids patch-level has() support risk); a buffer-local/TermOpen-based marker instead of the global override (correct altitude but touches provider internals outside this change — the latch + pcall capture most of the benefit); cosmetic divergences from the default handler (progress dots, empty-chunk skip) which were verified to produce byte-identical output. Change-Id: I0e3fc55dc517c98cde042a8c1babea2e7e45bed3 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent 8bdcefa commit cb225ef

5 files changed

Lines changed: 127 additions & 73 deletions

File tree

fixtures/paste-repro/README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -148,12 +148,15 @@ This is **shipped** in the plugin as of the change that added this fixture:
148148
segments to 1, byte-identical to 0.12.2. Set `fix_streamed_paste = false` to opt out, or `true`
149149
to force it on. Unit tests: `tests/unit/terminal/paste_fix_spec.lua`.
150150

151-
The `APPLY_PASTE_FIX=1` toggle in this fixture applies the _standalone_ community override and is
152-
kept for isolating the workaround independently of the plugin.
151+
This fixture sets `terminal.fix_streamed_paste = false` by default so it reproduces the **raw** bug
152+
(`APPLY_PASTE_FIX` toggles the _standalone_ community override, kept for isolating the workaround
153+
independently of the plugin). To instead exercise the **shipped plugin fix** end-to-end, launch
154+
with `PASTE_REPRO_PLUGIN_FIX=auto` (or `=true`).
153155

154156
## Files
155157

156-
- `init.lua` — fixture config (native provider; `terminal_cmd` → observer; `APPLY_PASTE_FIX`,
157-
`PASTE_REPRO_AUTOOPEN`, `PASTE_OBSERVER_LOG` env toggles).
158+
- `init.lua` — fixture config (native provider; `terminal_cmd` → observer). Env toggles:
159+
`APPLY_PASTE_FIX` (standalone workaround), `PASTE_REPRO_PLUGIN_FIX` (the plugin's own
160+
`fix_streamed_paste`, default off here), `PASTE_REPRO_AUTOOPEN`, `PASTE_OBSERVER_LOG`.
158161
- `observer.py` — bracketed-paste segment counter (the measurement instrument).
159162
- `agent-repro.sh` — self-contained automated reproduction via agent-tty.

fixtures/paste-repro/init.lua

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,19 @@ if fix_active then
7373
end
7474
end
7575

76+
-- Whether to enable the *plugin's own* paste shim. Defaults to false so the
77+
-- fixture reproduces the raw bug (with APPLY_PASTE_FIX as the controlled
78+
-- variable). Set PASTE_REPRO_PLUGIN_FIX=auto|true to instead exercise the
79+
-- shipped plugin fix end-to-end.
80+
local plugin_fix = os.getenv("PASTE_REPRO_PLUGIN_FIX")
81+
if plugin_fix == "auto" then
82+
plugin_fix = "auto"
83+
elseif plugin_fix == "true" then
84+
plugin_fix = true
85+
else
86+
plugin_fix = false
87+
end
88+
7689
local ok, claudecode = pcall(require, "claudecode")
7790
assert(ok, "Failed to load claudecode.nvim from repo root: " .. tostring(claudecode))
7891

@@ -86,6 +99,12 @@ claudecode.setup({
8699
terminal = {
87100
provider = "native",
88101
auto_close = false,
102+
-- Disabled by default so this fixture reproduces the RAW bug (APPLY_PASTE_FIX
103+
-- is the controlled variable). If the plugin's shim ("auto") were left on, it
104+
-- would coalesce the paste on exactly the affected Neovim versions, so the
105+
-- "default" run could never observe the >1-segment fragmentation it is meant
106+
-- to show. Set PASTE_REPRO_PLUGIN_FIX=auto to exercise the shipped fix.
107+
fix_streamed_paste = plugin_fix,
89108
},
90109
})
91110

lua/claudecode/terminal.lua

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ local defaults = {
1919
auto_close = true,
2020
env = {},
2121
snacks_win_opts = {},
22-
-- Workaround for a Neovim core bug (< 0.12.2) that fragments large bracketed
23-
-- pastes into the terminal; see lua/claudecode/terminal/paste_fix.lua and
24-
-- https://github.com/coder/claudecode.nvim/issues/161.
25-
-- true = force on, false = off, "auto" = on only for affected Neovim versions.
26-
fix_streamed_paste = "auto",
22+
fix_streamed_paste = "auto", -- work around Neovim <0.12.2 paste fragmentation (#161): true|false|"auto"
2723
-- Working directory control
2824
cwd = nil, -- static cwd override
2925
git_repo_cwd = false, -- resolve to git root when spawning
@@ -508,8 +504,7 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
508504
-- Setup providers with config
509505
get_provider().setup(defaults)
510506

511-
-- Install the streamed-paste compatibility shim for issue #161. No-op on
512-
-- Neovim >= 0.12.2 (and when disabled), scoped to the managed terminal buffer.
507+
-- Streamed-paste compatibility shim for #161 (no-op on Neovim >= 0.12.2).
513508
require("claudecode.terminal.paste_fix").apply(defaults.fix_streamed_paste)
514509
end
515510

Lines changed: 59 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,18 @@
1-
--- Compatibility shim for a Neovim core bug that fragments large bracketed
2-
--- pastes into a `:terminal` buffer.
3-
---
4-
--- See https://github.com/coder/claudecode.nvim/issues/161 and the upstream fix
5-
--- https://github.com/neovim/neovim/pull/39152 (landed in Neovim 0.12.2).
6-
---
7-
--- On affected Neovim (< 0.12.2), the default `vim.paste` runs `nvim_put()` once
8-
--- per streamed phase (1 -> 2 -> 3). In a terminal buffer each write is
9-
--- independently wrapped in bracketed-paste markers (ESC[200~ .. ESC[201~) when
10-
--- the inner program enabled them, so a single large paste reaches the program
11-
--- running in the terminal (e.g. the Claude CLI) as N separate paste events.
12-
--- Claude renders N `[Pasted text #k]` placeholders with phase-boundary bytes
13-
--- leaking between them, which the user perceives as truncation.
14-
---
15-
--- This module installs a cooperative `vim.paste` override that coalesces the
16-
--- streamed phases of a paste into ONE non-streamed (`phase == -1`) replay, but
17-
--- only for the plugin's own managed terminal buffer. Everything else delegates
18-
--- to the original handler unchanged. The override is a no-op on Neovim >= 0.12.2
19-
--- (which already coalesces) unless explicitly forced.
1+
--- Compatibility shim for a Neovim core bug (< 0.12.2) that fragments a large
2+
--- bracketed paste into a `:terminal` across `vim.paste` phases, so the program
3+
--- in the terminal sees it as N separate pastes. Coalesces the streamed phases
4+
--- into one `phase == -1` replay, scoped to the plugin's managed terminal; a
5+
--- no-op on Neovim >= 0.12.2 unless forced. See coder/claudecode.nvim#161 and the
6+
--- upstream fix neovim/neovim#39152.
207
--- @module 'claudecode.terminal.paste_fix'
218

229
local M = {}
2310

24-
-- Accumulator for the in-flight streamed paste. Pastes are processed
25-
-- sequentially, so a single shared buffer is safe.
26-
local chunks = {}
27-
local installed = false
11+
local chunks = {} -- accumulator for the in-flight streamed paste (pastes are sequential)
12+
local installed = false -- the global vim.paste wrap happens at most once
13+
local enabled = false -- config-controlled; lets a later setup() disable in place
14+
local streaming = false -- does the current stream target the managed terminal? (decided at phase 1)
15+
local terminal_mod = nil -- lazily required to avoid a terminal -> paste_fix cycle
2816

2917
--- True if the running Neovim has the per-phase terminal-paste fragmentation
3018
--- bug, i.e. is older than the 0.12.2 fix.
@@ -37,7 +25,9 @@ function M.is_affected_version()
3725
if v.major ~= 0 then
3826
return false -- 1.0+ is well past the fix
3927
end
40-
return v.minor < 12 or (v.minor == 12 and (v.patch or 0) < 2)
28+
local minor = v.minor or 0
29+
local patch = v.patch or 0
30+
return minor < 12 or (minor == 12 and patch < 2)
4131
end
4232

4333
--- Resolve whether the shim should be active for a given config value.
@@ -55,86 +45,88 @@ function M.should_enable(opt)
5545
return M.is_affected_version()
5646
end
5747

58-
--- Append one streamed phase's `lines` to `acc`, re-gluing the mid-line seam.
59-
---
60-
--- `lines` is a `readfile()`-style split of the chunk on "\n" with the delimiters
61-
--- dropped, so when a paste is fragmented across chunks the boundary usually
62-
--- falls mid-line: the last element of the previous chunk and the first element
63-
--- of this chunk are two halves of the same source line. Appending naively would
64-
--- insert a spurious newline at every seam, so the first incoming line is joined
65-
--- onto the last buffered line instead.
48+
--- Append one streamed phase's `lines` to `acc`. `lines` is a `readfile()`-style
49+
--- split, so consecutive chunks meet mid-line; re-glue the seam rather than
50+
--- inserting a spurious newline at every chunk boundary.
6651
--- @param acc string[] accumulator (mutated in place)
67-
--- @param lines string[] the current phase's lines
52+
--- @param lines string[]
6853
function M._accumulate(acc, lines)
6954
if #lines == 0 then
7055
return
7156
end
72-
if #acc == 0 then
73-
for _, line in ipairs(lines) do
74-
acc[#acc + 1] = line
75-
end
76-
else
77-
acc[#acc] = acc[#acc] .. lines[1]
78-
for i = 2, #lines do
79-
acc[#acc + 1] = lines[i]
80-
end
57+
local start = 1
58+
if #acc > 0 then
59+
acc[#acc] = acc[#acc] .. lines[1] -- re-glue mid-line seam
60+
start = 2
61+
end
62+
for i = start, #lines do
63+
acc[#acc + 1] = lines[i]
8164
end
8265
end
8366

84-
--- Whether `bufnr` is the plugin's managed Claude terminal buffer.
67+
--- Whether `bufnr` is the plugin's managed terminal. Must never throw: it runs on
68+
--- the paste hot path, so failures degrade to "not managed".
8569
--- @param bufnr integer
8670
--- @return boolean
8771
local function is_managed_terminal(bufnr)
8872
if vim.bo[bufnr].buftype ~= "terminal" then
8973
return false
9074
end
91-
-- Lazy require to avoid a load-time dependency cycle (terminal -> paste_fix).
92-
local ok, terminal = pcall(require, "claudecode.terminal")
93-
if not ok then
94-
return false
75+
if not terminal_mod then
76+
local ok, mod = pcall(require, "claudecode.terminal")
77+
if not ok then
78+
return false
79+
end
80+
terminal_mod = mod
9581
end
96-
local active = terminal.get_active_terminal_bufnr()
97-
return active ~= nil and active == bufnr
82+
local ok, active = pcall(terminal_mod.get_active_terminal_bufnr)
83+
return ok and active ~= nil and active == bufnr
9884
end
9985

100-
--- Install the cooperative `vim.paste` override (idempotent). Captures the
101-
--- current `vim.paste` and delegates to it for everything except streamed pastes
102-
--- into the plugin's managed terminal buffer.
86+
--- Install the cooperative `vim.paste` override (idempotent). Delegates to the
87+
--- captured original except for a streamed paste into the managed terminal, and
88+
--- honours `enabled` at call time so it can be disabled without uninstalling.
10389
function M.install()
10490
if installed then
10591
return
10692
end
10793
installed = true
108-
chunks = {}
10994

11095
local orig_paste = vim.paste
11196
vim.paste = function(lines, phase)
112-
-- Only intervene for streamed pastes (phase 1/2/3) into the plugin's own
113-
-- terminal buffer. `phase == -1` is already a whole, non-streamed paste.
114-
if phase == -1 or not is_managed_terminal(vim.api.nvim_get_current_buf()) then
97+
if not enabled then
11598
return orig_paste(lines, phase)
11699
end
117100

101+
-- Decide once per stream (at phase 1) whether it targets the managed
102+
-- terminal, so phases 2/3 stay coalesced even if focus moves mid-stream.
118103
if phase == 1 then
104+
streaming = is_managed_terminal(vim.api.nvim_get_current_buf())
119105
chunks = {}
120106
end
107+
108+
if not streaming or phase == -1 then
109+
return orig_paste(lines, phase)
110+
end
111+
121112
M._accumulate(chunks, lines)
122113

123114
if phase == 3 then
124115
local buffered = chunks
125116
chunks = {}
126-
-- Replay as a single non-streamed paste so the terminal wraps it in one
127-
-- bracketed-paste segment.
128-
return orig_paste(buffered, -1)
117+
streaming = false
118+
return orig_paste(buffered, -1) -- one non-streamed replay = one bracketed segment
129119
end
130120
return true
131121
end
132122
end
133123

134-
--- Apply the shim according to the resolved config value.
124+
--- Apply the shim per the resolved config value: set the active state (so a later
125+
--- call with `false` disables an installed override) and install on first enable.
135126
--- @param opt boolean|string|nil
136127
function M.apply(opt)
137-
if M.should_enable(opt) then
128+
enabled = M.should_enable(opt)
129+
if enabled then
138130
M.install()
139131
end
140132
end
@@ -145,4 +137,10 @@ function M._is_installed()
145137
return installed
146138
end
147139

140+
--- Test helper: report whether the override is currently active.
141+
--- @return boolean
142+
function M._is_enabled()
143+
return enabled
144+
end
145+
148146
return M

tests/unit/terminal/paste_fix_spec.lua

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,8 @@ describe("claudecode.terminal.paste_fix", function()
155155
return managed_bufnr
156156
end,
157157
}
158-
paste_fix.install()
158+
-- apply(true) sets enabled and installs the override.
159+
paste_fix.apply(true)
159160
end
160161

161162
it("coalesces a streamed paste into one phase==-1 replay for the managed terminal", function()
@@ -169,6 +170,18 @@ describe("claudecode.terminal.paste_fix", function()
169170
assert.are.same({ "hello world", "second line" }, orig_calls[1].lines)
170171
end)
171172

173+
it("coalesces a three-phase (1->2->3) stream including the middle phase", function()
174+
setup_env()
175+
-- Source "foo\nbar\nbaz" streamed across three chunks, each split mid-line.
176+
assert.is_true(vim.paste({ "foo", "ba" }, 1))
177+
assert.is_true(vim.paste({ "r", "ba" }, 2))
178+
assert.is_true(vim.paste({ "z" }, 3))
179+
180+
assert.are.equal(1, #orig_calls)
181+
assert.are.equal(-1, orig_calls[1].phase)
182+
assert.are.same({ "foo", "bar", "baz" }, orig_calls[1].lines)
183+
end)
184+
172185
it("delegates a non-streamed (phase==-1) paste unchanged", function()
173186
setup_env()
174187
vim.paste({ "whole" }, -1)
@@ -177,6 +190,32 @@ describe("claudecode.terminal.paste_fix", function()
177190
assert.are.same({ "whole" }, orig_calls[1].lines)
178191
end)
179192

193+
it("keeps buffered content when focus leaves the managed terminal mid-stream", function()
194+
setup_env()
195+
-- Phase 1 targets the managed terminal; the decision is latched.
196+
assert.is_true(vim.paste({ "kept ", "dat" }, 1))
197+
-- Focus moves away before phase 3 (active bufnr no longer current).
198+
current_bufnr = 99
199+
buftype_by_buf[99] = ""
200+
vim.paste({ "a" }, 3)
201+
-- Still coalesced into one replay; buffered content is not stranded.
202+
assert.are.equal(1, #orig_calls)
203+
assert.are.equal(-1, orig_calls[1].phase)
204+
assert.are.same({ "kept ", "data" }, orig_calls[1].lines)
205+
end)
206+
207+
it("respects a later apply(false): delegates instead of coalescing", function()
208+
setup_env()
209+
paste_fix.apply(false) -- disable without uninstalling
210+
assert.is_false(paste_fix._is_enabled())
211+
vim.paste({ "a", "b" }, 1)
212+
vim.paste({ "c" }, 3)
213+
-- No coalescing: each phase passes straight through.
214+
assert.are.equal(2, #orig_calls)
215+
assert.are.equal(1, orig_calls[1].phase)
216+
assert.are.equal(3, orig_calls[2].phase)
217+
end)
218+
180219
it("delegates pastes into a non-managed terminal unchanged", function()
181220
setup_env()
182221
current_bufnr = 99 -- not the managed terminal

0 commit comments

Comments
 (0)