Skip to content

Latest commit

 

History

14 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

tsk.nvim

Kanban boards in Neovim, backed by plain markdown — and readable as boards in Obsidian Kanban at the same time.

tests

The board view

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.

Features

  • 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.select otherwise.

Requirements

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.

Installation

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.

Commands

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

Board keys

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.

Agenda

The agenda view

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.

Calendar and date picker

The calendar

: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.

Due vs. scheduled

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.

Recurring tasks

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.

Obsidian Kanban compatibility

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.

File format

---
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.

Configuration

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.

From the shell

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 }

Known limitations

  • Undo is in-memory and session-scoped. u rewinds 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.

Development

./tests/all.sh

354 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.

License

MIT. See LICENSE.

About

Kanban boards in Neovim, backed by plain markdown that Obsidian Kanban can read

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages