Kanban boards in Neovim, backed by plain markdown — and readable as boards in Obsidian Kanban at the same time.
The files are the source of truth, not a database. The board is a view over markdown you can open in any editor, grep from a shell, sync with Syncthing, and keep in git. If you get bored of this plugin, your data is still just notes.
- Kanban board in a single floating window — move cards between columns and within them, wrap long cards, scroll horizontally when there are more columns than fit.
- Cross-board agenda answering "what is due" across every board at once, bucketed into overdue / today / tomorrow / rest of week / later.
- Deadlines and scheduled dates as separate things, so missing "cut the grass on Wednesday" doesn't cry wolf the way missing a real deliverable should.
- A calendar for both browsing dated cards and picking a date in two keystrokes instead of spelling one out.
- Obsidian Kanban round trip — settings block, archive,
**Complete**marker and block IDs all survive, so the same file opens as a board on your phone. - Recurring cards via
[every:: 1w]. - No required dependencies. Fuzzy search uses snacks, telescope or fzf-lua
if you happen to have one, and
vim.ui.selectotherwise.
Neovim 0.9 or newer. Nothing else.
The default glyphs are Nerd Font codepoints. Without a Nerd Font they render as
boxes, so set board = { icons = false } for an ASCII set instead.
With lazy.nvim:
{
"Perkelatorz/tsk.nvim",
config = function()
require("tsk").setup({
boards = {
work = "~/notes/work.md",
home = "~/notes/home.md",
},
})
end,
-- Optional. tsk sets no global mappings of its own; the commands are the
-- interface and these are only an example of wiring them up.
keys = {
{ "<leader>tw", "<cmd>Tsk work<cr>", desc = "Work board" },
{ "<leader>ta", "<cmd>TskAgenda<cr>", desc = "Agenda" },
},
}With vim.pack (Neovim 0.12+), which has no config hook, so call setup
yourself:
vim.pack.add({ "https://github.com/Perkelatorz/tsk.nvim" })
require("tsk").setup({ boards = { work = "~/notes/work.md" } })boards has no default, and nothing works until it names at least one file.
The names are yours — work and home above are just an example, and
{ garden = "...", ["side-project"] = "..." } is equally fine. Whatever you
call a board is what :Tsk <name> completes, what the agenda tags cards with,
and what b cycles through. Column names are configurable too, via
default_columns (and done_column / active_column if you rename those two).
Each entry maps a name to a path, and the file is created from a template the first time that board is opened — so point it where you want the file to live, not necessarily at something that already exists.
The only mappings tsk defines are buffer-local ones inside its own windows —
the board, agenda and calendar keys listed below. It never touches your global
keymaps, and it has no opinion about <leader>, which-key, or how you like to
bind things.
:checkhealth tsk reports what is configured, whether each board path is
readable or will be created, which picker was found, and which glyph set is in
use. It is the quickest way to find out why a board is not showing up.
| Command | What it does |
|---|---|
:Tsk [board] |
Open a board (tab-completes board names) |
:TskAgenda |
Everything due, across all boards |
:TskFind[!] |
Fuzzy search cards (! includes done ones) |
:TskAdd home Cut the grass @due(wed) |
Add a card without opening anything |
:TskCalendar |
Month overview of every dated card |
:TskBack |
Return to the board after <CR> jumped into a card's file |
| Key | Action |
|---|---|
h l |
Move between columns |
j k |
Move between cards |
H L |
Send card to the previous/next column |
J K |
Reorder card within its column |
o / O |
New card below / above the current card |
e |
Edit card text |
x |
Toggle done in place |
X |
Complete and file under Done |
d |
Delete card (confirms) |
D / s |
Set due date / scheduled date (date picker) |
@ |
Set area |
C |
Set or add a card color |
u |
Undo last change |
+ = - |
Add column (asks icon + color) / rename / delete |
c |
Calendar overview (all boards) |
<CR> |
Open the markdown file at that card (:TskBack returns) |
/ |
Fuzzy search every card on every board |
b / B |
Next board / pick a board by name |
t |
Jump to the agenda |
r |
Reload from disk |
? |
Onscreen key reference |
q |
Close |
In the agenda: <CR> jumps to the card in its file, b opens that card's
board, / opens the picker, r refreshes, q closes.
One list across every configured board, so nothing hides on the board you did
not open. Cards in the Done column and cards marked [x] are excluded.
Undated cards sitting in In Progress appear at the bottom as active work — the
things you started and might have forgotten.
Buckets are Overdue, Today, Tomorrow, Rest of week, Later, and In progress.
"Rest of week" ends on your week_start boundary, Monday by default.
For a statusline, require("tsk").status() returns something like
"2 overdue, 3 today", or an empty string when you are clear.
:TskCalendar (or c on a board) shows a month grid of every dated card: ●
marks deadlines, ◦ scheduled dates, and + a day holding more than two.
Navigate with h/j/k/l, flip months with [/], and press <CR> on a
day to jump to a card. r re-reads the boards, q closes.
D and s on the board open the same grid as a date picker, seeded with the
card's current value. <CR> picks the day, t types shorthand instead (mon,
3d, 2026-09-01), x clears the date, q cancels. Movement is by day, so
the end of a month steps straight into the next one.
Two dates, and the distinction matters more than it looks:
@{2026-08-28}— a real deadline. Miss it and the card goes to Overdue in red. This is Obsidian's native date, so it shows in Obsidian's card UI too.[scheduled:: 2026-08-26]— when you plan to work on it. Miss it and it just rolls into Today, marked with~.
"Cut the grass Wednesday" is a scheduled date. It is not late on Thursday, it is still waiting. A work deliverable is a deadline. Collapsing both into one field is what makes todo systems start crying wolf, so it is worth using both from the start rather than retrofitting.
Obsidian Kanban has one date slot per card, so the deadline gets it. Everything
else uses Dataview inline fields ([key:: value]), which render as clean
key/value pairs in Obsidian rather than as noise in the card title, and stay
greppable either way.
Add [every:: 1w] to a card. Completing it — x, X, or moving it into the
done lane — files the finished instance and drops a fresh card back where it
came from. The interval accepts d, w, m and y (3d, 2w, 1m). The
new card's deadline advances to the next occurrence, catching up past today if
you missed several; text, area and the every field carry over. The scheduled
date and block ID are instance-specific, so they are dropped rather than copied.
Boards are read and written in Obsidian Kanban's own format, so the same file opens as a board in either tool.
This is not just "same headings and checkboxes". Obsidian keeps load-bearing state in four places a naive line parser silently destroys:
| What | Where | Breaks if lost |
|---|---|---|
| Board settings JSON | %% kanban:settings block at end of file |
Lane widths, collapsed state |
| Done-lane marker | **Complete** under the heading |
Obsidian stops marking that lane complete |
| Archive | ## Archive behind a *** rule |
Archived cards vanish |
| Block IDs | ^block-id at end of a card |
Links from other notes break |
All four are captured verbatim and re-emitted in the exact structural position
Obsidian expects. tests/obsidian.lua runs a realistic Obsidian-generated file
through parse and render; tests/session.lua puts one through a live editing
session and asserts every one of them is still intact afterwards.
Two more details worth knowing:
- Linked dates stay linked. A card written as
@[[2026-09-15]](Obsidian's "link dates to daily notes" mode) is rewritten in the same form, not flattened to@{...}. - The done lane is found by its marker, not its name. tsk looks for the
**Complete**line under the heading, so a board you built in Obsidian with a lane called "Shipped" behaves correctly here.
If you would rather not care about Obsidian, compat = "native" writes the
terser @due(...) / @do(...) / @area(...) dialect instead. Both dialects are
always read, so you can switch at any time and old cards keep working.
---
kanban-plugin: board
---
## Backlog
- [ ] Rebuild the backup job [area:: homelab]
## This Week
- [ ] Cut the grass [scheduled:: 2026-08-26]
skip if it rained, do thursday instead
- [ ] Chapter 4 labs @{2026-08-28} [area:: study]
## In Progress
- [ ] Migrate DNS [area:: homelab] ^dns-migration
## Done
**Complete**
- [x] Replace sprinkler head [done:: 2026-08-22]
%% kanban:settings
```
{"kanban-plugin":"board","lane-width":320}
```
%%Indented lines under a card are notes and travel with it. Sub-lanes are not
flattened into sibling columns: ### Slow under ## In Progress renders as a
section inside that column, and the header count includes every card in the
lane, sub-lanes included.
Only boards is required. Everything below is the default.
Full default configuration
require("tsk").setup({
boards = {}, -- required; name -> file path
default_columns = { "Backlog", "This Week", "In Progress", "Done" },
done_column = "Done",
active_column = "In Progress",
week_start = 1, -- 1 = Monday
compat = "obsidian", -- or "native"
link_dates = false, -- write @[[date]] instead of @{date}
picker = { "snacks", "telescope", "fzf_lua", "builtin" },
board = {
width = 0.9, height = 0.8, min_col_width = 22, border = "rounded",
-- Column colors, keyed by title: a colored icon in the header.
-- "#hex" creates a highlight group on the fly; anything else is used as
-- an existing group name. `+` also asks for a color when adding a column.
column_colors = { Backlog = "#3b82f6", Done = "#475569" },
-- Card colors, keyed by the card's `[color:: key]` field: the card's
-- state icon is tinted. Set it with `C` on a card, or write
-- `[color:: urgent]` inline.
card_colors = { urgent = "#ef4444", waiting = "#f59e0b" },
-- Set `icons = false` for an ASCII set if you have no Nerd Font.
icons = {
column = vim.fn.nr2char(0xF0DB),
undone = vim.fn.nr2char(0xF10C),
done = vim.fn.nr2char(0xF046),
overdue = vim.fn.nr2char(0xF071),
active = vim.fn.nr2char(0xF110),
},
column_icons = {
Backlog = vim.fn.nr2char(0xF03A),
["This Week"] = vim.fn.nr2char(0xF073),
["In Progress"] = vim.fn.nr2char(0xF110),
Done = vim.fn.nr2char(0xF046),
},
},
agenda = { width = 0.6, height = 0.7, show_active_undated = true },
calendar = { width = 0.55, height = 0.75, border = "rounded" },
})Highlight groups all link to standard ones, so the board inherits your
colorscheme: TskOverdue, TskToday, TskSoon, TskLater, TskDone,
TskBoard, TskColumn, TskColumnActive, TskSelected. Override any of them
after setup.
alias tw='nvim -c "Tsk work"'
alias ta='nvim -c TskAgenda'
# Capture without breaking flow: t "cut the grass @due(wed)"
t() { nvim --headless -c "lua require('tsk').add('home', '$*')" -c qa }- Undo is in-memory and session-scoped.
urewinds the last change, but closing the board drops the history; git in your notes directory is the safety net for anything older. - Recurring cards spawn on completion in the TUI only. Checking a card off by hand-editing the file does not spawn the next instance.
- If Obsidian has the file open while you edit it in Neovim, last write wins.
./tests/all.sh354 assertions under plain lua5.1 or luajit, no Neovim required. The date,
parser and calendar-grid modules are pure Lua; the board, agenda and calendar run
against a stubbed vim API in tests/nvim_stub.lua that captures buffer-scoped
keymaps, so tests/session.lua drives a real board session keystroke by
keystroke and asserts on the resulting file.
| Suite | Covers |
|---|---|
run.lua |
Date shorthand, DST arithmetic, parser round trip |
obsidian.lua |
Obsidian format: settings, archive, complete marker, block IDs, both date forms, dialect switching |
ui.lua |
Board layout geometry, agenda bucketing, picker collection |
session.lua |
A live editing session, including one on a real Obsidian file |
If you change the renderer, the tests that matter are the ones asserting every rendered line has identical display width and that extmark byte ranges stay inside their column. That is what keeps multibyte card text from smearing columns into each other.
Layout and design notes
lua/tsk/
init.lua setup, highlights, :TskAdd API, statusline
config.lua defaults
date.lua date parsing/arithmetic (pure, testable)
parse.lua Obsidian-compatible markdown <-> board round trip (pure)
board.lua kanban TUI
agenda.lua cross-board agenda
calendar.lua month grid, overview and date picker
pick.lua fuzzy card search, backend auto-detected
health.lua :checkhealth tsk
util.lua file IO, geometry, truncation
plugin/tsk.lua user commands
The board renders all columns into one scratch buffer as padded text and tracks
selection in Lua rather than with the real cursor. One window instead of
one-per-column means no focus bookkeeping and no resize races; card movement is
table manipulation plus a redraw. Cards that do not fit their column wrap onto
extra rows, and sub-lanes render as sections inside their lane. Columns never
shrink below min_col_width: a wider board scrolls horizontally with ◀N/N▶
edge indicators counting what is off-screen each way.
nui.nvim was considered and left out on purpose. The board is a single scratch
buffer with hand-computed column offsets, so nui's Popup/Layout components would
only wrap nvim_open_win calls that are already three lines long, and
vim.ui.input already routes through dressing.nvim / snacks / noice if you have
them. A hard dependency there would buy nothing and hand you someone else's
release schedule to worry about. Fuzzy search was the opposite case, which is why
that one detects rather than reimplements.
MIT. See LICENSE.


