feat(exmode): "1q:", :exmode #41010

Problem:
Want `gQ` for _le multicursor_.

Solution:
- Don't use `gQ` for exmode.
- Introduce `:exmode`.
- Introduce `[count]q:` as an alias to `:exmode`.
This commit is contained in:
Justin M. Keyes 2026-07-27 11:12:47 -04:00 committed by GitHub
parent 97a71ab484
commit fbcb7a056c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 97 additions and 66 deletions

View File

@ -403,7 +403,8 @@ Tag Char Note Normal-mode action ~
{0-9a-zA-Z"} (uppercase to append)
|q| q (while recording) stops recording
|Q| Q 2 replay last recorded register
|q:| q: edit : command-line in command-line window
|q:| [count]q: edit : command-line in command-line window,
with [count]: switch to |Ex-mode|
|q/| q/ edit / command-line in command-line window
|q?| q? edit ? command-line in command-line window
|r| r{char} 2 replace N chars with {char}
@ -743,7 +744,6 @@ Tag Char Note Normal-mode action ~
search pattern and Visually select it
|gP| ["x]gP 2 put the text [from register x] before the
cursor N times, leave the cursor after it
|gQ| gQ switch to "Ex" mode with Vim editing
|gR| gR 2 enter Virtual Replace mode
|gT| gT go to the previous tabpage
|gU| gU{motion} 2 make Nmove text uppercase
@ -1312,6 +1312,7 @@ Tag Command Action ~
|:ex| :ex same as ":edit"
|:execute| :exe[cute] execute result of expressions
|:exit| :exi[t] same as ":xit"
|:exmode| :exm[ode] switch to |Ex-mode|
|:exusage| :exu[sage] overview of Ex commands
|:fclose| :fc[lose] close floating window
|:file| :f[ile] show or set the current file name

View File

@ -340,7 +340,7 @@ CTRL-O in Insert mode you get a beep but you are still in Insert mode, type
>
FROM mode TO mode
Normal Visual Select Insert Replace Cmd-line Ex >
Normal v V ^V *4 *1 R gR : / ? ! gQ
Normal v V ^V *4 *1 R gR : / ? ! 1q:
Visual *2 ^G c C -- : --
Select *5 ^O ^G *6 -- -- --
Insert <Esc> -- -- <Insert> -- --
@ -386,10 +386,12 @@ a command that takes an argument, such as |f| or |m|, the timeout set with
*CTRL-\_CTRL-G* *i_CTRL-\_CTRL-G* *c_CTRL-\_CTRL-G* *v_CTRL-\_CTRL-G*
CTRL-\ CTRL-G works the same as |CTRL-\_CTRL-N| for backward compatibility.
*gQ* *mode-Ex* *Ex-mode* *Ex* *EX* *E501*
gQ Switch to Ex mode. This is like typing ":" commands
*:exm* *:exmode* *gQ* *mode-Ex* *Ex-mode* *Ex* *EX* *E501*
:exm[ode]
[count]q: Switch to Ex mode. This is like typing ":" commands
one after another, except you don't have to keep
pressing ":". Use |:visual| to exit this mode.
Without [count], "q:" opens the regular |cmdwin|.
Implementation details: a persistent |cmdwin| where
<Enter> executes the current line as a command

View File

@ -93,10 +93,11 @@ DIAGNOSTICS
EDITOR
• Interactive |Ex-mode| (|gQ|, `nvim -e`) is a persistent |cmdwin|. This is much
• Interactive |Ex-mode| (|:exmode|, `nvim -e`) is a persistent |cmdwin|. This is much
more flexible and makes "Ex mode" more useful as a kind of "cmdline REPL",
with the tradeoff of dropping some POSIX Ex-mode quirks.
• |mode()| returns "n" or "i"; "cv" indicates |-es| (script mode) only.
• Ex mode is entered with "1q:" or |:exmode|, not "gQ".
• ":global/pat/visual" no longer switches to Normal mode per matching line.
• Non-interactive Ex-mode (|-es|, "script mode") executes stdin as Ex commands
directly. It is a "script host", not "POSIX Ex mode".

View File

@ -1019,7 +1019,7 @@ Short explanation of each option: *option-list*
|:version| :ve[rsion] show version information
|:normal| :norm[al][!] {commands}
execute Normal mode commands
|gQ| gQ switch to |Ex-mode|
|:exmode| 1q: switch to |Ex-mode|
|:redir| :redir >{file} redirect messages to {file}
|:silent| :silent[!] {command} execute {command} silently

View File

@ -401,7 +401,8 @@ Input/Mappings:
Normal commands:
- |gO| shows a filetype-defined "outline" of the current buffer.
- |Q| replays the last recorded macro instead of switching to Ex mode (|gQ|).
- |Q| replays the last recorded macro instead of switching to Ex mode.
- [count]q: enters |Ex-mode|.
- |ZR| performs |:restart|
- |v_al| selects the whole buffer; |v_il| selects the current line without
leading or trailing white space.

View File

@ -1,6 +1,6 @@
--- Ex mode.
--- - `run()`: non-interactive (`nvim -es`): executes stdin as Ex commands.
--- - `open()`: interactive (`gQ`, `nvim -e`), a keep-open "cmdwin" REPL.
--- - `open()`: interactive (`[count]q:`, `nvim -e`), a keep-open "cmdwin" REPL.
local api = vim.api
local N_ = vim.fn.gettext
@ -101,6 +101,7 @@ function M.open()
return -- cmdwin failed to open (E1292 already echoed).
end
local buf = api.nvim_get_current_buf()
api.nvim_buf_set_name(buf, N_('[Ex mode]'))
local block_start = api.nvim_buf_line_count(buf)
cont_lines = {}
vim.wo[0][0].statuscolumn = '%#NonText#%{v:lua.require("vim._core.exmode")._statuscolumn()}'
@ -216,7 +217,7 @@ function M.open()
vim.keymap.set({ 'n', 'i' }, '<CR>', run, { buffer = buf })
vim.keymap.set({ 'n', 'i' }, '<NL>', run, { buffer = buf })
-- Enter Insert mode before any already-typed keys, so `gQcmd<CR>` types "cmd" into the REPL.
-- Enter Insert mode before any already-typed keys, so `1q:cmd<CR>` types "cmd" into the REPL.
api.nvim_feedkeys('i', 'ni', false)
end

View File

@ -962,6 +962,12 @@ M.cmds = {
addr_type = 'ADDR_LINES',
func = 'ex_exit',
},
{
command = 'exmode',
flags = bit.bor(TRLBAR),
addr_type = 'ADDR_NONE',
func = 'ex_exmode',
},
{
command = 'exusage',
flags = bit.bor(TRLBAR),

View File

@ -4846,6 +4846,21 @@ static void ex_quitall(exarg_T *eap)
not_exiting(save_exiting);
}
/// ":exmode": Enter interactive Ex mode.
static void ex_exmode(exarg_T *eap)
{
if (silent_mode) {
return;
}
if (ex_normal_busy > 0 || global_busy) {
// Ex mode cannot run inside ":normal" or ":global"; discard the rest of the command.
flush_buffers(FLUSH_TYPEAHEAD);
return;
}
typval_T args[] = { { .v_type = VAR_UNKNOWN } };
nlua_call_typval("vim._core.exmode", "open", args, NULL);
}
/// ":restart": restart the Nvim server (using ":qall!").
/// ":restart +cmd": restart the Nvim server using ":cmd".
/// ":restart +cmd <command>": restart the Nvim server using ":cmd" and runs <command> in the new server.

View File

@ -687,8 +687,7 @@ int main(int argc, char **argv)
if (params.exmode) {
// Interactive Ex mode (-e/-E): cmdwin REPL. #40962
typval_T args[] = { { .v_type = VAR_UNKNOWN } };
nlua_call_typval("vim._core.exmode", "open", args, NULL);
do_cmdline_cmd("exmode");
}
TIME_MSG("before starting main loop");

View File

@ -279,7 +279,7 @@ static const struct nv_cmd {
{ 'n', nv_next, 0, 0 },
{ 'o', nv_open, 0, 0 },
{ 'p', nv_put, 0, 0 },
{ 'q', nv_record, NV_NCH, 0 },
{ 'q', nv_q, NV_NCH, 0 },
{ 'r', nv_replace, NV_NCH_NOP|NV_LANG, 0 },
{ 's', nv_subst, NV_KEEPREG, 0 },
{ 't', nv_csearch, NV_NCH_ALW|NV_LANG, FORWARD },
@ -5673,19 +5673,6 @@ static void nv_g_cmd(cmdarg_T *cap)
goto_byte(cap->count0);
break;
// "gQ": improved Ex mode
case 'Q':
if (!check_text_locked(cap->oap) && !checkclearopq(oap)) {
if (ex_normal_busy > 0 || global_busy) {
// Ex mode cannot run inside ":normal" or ":global"; discard the rest of the command.
flush_buffers(FLUSH_TYPEAHEAD);
} else if (!silent_mode) {
typval_T args[] = { { .v_type = VAR_UNKNOWN } };
nlua_call_typval("vim._core.exmode", "open", args, NULL);
}
}
break;
case ',':
nv_pcmark(cap);
break;
@ -6370,9 +6357,10 @@ static void nv_object(cmdarg_T *cap)
curwin->w_set_curswant = true;
}
/// "q" command: Start/stop recording.
/// "q" command: Start/stop macro recording.
/// "q:", "q/", "q?": cmdwin.
static void nv_record(cmdarg_T *cap)
/// "[count]q:": interactive Ex-mode.
static void nv_q(cmdarg_T *cap)
{
if (cap->oap->op_type == OP_FORMAT) {
// "gqq" is the same as "gqgq": format line
@ -6391,6 +6379,11 @@ static void nv_record(cmdarg_T *cap)
emsg(_(e_cmdline_window_already_open));
return;
}
if (cap->nchar == ':' && cap->count0 > 0) {
// "[count]q:": interactive Ex mode. E565 on textlock.
do_cmdline_cmd("exmode");
return;
}
bool insert_range = Visual.active && cap->nchar == ':';
char fc[2] = { (char)cap->nchar, 0 };
typval_T tv_args[] = {

View File

@ -5321,7 +5321,7 @@ describe('API', function()
insert([[
foo
bar]])
feed('gQ1')
feed('1q:1')
screen:expect({ any = vim.pesc('{1::}1^') })
eq('Parsing command-line', pcall_err(api.nvim_parse_cmd, '', {}))
-- Executing the line still auto-prints it.

View File

@ -56,7 +56,7 @@ describe('exit:', function()
it('v:exiting=0, v:exitreason=quit on exit from Ex mode try-catch vim-patch:8.0.0184', function()
test_exiting(function()
feed('gQ')
feed('1q:')
feed_command('try', 'call NoFunction()', 'catch', 'echo "bye"', 'endtry', 'quit')
end)
end)

View File

@ -870,7 +870,7 @@ describe('startup', function()
{1:~ }|*6
{2:[No Name] [+] }|
{1::}^ |
{3:[Command Line] }|
{3:[Ex mode] }|
{5:-- INSERT --} |
]])
@ -883,7 +883,7 @@ describe('startup', function()
{1:~ }|*6
{2:[No Name] [+] }|
{1::}^ |
{3:[Command Line] }|
{3:[Ex mode] }|
{5:-- INSERT --} |
]])
end)
@ -892,7 +892,7 @@ describe('startup', function()
clear('-e')
local screen = Screen.new(40, 12)
-- The keep-open cmdwin REPL is focused, in Insert mode. #40962
screen:expect({ any = vim.pesc('[Command Line]') })
screen:expect({ any = vim.pesc('[Ex mode]') })
eq(':', fn.getcmdwintype())
feed('visual<CR>')
eq('n', fn.mode(1))

View File

@ -14,19 +14,19 @@ before_each(clear)
describe(':normal!', function()
it('can get out of Insert mode if called from Ex mode #17924', function()
feed('gQnormal! Ifoo<CR>')
feed('1q:normal! Ifoo<CR>')
feed('visual<CR>')
expect('foo')
end)
it('does not execute command in Ex mode when running out of characters', function()
command('let g:var = 0')
command('normal! gQlet g:var = 1')
command('normal! 1q:let g:var = 1')
eq(0, eval('g:var'))
end)
it('gQinsert does not hang #17980', function()
command('normal! gQinsert')
it('"1q:insert" does not hang #17980', function()
command('normal! 1q:insert')
expect('')
end)

View File

@ -11,18 +11,18 @@ local fn = n.fn
before_each(clear)
-- Interactive Ex mode (gQ) is a keep-open |cmdwin| REPL. #40962
-- Interactive Ex mode ([count]q:) is a keep-open |cmdwin| REPL. #40962
describe('Ex mode', function()
it('executes commands against the caller window, auto-prints, records history', function()
local screen = Screen.new(60, 10)
command([[call setline(1, ['line1', 'line2', 'line3'])]])
feed('gQ')
feed('1q:')
screen:expect([[
line1 |
{2:[No Name] [+] }|
{1::}^ |
{1:~ }|*5
{3:[Command Line] }|
{3:[Ex mode] }|
{5:-- INSERT --} |
]])
t.matches('Entering Ex mode%.', n.exec_capture('messages'))
@ -32,12 +32,24 @@ describe('Ex mode', function()
eq({ 'line1', 'inserted', 'line2', 'line3' }, fn.getline(1, '$'))
eq('visual', fn.histget('cmd', -1))
eq("put ='inserted'", fn.histget('cmd', -2))
-- Ex mode can be re-entered (the "[Ex mode]" buffer name is not duplicated).
feed('1q:')
screen:expect({ any = vim.pesc('{3:[Ex mode]') })
feed('visual<CR>')
eq('n', fn.mode(1))
-- ":exmode" (but not ":ex", which is ":edit") also enters Ex mode.
eq('ex', fn.fullcommand('ex'))
eq('exmode', fn.fullcommand('exm'))
command('exmode')
screen:expect({ any = vim.pesc('{3:[Ex mode]') })
feed('visual<CR>')
eq('n', fn.mode(1))
end)
it('bare address and empty line move the cursor and auto-print', function()
local screen = Screen.new(60, 8)
command([[call setline(1, ['line1', 'line2'])]])
feed('gQ')
feed('1q:')
feed('1<CR>') -- Bare address: move and auto-print.
screen:expect({ any = vim.pesc('" line1') })
feed('<CR>') -- Empty line: advance and auto-print (POSIX ex "+").
@ -50,7 +62,7 @@ describe('Ex mode', function()
it('explicit :print is not doubled by auto-print', function()
command([[call setline(1, ['line1', 'line2'])]])
feed('gQ')
feed('1q:')
feed('1,2print<CR>')
-- Transcript records each printed line once, not the cursor line again.
eq({ '1,2print', '" line1', '" line2', '' }, n.api.nvim_buf_get_lines(0, 0, -1, false))
@ -61,7 +73,7 @@ describe('Ex mode', function()
local screen = Screen.new(60, 8)
command('set inccommand=')
command([[call setline(1, repeat(['foo foo'], 2))]])
feed('gQ')
feed('1q:')
feed('%s/foo/bar/gc<CR>')
screen:expect({ any = vim.pesc('replace with bar? (y)es/(n)o/(a)ll') })
feed('y') -- Replace the first match ...
@ -73,7 +85,7 @@ describe('Ex mode', function()
it('collects multi-line commands until the block is complete', function()
local screen = Screen.new(60, 10)
feed('gQ')
feed('1q:')
feed('append<CR>')
-- Continuation lines should not have ":" prefix in 'statuscolumn'.
screen:expect([[
@ -82,7 +94,7 @@ describe('Ex mode', function()
{1::}append |
{1: }^ |
{1:~ }|*4
{3:[Command Line] }|
{3:[Ex mode] }|
{5:-- INSERT --} |
]])
feed('x1<CR>x2<CR>.<CR>')
@ -102,7 +114,7 @@ describe('Ex mode', function()
{1::}let g:x = 0 |
{1::}if 1 |
{1: }^ |
{3:[Command Line] }|
{3:[Ex mode] }|
{5:-- INSERT --} |
]])
feed('let g:x = 1<CR>endif<CR>')
@ -116,7 +128,7 @@ describe('Ex mode', function()
end)
it('pressing Ctrl-C in :append inside a loop does not hang', function()
feed('gQ')
feed('1q:')
feed('for i in range(1)<CR>')
feed('append<CR>')
-- CTRL-C cancels the cmdwin (ends Ex mode): back to the ":" cmdline, like |q:|.

View File

@ -31,7 +31,7 @@ describe(':global', function()
]])
-- Also test in Ex mode (keep-open cmdwin REPL)
feed('gQg/foo/norm :<C-V>;<CR>')
feed('1q:g/foo/norm :<C-V>;<CR>')
poke_eventloop() -- Wait for :sleep to start
feed('<C-C>')
screen:expect([[
@ -39,7 +39,7 @@ describe(':global', function()
{2:[No Name] [+] }|
{1::}" Keyboard interrupt |
{1::}^ |
{3:[Command Line] }|
{3:[Ex mode] }|
{5:-- INSERT --} |
]])
end)

View File

@ -783,14 +783,14 @@ local function test_cmdline(linegrid)
it('works with exmode', function()
screen:try_resize(60, 12)
feed('gQ')
feed('1q:')
screen:expect([[
|
{1:~ }|
{2:[No Name] }|
{1::}^ |
{1:~ }|*6
{3:[Command Line] }|
{3:[Ex mode] }|
{5:-- INSERT --} |
]])
feed('echo "foo"<CR>')
@ -802,7 +802,7 @@ local function test_cmdline(linegrid)
{1::}" foo |
{1::}^ |
{1:~ }|*4
{3:[Command Line] }|
{3:[Ex mode] }|
{5:-- INSERT --} |
]])
feed('<C-\\><C-N>vis<CR>')
@ -1229,14 +1229,14 @@ it('tabline is not redrawn in Ex mode #24122', function()
endfunction
]])
feed('gQ')
feed('1q:')
screen:expect([[
{2:foo }|
|
{2:[No Name] }|
{1::}^ |
{1:~ }|*2
{3:[Command Line] }|
{3:[Ex mode] }|
{5:-- INSERT --} |
]])
feed('echo 1<CR>')
@ -1247,7 +1247,7 @@ it('tabline is not redrawn in Ex mode #24122', function()
{1::}echo 1 |
{1::}" 1 |
{1::}^ |
{3:[Command Line] }|
{3:[Ex mode] }|
{5:-- INSERT --} |
]])
end)

View File

@ -2091,7 +2091,7 @@ describe('ui/builtin messages', function()
command('set number')
api.nvim_buf_set_lines(0, 0, 0, true, { 'aaa', 'bbb', 'ccc' })
-- Empty lines advance the cursor and print; a bare address moves and prints.
feed('gggQ<CR><CR>1<CR><CR>')
feed('gg1q:<CR><CR>1<CR><CR>')
screen:expect({ any = vim.pesc('" bbb') })
feed('vi<CR>')
screen:expect([[

View File

@ -38,7 +38,7 @@ if exists('s:did_load')
if g:testname !~ 'test_mapping\.vim$'
" Make "Q" switch to Ex mode.
" This does not work for all tests as Nvim only supports Vim Ex mode.
nnoremap Q gQ<Cmd>call<SID>ExStart()<CR>
nnoremap Q 1q:<Cmd>call<SID>ExStart()<CR>
endif
endif

View File

@ -2606,7 +2606,7 @@ func Test_cmdwin_ex_mode_with_modifier()
" this was accessing memory after allocated text in Ex mode
new
call setline(1, ['some', 'text', 'lines'])
silent! call feedkeys("gQnormal vq:atopleft\<C-V>\<CR>\<CR>", 'xt')
silent! call feedkeys("1q:normal vq:atopleft\<C-V>\<CR>\<CR>", 'xt')
bwipe!
endfunc

View File

@ -71,7 +71,7 @@ func Test_Ex_substitute()
call term_sendkeys(buf, ":call setline(1, repeat(['foo foo'], 4))\<CR>")
call term_sendkeys(buf, ":set number\<CR>")
call term_sendkeys(buf, "gQ")
call term_sendkeys(buf, "1q:")
call WaitForAssert({-> assert_match(':', term_getline(buf, 6))}, 1000)
call term_sendkeys(buf, "%s/foo/bar/gc\<CR>")
@ -217,7 +217,7 @@ func Test_Ex_append_in_loop()
CheckRunVimInTerminal
let buf = RunVimInTerminal('', {'rows': 6})
call term_sendkeys(buf, "gQ")
call term_sendkeys(buf, "1q:")
call term_sendkeys(buf, "for i in range(1)\<CR>")
call term_sendkeys(buf, "append\<CR>")
call WaitForAssert({-> assert_match(': append', term_getline(buf, 5))}, 1000)
@ -260,7 +260,7 @@ endfunc
func Test_ex_mode_errors()
" Not allowed to enter ex mode when text is locked
au InsertCharPre <buffer> normal! gQ<CR>
au InsertCharPre <buffer> normal! 1q:<CR>
let caught_e565 = 0
try
call feedkeys("ix\<esc>", 'xt')
@ -275,13 +275,13 @@ func Test_ex_mode_errors()
func ExEnterFunc()
endfunc
call feedkeys("gQvi\r", 'xt')
call feedkeys("1q:vi\r", 'xt')
au! CmdLineEnter
delfunc ExEnterFunc
au CmdlineEnter * :
call feedkeys("gQecho 1\r", 'xt')
call feedkeys("1q:echo 1\r", 'xt')
au! CmdlineEnter
@ -298,7 +298,7 @@ func Test_ex_mode_with_global()
" call ch_logfile('logfile', 'w')
pedit
func FeedQ(id)
call feedkeys('gQ', 't')
call feedkeys('1q:', 't')
endfunc
call timer_start(10, 'FeedQ')
g/^/vi|HJ
@ -320,7 +320,7 @@ func Test_ex_mode_count_overflow()
" this used to cause a crash
let lines =<< trim END
call feedkeys("\<Esc>gQ\<CR>")
call feedkeys("\<Esc>1q:\<CR>")
v9|9silent! vi|333333233333y32333333%O
call writefile(['done'], 'Xdidexmode')
qall!
@ -337,7 +337,7 @@ func Test_ex_mode_large_indent()
new
set ts=500 ai
call setline(1, "\t")
exe "normal gQi\<CR>."
exe "normal 1q:i\<CR>."
set ts=8 noai
bwipe!
endfunc