From 52566856611ef9ff54a1cff2bdbcfedf4e72d95c Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Mon, 29 Jun 2026 21:47:09 +0200 Subject: [PATCH 1/3] feat(cmdwin): drop CHECK_CMDWIN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow window navigation/creation even while cmdwin is alive! 😱 --- runtime/doc/cmdline.txt | 9 +++-- runtime/doc/news.txt | 2 +- src/nvim/window.c | 33 ------------------- .../vimscript/api_functions_spec.lua | 17 ++++------ test/old/testdir/test_normal.vim | 7 ++-- 5 files changed, 16 insertions(+), 52 deletions(-) diff --git a/runtime/doc/cmdline.txt b/runtime/doc/cmdline.txt index 332786f391..dd8db14a1c 100644 --- a/runtime/doc/cmdline.txt +++ b/runtime/doc/cmdline.txt @@ -1223,9 +1223,12 @@ but it's not possible to open another cmdwin from there. There is no nesting. *E11* -|CTRL-W| navigation/split commands and some operations (e.g. |:terminal|) emit -E11. But mouse clicks may focus other windows. The cmdwin buffer is pinned by -'winfixbuf', so the window cannot switch to a different buffer. +|CTRL-W| commands work as usual: you can navigate between, split, resize, and +close windows while the command-line window is open. E11 is still emitted by +operations that would leave or repurpose the cmdwin: opening a new tab page +(CTRL-W_T, |:tabnew|) or turning its buffer into a terminal (|:terminal|). +The cmdwin buffer is pinned by 'winfixbuf', so its window cannot switch to a +different buffer. CLOSE diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 34ec454817..bb9f81a700 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -188,11 +188,11 @@ EDITOR kind/menu/info/abbr for the popup menu. • |cmdwin| (|q:|, |q/|, |q?|, |c_CTRL-F|) is implemented as a "normal" buffer+window instead of a nested-state modal loop: + • You can create (and navigate) windows while cmdwin is active. Chaos! • 'inccommand' works in cmdwin! • API calls (e.g. |nvim_buf_delete()|) that previously failed with "E11: Invalid in command-line window" while cmdwin was open, now work normally. Async plugins no longer need special |CmdwinLeave| workarounds. - • Removed most cmdwin-specific |E11| guards, except for window navigation. • The |cmdwin-char| is shown via 'statuscolumn'. • |gf| and || support `file://…` URIs. • |:log| opens log files. diff --git a/src/nvim/window.c b/src/nvim/window.c index 994111d195..77ebda0a39 100644 --- a/src/nvim/window.c +++ b/src/nvim/window.c @@ -271,20 +271,11 @@ void do_window(int nchar, int Prenum, int xchar) int Prenum1 = Prenum == 0 ? 1 : Prenum; -#define CHECK_CMDWIN \ - do { \ - if (cmdwin_buf != NULL) { \ - emsg(_(e_cmdwin)); \ - return; \ - } \ - } while (0) - switch (nchar) { // split current window in two parts, horizontally case 'S': case Ctrl_S: case 's': - CHECK_CMDWIN; reset_VIsual_and_resel(); // stop Visual mode // When splitting the quickfix window open a new buffer in it, // don't replicate the quickfix buffer. @@ -297,7 +288,6 @@ void do_window(int nchar, int Prenum, int xchar) // split current window in two parts, vertically case Ctrl_V: case 'v': - CHECK_CMDWIN; reset_VIsual_and_resel(); // stop Visual mode // When splitting the quickfix window open a new buffer in it, // don't replicate the quickfix buffer. @@ -310,7 +300,6 @@ void do_window(int nchar, int Prenum, int xchar) // split current window and edit alternate file case Ctrl_HAT: case '^': - CHECK_CMDWIN; reset_VIsual_and_resel(); // stop Visual mode if (buflist_findnr(Prenum == 0 ? curwin->w_alt_fnum : Prenum) == NULL) { @@ -331,7 +320,6 @@ void do_window(int nchar, int Prenum, int xchar) // open new window case Ctrl_N: case 'n': - CHECK_CMDWIN; reset_VIsual_and_resel(); // stop Visual mode newwindow: if (Prenum) { @@ -366,7 +354,6 @@ newwindow: // close preview window case Ctrl_Z: case 'z': - CHECK_CMDWIN; reset_VIsual_and_resel(); // stop Visual mode do_cmdline_cmd("pclose"); break; @@ -391,7 +378,6 @@ newwindow: // close all but current window case Ctrl_O: case 'o': - CHECK_CMDWIN; reset_VIsual_and_resel(); // stop Visual mode cmd_with_count("only", cbuf, sizeof(cbuf), Prenum); do_cmdline_cmd(cbuf); @@ -402,7 +388,6 @@ newwindow: case 'w': // cursor to previous window with wrap around case 'W': - CHECK_CMDWIN; if (ONE_WINDOW && Prenum != 1) { // just one window beep_flush(); } else { @@ -454,7 +439,6 @@ newwindow: case 'j': case K_DOWN: case Ctrl_J: - CHECK_CMDWIN; win_goto_ver(false, Prenum1); break; @@ -462,7 +446,6 @@ newwindow: case 'k': case K_UP: case Ctrl_K: - CHECK_CMDWIN; win_goto_ver(true, Prenum1); break; @@ -471,7 +454,6 @@ newwindow: case K_LEFT: case Ctrl_H: case K_BS: - CHECK_CMDWIN; win_goto_hor(true, Prenum1); break; @@ -479,13 +461,11 @@ newwindow: case 'l': case K_RIGHT: case Ctrl_L: - CHECK_CMDWIN; win_goto_hor(false, Prenum1); break; // move window to new tab page case 'T': - CHECK_CMDWIN; if (one_window(curwin, NULL)) { msg(_(m_onlyone), 0); } else { @@ -533,21 +513,18 @@ newwindow: // exchange current and next window case 'x': case Ctrl_X: - CHECK_CMDWIN; win_exchange(Prenum); break; // rotate windows downwards case Ctrl_R: case 'r': - CHECK_CMDWIN; reset_VIsual_and_resel(); // stop Visual mode win_rotate(false, Prenum1); // downwards break; // rotate windows upwards case 'R': - CHECK_CMDWIN; reset_VIsual_and_resel(); // stop Visual mode win_rotate(true, Prenum1); // upwards break; @@ -557,7 +534,6 @@ newwindow: case 'J': case 'H': case 'L': - CHECK_CMDWIN; if (one_window(curwin, NULL)) { beep_flush(); } else { @@ -608,7 +584,6 @@ newwindow: // jump to tag and split window if tag exists (in preview window) case '}': - CHECK_CMDWIN; if (Prenum) { g_do_tagpreview = Prenum; } else { @@ -617,7 +592,6 @@ newwindow: FALLTHROUGH; case ']': case Ctrl_RSB: - CHECK_CMDWIN; // Keep visual mode, can select words to use as a tag. if (Prenum) { postponed_split = Prenum; @@ -640,7 +614,6 @@ newwindow: case 'F': case Ctrl_F: { wingotofile: - CHECK_CMDWIN; if (check_text_or_curbuf_locked(NULL)) { break; } @@ -698,7 +671,6 @@ wingotofile: FALLTHROUGH; case 'd': // Go to definition, using 'define' case Ctrl_D: { - CHECK_CMDWIN; size_t len; char *ptr; if ((len = find_ident_under_cursor(&ptr, FIND_IDENT, NULL)) == 0) { @@ -726,7 +698,6 @@ wingotofile: // CTRL-W g extended commands case 'g': case Ctrl_G: - CHECK_CMDWIN; no_mapping++; allow_keys++; // no mapping for xchar, but allow key codes if (xchar == NUL) { @@ -4936,10 +4907,6 @@ void goto_tabpage(int n) /// @param trigger_leave_autocmds when true trigger *Leave autocommands. void goto_tabpage_tp(tabpage_T *tp, bool trigger_enter_autocmds, bool trigger_leave_autocmds) { - if (trigger_enter_autocmds || trigger_leave_autocmds) { - CHECK_CMDWIN; - } - // Don't repeat a message in another tab page. set_keep_msg(NULL, 0); diff --git a/test/functional/vimscript/api_functions_spec.lua b/test/functional/vimscript/api_functions_spec.lua index b341c191d6..cc08108d15 100644 --- a/test/functional/vimscript/api_functions_spec.lua +++ b/test/functional/vimscript/api_functions_spec.lua @@ -60,7 +60,7 @@ describe('eval-API', function() eq('Vim(call):E5555: API call: Invalid buffer id: 17', err) end) - it('cannot change text or window if textlocked', function() + it('cannot change text or window if textlock', function() command('autocmd TextYankPost ++once call nvim_buf_set_lines(0, 0, -1, v:false, [])') matches( 'Vim%(call%):E5555: API call: E565: Not allowed to change text or change window$', @@ -90,19 +90,16 @@ describe('eval-API', function() api.nvim_get_vvar('errmsg') ) - -- cmdwin behavior (changed since #40312). + -- cmdwin (#40312, #40484): E11 "Invalid in command-line window" restriction was removed. local old_win = api.nvim_get_current_win() feed('q:') local cmdwin_win = api.nvim_get_current_win() - eq( - 'Vim:E11: Invalid in command-line window; executes, CTRL-C quits', - pcall_err(api.nvim_set_current_win, old_win) - ) + neq(old_win, cmdwin_win) + -- Switching to another window from the cmdwin now works (previously E11). + api.nvim_set_current_win(old_win) eq(old_win, api.nvim_get_current_win()) - -- TODO(justinmk): awkward: E11 is raised but the focus switch happens anyway; inherited bug in - -- goto_tabpage_win (calls win_enter unconditionally even when goto_tabpage_tp's CHECK_CMDWIN - -- emsg'd). We should probably just remove CHECK_CMDWIN from goto_tabpage_tp. #40407 - pcall(api.nvim_set_current_win, cmdwin_win) + -- ...and back into the cmdwin. + api.nvim_set_current_win(cmdwin_win) eq(cmdwin_win, api.nvim_get_current_win()) -- nvim_buf_set_lines() in the cmdwin buffer is OK. diff --git a/test/old/testdir/test_normal.vim b/test/old/testdir/test_normal.vim index 4f57251036..1f25bf7c3c 100644 --- a/test/old/testdir/test_normal.vim +++ b/test/old/testdir/test_normal.vim @@ -3242,11 +3242,8 @@ func Test_normal50_commandline() func! DoTimerWork(id) call assert_equal(1, getbufinfo('')[0].command) - " should fail, with E11, but does fail with E23? - "call feedkeys("\", 'tm') - - " should fail with E11 - "Invalid in command-line window" - call assert_fails(":wincmd p", 'E11') + " Nvim removed the E11 "Invalid in command-line window" restriction (#40312, #40484). + "call assert_fails(":wincmd p", 'E11') " Return from commandline window. call feedkeys("\", 't') From 2a4bb3eb378a30093e6ce897a7398cb8bf3df013 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Mon, 29 Jun 2026 22:10:53 +0200 Subject: [PATCH 2/3] feat(cmdwin): allow tabpage actions --- runtime/doc/cmdline.txt | 16 ++++++------- runtime/doc/news.txt | 2 +- src/nvim/window.c | 4 ---- .../functional/autocmd/tabnewentered_spec.lua | 24 +++++++------------ 4 files changed, 17 insertions(+), 29 deletions(-) diff --git a/runtime/doc/cmdline.txt b/runtime/doc/cmdline.txt index dd8db14a1c..182f03c006 100644 --- a/runtime/doc/cmdline.txt +++ b/runtime/doc/cmdline.txt @@ -1209,6 +1209,11 @@ opens in Normal mode. The last line contains the command as typed so far. The The window height is decided by 'cmdwinheight' (if there is room). The window is full width and is positioned just above the command-line. +Window and tabpage commands work as usual while cmdwin is open: you can +navigate, resize, close and open windows/tabpages. The mouse works as in any +window: clicks focus other windows, and statuslines and vertical separators +can be dragged to resize. + *E1292* Only one command-line window can be open at a time. @@ -1223,12 +1228,8 @@ but it's not possible to open another cmdwin from there. There is no nesting. *E11* -|CTRL-W| commands work as usual: you can navigate between, split, resize, and -close windows while the command-line window is open. E11 is still emitted by -operations that would leave or repurpose the cmdwin: opening a new tab page -(CTRL-W_T, |:tabnew|) or turning its buffer into a terminal (|:terminal|). -The cmdwin buffer is pinned by 'winfixbuf', so its window cannot switch to a -different buffer. +The cmdwin buffer cannot be used as a |:terminal|. Its window cannot switch to +a different buffer ('winfixbuf'). CLOSE @@ -1284,9 +1285,6 @@ If you don't want these mappings, disable them with: > au CmdwinEnter [:>] nunmap You could put these lines in your vimrc file. -The mouse works as in any window: clicks focus other windows, and statuslines -and vertical separators can be dragged to resize. - The |getcmdwintype()| function returns the type of the command-line being edited as described in |cmdwin-char|. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index bb9f81a700..d369034de2 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -188,7 +188,7 @@ EDITOR kind/menu/info/abbr for the popup menu. • |cmdwin| (|q:|, |q/|, |q?|, |c_CTRL-F|) is implemented as a "normal" buffer+window instead of a nested-state modal loop: - • You can create (and navigate) windows while cmdwin is active. Chaos! + • You can create/navigate windows/tabpages while cmdwin is active. Chaos! • 'inccommand' works in cmdwin! • API calls (e.g. |nvim_buf_delete()|) that previously failed with "E11: Invalid in command-line window" while cmdwin was open, now work diff --git a/src/nvim/window.c b/src/nvim/window.c index 77ebda0a39..3e5d437177 100644 --- a/src/nvim/window.c +++ b/src/nvim/window.c @@ -4469,10 +4469,6 @@ tabpage_T *win_new_tabpage(int after, char *filename, bool enter, win_T **first) { tabpage_T *old_curtab = curtab; - if (enter && cmdwin_buf != NULL) { - emsg(_(e_cmdwin)); - return NULL; - } if (window_layout_locked(CMD_tabnew)) { return NULL; } diff --git a/test/functional/autocmd/tabnewentered_spec.lua b/test/functional/autocmd/tabnewentered_spec.lua index 93031a6b1c..5ed585a32d 100644 --- a/test/functional/autocmd/tabnewentered_spec.lua +++ b/test/functional/autocmd/tabnewentered_spec.lua @@ -618,7 +618,7 @@ describe('tabpage/previous', function() -- it('does not switch to previous via :tabn # after entering operator pending', -- does_not_switch_to_previous_after_entering_operator_pending(':tabn #')) - local function cmdwin_prevents_tab_switch(characters, completion_visible) + local function cmdwin_allows_tab_switch(characters) return function() -- Add three tabs for a total of four command('tabnew') @@ -633,25 +633,19 @@ describe('tabpage/previous', function() local cmdline_win_id = eval('win_getid()') - -- At this point switching to the previous tab should have no effect. + -- Switching to the previous tab now works. feed(characters) - -- Attempting to switch tabs maintains the current window. - eq(cmdline_win_id, eval('win_getid()')) - eq(completion_visible, eval('complete_info().pum_visible')) - - -- The current tab is still the fourth. - eq(4, eval('tabpagenr()')) - - -- The previous tab is still the third. - eq(3, eval("tabpagenr('#')")) + -- Focus moved out of the cmdwin to the previous (third) tab. + t.neq(cmdline_win_id, eval('win_getid()')) + eq(3, eval('tabpagenr()')) end end - it('cmdwin prevents tab switch via g', cmdwin_prevents_tab_switch('g', 0)) - it('cmdwin prevents tab switch via g', cmdwin_prevents_tab_switch('g', 0)) - it('cmdwin prevents tab switch via ', cmdwin_prevents_tab_switch('', 0)) - it('cmdwin prevents tab switch via :tabn #', cmdwin_prevents_tab_switch(':tabn #', 0)) + it('cmdwin allows tab switch via g', cmdwin_allows_tab_switch('g')) + it('cmdwin allows tab switch via g', cmdwin_allows_tab_switch('g')) + it('cmdwin allows tab switch via ', cmdwin_allows_tab_switch('')) + it('cmdwin allows tab switch via :tabn #', cmdwin_allows_tab_switch(':tabn #')) it(':tabs indicates correct prevtab curwin', function() -- Add three tabs for a total of four From 684371ba5dbbb14d8348c01997fe7b423890d62f Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Mon, 29 Jun 2026 23:19:52 +0200 Subject: [PATCH 3/3] fix(cmdwin): handle split/tabpage Problem: - If cmdwin window is split, ENTER in one does not close the others. - If cmdwin is put into a different tabpage via T, it stops working (ENTER does not execute the cmd). Solution: - Close the buffer instead of the window. - In the WinClosed handler, skip `M._cleanup()` unless this is the last cmdwin window. --- runtime/doc/cmdline.txt | 14 ++++++------- runtime/lua/vim/_core/cmdwin.lua | 21 ++++++++++++------- test/functional/editor/cmdwin_spec.lua | 29 ++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/runtime/doc/cmdline.txt b/runtime/doc/cmdline.txt index 182f03c006..a95437ba24 100644 --- a/runtime/doc/cmdline.txt +++ b/runtime/doc/cmdline.txt @@ -1205,9 +1205,8 @@ There are two ways to open the command-line window: When the window opens it is filled with the command-line history. The editor opens in Normal mode. The last line contains the command as typed so far. The |cmdwin-char| is shown in 'statuscolumn', indicating the type of command-line. - -The window height is decided by 'cmdwinheight' (if there is room). The window -is full width and is positioned just above the command-line. +The window is full width, positioned just above the command-line; its height +is decided by 'cmdwinheight' (if there is room). Window and tabpage commands work as usual while cmdwin is open: you can navigate, resize, close and open windows/tabpages. The mouse works as in any @@ -1236,8 +1235,9 @@ CLOSE There are several ways to leave the command-line window: - Execute the command-line under the cursor. Works both in - Insert and in Normal mode. + Execute the command-line under the cursor, then close the + window(s) where cmdwin is visible. Works both in Insert and + in Normal mode. CTRL-C Continue in Command-line mode. The command-line under the cursor is pre-filled in the command-line. Works both in Insert and in Normal mode. @@ -1249,8 +1249,8 @@ CTRL-C Continue in Command-line mode. The command-line under the When the cmdwin closes, focus returns to the window where the command-line was started from (if possible). The cmdwin scratch buffer is destroyed -(`bufhidden=wipe`). Any text edits other than the line executed by - are lost. +(`bufhidden=wipe`). Any text edits other than the line executed by +are lost. If you would like to execute the command under the cursor and then have the command-line window open again, you may find this mapping useful: > diff --git a/runtime/lua/vim/_core/cmdwin.lua b/runtime/lua/vim/_core/cmdwin.lua index 699b72e8db..05bc112b33 100644 --- a/runtime/lua/vim/_core/cmdwin.lua +++ b/runtime/lua/vim/_core/cmdwin.lua @@ -101,15 +101,22 @@ function M.open(type, init_line, init_col) caller_win = caller, } - -- Clean up if the window is closed by other means (`:q`, `:close`, etc.). + -- Clean up when the (last-visible) cmdwin is closed by other means (`:q`, `:close`, etc.). vim.api.nvim_create_autocmd({ 'WinClosed' }, { buffer = buf, - once = true, nested = true, - callback = function() - if state ~= nil then - M._cleanup() + callback = function(ev) + if state == nil then + return end + local closing = tonumber(ev.match) + for _, w in ipairs(vim.fn.win_findbuf(buf)) do + if w ~= closing then + return -- Still visible elsewhere; keep cmdwin (and this autocmd) active. + end + end + M._cleanup() + return true -- Last cmdwin window gone; delete this autocmd. end, }) @@ -125,8 +132,8 @@ function M._cleanup() state = nil pcall(vim.api.nvim__cmdwin_set, '', 0) -- Clear the C-side globals. pcall(vim.api.nvim_exec_autocmds, 'CmdwinLeave', { pattern = s.type, modeline = false }) - if vim.api.nvim_win_is_valid(s.win) then - pcall(vim.api.nvim_win_close, s.win, true) + if vim.api.nvim_buf_is_valid(s.buf) then + pcall(vim.api.nvim_buf_delete, s.buf, { force = true }) end if vim.api.nvim_win_is_valid(s.caller_win) then pcall(vim.api.nvim_set_current_win, s.caller_win) diff --git a/test/functional/editor/cmdwin_spec.lua b/test/functional/editor/cmdwin_spec.lua index c34acea192..d43cb7d4fa 100644 --- a/test/functional/editor/cmdwin_spec.lua +++ b/test/functional/editor/cmdwin_spec.lua @@ -32,6 +32,35 @@ describe('cmdwin', function() eq('', fn.getcmdwintype()) end) + it(' closes all cmdwin (split) windows #40484', function() + feed('q:') + feed('ilet g:cmdwin_result = 7') + feed('s') -- split: two windows now show the cmdwin buffer. + local cmdwin_buf = api.nvim_get_current_buf() + eq(2, #fn.win_findbuf(cmdwin_buf)) -- sanity: the split happened + + feed('') + eq(7, api.nvim_get_var('cmdwin_result')) -- the cmdline executed + eq('', fn.getcmdwintype()) + eq(0, #fn.win_findbuf(cmdwin_buf)) -- All cmdwin windows are closed. + eq(1, #api.nvim_list_wins()) -- Back to the single original window. + end) + + it(' executes when cmdwin was moved to another tabpage #40484', function() + feed('q:') + feed('ilet g:cmdwin_result = 9') + feed('T') -- Move the cmdwin to its own new tabpage. + eq(2, fn.tabpagenr('$')) -- sanity: the new tabpage exists + local cmdwin_buf = api.nvim_get_current_buf() + + feed('') + eq(9, api.nvim_get_var('cmdwin_result')) -- the cmdline executed. + eq('', fn.getcmdwintype()) + eq(0, #fn.win_findbuf(cmdwin_buf)) -- cmdwin window closed. + eq(1, fn.tabpagenr('$')) -- cmdwin's tabpage is gone. + eq(1, #api.nvim_list_wins()) + end) + it('q/ opens cmdwin with search history', function() fn.histadd('/', 'foo') fn.histadd('/', 'bar')