A Markdown-based todo/task plugin for Neovim.
- Saves files in plain Markdown format (compatible with other apps)
- Customizable markers and styling
- Visual mode support for toggling multiple items at once
- Metadata e.g.
@tag(value)
annotations with extensive customization- e.g. @started, @done, @priority, @your-custom-tag
- Todo completion counts/percentage
- Smart toggling behavior
- Archive completed todos
- Todo templates with LuaSnip snippet integration
- Custom todo states
- More than just "checked" and "unchecked", e.g. "partial", "in-progress", "on-hold"
- 🆕 Automatic todo creation (list continuation in insert mode)
Note
Check out the Wiki for additional documentation and recipes, including:
- Advanced metadata
- Snippets
- How to setup a per-project, low-friction
checkmate.nvim
buffer with snacks.nvim


Checkmate.v0.11.mp4
- Installation
- Requirements
- Usage
- Commands
- Configuration
- Metadata
- Archiving
- Integrations
- Linting
- Roadmap
- Contributing
- Credits
- Neovim 0.10 or higher
Using lazy.nvim
{
"bngarren/checkmate.nvim",
ft = "markdown", -- Lazy loads for Markdown files matching patterns in 'files'
opts = {
-- files = { "*.md" }, -- any .md file (instead of defaults)
},
}
If you'd like stable-ish version during pre-release, can add a minor version to the lazy spec:
{
version = "~0.11.0" -- pins to minor 0.11.x
}
Checkmate automatically activates when you open a Markdown file that matches your configured file name patterns.
Default patterns:
todo
orTODO
(exact filename)todo.md
orTODO.md
- Files with
.todo
extension (e.g.,project.todo
,work.todo.md
)
Note
Checkmate only activates for files with the "markdown" filetype. Files without extensions need their filetype set to markdown (:set filetype=markdown
)
You can customize which files activate Checkmate using the files
configuration option:
files = {
"*.md", -- Any markdown file (basename matching)
"**/todo.md", -- 'todo.md' anywhere in directory tree
"project/todo.md", -- Any path ending with 'project/todo.md'
"/absolute/path.md", -- Exact absolute path match
}
Patterns support Unix-style globs including *
, **
, ?
, [abc]
, and {foo,bar}
- Use the mapped key (default:
<leader>Tn
) or the:Checkmate create
command - Or manually using Markdown syntax:
- [ ] Unchecked todo
- [x] Checked todo
(These will automatically convert when you leave insert mode!)
- Toggle items with
:Checkmate toggle
(default:<leader>Tt
) - Check items with
:Checkmate check
(default:<leader>Tc
) - Uncheck items with
:Checkmate uncheck
(default:<leader>Tu
) - Cycle to other custom states with
:Checkmate cycle_next
(default:<leader>T=
) and:Checkmate cycle_previous
(default<leader>T-
) - Select multiple items in visual mode and use the same commands
- Archive completed todos with
:Checkmate archive
(default:<leader>Ta
)
Enhance your todos with custom metadata with quick keymaps!
The Checkmate buffer is saved as regular Markdown which means it's compatible with any Markdown editor!
:Checkmate [subcommand]
subcommand | Description |
---|---|
archive |
Archive all completed todo items in the buffer. This extracts them and moves them to a bottom section. See api archive() and Archiving section. |
check |
Mark the todo item under the cursor as checked. See api check() |
create |
In normal mode, converts the current line into a todo (or if already a todo, creates a sibling below). In visual mode, converts each selected line into a todo. In insert mode, creates a new todo on the next line and keeps you in insert mode. For more advanced placement, indentation, and state options, see the create(opts) API. |
cycle_next |
Cycle a todo's state to the next available. See api cycle() |
cycle_previous |
Cycle a todo's state to the previous. See api cycle() |
lint |
Lint this buffer for Checkmate formatting issues. Runs automatically on InsertLeave and TextChanged . See api lint() andLinting section. |
metadata add |
Add a metadata tag to the todo under the cursor or within the selection. Usage: :Checkmate metadata add <key> [value] . See api add_metadata(key, value) and Metadata section. |
metadata jump_next |
Move the cursor to the next metadata tag for the todo item under the cursor. See api jump_next_metadata() |
metadata jump_previous |
Move the cursor to the previous metadata tag for the todo item under the cursor. See api jump_previous_metadata() |
metadata remove |
Remove a specific metadata tag from the todo under the cursor or within the selection. Usage: :Checkmate metadata remove <key> . See api remove_metadata(key) |
metadata select_value |
Select a value from the 'choices' option for the metadata tag under the cursor. See api select_metadata_value() |
metadata toggle |
Toggle a metadata tag on/off for the todo under the cursor or within the selection. Usage: :Checkmate metadata toggle <key> [value] . See api toggle_metadata(key, value) |
remove |
Convert a todo line back to regular text. See api remove(opts) . By default, will preserve the list item marker and remove any metadata. This can be configured via opts . |
remove_all_metadata |
Remove all metadata tags from the todo under the cursor or within the selection. See api remove_all_metadata() |
toggle |
Toggle the todo item under the cursor (normal mode) or all todo items within the selection (visual mode). See api toggle() . Without a parameter, toggles between unchecked and checked . To change to custom states, use the api toggle(target_state) or the cycle_* commands. |
uncheck |
Mark the todo item under the cursor as unchecked. See api uncheck() |
For config definitions/annotations, see here.
---@type checkmate.Config
return {
enabled = true,
notify = true,
-- Default file matching:
-- - Any `todo` or `TODO` file, including with `.md` extension
-- - Any `.todo` extension (can be ".todo" or ".todo.md")
-- To activate Checkmate, the filename must match AND the filetype must be "markdown"
files = {
"todo",
"TODO",
"todo.md",
"TODO.md",
"*.todo",
"*.todo.md",
},
log = {
level = "warn",
use_file = true,
},
-- Default keymappings
keys = {
["<leader>Tt"] = {
rhs = "<cmd>Checkmate toggle<CR>",
desc = "Toggle todo item",
modes = { "n", "v" },
},
["<leader>Tc"] = {
rhs = "<cmd>Checkmate check<CR>",
desc = "Set todo item as checked (done)",
modes = { "n", "v" },
},
["<leader>Tu"] = {
rhs = "<cmd>Checkmate uncheck<CR>",
desc = "Set todo item as unchecked (not done)",
modes = { "n", "v" },
},
["<leader>T="] = {
rhs = "<cmd>Checkmate cycle_next<CR>",
desc = "Cycle todo item(s) to the next state",
modes = { "n", "v" },
},
["<leader>T-"] = {
rhs = "<cmd>Checkmate cycle_previous<CR>",
desc = "Cycle todo item(s) to the previous state",
modes = { "n", "v" },
},
["<leader>Tn"] = {
rhs = "<cmd>Checkmate create<CR>",
desc = "Create todo item",
modes = { "n", "v" },
},
["<leader>Tr"] = {
rhs = "<cmd>Checkmate remove<CR>",
desc = "Remove todo marker (convert to text)",
modes = { "n", "v" },
},
["<leader>TR"] = {
rhs = "<cmd>Checkmate remove_all_metadata<CR>",
desc = "Remove all metadata from a todo item",
modes = { "n", "v" },
},
["<leader>Ta"] = {
rhs = "<cmd>Checkmate archive<CR>",
desc = "Archive checked/completed todo items (move to bottom section)",
modes = { "n" },
},
["<leader>Tv"] = {
rhs = "<cmd>Checkmate metadata select_value<CR>",
desc = "Update the value of a metadata tag under the cursor",
modes = { "n" },
},
["<leader>T]"] = {
rhs = "<cmd>Checkmate metadata jump_next<CR>",
desc = "Move cursor to next metadata tag",
modes = { "n" },
},
["<leader>T["] = {
rhs = "<cmd>Checkmate metadata jump_previous<CR>",
desc = "Move cursor to previous metadata tag",
modes = { "n" },
},
},
default_list_marker = "-",
todo_states = {
-- we don't need to set the `markdown` field for `unchecked` and `checked` as these can't be overriden
---@diagnostic disable-next-line: missing-fields
unchecked = {
marker = "□",
order = 1,
},
---@diagnostic disable-next-line: missing-fields
checked = {
marker = "✔",
order = 2,
},
},
style = {}, -- override defaults
enter_insert_after_new = true, -- Should enter INSERT mode after `:Checkmate create` (new todo)
list_continuation = {
enabled = true,
split_line = true,
keys = {
["<CR>"] = function()
require("checkmate").create({
position = "below",
indent = false,
})
end,
["<S-CR>"] = function()
require("checkmate").create({
position = "below",
indent = true,
})
end,
},
},
smart_toggle = {
enabled = true,
include_cycle = false,
check_down = "direct_children",
uncheck_down = "none",
check_up = "direct_children",
uncheck_up = "direct_children",
},
show_todo_count = true,
todo_count_position = "eol",
todo_count_recursive = true,
use_metadata_keymaps = true,
metadata = {
-- Example: A @priority tag that has dynamic color based on the priority value
priority = {
style = function(context)
local value = context.value:lower()
if value == "high" then
return { fg = "#ff5555", bold = true }
elseif value == "medium" then
return { fg = "#ffb86c" }
elseif value == "low" then
return { fg = "#8be9fd" }
else -- fallback
return { fg = "#8be9fd" }
end
end,
get_value = function()
return "medium" -- Default priority
end,
choices = function()
return { "low", "medium", "high" }
end,
key = "<leader>Tp",
sort_order = 10,
jump_to_on_insert = "value",
select_on_insert = true,
},
-- Example: A @started tag that uses a default date/time string when added
started = {
aliases = { "init" },
style = { fg = "#9fd6d5" },
get_value = function()
return tostring(os.date("%m/%d/%y %H:%M"))
end,
key = "<leader>Ts",
sort_order = 20,
},
-- Example: A @done tag that also sets the todo item state when it is added and removed
done = {
aliases = { "completed", "finished" },
style = { fg = "#96de7a" },
get_value = function()
return tostring(os.date("%m/%d/%y %H:%M"))
end,
key = "<leader>Td",
on_add = function(todo_item)
require("checkmate").set_todo_item(todo_item, "checked")
end,
on_remove = function(todo_item)
require("checkmate").set_todo_item(todo_item, "unchecked")
end,
sort_order = 30,
},
},
archive = {
heading = {
title = "Archive",
level = 2, -- e.g. ##
},
parent_spacing = 0, -- no extra lines between archived todos
newest_first = true,
},
linter = {
enabled = true,
},
}
Default keymaps can be disabled by setting keys = false
.
Keymaps should be defined as a dict-like table or a sequence of {rhs, desc?, modes?}
.
keys = {
["<leader>Ta"] = {
rhs = "<cmd>Checkmate archive<CR>",
desc = "Archive todos",
modes = { "n" },
},
}
or
keys = {
["<leader>Ta"] = {"<cmd>Checkmate archive<CR>", "Archive todos", {"n"} }
}
The rhs
parameter follows :h vim.keymap.set()
and can be a string or Lua function.
Default styles are calculated based on the current colorscheme. This attempts to provide reasonable out-of-the-box defaults based on colorscheme-defined hl groups and contrast ratios.
Individual styles can still be overriden using the style
option and passing a 'highlight definition map' according to :h nvim_set_hl()
and vim.api.keyset.highlight
for the desired highlight group (see below).
hl_group | description |
---|---|
CheckmateListMarkerUnordered | Unordered list markers, e.g. - ,* , and + . (Only those associated with a todo) |
CheckmateListMarkerOrdered | Ordered list markers, e.g. 1. , 2) . (Only those associated with a todo) |
CheckmateUncheckedMarker | Unchecked todo marker, e.g. □ . See todo_states marker option |
CheckmateUncheckedMainContent | The main content of an unchecked todo (typically the first paragraph) |
CheckmateUncheckedAdditionalContent | Additional content for an unchecked todo (subsequent paragraphs, list items, etc.) |
CheckmateCheckedMarker | Checked todo marker, e.g. ✔ . See todo_states marker option |
CheckmateCheckedMainContent | The main content of a checked todo (typically the first paragraph) |
CheckmateCheckedAdditionalContent | Additional content for a checked todo (subsequent paragraphs, list items, etc.) |
CheckmateTodoCountIndicator | The todo count indicator, e.g. 1/4 , shown on the todo line, if enabled. See show_todo_count option |
Metadata highlights are prefixed with CheckmateMeta_
and keyed with the tag name and style.
Highlight groups with 'MainContent' refer to the todo item's first paragraph. 'AdditionalContent' refers to subsequent paragraphs, list items, etc.

opts = {
style = {
CheckmateCheckedMarker = { fg = "#7bff4f", bold = true}
}
}
Custom todo states will be styled following the same highlight group naming convention:
e.g. Checkmate[State]Marker
So, if you define a partial
state:
todo_states = {
partial = {
-- ...
}
}
You can then style it:
styles = {
CheckmatePartialMarker = { fg = "#f0fc03" }
CheckmatePartialMainContent = { fg = "#faffa1" }
}
State names will be converted to CamelCase when used in highlight group names. E.g.
not_planned
=NotPlanned
Checkmate supports both standard GitHub-flavored Markdown states and custom states for more nuanced task management.
The standard states are checked
and unchecked
, which are always saved to disk as - [ ]
and - [x]
per the Github-flavored Markdown spec. You can customize their visual appearance with the todo_states
marker
opt:
todo_states = {
checked = {
marker = "☒" -- how it appears in Neovim
},
unchecked = {
marker = "☐" -- how it appears in Neovim
}
}
Add custom states to track tasks more precisely. Each state needs:
marker
: How it appears in Neovim (must be unique)markdown
: How it's saved to disk (must be unique)
and optionally:type
: How it behaves in the task hierarchy
todo_states = {
-- Built-in states (cannot change markdown or type)
unchecked = { marker = "□" },
checked = { marker = "✔" },
-- Custom states
in_progress = {
marker = "◐",
markdown = ".", -- Saved as `- [.]`
type = "incomplete", -- Counts as "not done"
order = 50,
},
cancelled = {
marker = "✗",
markdown = "c", -- Saved as `- [c]`
type = "complete", -- Counts as "done"
order = 2,
},
on_hold = {
marker = "⏸",
markdown = "/", -- Saved as `- [/]`
type = "inactive", -- Ignored in counts
order = 100,
}
}

States have three behavior types that affect smart toggle and todo counts:
Type | Behavior | Example States |
---|---|---|
incomplete |
Counts as "not done" | unchecked, in_progress, pending, future |
complete |
Counts as "done" | checked, cancelled |
inactive |
Ignored in calculations | on_hold, not_planned |
Warning
Custom states like - [.]
or - [/]
are not standard Markdown and may not be recognized by other apps.
You can then cycle through a todo's states with :Checkmate cycle_next
and :Checkmate cycle_previous
or using the API, such as:
require("checkmate").cycle() -- Next state
require("checkmate").cycle(true) -- Previous state
-- or to toggle to a specific state
require("checkmate").toggle("on_hold")
Shows completion progress for todos with subtasks.
It displays the number of complete / incomplete
todos in a hierarchy. It counts the standard "checked" and "unchecked" states, as well as custom states based on their type
(incomplete or complete). The "inactive" type is not included.
![]() Todo count indicator using eol position
|
![]() Todo count indicator using inline position
|
-- Custom formatter that returns the % completed
todo_count_formatter = function(completed, total)
return string.format("[%.0f%%]", completed / total * 100)
end,
style = {
CheckmateTodoCountIndicator = { fg = "#faef89" },
},

Todo count indicator using
todo_count_formatter
function
Progress bar example, see Wiki for code.

If you want the todo count of a parent todo item to include all nested todo items, set the todo_count_recursive
option.
![]() todo_count_recursive false. Only direct children are counted.
|
![]() todo_count_recursive true. All children are counted.
|
Intelligently propagates a todo's state change through its hierarchy.
When you toggle a todo item, it can automatically update related todos based on your configuration.
Note
Smart toggle only propagates "unchecked" and "checked" states (the default/standard todo states). If custom todo states are used, they may influence parent completion but will not be changed themselves.
Smart toggle operates in two phases:
- Downward propagation: When toggling a parent, optionally propagate the state change to children
- Upward propagation: When toggling a child, optionally update the parent based on children states
Smart toggle is enabled by default with sensible defaults. You can customize the behavior:
opts = {
smart_toggle = {
enabled = true,
check_down = "direct", -- How checking a parent affects children
uncheck_down = "none", -- How unchecking a parent affects children
check_up = "direct_children", -- When to auto-check parents
uncheck_up = "direct_children", -- When to auto-uncheck parents
}
}
Metadata tags allow you to add custom @tag(value)
annotations to todo items.

- Default tags:
@started
- default value is the current date/time@done
- default value is the current date/time@priority
- "low" | "medium" (default) | "high"
The default tags are not deeply merged in order to avoid unexpected behavior. If you wish to modify a default metadata, you should copy the default implementation.
By configuring a metadata's choices
option, you can populate your own lists of metadata values for powerful workflows, e.g. project file names, Git branches, PR's, issues, etc., team member names, external APIs, etc.
For in-depth guide and recipes for custom metadata, see the Wiki page.
Allows you to easily reorganize the buffer by moving all completed todo items to a Markdown section beneath all other content. The remaining unchecked/incomplete todos are reorganized up top and spacing is adjusted.
Archiving collects all todos with the "completed" state type, which includes the default "checked" state, but possibly others based on custom todo states.
See Checkmate archive
command or require("checkmate").archive()
Current behavior (could be adjusted in the future): a completed todo item that is nested under an incomplete parent will not be archived. This prevents 'orphan' todos being separated from their parents. Similarly, a completed parent todo will carry all nested todos (completed and incomplete) when archived.
By default, a Markdown level 2 header (##) section named "Archive" is used. You can configure the archive section heading via config.archive.heading
The following will produce an archive section labeled:
#### Completed
opts = {
archive = {
heading = {
title = "Completed",
level = 4
}
}
}
The amount of blank lines between each archived todo item can be customized via config.archive.parent_spacing
E.g. parent_spacing = 0
## Archive
- ✔ Update the dependencies
- ✔ Refactor the User api
- ✔ Add additional tests
E.g. parent_spacing = 1
## Archive
- ✔ Update the dependencies
- ✔ Refactor the User api
- ✔ Add additional tests
Please see Wiki for additional details/recipes.
Integration | Capable? |
---|---|
render-markdown | ✅ wiki |
LuaSnip | ✅ wiki |
scratch buffer/floating window for quick todos, e.g. snacks.nvim | ✅ wiki |
Checkmate uses a very limited custom linter in order require zero dependencies but attempt to warn the user of Markdown (CommonMark spec) formatting issues that could cause unexpected plugin behavior.
The embedded linter is NOT a general-purpose Markdown linter and may interfere with other linting tools. Though, in testing with conform.nvim and prettier, I have not found any issues.
❌ misaligned list marker
1. ☐ Parent todo item
- ☐ Child todo item (indented only 2 spaces!)
✅ correctly aligned list marker
1. ☐ Parent todo item
- ☐ Child todo item (indented 3 spaces!)
The CommonMark spec requires that nested list markers begin at the col of the first non-whitespace content after the parent list marker (which will be a different col for bullet list vs ordered list markers)
If you feel comfortable with the nuances of Markdown list syntax, you can disable the linter (default is enabled) via config:
{
linter = {
enabled = false
}
}
Planned features:
-
Metadata support - mappings for quick addition of metadata/tags such as @start, @done, @due, @priority, etc. with custom highlighting. Added v0.2.0
-
Sub-task counter - add a completed/total count (e.g. 1/4) to parent todo items. Added v0.3.0
-
Archiving - manually or automatically move completed items to the bottom of the document. Added v0.7.0
-
Smart toggling - toggle all children checked if a parent todo is checked. Toggle a parent checked if the last unchecked child is checked. Added v0.7.0
-
Metadata upgrade - callbacks, async support, jump to. Added v0.9.0
-
Custom todo states - support beyond binary "checked" and "unchecked", allowing for todos to be in custom states, e.g. pending, not-planned, on-hold, etc. Added v0.10.0
-
List (todo) continuation - automatically created new todo lines in insert mode, e.g.
<CR>
on a todo line will create a new todo below. Added v0.11.0
If you have feature suggestions or ideas, please feel free to open an issue on GitHub!
- Inspired by the Todo+ VS Code extension (credit to @fabiospampinato)