From 7641177c5f981d90b3c080763a401496034f35c9 Mon Sep 17 00:00:00 2001 From: Sean Dewar <6256228+seandewar@users.noreply.github.com> Date: Sun, 15 Feb 2026 03:21:07 +0000 Subject: [PATCH 1/3] fix(prompt): wrong cursor col after prompt_setprompt, no on_lines Problem: prompt_setprompt calls coladvance with a byte column, but it expects a screen column. on_lines buffer-updates aren't fired when fixing the prompt line. Solution: don't use coladvance. Call changed_lines, which also simplifies the redraw logic. (and calls changed_cline_bef_curs if needed; added test checks this) Unlike https://github.com/neovim/neovim/pull/37743/changes#r2775398744, this means &modified is set by prompt_setprompt if it fixes the prompt line. Not setting &modified is inconsistent anyway -- even init_prompt sets it if it fixes the prompt line. --- src/nvim/eval/buffer.c | 5 +- test/functional/legacy/prompt_buffer_spec.lua | 46 ++++++++++++++++--- test/functional/lua/buffer_updates_spec.lua | 42 +++++++++++++++++ 3 files changed, 83 insertions(+), 10 deletions(-) diff --git a/src/nvim/eval/buffer.c b/src/nvim/eval/buffer.c index 80202a8cec..dd731d0d26 100644 --- a/src/nvim/eval/buffer.c +++ b/src/nvim/eval/buffer.c @@ -801,10 +801,9 @@ void f_prompt_setprompt(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) } if (curwin->w_buffer == buf && curwin->w_cursor.lnum == prompt_lno) { - coladvance(curwin, cursor_col); + curwin->w_cursor.col = cursor_col; } - changed_lines_redraw_buf(buf, prompt_lno, prompt_lno + 1, 0); - redraw_buf_later(buf, UPD_INVERTED); + changed_lines(buf, prompt_lno, 0, prompt_lno + 1, 0, true); } // Clear old prompt text and replace with the new one diff --git a/test/functional/legacy/prompt_buffer_spec.lua b/test/functional/legacy/prompt_buffer_spec.lua index 550853d06b..36535245ea 100644 --- a/test/functional/legacy/prompt_buffer_spec.lua +++ b/test/functional/legacy/prompt_buffer_spec.lua @@ -62,7 +62,7 @@ describe('prompt buffer', function() screen:expect([[ cmd: ^ | {1:~ }|*3 - {3:[Prompt] }| + {3:[Prompt] [+] }| other buffer | {1:~ }|*3 {5:-- INSERT --} | @@ -149,7 +149,7 @@ describe('prompt buffer', function() screen:expect([[ cmd: | {1:~ }|*3 - {2:[Prompt] }| + {2:[Prompt] [+] }| ^other buffer | {1:~ }|*3 | @@ -158,7 +158,7 @@ describe('prompt buffer', function() screen:expect([[ cmd: ^ | {1:~ }|*3 - {3:[Prompt] }| + {3:[Prompt] [+] }| other buffer | {1:~ }|*3 {5:-- INSERT --} | @@ -167,7 +167,7 @@ describe('prompt buffer', function() screen:expect([[ cmd:^ | {1:~ }|*3 - {3:[Prompt] }| + {3:[Prompt] [+] }| other buffer | {1:~ }|*3 | @@ -892,10 +892,42 @@ describe('prompt buffer', function() {1:~ }|*7 | ]]) + -- Correct col when prompt has multi-cell chars. + feed('i') + screen:expect([[ + new-prompt > user input | + <>< user inp^ut | + {1:~ }|*7 + {5:-- INSERT --} | + ]]) + set_prompt('\t > ') + screen:expect([[ + new-prompt > user input | + > user inp^ut | + {1:~ }|*7 + {5:-- INSERT --} | + ]]) + -- Works with 'virtualedit': coladd remains sensible. Cursor is redrawn correctly. + -- Tab size visually changes due to multiples of 'tabstop'. + command('set virtualedit=all') + feed('Sab3h') + screen:expect([[ + new-prompt > user input | + > a ^ b | + {1:~ }|*7 + {5:-- INSERT --} | + ]]) + set_prompt('😊 > ') + screen:expect([[ + new-prompt > user input | + 😊 > a ^ b | + {1:~ }|*7 + {5:-- INSERT --} | + ]]) -- No crash when setting shorter prompt than curbuf's in other buffer. - feed('izt') - command('new | setlocal buftype=prompt') + feed('zt') + command('set virtualedit& | new | setlocal buftype=prompt') set_prompt('looooooooooooooooooooooooooooooooooooooooooooong > ', '') -- curbuf set_prompt('foo > ') screen:expect([[ @@ -904,7 +936,7 @@ describe('prompt buffer', function() ^ | {1:~ }| {3:[Prompt] [+] }| - foo > user input | + foo > a b | {1:~ }|*3 {5:-- INSERT --} | ]]) diff --git a/test/functional/lua/buffer_updates_spec.lua b/test/functional/lua/buffer_updates_spec.lua index dfca9f02f6..6c142cd01e 100644 --- a/test/functional/lua/buffer_updates_spec.lua +++ b/test/functional/lua/buffer_updates_spec.lua @@ -422,6 +422,48 @@ describe('lua: nvim_buf_attach on_lines', function() feed('I ') eq({ api.nvim_get_current_buf(), 0, 1, 1 }, exec_lua('return _G.res')) end) + + it('prompt buffer', function() + local check_events = setup_eventcheck(false, nil, {}) + api.nvim_set_option_value('buftype', 'prompt', {}) + feed('i') + check_events { + { 'test1', 'lines', 1, 4, 0, 1, 1, 1 }, + } + fn.prompt_setprompt('', 'foo > ') + check_events { + { 'test1', 'lines', 1, 5, 0, 1, 1, 3 }, + } + feed('hello') + check_events { + { 'test1', 'lines', 1, 6, 0, 1, 1, 7 }, + } + fn.prompt_setprompt('', 'super-foo > ') + check_events { + { 'test1', 'lines', 1, 7, 0, 1, 1, 12 }, + } + eq({ 'super-foo > hello' }, api.nvim_buf_get_lines(0, 0, -1, true)) + -- Do this in the same event. + exec_lua(function() + vim.fn.setpos("':", { 0, 1, 999, 0 }) + vim.fn.prompt_setprompt('', 'discard > ') + end) + check_events { + { 'test1', 'lines', 1, 8, 0, 1, 1, 18 }, + } + eq({ 'discard > ' }, api.nvim_buf_get_lines(0, 0, -1, true)) + feed('hellothere') + check_events { + { 'test1', 'lines', 1, 9, 0, 1, 1, 11 }, + { 'test1', 'lines', 1, 10, 0, 1, 2, 16 }, + { 'test1', 'lines', 1, 11, 1, 2, 2, 1 }, + } + fn.prompt_setprompt('', 'foo > ') + check_events { + { 'test1', 'lines', 1, 12, 0, 1, 1, 16 }, + } + eq({ 'foo > hello', 'there' }, api.nvim_buf_get_lines(0, 0, -1, true)) + end) end) describe('lua: nvim_buf_attach on_bytes', function() From 3a10405214ace2ac4bf7a289bd789fa16e18c437 Mon Sep 17 00:00:00 2001 From: Sean Dewar <6256228+seandewar@users.noreply.github.com> Date: Sun, 15 Feb 2026 16:22:47 +0000 Subject: [PATCH 2/3] fix(prompt): prompt_setprompt does not adjust extmarks, no on_bytes Problem: prompt_setprompt does not adjust extmarks or trigger on_bytes buffer-updates when fixing the prompt line. Solution: adjust them, trigger on_bytes. Notably, hides extmarks when replacing the entire line (and clearing user input). Otherwise, when just replacing the prompt text, hides extmarks there, but moves those after (in the user input area) to the correct spot. --- src/nvim/eval/buffer.c | 4 ++ test/functional/api/extmark_spec.lua | 41 ++++++++++++++++++++- test/functional/lua/buffer_updates_spec.lua | 34 +++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) diff --git a/src/nvim/eval/buffer.c b/src/nvim/eval/buffer.c index dd731d0d26..b9070eb0dc 100644 --- a/src/nvim/eval/buffer.c +++ b/src/nvim/eval/buffer.c @@ -20,6 +20,7 @@ #include "nvim/eval/typval_defs.h" #include "nvim/eval/window.h" #include "nvim/ex_cmds.h" +#include "nvim/extmark.h" #include "nvim/globals.h" #include "nvim/macros_defs.h" #include "nvim/memline.h" @@ -790,6 +791,7 @@ void f_prompt_setprompt(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) // If for some odd reason the old prompt is missing, // replace prompt line with new-prompt (discards user-input). ml_replace_buf(buf, prompt_lno, (char *)new_prompt, true, false); + extmark_splice_cols(buf, prompt_lno - 1, 0, old_line_len, new_prompt_len, kExtmarkUndo); cursor_col = new_prompt_len; } else { // Replace prev-prompt + user-input with new-prompt + user-input @@ -797,6 +799,8 @@ void f_prompt_setprompt(typval_T *argvars, typval_T *rettv, EvalFuncData fptr) if (ml_replace_buf(buf, prompt_lno, new_line, false, false) != OK) { xfree(new_line); } + extmark_splice_cols(buf, prompt_lno - 1, 0, buf->b_prompt_start.mark.col, new_prompt_len, + kExtmarkUndo); cursor_col += new_prompt_len - old_prompt_len; } diff --git a/test/functional/api/extmark_spec.lua b/test/functional/api/extmark_spec.lua index 42f3a8fe30..65a1f9ccd3 100644 --- a/test/functional/api/extmark_spec.lua +++ b/test/functional/api/extmark_spec.lua @@ -11,7 +11,9 @@ local feed = n.feed local clear = n.clear local command = n.command local exec = n.exec +local exec_lua = n.exec_lua local api = n.api +local fn = n.fn local assert_alive = n.assert_alive local function expect(contents) @@ -1562,10 +1564,45 @@ describe('API/extmarks', function() it('in prompt buffer', function() feed('dd') - local id = set_extmark(ns, marks[1], 0, 0, {}) + set_extmark(ns, marks[1], 0, 0, {}) api.nvim_set_option_value('buftype', 'prompt', {}) feed('i') - eq({ { id, 0, 2 } }, get_extmarks(ns, 0, -1)) + eq({ { marks[1], 0, 2 } }, get_extmarks(ns, 0, -1)) + fn.prompt_setprompt('', 'foo > ') + eq({ { marks[1], 0, 6 } }, get_extmarks(ns, 0, -1)) + feed('ihello') + eq({ { marks[1], 0, 11 } }, get_extmarks(ns, 0, -1)) + + local function get_extmark_range(id) + local rv = get_extmark_by_id(ns, id, { details = true }) + return rv[3].invalid and 'invalid' or { rv[1], rv[2], rv[3].end_row, rv[3].end_col } + end + + set_extmark(ns, marks[2], 0, 0, { invalidate = true, end_col = 6 }) + set_extmark(ns, marks[3], 0, 6, { invalidate = true, end_col = 11 }) + set_extmark(ns, marks[4], 0, 0, { invalidate = true, end_col = 11 }) + set_extmark(ns, marks[5], 0, 0, { invalidate = true, end_row = 1 }) + fn.prompt_setprompt('', 'floob > ') + eq({ 0, 13 }, get_extmark_range(marks[1])) + eq('invalid', get_extmark_range(marks[2])) -- extmark spanning old prompt invalidated + eq({ 0, 8, 0, 13 }, get_extmark_range(marks[3])) + eq({ 0, 8, 0, 13 }, get_extmark_range(marks[4])) + eq({ 0, 8, 1, 0 }, get_extmark_range(marks[5])) + + set_extmark(ns, marks[2], 0, 0, { invalidate = true, end_col = 8 }) + set_extmark(ns, marks[3], 0, 8, { invalidate = true, end_col = 13 }) + set_extmark(ns, marks[4], 0, 0, { invalidate = true, end_col = 13 }) + set_extmark(ns, marks[5], 0, 0, { invalidate = true, end_row = 1 }) + -- Do this in the same event. + exec_lua(function() + vim.fn.setpos("':", { 0, 1, 999, 0 }) + vim.fn.prompt_setprompt('', 'discard > ') + end) + eq({ 0, 10 }, get_extmark_range(marks[1])) + eq('invalid', get_extmark_range(marks[2])) -- all spans on line invalidated + eq('invalid', get_extmark_range(marks[3])) + eq('invalid', get_extmark_range(marks[4])) + eq({ 0, 10, 1, 0 }, get_extmark_range(marks[5])) end) it('can get details', function() diff --git a/test/functional/lua/buffer_updates_spec.lua b/test/functional/lua/buffer_updates_spec.lua index 6c142cd01e..e884499a98 100644 --- a/test/functional/lua/buffer_updates_spec.lua +++ b/test/functional/lua/buffer_updates_spec.lua @@ -1618,6 +1618,40 @@ describe('lua: nvim_buf_attach on_bytes', function() { 'test1', 'bytes', 1, 6, 2, 0, 6, 0, 0, 0, 1, 0, 1 }, { 'test1', 'bytes', 1, 7, 2, 0, 6, 0, 0, 0, 0, 2, 2 }, } + fn.prompt_setprompt('', 'foo > ') + check_events { + { 'test1', 'bytes', 1, 8, 2, 0, 6, 0, 2, 2, 0, 6, 6 }, + } + feed('hello') + check_events { + { 'test1', 'bytes', 1, 9, 2, 6, 12, 0, 0, 0, 0, 5, 5 }, + } + fn.prompt_setprompt('', 'uber-foo > ') + check_events { + { 'test1', 'bytes', 1, 10, 2, 0, 6, 0, 6, 6, 0, 11, 11 }, + } + eq({ '% ', '% ', 'uber-foo > hello' }, api.nvim_buf_get_lines(0, 0, -1, true)) + -- Do this in the same event. + exec_lua(function() + vim.fn.setpos("':", { 0, vim.fn.line('.'), 999, 0 }) + vim.fn.prompt_setprompt('', 'discard > ') + end) + check_events { + { 'test1', 'bytes', 1, 11, 2, 0, 6, 0, 16, 16, 0, 10, 10 }, + } + eq({ '% ', '% ', 'discard > ' }, api.nvim_buf_get_lines(0, 0, -1, true)) + feed('supdood') + check_events { + { 'test1', 'bytes', 1, 12, 2, 10, 16, 0, 0, 0, 0, 3, 3 }, + { 'test1', 'bytes', 1, 13, 2, 13, 19, 0, 0, 0, 1, 0, 1 }, + { 'test1', 'bytes', 1, 14, 3, 0, 20, 0, 0, 0, 0, 4, 4 }, + } + eq({ '% ', '% ', 'discard > sup', 'dood' }, api.nvim_buf_get_lines(0, 0, -1, true)) + fn.prompt_setprompt('', 'cool > ') + check_events { + { 'test1', 'bytes', 1, 15, 2, 0, 6, 0, 10, 10, 0, 7, 7 }, + } + eq({ '% ', '% ', 'cool > sup', 'dood' }, api.nvim_buf_get_lines(0, 0, -1, true)) end) local function test_lockmarks(mode) From 51dc752e6cfaee5f76424b16c4c48bd4915bbffb Mon Sep 17 00:00:00 2001 From: Sean Dewar <6256228+seandewar@users.noreply.github.com> Date: Sun, 15 Feb 2026 17:02:40 +0000 Subject: [PATCH 3/3] fix(prompt): wrong changed lnum in init_prompt Problem: if init_prompt replaces the prompt line at the ': mark, it calls inserted_bytes with the wrong lnum. Solution: use the correct lnum. Call appended_lines_mark instead when appending the prompt at the end. --- src/nvim/edit.c | 6 ++++-- test/functional/api/extmark_spec.lua | 9 +++++++++ test/functional/lua/buffer_updates_spec.lua | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/nvim/edit.c b/src/nvim/edit.c index 03ab2a76ad..058b36b8f4 100644 --- a/src/nvim/edit.c +++ b/src/nvim/edit.c @@ -1609,14 +1609,16 @@ static void init_prompt(int cmdchar_todo) // prompt is missing, insert it or append a line with it if (*text == NUL) { ml_replace(curbuf->b_prompt_start.mark.lnum, prompt, true); + inserted_bytes(curbuf->b_prompt_start.mark.lnum, 0, 0, prompt_len); } else { - ml_append(curbuf->b_ml.ml_line_count, prompt, 0, false); + const linenr_T lnum = curbuf->b_ml.ml_line_count; + ml_append(lnum, prompt, 0, false); + appended_lines_mark(lnum, 1); curbuf->b_prompt_start.mark.lnum = curbuf->b_ml.ml_line_count; } curbuf->b_prompt_start.mark.col = prompt_len; curwin->w_cursor.lnum = curbuf->b_ml.ml_line_count; coladvance(curwin, MAXCOL); - inserted_bytes(curbuf->b_ml.ml_line_count, 0, 0, (colnr_T)prompt_len); } // Insert always starts after the prompt, allow editing text after it. diff --git a/test/functional/api/extmark_spec.lua b/test/functional/api/extmark_spec.lua index 65a1f9ccd3..50dd566489 100644 --- a/test/functional/api/extmark_spec.lua +++ b/test/functional/api/extmark_spec.lua @@ -1603,6 +1603,15 @@ describe('API/extmarks', function() eq('invalid', get_extmark_range(marks[3])) eq('invalid', get_extmark_range(marks[4])) eq({ 0, 10, 1, 0 }, get_extmark_range(marks[5])) + + feed('hello') + eq({ 0, 15 }, get_extmark_range(marks[1])) + eq({ 0, 15, 1, 0 }, get_extmark_range(marks[5])) + -- init_prompt uses correct range for inserted_bytes when fixing empty prompt. + fn.setline('.', { '', 'last line' }) + eq({ 'discard > ', 'last line' }, api.nvim_buf_get_lines(0, 0, -1, true)) + eq({ 0, 10 }, get_extmark_range(marks[1])) + eq({ 0, 10, 1, 0 }, get_extmark_range(marks[5])) end) it('can get details', function() diff --git a/test/functional/lua/buffer_updates_spec.lua b/test/functional/lua/buffer_updates_spec.lua index e884499a98..e87407f176 100644 --- a/test/functional/lua/buffer_updates_spec.lua +++ b/test/functional/lua/buffer_updates_spec.lua @@ -463,6 +463,14 @@ describe('lua: nvim_buf_attach on_lines', function() { 'test1', 'lines', 1, 12, 0, 1, 1, 16 }, } eq({ 'foo > hello', 'there' }, api.nvim_buf_get_lines(0, 0, -1, true)) + + -- init_prompt uses appended_lines_mark when appending to fix prompt. + api.nvim_buf_set_lines(0, 0, -1, true, { 'hi' }) + eq({ 'hi', 'foo > ' }, api.nvim_buf_get_lines(0, 0, -1, true)) + check_events { + { 'test1', 'lines', 1, 13, 0, 2, 1, 18 }, + { 'test1', 'lines', 1, 14, 1, 1, 2, 0 }, + } end) end) @@ -1652,6 +1660,14 @@ describe('lua: nvim_buf_attach on_bytes', function() { 'test1', 'bytes', 1, 15, 2, 0, 6, 0, 10, 10, 0, 7, 7 }, } eq({ '% ', '% ', 'cool > sup', 'dood' }, api.nvim_buf_get_lines(0, 0, -1, true)) + + -- init_prompt uses appended_lines_mark when appending to fix prompt. + api.nvim_buf_set_lines(0, 0, -1, true, { 'hi' }) + eq({ 'hi', 'cool > ' }, api.nvim_buf_get_lines(0, 0, -1, true)) + check_events { + { 'test1', 'bytes', 1, 16, 0, 0, 0, 4, 0, 22, 1, 0, 3 }, + { 'test1', 'bytes', 1, 17, 1, 0, 3, 0, 0, 0, 1, 0, 8 }, + } end) local function test_lockmarks(mode)