From 2514256d95d7161ec55e8e54c01f667a1551809c Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Fri, 28 Aug 2026 18:46:03 +0200 Subject: [PATCH 1/2] refactor(input): exec stuffed keys eagerly Problem: The Vim "stuff" concept breaks the ability to reason about the call stack and thus the boundaries of a `CmdAtom`: a stuffed translation ("x" => "dl") defers to the main loop. This "continuation" must be modeled in `CmdAtom`, by checking global flags at undefined times, during undefined circumstances. Solution: - After a stuffed "translation", eagerly execute the stuff buffer (`exec_stuffed()`). - Delete the CmdAtom "continuation" junk. Note: - op_colon runs its cmdline "nested", but that's fine because operators already nest interactive sessions there (op_change runs edit()), and the cmdline is frameless so the operator's frame can own/capture it. --- runtime/doc/dev_arch.txt | 11 ++++- src/nvim/ex_getln.c | 2 +- src/nvim/input.c | 21 ++++++--- src/nvim/input_cmdatom.c | 63 ++++++++----------------- src/nvim/input_cmdatom.h | 1 - src/nvim/mouse.c | 1 + src/nvim/normal.c | 48 ++++++++++++++++++- src/nvim/ops.c | 11 +++-- src/nvim/register.c | 4 ++ test/functional/editor/cmdatom_spec.lua | 39 ++++++++++++++- 10 files changed, 142 insertions(+), 59 deletions(-) diff --git a/runtime/doc/dev_arch.txt b/runtime/doc/dev_arch.txt index ab65823cb3..d7e79620e7 100644 --- a/runtime/doc/dev_arch.txt +++ b/runtime/doc/dev_arch.txt @@ -485,11 +485,18 @@ All the global variables are declared in `globals.h`. THE MAIN EVENT-LOOP -The main loop is implemented in state_enter. The basic idea is that Vim waits +The main loop is implemented in state_enter. The basic idea is that Nvim waits for the user to type a character and processes it until another character is -needed. Thus there are several places where Vim waits for a character to be +needed. Thus there are several places where Nvim waits for a character to be typed. The `vgetc()` function is used for this. It also handles mapping. +Internal logic "stuffs" keys (readahead) only for a consumer on the current +call stack: `exec_stuffed()`, or a nested reader it invokes. See "Stuffing" +in input.c. (This is simpler and more predictable than Vim, where a command +may stuff chars, and let the main loop deal with them at some arbitrary future +point, thus requiring global flags checked at the right time, scattered +throughout the codebase.) + What we consider the "Nvim event loop" is actually a wrapper around `uv_run` to handle both the `fast_events` queue and possibly (a suitable subset of) deferred events. Therefore "raw" `vim.uv.run()` is often not enough to yield from Lua; diff --git a/src/nvim/ex_getln.c b/src/nvim/ex_getln.c index a0f42c204d..ada5d96a16 100644 --- a/src/nvim/ex_getln.c +++ b/src/nvim/ex_getln.c @@ -4708,7 +4708,7 @@ void get_user_input(const typval_T *const argvars, typval_T *const rettv, const } cmdline_row = msg_row; - stuffReadbuffSpec(defstr); + stuffReadbuffSpecial(defstr); const int save_ex_normal_busy = ex_normal_busy; ex_normal_busy = 0; diff --git a/src/nvim/input.c b/src/nvim/input.c index 61c94cfb86..96cf306ddf 100644 --- a/src/nvim/input.c +++ b/src/nvim/input.c @@ -1,4 +1,4 @@ -// input.c: The input engine "bytes layer" (input_cmdatom.c. is the "policy layer"). +// input.c: The input engine "bytes layer" (input_cmdatom.c is the "policy layer"). // // - Get a character from the user, a script file, or the keybufs described below. // - Apply mappings and abbreviations to typed keys (the :map tables live in mapping.c; the @@ -8,7 +8,12 @@ // // Concepts: // - "Stuffing" = when some internal logic pushes keys to execute next. This is how a cmd -// "translates" into another cmd: "x" stuffs "dl" (nv_optrans()), "." stuffs the redo buf. +// "translates" into another cmd: "x" stuffs "dl", "." stuffs the redo buf. +// - XXX NOTE: stuffed keys must have a consumer in the current codepath (not "later on the main +// loop"). There are 3 different rituals, depending on the consumer: +// 1. basic translation: stuff, then exec_stuffed(); runs as CmdFrames of the stuffing command. +// 2. input staged for a nested reader (getcmdline(), edit()): stuff only. +// 3. main-loop wakeup (K_NOP): stuffed so it precedes input (unlike an event). // - TYPEAHEAD vs READ-AHEAD: // - typeahead = external input that arrived faster than can be executed (user typed fast). // - readahead = Nvim's own self-input. @@ -18,10 +23,14 @@ // (KeyStuffed). // // These buffers are used: -// - stuff buffers (`readbuf1`, `readbuf2`) are readahead buffers. -// - TWO stuff buffers, because stuffing nests: a cmd executed FROM redo keys (readbuf2) may +// +// input ──► [ typebuf │ readbuf2 (redo) │ readbuf1 (stuff) ] ──► vgetc() ──► exec +// ▲ mappings expand at typebuf front +// +// - typeahead (`typebuf`). +// - readahead/stuff buffers (`readbuf1`, `readbuf2`). +// - Two stuff buffers, because stuffing nests: a cmd executed FROM redo keys (readbuf2) may // itself stuff a translation (readbuf1), which must be consumed before the remaining redo. -// - `typebuf`: typeahead (see below). // - `redobuff` (RedoState): the current + previous change. // - `recordbuff`: accumulates the keys of a recording ("q"). // @@ -750,7 +759,7 @@ void stuffReadbuffLen(const char *s, ptrdiff_t len) /// Stuff "s" into the stuff buffer, leaving special key codes unmodified and /// escaping other K_SPECIAL bytes. /// Change CR, LF and ESC into a space. -void stuffReadbuffSpec(const char *s) +void stuffReadbuffSpecial(const char *s) FUNC_ATTR_NONNULL_ALL { while (*s != NUL) { diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index dad8e55e01..e83b4388a3 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -78,14 +78,12 @@ static uint64_t frame_id = 0; /// The executing command's frame; its `parent` chain spans nested `normal_execute()`. static CmdFrame *cur_frame = NULL; -/// The pending atom, while it spans CmdFrames (a mapping's commands, a stuffed continuation, an -/// operator awaiting its motion). See `vatom` for Visual composite. +/// The pending atom, while it spans CmdFrames (a mapping's commands, an operator awaiting its +/// motion, i_CTRL-O). See `vatom` for Visual composite. /// /// Also used by undo, to restore cursor position. static struct { bool open; ///< True from the atom's first toplevel frame until it resolves. - bool stuffed; ///< Last frame had stuffed keys pending (next frame continues the atom). - bool redo; ///< The continuation completes the prepped redo ("!ip" stuffs ":.,.+1!"). CmdOrigin origin; ///< State at the atom's start. CmdAtomVec atoms; ///< Subatoms of the mapping/macro. char *lhs; ///< Label: mapping LHS or macro "@x" (NULL: not collecting). @@ -235,14 +233,6 @@ pos_T atom_origin_pos(buf_T *buf) return curwin->w_cursor; } -/// Decides the "origin" state. Usually `frame->origin`, except for "stuffed" cases. -/// A "stuffed" continuation belongs to its stuffing frame (`x` stuffs `dl`: the `dl` atom's origin -/// is at `x`). -static CmdOrigin frame_origin(const CmdFrame *frame) -{ - return frame->cont ? composite.origin : frame->origin; -} - /// Composes a CmdSpec into `redo_keys` format. /// @return Allocated key sequence. static char *atom_redo_keys(CmdSpec spec) @@ -822,10 +812,11 @@ void atom_typed_del(size_t len) kv_size(typed.keys) -= MIN(len, kv_size(typed.keys)); } -/// Discards the redo-atom: new or invalid command. Only at toplevel (not nested ":normal!"). +/// Discards the redo-atom: new or invalid command. Only at toplevel: a nested frame (":normal!", +/// an exec_stuffed() drain) must not disturb the enclosing command's. static void atom_redo_reset(void) { - if (!atom_is_user_cmd()) { + if (!atom_is_user_cmd() || (cur_frame != NULL && cur_frame->parent != NULL)) { return; } curcmd.redo_frame = 0; @@ -875,7 +866,7 @@ void atom_macro_start(int regname) } } -/// Starts accumulating a composite for a command that stuffs its "translation" ("x" => "dl"). +/// Starts accumulating composite for a cmd that stuffs its translation ("x" => "dl", "." => redo). void atom_stuff_start(const cmdarg_T *cap) { // Not while another composite collects: a mapping's own label wins ("nnoremap xw"). @@ -1017,7 +1008,7 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa }; if (spec != NULL) { kv_push(vatom.atoms, ((CmdAtom){ .type = kAOperator, .spec = *spec, .keys = suffix, - .origin = frame_origin(cur_frame) })); + .origin = cur_frame->origin })); } else { xfree(suffix); } @@ -1068,7 +1059,7 @@ void atom_capture_op(oparg_T *oap, cmdarg_T *cap, bool redo_yank) spec.cmd2 = operand ? NUL : cap->nchar; spec.cmdarg = operand ? cap->nchar : NUL; CmdAtom op_atom = atom_from_spec(kAOperator, spec); - op_atom.origin = frame_origin(cur_frame); + op_atom.origin = cur_frame->origin; atom_stage_set(&op_atom); } } else if (!Visual.active || oap->motion_force) { @@ -1169,7 +1160,7 @@ InsSession atom_ins_start(int cmd, long count, VisualIns vis, bool vblock) // A consumed selection opens the redo body, so the atom starts where the selection did. // Else the CmdFrame origin, from before the entry moved the cursor (a/A/…). .origin = vis == kVInsKeys ? vatom.origin - : cur_frame != NULL ? frame_origin(cur_frame) : atom_origin(), + : cur_frame != NULL ? cur_frame->origin : atom_origin(), }; if (vis != kVInsNone && !mc_replaying()) { if (vis == kVInsKeys && !(atom_visual_replayable() && (vatom.state & kVatomTyped))) { @@ -1226,14 +1217,9 @@ static void atom_ins_push(const InsSession *session, bool cascade) /// Toplevel entry: starts a new atom. Samples the pre-cmd state (`origin`); pushes the frame. void atom_cmd_start(CmdFrame *old) { - // A stuffed continuation frame is one cmd with its stuffing frame: `v2e".p` stuffs `c…`, `!ip` - // stuffs `:.,.+1!`. KeyStuffed=false if error/CTRL-C aborted, `composite.stuffed` is then stale. - old->cont = KeyStuffed && cur_frame == NULL && composite.open && composite.stuffed; old->origin = atom_origin(); if (cur_frame == NULL && !composite.open) { composite.open = true; - composite.stuffed = false; - composite.redo = false; composite.origin = old->origin; } old->visual = Visual; @@ -1251,11 +1237,7 @@ void atom_cmd_start(CmdFrame *old) old->parent = cur_frame; cur_frame = old; curcmd.op_global = false; - if (old->cont && composite.redo) { - curcmd.redo_frame = old->id; // The continuation completes the prepped redo. - } else { - atom_redo_reset(); - } + atom_redo_reset(); } /// Captures the typed command's atom: one atom per command, produced from the CmdFrame diff and @@ -1313,7 +1295,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old) // Decided once, at session start. vatom.state = (old->keytyped || (atom_composite_active() && atom_is_user_cmd())) ? kVatomTyped : kVatomFed; - vatom.origin = frame_origin(old); + vatom.origin = old->origin; } // Decided by the session (not atom_capturable()), so fed selections (":normal! vjd") still // accumulate for redo-prep. Recording/replay commands are meta (not part of the edit). @@ -1382,7 +1364,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old) // did neither is a no-op (vim-surround "ysa[" whose surround char was ). bool effect = changed || reg_max_ts(true) > old->reg_ts; if (atom.keys != NULL && *atom.keys != NUL) { - atom.origin = frame_origin(old); + atom.origin = old->origin; atom_push(effect, &atom); } else { atom_free(&atom); @@ -1391,14 +1373,14 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old) && !(vis && unchanged)) { // Payload typed in the cmdline ("/pat"). Emit-only. Not if pattern was not found. CmdAtom atom = atom_from_cmdline(kAMotion, ca, ca->searchbuf); - atom.origin = frame_origin(old); + atom.origin = old->origin; atom_push(false, &atom); } else if (!vis && curcmd.cmdline != NULL && (ca->cmdchar == ':' || ca->cmdchar == K_COMMAND)) { // Same for ":cnext" or "cnext". Never a Visual subatom. CmdAtom atom = atom_from_cmdline(kAExcmd, ca, curcmd.cmdline); // Payload read during the cmdline execution (`ds)` getchar() => ")"). atom_payload_append(&atom, old); - atom.origin = frame_origin(old); + atom.origin = old->origin; atom_push(false, &atom); } else if (replayable && (!vis || (keycls & kKeyPayload) == 0)) { // Non-redoable command (u, zz, q=): never cascaded as an edit. @@ -1408,7 +1390,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old) spec.regname = 0; } CmdAtom atom = atom_from_spec(motion ? kAMotion : jump_cmd ? kAJump : kANormal, spec); - atom.origin = frame_origin(old); + atom.origin = old->origin; atom_push(follow, &atom); } else if ((scroll_cmd || mouse_cmd) && !atom_composite_active()) { // Emit-only (viewport-dependent). @@ -1418,7 +1400,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old) spec.count = 0; } CmdAtom atom = atom_from_spec(scroll_cmd ? kAScroll : kAMouse, spec); - atom.origin = frame_origin(old); + atom.origin = old->origin; atom_push(false, &atom); } if (vis && kv_size(vatom.atoms) == collected && !unchanged) { @@ -1448,8 +1430,9 @@ void atom_cmd_end(cmdarg_T *ca, CmdFrame *old) } // The clock edge. Only at toplevel: cascading from a nested normal_execute() would recurse. - // Deferred while a mapping executes (its keys are still in typebuf), so its commands collapse as - // one unit; likewise for a macro's LAST command, which may stuff a translation ("x" => "dl"). + // Deferred while a mapping executes (keys in typebuf), so its commands collapse as one unit; + // likewise while stuffed keys are pending (i_CTRL-O dance, or exec_stuffed() deferred under + // textlock). if (old->parent == NULL && !mc_replaying() && typebuf_typed() && stuff_empty()) { mc_clock_edge(map_edit); map_edit = false; @@ -1460,13 +1443,5 @@ void atom_cmd_end(cmdarg_T *ca, CmdFrame *old) composite.open = false; // Resolved. } } - if (old->parent == NULL) { - // Stuffed keys pending: the next frame continues the atom. - composite.stuffed = !stuff_empty(); - // The continuation also completes the redo-prep. - // Except Visual: stuffing is captured in its payload. - composite.redo = composite.stuffed && curcmd.redo_frame == old->id - && !Visual.active && !old->visual.active; - } cur_frame = old->parent; } diff --git a/src/nvim/input_cmdatom.h b/src/nvim/input_cmdatom.h index 71d20f8dbe..4bfb077dc2 100644 --- a/src/nvim/input_cmdatom.h +++ b/src/nvim/input_cmdatom.h @@ -17,7 +17,6 @@ extern CmdAtomVec g_atoms; typedef struct CmdFrame CmdFrame; struct CmdFrame { CmdOrigin origin; ///< State at entry. - bool cont; ///< Stuffed continuation frame: the atom began earlier. VisualState visual; ///< Visual-mode state (active/start/mode are diffed). bool keytyped; ///< KeyTyped uint64_t captures; ///< Capture counter. diff --git a/src/nvim/mouse.c b/src/nvim/mouse.c index 5264f859de..7992dc6b39 100644 --- a/src/nvim/mouse.c +++ b/src/nvim/mouse.c @@ -492,6 +492,7 @@ bool do_mouse(oparg_T *oap, int c, int dir, int count, bool fixindent) stuffcharReadbuff('y'); stuffcharReadbuff(K_MIDDLEMOUSE); } + exec_stuffed(oap); return false; } // The rest is below jump_to_mouse() diff --git a/src/nvim/normal.c b/src/nvim/normal.c index e804c5ab2f..46a182f6e4 100644 --- a/src/nvim/normal.c +++ b/src/nvim/normal.c @@ -4561,6 +4561,9 @@ static void nv_replace(cmdarg_T *cap) stuffcharReadbuff('R'); stuffcharReadbuff('\t'); stuffcharReadbuff(ESC); + if (exec_stuffed(cap->oap)) { + cap->retval |= CA_COMMAND_BUSY; + } return; } @@ -4868,7 +4871,7 @@ static void nv_abbrev(cmdarg_T *cap) } } -/// Translate a command into another command. +/// Translate a command into another command, and execute it. static void nv_optrans(cmdarg_T *cap) { static const char *(ar[]) = { "dl", "dh", "d$", "c$", "cl", "cc", "yy", @@ -4881,6 +4884,9 @@ static void nv_optrans(cmdarg_T *cap) stuffnumReadbuff(cap->count0); } stuffReadbuff(ar[strchr(str, (char)cap->cmdchar) - str]); + if (exec_stuffed(cap->oap)) { + cap->retval |= CA_COMMAND_BUSY; + } } cap->opcount = 0; } @@ -5650,6 +5656,7 @@ static void nv_g_cmd(cmdarg_T *cap) case K_LEFTMOUSE: if (do_mouse(oap, cap->nchar, BACKWARD, cap->count1, 0)) { stuffcharReadbuff(Ctrl_RSB); + exec_stuffed(oap); } break; @@ -5772,6 +5779,11 @@ static void nv_dot(cmdarg_T *cap) atom_stuff_start(cap); if (start_redo(cap->count0, restart_edit != 0 && Ins.moved == kInsNone) == false) { clearopbeep(cap->oap); + return; + } + // Execute the redo keys here: the whole replay resolves within this "." command. + if (exec_stuffed(cap->oap)) { + cap->retval |= CA_COMMAND_BUSY; } } @@ -6699,6 +6711,12 @@ static void nv_event(cmdarg_T *cap) } } +/// Executes one normal-mode command from pending input, outside the main state machine. +/// +/// Called in a loop; a count/register prefix or a pending operator travels into the next call via +/// `oap` ("3dl" is three calls, one command). +/// +/// @param toplevel `NormalState.toplevel` (full interactive-command treatment). void normal_cmd(oparg_T *oap, bool toplevel) { NormalState s; @@ -6709,3 +6727,31 @@ void normal_cmd(oparg_T *oap, bool toplevel) normal_execute(&s.state, safe_vgetc()); *oap = s.oa; } + +/// Executes the pending readahead (see "Stuffing", input.c). +/// +/// During "textlock" the stuffed keys are left for the main loop instead (for CmdAtom/multicursor +/// purposes, that's fine: if stuff_empty()=false, the pending CmdAtom stays open and will collect +/// the effects later). +/// +/// @param oap The continuing operator state (see `normal_cmd`), or NULL. +/// @return True if insert-session-resume is pending (i_CTRL-O): the caller reports +/// CA_COMMAND_BUSY, so resume happens after next command instead. +bool exec_stuffed(oparg_T *oap) +{ + if (text_locked() || curbuf_locked()) { + return false; + } + oparg_T oa; + clear_oparg(&oa); + if (oap == NULL) { + oap = &oa; + } + finish_op = false; + while (!stuff_empty() && !got_int) { + update_topline_cursor(); + normal_cmd(oap, true); + } + finish_op = false; + return restart_edit != 0; +} diff --git a/src/nvim/ops.c b/src/nvim/ops.c index b9a3abc4c0..9d0b3632a4 100644 --- a/src/nvim/ops.c +++ b/src/nvim/ops.c @@ -31,6 +31,7 @@ #include "nvim/eval/typval_defs.h" #include "nvim/ex_cmds2.h" #include "nvim/ex_cmds_defs.h" +#include "nvim/ex_docmd.h" #include "nvim/ex_getln.h" #include "nvim/extmark.h" #include "nvim/file_search.h" @@ -3073,10 +3074,10 @@ void cursor_pos_info(dict_T *dict) } } -/// Handle indent and format operators and visual mode ":". +/// Handle indent, format operators and visual mode ":". Stuff the ":[range]…" cmdline and run it +/// here. For OP_COLON/OP_FILTER the user types the rest after the stuffed part. static void op_colon(oparg_T *oap) { - stuffcharReadbuff(':'); if (oap->is_VIsual) { stuffReadbuff("'<,'>"); } else { @@ -3126,7 +3127,11 @@ static void op_colon(oparg_T *oap) stuffReadbuff("\n']"); } - // do_cmdline() does the rest + do_cmdline(NULL, getexline, NULL, 0); + // Keys stuffed past the cmdline ("']" for OP_FORMAT): internal cleanup, not an atom. + atom_suppress(true); + exec_stuffed(NULL); + atom_suppress(false); } #ifdef EXITFREE diff --git a/src/nvim/register.c b/src/nvim/register.c index eec04da895..5ccf5ab1e0 100644 --- a/src/nvim/register.c +++ b/src/nvim/register.c @@ -14,6 +14,7 @@ #include "nvim/eval.h" #include "nvim/eval/typval.h" #include "nvim/ex_cmds2.h" +#include "nvim/ex_docmd.h" #include "nvim/ex_getln.h" #include "nvim/extmark.h" #include "nvim/file_search.h" @@ -1525,6 +1526,9 @@ void do_put(int regname, yankreg_T *reg, int dir, int count, int flags) return; } } + + // Execute the stuffed keys here: the put resolves within the current command. + exec_stuffed(NULL); return; } diff --git a/test/functional/editor/cmdatom_spec.lua b/test/functional/editor/cmdatom_spec.lua index d33c78571d..a701ef9ec5 100644 --- a/test/functional/editor/cmdatom_spec.lua +++ b/test/functional/editor/cmdatom_spec.lua @@ -320,7 +320,7 @@ describe('CmdAtom', function() ) end) - it('"!" operator captures its stuffed cmdline continuation', function() + it('"!" operator captures its stuffed cmdline', function() -- ":.,.+1!" + typed "{prg}" completes the operator atom, like "d/END". #41447 local prg = n.testprg('shell-test') .. ' REP 2 X' fn.setline(1, { 'b', 'a', '', 'e', 'c', 'd' }) @@ -358,6 +358,43 @@ describe('CmdAtom', function() ) end) + it('stuffed translations execute within their command (exec_stuffed)', function() + -- A register prefix crosses into the "." replay; an explicit register must not touch the + -- small-delete register. + fn.setline(1, { 'aaa bbb', 'ccc ddd' }) + feed('gg0dw') + eq('aaa ', fn.getreg('-')) + feed('j0"z.') + eq('ccc ', fn.getreg('z')) + eq('aaa ', fn.getreg('-')) + eq({ 'bbb', 'ddd' }, get_lines()) + -- i_CTRL-O inside a stuffed translation's insert session ("S" == "cc"): the session resumes + -- after ONE normal command. + api.nvim_buf_set_lines(0, 0, -1, true, { 'xxxx', 'yyyy' }) + feed('ggSabc0def') + eq({ 'defabc', 'yyyy' }, get_lines()) + -- r under 'expandtab'/'smarttab' ("{count}R": edit() does the tab). + command('set expandtab shiftwidth=4 tabstop=4') + api.nvim_buf_set_lines(0, 0, -1, true, { 'abcdefgh', 'ABCDEFGH' }) + feed('gg03r') + eq(' defgh', fn.getline(1)) + feed('j0.') + eq(' DEFGH', fn.getline(2)) + api.nvim_buf_set_lines(0, 0, -1, true, { 'abcdefgh' }) + feed('gg0r') + eq('\tbcdefgh', fn.getline(1)) + command('set noexpandtab smarttab shiftwidth=4 tabstop=8') + api.nvim_buf_set_lines(0, 0, -1, true, { 'abcdefgh' }) + feed('gg02r') + eq('\tcdefgh', fn.getline(1)) + command('set expandtab& smarttab& shiftwidth& tabstop&') + -- "&" and "count&" (":.,.+Ns"). + api.nvim_buf_set_lines(0, 0, -1, true, { 'blue a', 'blue b', 'blue c', 'blue d' }) + command('1s/blue/red/') + feed('2G&3G2&') + eq({ 'red a', 'red b', 'red c', 'red d' }, get_lines()) + end) + it('mapping that enters :terminal mode', function() -- Fake picker/fuzzy-finder: a mapping that opens a :terminal UI (like fzf-lua). -- Its composite ends when :terminal is entered. From bdbd99ab109c47996038077c5dd1ba8a1e2783a2 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Sat, 29 Aug 2026 00:59:26 +0200 Subject: [PATCH 2/2] test(tui): unreliable "TUI :detach % detaches other UIs" --- test/functional/terminal/tui_spec.lua | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/test/functional/terminal/tui_spec.lua b/test/functional/terminal/tui_spec.lua index ead5f55e35..b6aeeea992 100644 --- a/test/functional/terminal/tui_spec.lua +++ b/test/functional/terminal/tui_spec.lua @@ -174,6 +174,10 @@ describe('TUI :detach', function() nvim_set .. ' laststatus=2 background=dark', }, { env = env_notermguicolors, cols = opts.cols }) tt.override_screen_expect_for_conpty(screen) + -- The child's `--listen` socket is created asynchronously wrt its PTY output. + t.retry(nil, 2000, function() + assert(vim.uv.fs_stat(child_server)) + end) end it('does not stop server', function()