Open-source project
max397574/better-escape.nvim avatar
max397574/better-escape.nvim

better-escape.nvim: jk and jj escape mappings that do not slow down typing

Map keys without delay when typing

797 stars20 forksLuaGPL-3.0

At a glance

What is it?
better-escape.nvim removes the half-second lag that Neovim's timeoutlen introduces when jk or jj is mapped to leave insert mode, and it does so by letting you define key sequences and Lua functions per mode. The catch is that the current version is a rewrite, the mapping option was removed, and the plugin has not been tagged since 2024.
Who is it for?
Adopt better-escape.nvim if you use jk or jj to leave insert mode and the delay has become noticeable, particularly in Telescope where the filtering feels sluggish. Do not adopt it if you are on the pre-rewrite configuration format, since the mapping option was removed and clear_empty_lines was deprecated, so an old config will not work as written.
Can I use it commercially?
Yes, with conditions. GPL-3.0 is a copyleft licence: if you distribute software that includes it, you must release that software's source code under the same licence. Running it internally without distributing it does not trigger that obligation.
Is it still maintained?
Activity is slowing. The repository last received commits 6 months ago.
What is it written in?
Mainly Lua, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The delay is Neovim's timeoutlen, not the plugin

The problem the plugin addresses is described concretely. A lot of people have mappings like jk or jj to escape insert mode. The problem with those mappings is that whenever you type a j, Neovim waits about 100 to 500 milliseconds, depending on your timeoutlen, to see whether you are going to type a j or a k, because those are mapped. Only after that time does the j get inserted. You always get a delay when typing a j.

That framing matters, because it identifies the cause. This is not a quirk of one mapping style, it is how Neovim resolves an ambiguous key sequence in insert mode, and it applies to any two-key sequence you map there. The wait is bounded by timeoutlen, which is the option that sets the delay used for mappings and, as the Neovim documentation puts it, how long to wait for a mapped sequence to complete.

The impact is described with a concrete case. Telescope is the example, because the characters which are mapped are not really inserted at first, so the whole filtering is not instant. Any fuzzy finder, incremental search or completion that reads typed characters feels slower than it should, and the user experiences it as the tool being sluggish rather than as a mapping problem.

So the plugin is aimed at a specific, measurable annoyance rather than at a general workflow. If you do not map two-key sequences in insert mode, you do not need it.

The rewrite changed the configuration format and dropped two options

There was a big rewrite which allows much more flexibility now, and the README is direct that this is a breaking change. You can now define mappings in most modes and also use functions.

The biggest change is that the mapping config option was removed, and the README points you at the default configuration to see the new structure. This also deprecated the clear_empty_lines setting, and the replacement is to set a mapping to a function instead:

lua
-- `k` would be the second key of a mapping
k = function()
    vim.api.nvim_input("<esc>")
    local current_line = vim.api.nvim_get_current_line()
    if current_line:match("^%s+.$") then
        vim.schedule(function()
            vim.api.nvim_set_current_line("")
        end)
    end
end

That example is worth reading on its own merits, independent of the deprecation. It escapes insert mode, then checks whether the current line consists only of whitespace, and if so clears it, deferring the line change with vim.schedule so it does not interfere with the escape. This is the sort of small behavioural add-on that used to require a separate mapping and is now just a function in the config.

The migration path is thus clear. Old configs need rewriting, and the plugin has no compatibility shim for the removed options. If you are reading this because you found the plugin in a config snippet from a year ago, expect to redo the mapping block.

Installation is a single lazy.nvim entry with an empty opts table

Use your favourite package manager and call the setup function. With lazy.nvim, the whole installation is one table entry:

lua
-- lua with lazy.nvim
{ "max397574/better-escape.nvim", opts = {} }

An empty opts table is enough because the plugin ships working defaults. The default configuration shows what you get:

lua
require("better_escape").setup {
    timeout = vim.o.timeoutlen, -- after `timeout` passes, you can press the escape key and the plugin will ignore it
    default_mappings = true, -- setting this to false removes all the default mappings
    mappings = {
        -- i for insert
        i = {
            j = {
                -- These can all also be functions
                k = "<Esc>",
                j = "<Esc>",
            },
        },

The structure is a nested table: mode, then first key, then second key. The mode letter is the first letter, i for insert, c for command, t for terminal, v for visual and s for select, and the defaults map jk and jj to leave insert mode, jk and jj to interrupt in command mode, jk to move to normal mode from the terminal, and jk to leave visual and select mode.

Two switches control the whole set. Setting default_mappings to false removes all the default mappings, and setting an individual key to false disables that one mapping, which the README explains and shows in the customization section.

The timeout option is where the plugin earns its keep, and its default value is deliberately vim.o.timeoutlen rather than a literal number. The comment explains the semantics: after timeout passes, you can press the escape key and the plugin will ignore it. The plugin therefore still has a window during which it waits, it just makes that window explicit and configurable rather than tied to the global mapping timeout.

Mappings can be Lua functions, which is where the real flexibility lives

Every mapping target can be a key sequence or a function, and the README gives an example that is not about escaping at all. You could map space followed by tab to jump with luasnip:

lua
i = {
    [" "] = {
        ["<tab>"] = function()
            -- Defer execution to avoid side-effects
            vim.defer_fn(function()
                -- set undo point
                vim.o.ul = vim.o.ul
                require("luasnip").expand_or_jump()
            end, 1)
        end
    }
}

The comment inside the function matters as much as the function. Deferring execution to avoid side effects, and setting an undo point before running the snippet expansion, are the details that keep an editor action from behaving oddly when it runs mid-insertion.

The customization section also shows how a single mode can hold several first keys at once, with the note that the other modes use the same first letter convention. The example maps kj to exit insert mode, jk and jj to exit insert mode, and then shows how to disable jj by setting the second key to false, which is a neat trick because it shows the same first key appearing twice in the same table with different second keys.

So the model is: one table per mode, one entry per possible first key, one entry per second key, and a value that is either a key sequence or a function. It is a small configuration surface, and it covers the cases that previously needed separate plugins.

A waiting flag you can query, which is what a status line would use

The API section is one property. require("better_escape").waiting is a boolean indicating that it is waiting for a mapped sequence to complete.

The README then shows how you would use it, in a status line function:

lua
function escape_status()
  local ok, m = pcall(require, 'better_escape')
  return ok and m.waiting and '✺' or ""
end

The pattern is defensive on purpose. The pcall around the require means the status line still works if the plugin is not installed or has not been set up, and the expression returns the marker character only when the plugin loaded and is currently mid-sequence. That is the only integration surface the plugin offers, and it is a sensible one, because the waiting state is the thing a user might want to see, since it is the state during which their j is being held back.

For a plugin this small, that is a complete API. There is no configuration reload function documented, no event to subscribe to and no way to query which mappings are active beyond reading the config you set.

Maintenance, licence and the plugin it grew out of

The repository facts are modest and worth stating plainly. The licence is GPL-3.0, the primary language is Lua, and the repository is not archived. The last push was on 2026-04-22, which is a long way from the last tag: v2.3.3 was published on 2024-11-01, before v2.3.2 in July 2024 and v2.3.1 in the same month.

So the code has been touched more recently than the last release, and you should expect to install from the default branch rather than from a tag unless you pin one. There is a CHANGELOG.md at the root for the release history, and the small set of supporting files is what you would expect for a Neovim plugin: a .luacheckrc for linting Lua, a stylua.toml for formatting, and a lua/ directory holding the plugin source.

The lineage is documented too, and it explains a fair amount. The old version of this plugin was a Lua version of better_escape.vim by jdhao, with some additional features and optimizations. That changed with the rewrite, and the README notes that the current version has much more features. The jdhao plugin remains the reference point for the classic behaviour of making jk and jj work without delay, and the Lua rewrite added the per-mode and function support described above.

Support comes through GitHub Sponsors, and the author says every support motivates continued work on the open source projects. For a plugin this focused, with a documented config format, a small API and a test-free code base, the risk of using it is low and the risk of depending on a specific behaviour is also low, since the behaviour is one keypress.

Editorial conclusion

Adopt better-escape.nvim if you use jk or jj to leave insert mode and the delay has become noticeable, particularly in Telescope where the filtering feels sluggish. Do not adopt it if you are on the pre-rewrite configuration format, since the mapping option was removed and clear_empty_lines was deprecated, so an old config will not work as written. Before you install, read the rewrite section, check that your timeout is set from vim.o.timeoutlen rather than a hardcoded number, and remember that the last tagged release is v2.3.3 from 2024-11-01, so pin the version you test against.

Frequently asked questions

How do I install better-escape.nvim with lazy.nvim?

Add the plugin to your plugin list with an empty opts table, { "max397574/better-escape.nvim", opts = {} }. The plugin ships defaults, so the setup call is optional.

Why does jk or jj feel slow when escaping insert mode?

Neovim waits about 100 to 500 milliseconds, depending on your timeoutlen, to see whether you will type a j or a k, and only inserts the j after that time. The plugin makes that window explicit and configurable so the j is not held back.

How do I disable one of the default jk mappings?

Set the specific key to false in the configuration, for example the second key of a pair, which disables that one mapping. Setting default_mappings to false instead removes all the default mappings at once.

Can a better-escape.nvim mapping run a Lua function?

Yes. Every mapping target can be a key sequence or a function, and the README shows an example mapping a space then tab to expand or jump with luasnip using vim.defer_fn and an undo point.

Official sources

  1. Issues
  2. License: GPL-3.0
  3. max397574/better-escape.nvim on GitHub
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/max397574-better-escape-nvim.svg)](https://hysenlabs.com/projects/max397574-better-escape-nvim)

Was this useful?