feat(input)!: CmdAtom event #41297

Problem:
There is no unified notion of a "user action".

Vim processes input by one-char-at-a-time, and mostly throws away any
hints it might gather about the user's action, with one exception: it
stores the last _edit_ action (the "redo buffer", encoded as
unstructured `["x][v][count]body` bytes).

Plugins can only observe individual keys (vim.on_key) and high-level
effects (TextChanged, CursorMoved).

Solution:
- Users can subscribe to `CmdAtom` events to handle any user action.
  - Event is deferred; handlers cannot cancel or interfere with user
    actions.
- Capture `CmdSpec` from the normal/insert/visual subsystems.
  - typeahead/readahead stay unstructured (`buffheader_T`): they are key
    streams, not commands.
  - the redo/record buffers become `StringBuilder`: fewer
    allocations/copies.
- Repurpose the input/redo engine to accept `CmdSpec` objects.

"atom": one repeatable unit of user input, as a resolved (post-mapping)
keysequence plus structured fields. Only user actions, not `:normal`,
API calls, or non-"t" `feedkeys`.

BREAKING: dot-repeat of an Insert session, replays the entire session
including cursor-moves (:help ins-repeat).

BREAKING: dot-repeat of a Visual operation, replays the selection
instead of operating on a fixed-size region.
This commit is contained in:
Justin M. Keyes authored and GitHub committed 2026-08-14 09:30:31 -04:00
1 parent 485ae7e31a
commit 64a301184e
63 files changed
+3577 -909

No files matched your search

+79
View File
@@ -419,6 +419,85 @@ ChanOpen Just after a channel was opened.
Sets these |v:event| keys:
info as from |nvim_get_chan_info()|
*CmdAtom*
CmdAtom After a user action (an input "atom"): any
motion, operator, insert session, Visual-mode
keysequence, Ex cmdline (":cnext<CR>"),
mapping (and its sub-atoms), or scroll/mouse.
Only for user input, not programmatic input:
INPUT ATOM ~
typed keys yes
|nvim_input()| yes
|nvim_feedkeys()| with "t" (see note)
dot-repeat |.| yes
mapping, typed "@q" yes
|:normal| no
API requests no, lol
"@q" fed by a script no
"multicursor" replays no
aborted operation no
terminal-mode keys no
mouse drag/release no
Hydrogen yes
Note: keys fed from a typed mapping's own
execution (e.g. its Lua callback) count as
the mapping's expansion and fold into its
atom, "t" or not.
Fired at the next event-loop tick, not
synchronously.
<amatch> (the pattern) is the atom type.
The |event-data| has these fields; a field
that does not apply is omitted (type, keys,
changed, and cascade are always present):
- arg: Typed operand of `cmd`: the "x" of "fx",
the replacement char of |r|.
|key-notation|, like `cmd`.
- atoms: The constituent atoms of a composite
(multi-command mapping, Visual sequence),
in-order. A consumer of e.g. "motion" atoms
may want to inspect these "children".
- cascade: Queued for "multicursor" replay.
- changed: Buffer was changed.
- cmd: Command/motion/object name: "w", "fx" is
"f", "iw", "gJ", "<Home>", …. |key-notation|
(not raw bytes).
- count: Effective |count|; omitted if none.
- keys: Resolved (post-mapping) keysequence,
raw internal bytes (not |key-notation|):
feed directly to |feedkeys()| or
|nvim_feedkeys()| (mode "n") to replay. Use
|keytrans()| to key-notation. Empty for
a mapping whose commands have no replayable
keys (|<Cmd>|/Lua commands).
- lhs: LHS (user input). Raw bytes, like `keys`.
- motionforce |forced-motion|: "v", "V", or
"<C-V>" (|key-notation|).
- operator: Operator: "d", "g@", "zf", ….
|key-notation|, like `cmd`.
- pending: What is awaiting input after
a mapping: "operator", "visual".
- reg: Register name.
- text: Payload text: inserted text of an
insert session (after its last cursor-move,
like |quote.|), Ex cmdline, or search.
Literal text, not key encoding. Examples:
- typed "iab<Esc>" → text="ab"
- typed ":cnext<CR>" → text="cnext"
- typed "iab<Left>c<Esc>" → text="c"
- type: "command", "ex", "insert", "jump",
"mapping", "motion", "mouse", "operator",
"scroll", "visual".
- "command": neither edits nor moves the
cursor (undo, folds, CTRL-W commands, …).
- "jump": moves the cursor via
absolute/shared navigation state
(|jumplist|, marks, |star|).
- "mouse"/"scroll" atoms are emit-only:
never cascaded, not replayable.
*CmdlineChanged*
CmdlineChanged After EVERY change inside command line. Also
triggered during mappings! Use |<Cmd>| instead
+136
View File
@@ -169,6 +169,39 @@ Other references:
- https://github.com/neovim/neovim/pull/18375
- https://github.com/neovim/neovim/pull/21605
==============================================================================
State *dev-state*
"Editor state" is represented by these (sometimes overlapping) concepts:
- shada (`src/nvim/shada.c`): user data: registers, marks, …. Msgpack format.
- sessions/views (`src/nvim/ex_session.c`): windows/layout, per-window
cursor/options, CWD, …. Vimscript format: commands, not merged data.
Overlaps shada for the buffer list and global variables.
- context (`src/nvim/context.c`): |nvim_get_context()|.
Currently interfaces shada and other state. Needs more thought.
- scoped execution ("temporary state"): `ctx_switch()`, `vim._with()`,
|nvim_win_call()|, |nvim_buf_call()|, `cmdmod_T`. Temporary switches, not
snapshots. The C mechanisms overlap with each other; `vim._with()` is the
unified interface.
- reentrancy protection: save/restore around nested execution. `InsState`,
`RedoState`.
- TODO: ad hoc pairs remain for typeahead (`tasave_T`), |v:event|, view
state, cmdline, with inconsistent nesting semantics (stack vs refcount vs
single-shot).
LONG-TERM VISION(?): shada is the authority on all user-data state, sessions
on layout state? New state kinds extend the shada schema instead of adding
a format or an in-memory sidecar. Eliminate context.c?
LUA FUNCTIONS ACROSS PROCESSES
The test harness transfers Lua closures between Nvim instances
(`test/functional/testnvim/exec_lua.lua`). This works ONLY between same-build
instances (LuaJIT bytecode not portable across versions/builds) and only for
trusted peers. Upvalues are limited to msgpack-able values (not
function/userdata; tables lose metatables and identity).
==============================================================================
Filesystem *dev-filesystem*
@@ -502,6 +535,109 @@ When doing an `i` or `a` command, `normal_cmd()` will call the `edit()` function
It contains a loop that waits for the next character and handles it. It
returns when leaving Insert mode.
==============================================================================
Multicursor *dev-multicursor* *dev-cmdatom*
Multicursor is essentially a bunch of extmarks that "cascade" the user action
(|CmdAtom|) driven by the primary cursor.
An "atom" is any user action, as a resolved ("elemental", post-mapping)
keysequence, the same material ("redobuf") replayed by dot-repeat
(|single-repeat|). Whereas a macro is a series of meaningless characters
whose meaning is context-dependent, assigned at the moment of execution, an
atom scopes an input sequence to one, semantic, user action. Examples: "x" is
captured as "dl", an insert session as `ciwfoo<Esc>`, a Visual-mode edit as
`viweed`.
CONCEPTS
- ATOM: A repeatable unit of user input: the resolved keysequence, plus
structured fields (`CmdSpec`). Every user action emits a |CmdAtom| event.
- SPAN: Fragment of an Insert or Visual sequence, cascades but does not emit
a CmdAtom.
- COMPOSITE: An atom composed of subatoms (`CmdAtom.atoms`): a mapping/macro's
commands, or a Visual sequence.
- INSERT-SESSION: All changes from an insert-mode session, until <Esc>.
- INSERTION: An `Ins.start`..cursor "undo unit" within a session.
- INSERT-CASCADE: Replay an insert-session span-by-span as it is typed (each
span is packaged as a quasi-session: `i` + keys + `<Esc>`), via nested
`edit()`. Pending literal text is a PREVIEW until its span completes.
- REPLAY: Execute keys (dot-repeat, or atom).
- CASCADE: Replay queued atoms at the "clock edge" at every cursor. Visual
cascade "dry-runs" spans to display the selection.
- VOID: When a pending (Visual) atom's keys were tainted/poisoned (by: mouse,
`gv`, a scroll that drags the cursor, …), thus not replayable.
IMPLEMENTATION
Capture happens at the `atom_xx()` hooks.
- `prep_redo()` marks a redoable command (operators, `r`, `J`, `p`, …). Then
`atom_cmd_end()` takes the final redobuff as the atom, (including
payloads, e.g. `d/pat<CR>`). Prep-exempt commands (yank, `D`, folds) are
reconstructed instead, in `atom_capture_op()`.
- `atom_cmd_start()` delimits a command in `normal_execute()`. Everything not
covered above is decided here (motions, jumps, scrolls, mouse).
- `atom_ins_start()` delimits an insert session.
- `atom_map_start()`, `atom_macro_start()` delimits a composite.
- `atom_visual_end()` completes the pending Visual atom, `viweex` => `viweed`.
<Esc> discards it; non-replayable commands VOID it.
- `atom_cmdline_set()` gets an invoked cmdline payload (e.g. `/pat<CR>` is
a motion atom, `:cnext<CR>` is an "ex" atom.)
DOT-REPEAT
The redo buffer itself is the pending change atom (`RedoBuf`); the register,
Visual flag and count are FIELDS, and the command body is bytes appended
during execution by the `redo_append_xx()` family (Vim's `AppendToRedobuff*`).
The old `["x][v][count]body` byte encoding no longer exists; `prep_redo()`
stores the prefix structurally, and consumers recompose it at the boundaries
(`redo_keys()`, `start_redo()`, `atom_from_redo()`).
THE CASCADE
Cursors are `Context` entries in `mc_cursors`, tracked as extmarks. The
extmark is authoritative: it follows edits, and user code may delete cursors
by deleting their extmarks. Cursor-local state is swapped in on replay, so
e.g. each cursor reads/writes its own registers.
CmdAtoms queued for cascade (`g_atoms`) are replayed at every cursor on the
"clock edge": the completion of a toplevel `normal_execute()`. While
a mapping is executing (its keys are still in typebuf) the edge is deferred
(subatoms queue/accumulate).
Undo is buffer-global and applied in bulk. Each cascade adds exactly one undo
state, and undo/redo is itself never cascaded (`u_doit()` calls `atom_op_global_set()`).
The insert-cascade PREVIEW/COMMIT model (why text is previewed instead of
replayed) is documented at `mc_ins_span`. Sessions that cannot insert-cascade
(count `3iZ`, Replace mode, blockwise `CTRL-V c`) are captured at <Esc>.
- See `CmdAtomType` and `atom_key_class()` to understand how input is
classified to decide whether and how it can be replayed.
RENDERING
Display is owned by `runtime/lua/vim/_core/mcursor.lua`. the C core maintains
the model, as extmarks:
- "nvim.multicursor": the tracking marks (cursor positions).
- "nvim.multicursor.cursor": selection-END display cursors, during Visual mode
(each cursor displays at its own selection end). Consumers (UIs) that
display cursors use these positions
while any exist, instead of the tracking marks.
- "nvim.multicursor.visual": the pending selection ranges.
GUI SUPPORT
A GUI gets a working display for free, via cell highlights. If it wants to
draw "real" cursors, it is expected to:
- receive positions via the `win_extmark` UI event: the tracking marks are
ui_watched, so visible cursor positions are pushed per-redraw. (Known
issue: win_extmark does not give a removal signal; re-query when in doubt.)
- or query the namespaces above with |nvim_buf_get_extmarks()|; prefer the
"nvim.multicursor.cursor" positions while any exist (in-progress Visual
selection).
- render selections from "nvim.multicursor.visual", or simply keep their
"hl-MultiCursorVisual" grid highlights.
==============================================================================
+8 -2
View File
@@ -343,9 +343,13 @@ out of Insert mode. This is very handy if you prefer to use the Insert mode
all the time, just like editors that don't have a separate Normal mode. You
can use CTRL-O if you want to map a function key to a command.
*ins-repeat*
The changes (inserted or deleted characters) before and after these keys can
be undone separately. Only the last change can be redone and always behaves
like an "i" command.
be undone separately. Relative cursor-moves (the cursor keys, <S-Left>,
<Home>, CTRL-G j, …) are part of the insert: |.| repeats the whole insert,
including cursor-moves. After a jump (mouse, scroll, <PageUp>, …) only the
last change can be redone, and it behaves like an "i" command. The |quote.|
register always holds just the text typed after the last cursor-move.
char action ~
-----------------------------------------------------------------------
@@ -445,6 +449,8 @@ will be repeatable by using |.| to the expected
Lorem ipsum (dolor)
Cursor keys are captured by |.| (|ins-repeat|), so these mappings are only
needed to keep the cursor-move from starting a new undo block.
Using CTRL-O splits undo: the text typed before and after it is undone
separately. If you want to avoid this (e.g., in a mapping) you might be able
+16
View File
@@ -122,6 +122,20 @@ EDITOR
cmdwin via |c_CTRL-F|.
• Behavior of |:restart| changed. Use "!" (|:restart!|) to get the old behavior.
• |ZR| now performs |:restart|. Add a count to change the behavior.
• |ins-repeat| replays the full Insert session, including cursor-moves.
Undo and |quote.| still restart at each cursor-move. Use |i_CTRL-O| to get
the old behavior: >vim
inoremap <Left> <C-o><Left>
inoremap <Right> <C-o><Right>
• |visual-repeat| is "semantic" instead of "fixed-size". See
|visual-fixed-size| if you prefer Vim's "fixed-size" Visual-repeat.
• |Q| adds a |multicursor| instead of replaying the last recorded register.
To get the old behavior: >lua
vim.keymap.set('n', 'Q', function()
local reg = vim.fn.reg_recorded()
return reg == '' and '' or ('@' .. reg)
end, { expr = true })
<
EVENTS
@@ -297,6 +311,8 @@ EVENTS
• |:delmarks| now triggers the |MarkSet| autocommand with line==col==0, same
as |nvim_buf_del_mark()|
• |ChanClose| is triggered after a channel is closed, before it is removed.
• |CmdAtom| fires after each atomic user action (motion, mapping, operator,
insert session, visual, Ex command, …).
• |SessionWritePre| event emits just before |:mksession|.
• |TextPutPre| and |TextPutPost| are triggered before/after putting text.
• |TabMoved| is triggered when tabs are reordered.
+1
View File
@@ -2570,6 +2570,7 @@ A jump table for the options with a short description can be found at |Q_op|.
|ChanClose|,
|ChanInfo|,
|ChanOpen|,
|CmdAtom|,
|CmdUndefined|,
|CmdlineChanged|,
|CmdlineEnter|,
+38 -5
View File
@@ -1,7 +1,7 @@
*repeat.txt* Nvim
VIM REFERENCE MANUAL by Bram Moolenaar
NVIM REFERENCE MANUAL
Repeating commands, Vim scripts and debugging *repeating*
@@ -19,6 +19,9 @@ Single repeats *single-repeat*
included in 'cpoptions'. Does not repeat a
command-line command.
*@:*
@: Repeat last command-line [count] times.
Simple changes can be repeated with the "." command. Without a count, the
count of the last change is used. If you enter a count, it will replace the
last one. |v:count| and |v:count1| will be set.
@@ -27,12 +30,34 @@ If the last change included a specification of a numbered register, the
register number will be incremented. See |redo-register| for an example how
to use this.
Note that when repeating a command that used a Visual selection, the same SIZE
of area is used, see |visual-repeat|.
Repeating an insert |ins-repeat| re-executes the whole session, including
non-jump cursor-moves: "iab<Left>c<Esc>." produces "acb", not "c". Note that
jumps (mouse, |i_<PageUp>|, …) split an insert session.
*@:*
@: Repeat last command-line [count] times.
Repeating a Visual-mode command re-executes the captured keysequence,
selection included. See |visual-repeat|.
*motion-repeat*
Example: a "," mapping that repeats the last motion, like |;| but for any
motion ("3w", "fx", "/pat<CR>"): remember the last "motion" |CmdAtom| and
replay its keys: >lua
local last ---@type string?
vim.api.nvim_create_autocmd('CmdAtom', {
pattern = 'motion',
callback = function(ev)
last = ev.data.keys
end,
})
vim.keymap.set('n', ',', function()
-- CmdAtom is deferred: schedule the replay after any pending event,
-- so `last` is fresh when "," follows a motion.
vim.schedule(function()
if last then
vim.api.nvim_feedkeys(last, 'n', false) -- "n": already resolved
end
end)
end)
<
==============================================================================
Multiple repeats *multi-repeat*
@@ -156,6 +181,9 @@ Q Repeat the last recorded register [count] times.
register for each selected line.
See |visual-repeat|, |default-mappings|.
*v_Q*
{Visual}Q Place a cursor on each line of the Visual selection.
*:@*
:[addr]@{0-9a-z".=*+} Execute the contents of register {0-9a-z".=*+} as an
Ex command. First set cursor at line [addr] (default
@@ -180,6 +208,11 @@ Q Repeat the last recorded register [count] times.
:[addr]@@ Repeat the previous :@{register}. First set cursor at
line [addr] (default is current line).
==============================================================================
Multiple cursors *mcursor* *multicursor*
todo
==============================================================================
Using Vim scripts *using-scripts*
+8 -2
View File
@@ -150,7 +150,6 @@ you never want any default mappings, call |:mapclear| early in your config.
- <C-L> |CTRL-L-default|
- & |&-default|
- - |dir-mappings|
- Q |v_Q-default|
- @ |v_@-default|
- # |v_#-default|
- * |v_star-default|
@@ -232,6 +231,7 @@ New Features *nvim-features*
MAJOR COMPONENTS
- API |API|
- Command atoms |CmdAtom|
- Job control |job-control|
- LSP framework |lsp|
- Lua scripting |lua| |-l|
@@ -335,6 +335,10 @@ Commands:
- |:uptime|
Editor:
- |ins-repeat| replays the full Insert session, including cursor-moves:
"iab<Left>c<Esc>." produces "acb", not "c".
- |visual-repeat| is "semantic" instead of "fixed-size". If you prefer Vim's
"fixed-size" Visual-repeat, see |visual-fixed-size|.
- Interactive |Ex-mode| is implemented as persistent, insert-mode |cmdwin|.
- |prompt-buffer| supports multiline input/paste, undo/redo, and o/O normal
commands.
@@ -402,7 +406,9 @@ 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| restores |multicursor|s.
- |Q| adds a |multicursor| ([count]Q: at each match; {Visual}Q: at each
selected line |v_Q|) 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
+33 -11
View File
@@ -359,17 +359,39 @@ See |v_b_r_example|.
==============================================================================
6. Repeating *visual-repeat*
When repeating a Visual mode operator, the operator will be applied to the
same amount of text as the last time:
- Linewise Visual mode: The same number of lines.
- Blockwise Visual mode: The same number of lines and columns.
- Normal Visual mode within one line: The same number of characters.
- Normal Visual mode with several lines: The same number of lines, in the
last line the same number of characters as in the last line the last time.
The start of the text is the Cursor position. If the "$" command was used as
one of the last commands to extend the highlighted text, the repeating will
be applied up to the rightmost column of the longest line. Any count passed
to the `.` command is not used.
Repeating a Visual operation works "semantically": |.| replays the captured
keysequence (Visual selection plus operator) at the cursor: "viwd" then "."
deletes the word at the cursor, whatever its size. (Note: this is unlike Vim,
where Visual-repeat works on the same fixed-size region. If you prefer that
behavior, see |visual-fixed-size|.)
A [count] given to "." is prepended to the replayed keys. If the selection was
extended by a command that cannot be replayed (mouse, search, Ex command,
scroll), "." instead applies the operator to a same-size area, as if "1v"
(|v|) preceded it.
*visual-fixed-size*
Example: To mimic Vim's fixed-size Visual dot-repeat: after a Visual
operation, reselect the fixed-size region ("1v", |v|) and applies the
operator: >lua
local vop ---@type string?
vim.api.nvim_create_autocmd('CmdAtom', {
---@param ev {data: vim.event.cmdatom.data}
callback = function(ev)
if ev.data.type == 'visual' then
vop = ev.data.atoms and ev.data.atoms[#ev.data.atoms].keys or nil
elseif ev.data.changed then
vop = nil -- the last change is no longer the Visual one
end
end,
})
vim.keymap.set('n', '.', function()
vim.schedule(function()
vim.api.nvim_feedkeys(vop and ('1v' .. vop) or '.', 'n', false)
end)
end)
<
Visual mode |default-mappings| "@" and "Q" repeat a register for all selected
lines if the selection is linewise. See |v_@-default| and |v_Q-default| for
+1
View File
@@ -105,6 +105,7 @@ error('Cannot require a meta file')
--- |'ChanClose'
--- |'ChanInfo'
--- |'ChanOpen'
--- |'CmdAtom'
--- |'CmdUndefined'
--- |'CmdlineChanged'
--- |'CmdlineEnter'
+17
View File
@@ -4,6 +4,23 @@
-- See also `vim.api.keyset.events` in `api_keysets.gen.lua`.
error('Cannot require a meta file')
--- Data for the CmdAtom event.
--- @class vim.event.cmdatom.data
--- @field arg? string Typed operand of `cmd` ("fx" => "x").
--- @field atoms? vim.event.cmdatom.data[] Subatoms of a composite (mapping, Visual sequence).
--- @field cascade boolean Queued for multicursor replay.
--- @field changed boolean Changed the buffer.
--- @field cmd? string Command/motion/object name ("w", "f", "iw", "gJ").
--- @field count? integer Effective count.
--- @field keys string Resolved keysequence, raw bytes: feed to nvim_feedkeys() to replay.
--- @field lhs? string Mapping LHS or macro register ("gj", "@q"). Raw bytes, like `keys`.
--- @field motionforce? 'v'|'V'|'<C-V>' forced-motion type.
--- @field operator? string Operator name ("d", "g~", "g@"). key-notation.
--- @field pending? 'operator'|'visual' Mapping ended mid-operation.
--- @field reg? string Register name.
--- @field text? string Inserted text, or the Ex/search cmdline.
--- @field type 'command'|'ex'|'insert'|'jump'|'mapping'|'motion'|'mouse'|'operator'|'scroll'|'visual'
--- @class vim.event.lspattach.data
--- @field client_id integer
+1
View File
@@ -2193,6 +2193,7 @@ vim.go.ei = vim.go.eventignore
--- `ChanClose`,
--- `ChanInfo`,
--- `ChanOpen`,
--- `CmdAtom`,
--- `CmdUndefined`,
--- `CmdlineChanged`,
--- `CmdlineEnter`,