From d23507a0f61a28f6d56d5d2b43594a119ee78deb Mon Sep 17 00:00:00 2001 From: Chris Hebert <8251796+cphbrt@users.noreply.github.com> Date: Wed, 5 Aug 2026 05:37:02 -0500 Subject: [PATCH] fix(terminal): OSC 52 multiline copy replaces newlines with NUL #41097 Problem: An OSC 52 sequence from a :terminal job passes the decoded payload to the clipboard provider as a single list item. Command-line providers (pbcopy, xclip, ...) receive it with channel semantics, where a newline inside an item is sent as NUL (:h chansend()), so multiline copies arrive with NUL bytes instead of newlines. Solution: Split the payload on newlines into a proper list of lines. A trailing newline yields a final empty item, which chansend() turns back into a newline, so payloads round-trip exactly. (cherry picked from commit e58f29ca3e6935af5b14879db1e8f2b959555c10) --- src/nvim/terminal.c | 13 +++++++-- test/functional/terminal/clipboard_spec.lua | 29 ++++++++++++++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/nvim/terminal.c b/src/nvim/terminal.c index 004a00265a..c141c73d28 100644 --- a/src/nvim/terminal.c +++ b/src/nvim/terminal.c @@ -1828,8 +1828,17 @@ static void term_clipboard_set(void **argv) break; } - list_T *lines = tv_list_alloc(1); - tv_list_append_allocated_string(lines, data); + // Split the payload into readfile()-style list (:h chansend()). + // TODO(justinmk): drop this, support Blob in clipboard provider: #41097 + list_T *lines = tv_list_alloc(kListLenMayKnow); + char *start = data; + char *end; + while ((end = strchr(start, '\n')) != NULL) { + tv_list_append_string(lines, start, end - start); + start = end + 1; + } + tv_list_append_string(lines, start, -1); + xfree(data); list_T *args = tv_list_alloc(3); tv_list_append_list(args, lines); diff --git a/test/functional/terminal/clipboard_spec.lua b/test/functional/terminal/clipboard_spec.lua index f0ce407eaa..e0f6e043da 100644 --- a/test/functional/terminal/clipboard_spec.lua +++ b/test/functional/terminal/clipboard_spec.lua @@ -18,6 +18,7 @@ describe(':terminal', function() local function clipboard(reg, type) if type == 'copy' then return function(lines) + vim.g.clipboard_lines = lines local data = table.concat(lines, '\n') vim.g.clipboard_data = data end @@ -46,20 +47,40 @@ describe(':terminal', function() ]]) end) + local function osc52(arg) + return string.format('\027]52;;%s\027\\', arg) + end + it('can write to the system clipboard', function() eq('Test', eval('g:clipboard.name')) local text = 'Hello, world! This is some\nexample text\nthat spans multiple\nlines' local encoded = exec_lua('return vim.base64.encode(...)', text) - local function osc52(arg) - return string.format('\027]52;;%s\027\\', arg) - end - fn.jobstart({ testprg('shell-test'), '-t', osc52(encoded) }, { term = true }) retry(nil, 1000, function() eq(text, exec_lua([[ return vim.g.clipboard_data ]])) end) + + -- Multiline payloads must arrive split into separate list items: a newline + -- inside a single item would be sent to a command-line provider as NUL. + eq({ + 'Hello, world! This is some', + 'example text', + 'that spans multiple', + 'lines', + }, exec_lua([[ return vim.g.clipboard_lines ]])) + end) + + it('multiline copy does not send newlines as NUL #41097', function() + local text = '\nfoo\n\nbar\n' + local encoded = exec_lua('return vim.base64.encode(...)', text) + + fn.jobstart({ testprg('shell-test'), '-t', osc52(encoded) }, { term = true }) + + retry(nil, 1000, function() + eq({ '', 'foo', '', 'bar', '' }, exec_lua([[ return vim.g.clipboard_lines ]])) + end) end) end)