Skip to content

Editing

mnml quick tour — vim edit, stage + commit via the git status pane, swap to standard mode, edit again

A ~45-second tour: open a file from the picker, edit in vim mode, save with :w, stage + commit through the git status pane, view the commit graph, swap to standard mode with :set input=standard, and finish with a Ctrl+S save. Both modes are first-class; the editor is the same buffer underneath.

mnml’s editing model rests on one decision: both vim and standard keymaps are first-class, swappable at runtime, and the editor never branches on which is active. This page covers what each mode offers, how to switch between them, and the edit primitives both modes share.

mnml ships two Box<dyn InputHandler> implementations — one modal (vim), one modeless (standard). Both translate key events into a closed set of EditOp operations (Insert, Delete, Replace, MoveCursor, etc.) which the editor’s single apply chokepoint executes. The buffer, render layers, and LSP integration never know which handler produced the operations.

This is the part you don’t see but everything else depends on. Adding multi-cursor or a new motion in one mode doesn’t need ceremony in the other — input handlers compose into edit ops; ops compose into buffer state; render reads buffer state. Each layer is one concern.

The user-facing consequence: every feature in mnml works identically regardless of which mode you pick. Ctrl-P to fuzzy-find a file, : to open the ex-command line — same buffer, same LSP completion, same git gutter.

~/.config/mnml/config.toml
[editor]
input_style = "vim" # or "standard"

Switch at runtime:

:set input=vim
:set input=standard

Or via the command palette (Ctrl-Shift-P): editor: toggle keymap.

Per-workspace override at <workspace>/.mnml/config.toml if you want vim everywhere except, say, your team’s onboarding-friendly Rails repo.

mnml’s vim handler covers modal editing in depth. If you’ve used vim or Neovim, the muscle memory transfers directly.

ModeEnterUse for
NormalEscMovement, operators, ex-commands
Inserti / a / o / O / s / c…Typing text
Visual (char)vChar-by-char selection
Visual (line)VLine selection
Visual blockCtrl-VColumn / block selection (multi-cursor flavor)
ReplaceROverwrite-as-you-type

The mode chip in the bottom-left of the statusline shows which mode you’re in; the cursor shape changes per-mode too (block in Normal, bar in Insert, underline in Replace). The chip distinguishes the three visual flavors — VISUAL for char-wise, V-LINE for V, V-BLOCK for Ctrl-V — so the geometry is visible at a glance instead of collapsing into one label. The mode-chip tooltip differentiates them too.

A handful of motions and visual-entry chords match vim’s behavior precisely — worth knowing the edge cases:

  • $ lands on the last printable char of the line, not one cell past it. In Normal mode the block cursor sits on the last visible character; a paste lands immediately after it (rather than one column further right). Empty lines collapse to the line start.
  • G (bare) lands on the start of the last line. Past versions could overshoot onto the phantom row after a trailing newline; the cursor now anchors at line_start(last_line) cleanly.
  • V (visual-line) leaves the cursor where it was. The anchor moves to line_start; the cursor doesn’t snap down a row. The full line still reads as selected, and '< / '> marks reflect the cursor’s row after a yank.
  • * advances past the current match. The star chord now genuinely jumps to the next occurrence of the word under the cursor (rather than the first match at-or-after, which was the cursor’s current word). # is the same in the reverse direction.
  • <N>@<r> honors the count. 5@a replays macro a five times. Past versions silently dropped the count and ran the macro once; the count threads through to the App dispatcher’s replay loop.

Operator-pending motions are inclusive on e / E / $

Section titled “Operator-pending motions are inclusive on e / E / $”

Vim’s :help inclusive says that when an operator (d / y / c) is paired with e / E / $, the destination character is included in the operated range. mnml’s vim handler now follows that rule end-to-end:

  • de deletes from the cursor up to and including the last char of the current word (e.g. foo bar with the cursor on fde leaves bar). The trailing whitespace stays.
  • ye yanks the same span — registers see the whole word.
  • ce changes it: deletes through the last char and drops into INSERT mode.
  • d$ / y$ / c$ include the final character of the line (vs. stopping one cell short).
  • cw is now an alias for ce (vim canon: change-word excludes the trailing whitespace, so cwce is a substitution at the motion layer). Same for cWcE. dw and yw keep their exclusive semantics (whitespace eats the word boundary).

Exclusive motions (w, W, h, l, 0, ^, etc.) are unchanged — [anchor, cursor) is still the deleted/yanked span. Inclusive motions push an extra MoveRight op before DeleteSelection so the destination char is part of the range.

This is the kind of behavior you only notice when it’s wrong (a cw that left the trailing whitespace, then re-typing pushed it across) — but once it’s right, the muscle memory works exactly as vim’s :help documents it.

cw at "brown" in "The quick brown fox jumps", type BIG, Esc — line becomes "The quick BIG fox jumps" (space preserved); dd then u then  shows the unnamed + numbered registers

Ctrl+R Ctrl+W and Ctrl+R Ctrl+A in INSERT mode

Section titled “Ctrl+R Ctrl+W and Ctrl+R Ctrl+A in INSERT mode”

Two vim chords for inserting the symbol under the cursor without leaving INSERT:

ChordActionCommand
Ctrl+R Ctrl+WInsert the identifier under the cursor at the careteditor.insert_word_under_cursor
Ctrl+R Ctrl+ASame, but for the full WORD (whitespace-separated; includes punctuation)editor.insert_bigword_under_cursor

Useful when extracting a name into a new declaration — type let , then Ctrl+R Ctrl+W to pull the symbol you were just looking at, then = …. The vim INSERT handler checks for the Ctrl+W / Ctrl+A follow-up before the lowercase-letter register-paste arm (the prior implementation routed the chord into "a register paste and the Ctrl+R prefix was eaten with no insertion).

Ctrl+Shift+[ and Ctrl+Shift+] toggle and unfold respectively, mirroring VS Code’s canonical fold/unfold chords. The vim handler’s bracket prefix (for [c / ]c git hunks, [d / ]d diagnostics) only consumes the bare bracket when !ctrl — the modifier-bearing chord falls through to the chord-chain / global keymap so the editor’s fold commands can pick it up. Lowercase z (za / zR / zM) still works the same.

A global substitute that replaces twelve matches is a single undo entry — one u reverts the whole substitute. This matches vim’s behavior and removes a real footgun (the prior implementation pushed one undo per replaced line, so reverting felt like progress until you noticed nothing had actually finished). The :s family rolls every internal apply into one checkpoint via Editor::atomic_undo.

The standard vim composition rules:

  • Operator + motion: dw (delete word), c$ (change to end of line), >5j (indent 5 lines down)
  • Operator + text object: diw (delete inner word), ci( (change inside parens), da{ (delete around braces with whitespace)
  • Visual + operator: select first with v, then d / c / y / >

Standard operators (d delete, c change, y yank, > indent, < dedent, = reformat, gU uppercase, gu lowercase, gq rewrap), all the usual motions (hjkl, wbge, 0$^, f/t/F/T, %, gg/G, H/M/L, Ctrl-D/Ctrl-U/Ctrl-F/Ctrl-B), and a robust text-object inventory:

  • Inner / around: i / a modifier — iw inner word, aw around word; i( i) i[ i] i{ i} i< i> paired-delim inner; a( etc. around (includes the delimiter); i" i' i\`` quoted; ipparagraph;is` sentence.
  • Tree-sitter objects: if / af function, ic / ac class, ia / aa argument. Powered by tree-sitter, so the boundaries are AST-aware — not regex-based heuristics.
  • Indent objects: ii / ai based on indent level — handy in Python and YAML.

cc, guu, gUU, g~~ operate on the whole current line — change-line, lowercase-line, uppercase-line, toggle-case-line. After the op the cursor lands at the start of the next line, so a chord-chain (guuguu, g~~g~~) walks down lines one stroke pair at a time. dd and yy were already line-wise via their own ops; the doubled forms now mirror them precisely.

  • Named registers: "ay (yank into register a), "ap (paste from a), "+y (yank to system clipboard), "*y (yank to primary selection on X11/Wayland).
  • Numbered registers behave like vim’s delete-ring: "0 is the last yank, "1"9 are the last 9 deletes (newest first).
  • Macros: qa start recording into register a, q to stop, @a to play back, @@ to repeat the last. Macros persist across mnml restarts.
  • Marks: ma set mark a in this buffer; 'a jump to mark a (line); `a jump to mark a (exact column). Uppercase marks (mA) are global across files. Marks persist across restarts.
  • The dot repeat: . repeats the last edit operation. Includes inserted text. Works after dw, cw, >j, ciw"hello", etc.
  • Jumplist + change-list: Ctrl-O / Ctrl-I walks the jump history; g; / g, walks the change history.

A deep ex-command surface, beyond just :w / :q:

:w " write
:wa " write all
:q " quit
:qa! " force-quit all
:e <path> " open file
:bn / :bp " buffer next/prev
:b <name> " switch to buffer by name (fuzzy)
:tabnew / :tabn / :tabp " tab pages
:%s/old/new/g " global substitute
:'<,'>s/foo/bar/g " substitute in visual selection
:s//repl/g " repeat last search, swap replacement
:1,10s/x/y/g " line-range substitute
:g/pattern/d " delete all lines matching pattern
:v/pattern/d " delete all lines NOT matching
:g/^TODO/norm dd " delete every TODO line via :norm
:'<,'>norm @a " run macro `a` on every visual-mode line
:sort " sort current buffer
:sort u " sort + dedupe
:'<,'>sort n " numeric sort visual selection
:!cmd " shell command (output replaces visual; output appended w/ :.!cmd)
:r <path> " read file's content at cursor
:r !date " insert shell command output
:set input=vim " runtime config change
:set tab_width=2

Ex-command history is searchable: type : then Ctrl-P / Ctrl-N (or / ) to walk history.

You can define your own commands via [ex_commands] in config — mnml expands them as command-id calls so they appear in the palette too.

mnml ships a built-in vim-surround:

  • cs"' — change surrounding " to '
  • cs([ — change surrounding ( to [ (note: (/) differ — ( adds whitespace inside, ) doesn’t)
  • ds" — delete surrounding "
  • ysiw" — yank-surround inner word with " (i.e., wrap the word in quotes)
  • S" (visual mode) — surround the selection with "

Visual block (Ctrl-V) is the native multi-cursor primitive:

  • Select a column with Ctrl-V then I to insert at every line’s start, A to append at every line’s end.
  • Use c to change every selected cell at once; the change is replicated.

Plus flash-motion jumps — s<char><char> jumps to the nearest <char><char> digraph in view with a single-letter label. Bypasses the f / t / / / ? chain when you can see the target.

A modeless VS Code-style keymap. No mode chip in the statusline (the chip shows the mode you’d be in IF you were in vim; in standard mode it’s hidden). Everything you type goes in.

KeyAction
Ctrl-ASelect all
Ctrl-C / Ctrl-V / Ctrl-XCopy / paste / cut (system clipboard)
Ctrl-Z / Ctrl-Shift-ZUndo / redo
Ctrl-SSave
Ctrl-/Toggle line comment
Ctrl-DAdd next occurrence to selection (multi-cursor)
Ctrl-Alt-↑ / Ctrl-Alt-↓Add cursor on line above / below (column cursors)
Ctrl-Shift-LSelect all occurrences of current word
Alt-↑ / Alt-↓Move current line up / down
Alt-Shift-↑ / Alt-Shift-↓Duplicate line up / down
Ctrl-] / Ctrl-[Indent / dedent (standard mode; vim mode keeps Ctrl-] as tag-jump)
Ctrl-LSelect current line (standard mode)
Home / EndLine start / end (smart-home: first non-whitespace then column 0)
Ctrl-Home / Ctrl-EndFile start / end
Ctrl-GGo to line
Ctrl-FFind in buffer
Ctrl-HFind & replace
Ctrl-Shift-FWorkspace grep

A few VS-Code-faithful behaviors worth calling out:

  • Esc is a no-op from the editor — it doesn’t focus the tree the way it does in vim mode. Press Esc reflexively to dismiss “anything” and you stay in the buffer. (Multi-cursor selections still collapse on Esc in both modes.)
  • Ctrl-] / Ctrl-[ indent / outdent — overrides the vim-canonical bracket-match chord for standard mode. Tab at line start also indents; the chord is for the explicit case.
  • Ctrl-L selects the current line — the standard-mode SelectLine editor op. Past versions silently routed the chord to view.redraw (a global default) before the editor handler ever saw it; the standard-mode reservation now keeps the chord on the buffer.
  • Cmd+… chords parse — on terminals that forward the macOS Command key as KeyModifiers::SUPER (mostly Kitty / WezTerm protocol), cmd+shift+t and friends now parse into the keymap. Terminals that don’t forward the modifier let the spec sit inert without spewing startup warnings.

The Sublime / VS Code idiom:

  • Ctrl-D — select current word, then add next occurrence on each press
  • Ctrl-K Ctrl-D — skip current and add next (when iterating selectively)
  • Ctrl-Shift-L — select all occurrences in buffer at once
  • Ctrl-Alt-↑ / — column cursors (one cursor per line above/below current)
  • Esc — collapse to single cursor

All cursors apply edits in parallel — type and every cursor inserts; press Backspace and every cursor deletes.

These work the same regardless of input mode:

  • Vim mode: u / Ctrl-R (or :u / :redo)
  • Standard mode: Ctrl-Z / Ctrl-Shift-Z

Per-file undo history is persisted to <workspace>/.mnml/undo/<file-hash> — reopen a file tomorrow and your undo history is intact. The hash is content-based, so editing the same file externally invalidates the history (rather than producing bogus undos).

  • Vim mode: "+y / "+p (the + register) for system clipboard, "*y / "*p for the X11/Wayland primary selection.
  • Standard mode: Ctrl-C / Ctrl-V / Ctrl-X use the system clipboard directly.

Pasting handles bracketed-paste — long pastes from another terminal don’t trigger auto-indent on every line.

:set wrap (vim) or wrap = true in config. Visual wrap only — the underlying file isn’t modified. Wrap respects indent (continuation lines align with the original line’s indent).

On (auto_indent = true default). Indent on Enter matches the previous line’s indent + a level if the previous line opens a block (per language). Tree-sitter aware — Python : increases the indent expectation; Rust { does too.

On (auto_pairs = true default). Typing ( inserts () with the cursor between; [, {, ", ', ` likewise. Doesn’t fire inside strings or comments (language-aware).

Cursor on ( / ) / [ / ] / { / } lights up its match in the statusline color. Mismatches show in error color.

  • Manual: zf / za / zo / zc / zR / zM (vim-style)
  • LSP-suggested folds: when the LSP returns folding ranges, they show as fold markers in the gutter; click or za to toggle.
  • Indent folds: fall-back fold strategy for languages without LSP fold support — folds at indent boundaries.

Folds persist across buffer close and reopen, and across mnml restarts. Every time you toggle a fold (or fire zM / zR), mnml mirrors the buffer’s live fold ranges into a workspace-scoped map keyed by file path. Close the buffer with :bd, reopen it later with :e — the folds come back. Restart mnml — the folds come back too, restored from <workspace>/.mnml/session.json.

The map is capped at 200 files with soft-eviction (oldest entries drop when the cap is hit); files with no active folds get their entry removed so a “cleared everything” state doesn’t linger and re-apply on next open. If you want to force a clean slate on a file: zR (unfold all) then close the buffer — the empty-fold entry gets pruned.

mnml reads .editorconfig files and applies them as per-buffer settings — indent_style, indent_size, tab_width, end_of_line, insert_final_newline, trim_trailing_whitespace. Closer-to-file wins (root .editorconfig is overridden by a nested one).

Configurable in [snippets]:

[snippets.rust]
"fn" = "fn ${1:name}(${2:args}) -> ${3:Result<()>} {\n ${4:todo!()}\n}"
"err" = "Err(${1:anyhow!}(\"${2:message}\"))"

Trigger via fuzzy match in the completion popup; Tab cycles through stops; Esc exits.

[abbreviations.global]
"teh" = "the"
"wuld" = "would"

Fires on word boundary (space, punctuation). Vim mode has :ab and :una to manage them at runtime.

Everything other than input handling. Specifically:

  • LSP completion / hover / go-to-definition / etc. (configured globally per-language)
  • Git operations (gutter, diff pane, commit graph)
  • Pickers (fuzzy finder, command palette, buffer switcher) — though Ctrl-P is the default in standard, and <space>ff is the typical vim leader binding
  • Splits, tabs, the bufferline, the file tree
  • AI panes, HTTP client, browser, debugger
  • Themes, statusline, devicons

Everything in this list is keymap-driven by config ([keys.global] for cross-mode, [keys.vim] / [keys.standard] for mode-specific). You can remap any of it.