A super lightweight and fast Zettelkasten plugin for Neovim, powered by fzf-lua.
- Dependency: Relies on
ibhagwan/fzf-luaandripgrep(rg). - Customizable: All behaviors (tag notation, link format, directory structure) are user-configurable.
- LazyVim Ready: Optimized for lazy loading with a separate
setupfunction. - Extensible: Includes hooks for integrating external tools like Google Calendar.
Status: Beta. All planned features are implemented and in daily use, but the API may still shift based on feedback and edge cases encountered in real-world usage.
- Find Notes: Fast note searching using
fzf-luawith robust icon handling. - Grep Content: Live grep through your entire Zettelkasten.
- Daily/Weekly Notes: Automatic creation from templates with configurable directories.
- Tag Search: Search for
#tagsacross all notes. - Link Insertion: Interactive link insertion with
[[trigger. - Follow Link: Jump to the link under the cursor (or pick from all links in the buffer). Resolves notes recursively across sub-directories, and can create missing notes from a template. Mappable to
gfwith a native-gffallback. - Backlinks: Find all notes linking to the current note.
[[note]],[[note|alias]],[[note#heading]],[[folder/note]]and[[note.md]]all count as links tonote; only whole names match, so[[note-old]]is not one. - Line links:
:FzfKastenYankLinkmints a^idon the line under the cursor and yanks[[note#^id]], so a link can point at one task inside a note rather than at the note or at the heading over it. Following it lands on that line however it has been reworded since. The id survives ticking the task off, cancelling it and setting a due date, and never shows up in the task list. See Line links. - Link graph: The whole collection read as a graph, in four pickers — what the note you are in is joined to (both directions, several links out), which notes are joined to nothing, which links point at notes that were never written, and which notes everything converges on. See The link graph.
- Rename Note: Rename a note and retarget every link to it — see Renaming.
- Template Engine: Simple
{{title}},{{date}}, and{{hdate}}placeholders. - External Commands: Append external data (like
gcalcli) to daily notes. - Fzfkasten Panel: A central menu for common actions (Open, Backlinks, Rename, Delete).
- New Templated Notes: Create new notes from predefined templates with interactive selection.
- Log picker: One picker (
:FzfKastenLog) over recent days and weeks — existing notes preview and open, missing dates are created from a template, all in one place. - Claude Code Integration: Optional. Sends notes and named prompts to the Claude Code running in a herdr or tmux pane — on this machine or over ssh — by typing into it. Disabled by default; see Claude Code Integration.
- Link Aliasing:
[[note|alias]]syntax is supported across follow link, backlinks, and rename. Anchors too —[[note#heading]]for a section and[[note#^id]]for a single line — and all three read them alike. - Filename Sanitization: Unicode-safe default (preserves CJK) with a user-overridable
transform.sanitize_filenamehook. - Template Placeholders: Built-in
{{title}} {{date}} {{hdate}} {{year}} {{month}} {{day}} {{week}} {{time}}plus user-defined entries viatemplate_placeholders(string or function values). - Image Preview: Delegated to
fzf-lua's previewer; see the Image Preview section for configuration. - Tasks: Collect
- [ ]checkboxes across every note, jump to the one you pick, and tick it off without leaving the picker. Mark which checkboxes are yours with a tag, and triage the rest from an inbox. No index, no task file — see Tasks.
{
"barewalker/fzfkasten.nvim",
dependencies = { "ibhagwan/fzf-lua" },
config = function()
require("fzfkasten").setup({
-- Your custom settings go here
})
end,
}Here is the default configuration. You can override any of these settings in the setup function.
{
home = os.getenv("ZETTELKASTEN_HOME") or vim.fn.expand("~/notes"),
extension = "md",
patterns = {
tag = [[#([%w_-]+)]],
link = [[%[%[(.-)%]%]],
},
block_id = {
length = 6,
alphabet = "abcdefghijklmnopqrstuvwxyz0123456789",
alias = false,
alias_max = 40,
},
notes = {
daily = {
dir = "daily",
format = "%Y-%m-%d",
template = "templates/daily.md",
use_external_cmd = false,
external_cmd = "gcalcli agenda --tsv",
},
weekly = {
dir = "weekly",
format = "%Y-W%V",
template = "templates/weekly.md",
},
},
-- The collection read as a graph. See "The link graph".
graph = {
depth = 2, -- how far :FzfKastenLinkTree walks
ignore_dirs = { "templates" },-- directories that are not notes
},
transform = {
insert_link = function(filename)
return string.format("[[%s]]", filename)
end,
new_file_name = function(title)
return title
end,
-- Strips filesystem-unsafe characters (/\:*?"<>| and controls),
-- trims and collapses whitespace, and removes leading/trailing dots.
-- Unicode (CJK, emoji, accented) is preserved; override for ASCII-only
-- or slug-style names.
sanitize_filename = function(title)
local s = title or ""
s = s:gsub('[/\\:*?"<>|%c]', "")
s = s:gsub("^%s+", ""):gsub("%s+$", "")
s = s:gsub("%s+", " ")
s = s:gsub("^%.+", ""):gsub("%.+$", "")
return s
end,
},
-- Extra placeholders merged on top of the built-ins. Values may be
-- strings or functions receiving the note title.
template_placeholders = {
-- author = "barewalker",
-- uuid = function() return vim.fn.system("uuidgen"):gsub("%s+$", "") end,
},
-- Tweaks applied to note buffers that fzfkasten itself opens (pickers,
-- daily/weekly, follow-link, new note, etc.). See "Note buffer behaviour".
note_buffer = {
disable_diagnostics = true, -- turn off vim.diagnostic for the buffer
disable_format = true, -- set disable_autoformat / autoformat / format_on_save
on_open = nil, -- optional function(bufnr) for extra tweaks
},
claude = {
enabled = false, -- set to true to enable Claude Code integration
pane = { -- where Claude Code is running; see "Claude Code Integration"
via = "herdr", -- or "tmux"
target = nil, -- pane id/name; unset means pick from a list and remember
host = nil, -- ssh host the pane is on; unset means this machine
cmd = nil, -- the multiplexer's executable; defaults to `via`
root = nil, -- where `home` is on that machine, when the two differ
max_lines = 500, -- cap on pasting a buffer that has no file behind it
},
prompts = {}, -- named strings sent with :FzfKastenClaudePrompt <name>
},
find = {
headings_stale_after = 300, -- seconds the note finder keeps the headings it read
},
fzf = { -- passed through to fzf-lua for every picker
winopts = {
height = 0.85,
width = 0.80,
preview = { layout = "vertical" },
},
files = {
previewer = "builtin",
},
-- keys go here, per key, merged over fzf-lua's own defaults:
-- keymap = { fzf = { ["ctrl-h"] = "backward-delete-char" } },
},
}Anything fzf-lua accepts can go under fzf. Keys belong in keymap.fzf (fzf's own) or keymap.builtin (the ones Neovim handles) and not in fzf_opts["--bind"] — fzf-lua rebuilds that flag from keymap and a bind written there is silently dropped.
LSP diagnostics and autoformat are often noisy on prose. Every note buffer that fzfkasten itself opens (via the pickers, daily/weekly notes, follow-link, new note, rename, …) is therefore set up so that, by default:
- diagnostics are disabled for that buffer (
vim.diagnostic.enable(false, …)), and - autoformat is disabled — fzfkasten sets
vim.b.disable_autoformat = true(honoured by conform.nvim) plus the genericvim.b.autoformat/vim.b.format_on_saveflags.
This only affects buffers opened through fzfkasten — notes you open by other
means (:edit, netrw, another picker) are left untouched.
Turn either off in setup:
require("fzfkasten").setup({
note_buffer = {
disable_diagnostics = false, -- keep diagnostics on
disable_format = false, -- keep autoformat on
},
})Every fzfkasten-opened note buffer also gets a marker, vim.b.fzfkasten = true,
regardless of the flags above. If your format-on-save is a bespoke
BufWritePre autocmd (e.g. calling vim.lsp.buf.format() directly), gate it on
that marker:
vim.api.nvim_create_autocmd("BufWritePre", {
callback = function(args)
if vim.b[args.buf].fzfkasten then return end -- skip fzfkasten notes
vim.lsp.buf.format()
end,
})For anything more involved, use the on_open hook, which receives the buffer
number after it has been marked:
note_buffer = {
on_open = function(bufnr)
vim.bo[bufnr].spell = true
end,
},Fzfkasten provides several commands for managing your Zettelkasten notes:
-
:FzfKastenNewNote: Creates a new note. You will be prompted for a title and then presented with anfzf-luapicker to select an optional template from yourhome/templatesdirectory. If no template is selected, it defaults to a basic note structure. -
:FzfKastenLog: One picker for the whole journal. Lists the recent days (daily.lookback_days) and weeks (weekly.lookback_weeks), each marked ✓ when its note already exists. Existing notes preview and open; a date or week with no note yet is created from its template on select — so browsing old notes and filling in a missed day are the same action.<ctrl-x>enters a date by hand for anything older than the window. (:FzfKastenPickDailyDateis a kept alias.) -
:FzfKastenFindDailyNotes/:FzfKastenFindWeeklyNotes: Open anfzf-luapicker over just the existing daily / weekly notes.:FzfKastenLogcovers both with a preview and the ability to create, so these are mostly superseded, but they remain for browsing a single kind. -
:FzfKastenSearchByTag: First presents a list of all unique tags in your Zettelkasten, then displays notes containing the selected tag. -
:FzfKastenFollowLink: Follow a[[wikilink]]. If the cursor is on a link, it opens that link directly; otherwise it lists every link in the buffer in anfzf-luapicker. Targets are resolved recursively across sub-directories (so links to notes in e.g.lognote/resolve too). When several notes share the name, you get a picker to choose; when none exist, the link is created from a template iffollow_link.create_nonexistingis enabled (see below). -
:FzfKastenGotoLink: Like:FzfKastenFollowLinkbut cursor-only — follows the link under the cursor, and falls back to Vim's nativegfwhen the cursor isn't on a link. Designed to be mapped togfso the habit of pressinggf"just works":-- in a markdown ftplugin, or with an ft filter: { "gf", "<cmd>FzfKastenGotoLink<CR>", ft = "markdown", desc = "Follow wikilink / gf" }
-
:FzfKastenYankLink: Yank a link to the line the cursor is on. It mints a^idat the end of that line if it carries none, writes it into the buffer, and puts[[note#^id]]in the yank registers — so you copy the link from the note that holds the task, and paste it into the note that refers to it. An id already on the line is reused, so yanking twice writes the same link twice rather than a second id.Following such a link (
:FzfKastenFollowLink, orgf) puts the cursor on the line carrying that id, wherever it has moved to in the note and however it has been reworded since. This is for a task no heading identifies: a meeting note whose## その他, 議論holds three unrelated tasks cannot be pointed into with[[note#その他, 議論]], because that anchor names all three.Ids are kept apart from tags deliberately.
#qmsclassifies — a search for it is meant to return every line about the quality office.^t3k9aaidentifies, and is worth nothing the moment a second line carries it.The id survives the writers: ticking a task off, cancelling it, setting a due date and tagging it all leave it at the end of the line. It never reaches the task list, which shows what the task says and not its id.
Configure with
block_id:block_id = { length = 6, -- characters in a minted id alphabet = "abcdefghijklmnopqrstuvwxyz0123456789", alias = false, -- carry the line's text into the link alias_max = 40, -- cutting it to this many characters }
With
alias = truethe link reads[[note#^t3k9aa|(A) 案を作る]]. Tags are dropped from an alias either way — one carrying#todowould file the note it was pasted into under it too. -
:FzfKastenLinkTree [depth]: What the note you are in is joined to, both directions at once, as a tree — its links, the notes linking to it, and theirs. See The link graph. -
:FzfKastenOrphans/:FzfKastenDeadLinks/:FzfKastenHubs: The same graph read three other ways — notes joined to nothing, links pointing at notes that don't exist, and notes by how much meets there. See The link graph. -
:FzfKastenTasks: Lists every open- [ ]checkbox across your notes. Pick one to jump to that line in its note; press<ctrl-x>to mark it done in the note itself. See Tasks. -
:FzfKastenTaskToggle: Toggles the checkbox on the current line between- [ ]and- [x], stamping the completion time. -
:FzfKastenTaskAdd [text]: Captures a new task to a single fixed note (tasks.capture_note, or your firsttasks.alwaysentry), no note to open and no decision about where it goes. With no argument it prompts. See Tasks. -
:FzfKastenTaskInbox: Lists the checkboxes thattasks.require_tagleaves out, so you can triage them. See Tasks. -
:FzfKastenTaskTag: Tags the current line as a task, turning prose or a bare bullet into a checkbox on the way. Takes a range, so a visual selection is tagged in one go. See Tasks. -
:FzfKastenTaskCancel: Drops the task on the current line — out of the lists, still in the note — or reopens it if it is already dropped. See Tasks. -
:FzfKastenTaskUndo: Puts back the last task line the picker rewrote. Repeat to walk back through them. See Tasks. -
Other existing commands: (e.g.,
:FzfKastenDaily,:FzfKastenWeekly,:FzfKastenFindNotes,:FzfKastenTags,:FzfKastenInsert, etc.)
By default, following a link whose note doesn't exist anywhere under home just warns. To create it from a template instead (telekasten's follow_creates_nonexisting behaviour):
require("fzfkasten").setup({
follow_link = {
create_nonexisting = true, -- create the note (in home root) when missing
new_note_template = nil, -- template to use; falls back to `new_note_template`
},
})A link normally points at a note, and an anchor narrows that to a heading. Neither
is enough for a task written down in the middle of a meeting note: the heading
above it (## その他, 議論) covers three unrelated tasks, so [[note#その他, 議論]]
names all three, which is to say none of them.
:FzfKastenYankLink, on the line you want to point at:
- [ ] (A) 案を作り、品証室会議でまとめて提出。 #todo #qms ^715s2oThe ^715s2o is written into the note and [[note#^715s2o]] goes into the yank
registers, so you copy the link from the note that holds the task and paste it
into the note that refers to it. gf on the result lands on that line — the id
is what is matched, so rewording the task or moving it within the note changes
nothing. An id already on the line is reused: yanking twice gives you the same
link twice rather than a second id.
Ids are not tags, and the sigils say so. #qms classifies — a search for it
is meant to return every line about the quality office. ^715s2o identifies, and
is worth nothing the moment a second line carries it.
The id stays out of the way. Ticking the task off, cancelling it, setting a due date and tagging it all rewrite the line and all leave the id at the end of it — a completion stamp appended past it, a strikethrough wrapped around the text but not around it. The task list shows what the task says, never its id.
With block_id.alias on, the link carries the line's own words:
[[2026-08-17 戦略会議 論点#^715s2o|(A) 案を作り、品証室会議でまとめて提出。]]cut to block_id.alias_max characters (counted in characters, so a Japanese line
is not cut mid-glyph). Tags are dropped from an alias either way — the tag search
reads every line in the collection, not only the ones that meant it, so an alias
carrying #todo would file the note it was pasted into under it too.
Backlinks answer "what points at this note", one note at a time. Four pickers
read the same [[links]] as one graph and answer what a single note cannot.
Nothing is indexed and nothing is cached: each command walks the collection when
you run it, which over 466 notes (24k lines) takes about 40ms — the same order as
the note finder's own index, and less than fzf takes to appear. A cache would
have to be invalidated by every write, every git pull and every edit made on a
phone, and a graph that is quietly out of date is worse than one that costs 40ms.
The note you are in, and everything within graph.depth links of it, in either
direction:
● project-alpha
├─ → rig notes
│ └─ ← 2025-W41
├─ ↔ 2025-W34
├─ ← 1on1
└─ → measurement rework (no note)
→ is a link this note writes, ← one written at it, ↔ both. A note appears
once, at the shortest way to reach it, so a cycle is walked once and closes.
<enter> opens it — a note you link to at its top, a note linking back
at the line the link is written on, which is the sentence that made the
connection rather than the top of whatever note it sits in. (no note) marks a
link no note answers to; there is nothing to open, so it takes you to the line
naming it.
Give it a depth for one call: :FzfKastenLinkTree 3. Past 3 the tree is taller
than the window and stops being a shape you can take in.
Run with no note to start from — from a dashboard, a scratch buffer, or a file outside the collection — it asks which note first. The graph is a thing you browse, and "open a note, then ask what it is joined to" is a step that answers nothing.
Notes with no link in and none out. In a Zettelkasten this is the working list: these are the notes the collection has not connected to anything yet. A note whose only link is broken is not an orphan — it reached out, and the link it got wrong is the next picker's business.
Every [[link]] no note answers to: a note you meant to write, or a name that
drifted. <enter> opens the line the link is written on — with
follow_link.create_nonexisting set,
gf there writes the note from your template.
A link is resolved the way :FzfKastenFollowLink resolves it, so
[[folder/note]] and [[note.md]] are links to note and do not land here.
What is left is what is genuinely missing.
Every connected note by how many links meet there, most first, backlinks in the left column and links out in the right. Ties go to the note that is linked to: being linked to is what makes a note a place others meet, while linking out is something a note does on its own.
26 ← 6 → project-alpha
9 ← 0 → 1on1
0 ← 8 → project-alpha-experiment
The same [[link]] the rest of the plugin reads — [[note|alias]],
[[note#heading]], [[folder/note]] and [[note.md]] included — with two
rules of the graph's own:
- A note is its name, not its path. Two notes filed under the same name in different directories are one node, as they are one target to a link.
- Fenced code blocks are not read.
[[is a link in prose and a pair of brackets in a shell: read as prose,if [[ ! -f ~/.config/x ]]; thenis a link to a note called! -f ~/.config/x. In a collection that keeps command lines in it, that is what the dead-link list fills up with.
graph.ignore_dirs leaves whole directories out. It defaults to templates,
which are not notes: their [[{{title}}]] placeholders would be dead links, and
the templates themselves would sit in the orphan list forever.
Tasks are plain markdown checkboxes written wherever they were born — in the meeting note, in today's daily, mid-paragraph. There is no task file to maintain and no index to rebuild: :FzfKastenTasks re-scans with ripgrep on every call (a few milliseconds for a few hundred notes).
That property matters more than it looks. Because the notes are the ledger, anything else that can edit markdown joins in for free — a mobile git client, another editor, a script. Tick a box on your phone, and the next scan sees it. Nothing to sync, nothing to teach.
# Tasks
- [ ] (A) review the tech report due:2026-07-17
- [ ] get a quotation
- [x] already donePriority (A) and a due date are optional; tasks sort by priority, then by due date. Checkboxes inside fenced code blocks and frontmatter are ignored, so a note documenting this syntax won't report its own examples as tasks.
A due date is due:YYYY-MM-DD, or due:YYYY-MM-DDTHH:MM when a time matters — ISO 8601, no space, so it stays one token you can drop anywhere in the line. Type it by hand, or let :FzfKastenTaskDue write it for you: :FzfKastenTaskDue 2026-07-25 sets (or replaces) the due date on the current task, :FzfKastenTaskDue 2026-07-25T15:00 adds a time, and :FzfKastenTaskDue with no argument clears it. It also takes a relative spec and works out the day: tomorrow (or 明日), +3d, 2w, a weekday name (fri, 金, resolved to the nearest such day at or after today). What lands in the note is always the absolute date, though — the note is the ledger, and a bare due:2026-07-25 reads the same in every editor and on your phone. The <alt-a> capture asks for a due the same way.
It works from the task list too, on the row under the cursor: there the command writes the note the row came from rather than the line you are on, redraws the list, and is put back by u — the list's undo, like every other action there. The date is an argument, so there is no key for it to take: :FzfKastenTaskDue fri reads the same from a row as it does from the note.
| Key | Action |
|---|---|
<enter> |
Open the note at the task's line |
<ctrl-x> |
Mark done in the note; the list refreshes in place |
<ctrl-d> |
Drop the task: out of the list, still in the note |
<ctrl-t> |
Add require_tag, promoting an inbox entry to a task |
<alt-a> |
Capture a new task with a guided input: text (seeded from what you typed), tags picked from those your notes already use, then a due date. Writes to the capture note and reopens the list |
<alt-/> |
Narrow the list by romaji: kaigi finds 会議. Needs a romaji backend (ttyskk or kensaku.vim); empty input clears it |
<alt-u> |
Put back the last line any of these rewrote |
<alt-s> |
Cycle the ordering: priority → due → added → priority |
<alt-r> |
Reverse whichever ordering is in force |
The picker is the right tool for finding one task: you type, you press enter, and its <ctrl->/<alt-> bindings never come up. It is the wrong tool for working down a list, where you want j, k, /, gg and everything else you already know — and cannot have them, because fzf's prompt owns every unmodified key. No rebinding fixes that; the input field is the reason.
So :FzfKastenTaskList draws the same tasks into an ordinary scratch buffer, where the only keys defined are the actions:
Tasks — 14 · priority
(A) draft the monthly report [due 2026-07-27] tasks/active.md:32
(A) make the phi0.3 mm probe [0/1] [due 2026-07-31] tasks/active.md:26
↳ (A) draw it up and issue the drawing tasks/active.md:27
(B) calibrate the precision micrometer [due 2026-07-24] tasks/active.md:25
| Key | Action |
|---|---|
<enter> |
Open the note at this task |
x |
Tick it off |
c |
Drop it, keeping the line |
t |
Add require_tag — promotes an inbox entry |
a |
Capture a new task |
u |
Put back the last line an action rewrote |
s / S |
Cycle the ordering / reverse it |
i |
Switch between the task list and the inbox |
r |
Re-scan the notes |
q |
Close |
p |
Go into the preview; <esc> or <c-q> comes back |
P |
Show or hide the preview |
<c-d> / <c-u> |
Scroll the preview half a screen |
<c-f> / <c-b> |
Scroll the preview a page |
<c-e> / <c-y> |
Scroll the preview a line |
Everything else is Vim's, untouched: j, k, gg, G, /, n, {, }, <c-d>, <c-u>. No modifier is needed for anything, which is the point — x, c, a, u read as delete, change, append, undo, so there is almost nothing to learn.
Due dates have no key here because they need a date: :FzfKastenTaskDue fri sets one on the row under the cursor, writing the note that row came from, and u puts it back like any other action.
It is a listed buffer, so a bufferline shows it as a tab and you switch back to it the way you switch to any open file — <s-h>/<s-l>, :b, <c-^>. That matters more than it sounds: a list you glance at all day should not need a keystroke to summon each time. Switching back re-scans the notes and puts the preview back, so what you return to is the current state, not the state you left. Set list.listed = false to keep it out of the buffer list.
The buffer is never written and is not the ledger — the notes still are. Every action goes through the same writers the picker uses, and the buffer is redrawn from disk afterwards. Close it and nothing is lost. Complete a task and the next moves up under the cursor, so a run of them is one keypress each.
It keeps up with writes from outside it. Capture a task with :FzfKastenTaskAdd from the note you are writing, tick one off in the picker, set a due date from a row — the list redraws itself wherever it is on screen, so a list open in a split beside you is never behind the notes. r is still there for a note edited by hand or by something else entirely. (Editing a note in a buffer is a different matter: the list reads the notes from disk, so those changes show up when you save.)
Under the list is a split showing the task's note around its line, centred on it and following the cursor:
Tasks — 14 · priority
build a jig that takes the hook and the pan ← how much force it takes to…
────────────────────────────────────────────────────────────
134 # How to assess the gripping-force to hold the holder
136 - look over the sliding surfaces first, as soon as they arrive
138 - how much force it takes to pull the hemisphere off when locked
▶ 139 - [ ] build a jig that takes the hook and the pan #todo
140 - a way to apply any given load at a set position
It is an ordinary window, which is the whole design. p goes into it and every Vim key works there — j, gg, /, <c-d>, <c-w>p — because nothing has been reimplemented. <esc> or <c-q> comes back to the list; deliberately not q, which closes the list, since one key meaning "leave this window" in one place and "close the whole thing" in another is a coin toss you make every time.
Without leaving the list, all three of Vim's scroll pairs are pointed at it — <c-d>/<c-u> by half a screen, <c-f>/<c-b> by a page, <c-e>/<c-y> by a line. They mean exactly what they mean anywhere in Vim; only the window they act on is different, so there is no scroll vocabulary to learn.
Pointing all of them there makes the rule one line — scrolling is the preview, the list moves by cursor — instead of some keys going one way and some the other. They are free to reuse because you move through the list with j/k, gg, G and /, not by scrolling it; and with no preview up they fall through to what Vim would have done to the list anyway.
The preview reads a loaded buffer in preference to the file, so a note you have open and edited but not written previews as it actually is, not as the file on disk is lagging behind it.
It belongs to the list rather than to the window it sits under, so it goes away the moment the list does — <enter> onto a note, a walk back through the jumplist, :bnext, closing the window. What it will not do is close while you are reading it: stepping in with p leaves the list on screen, and that is what it checks.
tasks = {
list = {
open = "full", -- or "split" / "vsplit" / "tab"
listed = true, -- keep it in the buffer list, so a bufferline shows it
source = true, -- the note:line, right-aligned as virtual text
preview = {
enabled = true,
height = 0.5, -- a fraction of the list window below 1, a line count above
},
-- Every key is configurable, the preview's included. Each entry is a key,
-- a list of keys (all bound to that action), or false to leave it to Vim.
keys = {
done = "x", cancel = "c", sort = "s",
preview = "p", preview_toggle = "P",
preview_back = { "<Esc>", "<C-q>" }, -- pressed inside the preview window
preview_half_page_down = "<C-d>", preview_half_page_up = "<C-u>",
preview_page_down = "<C-f>", preview_page_up = "<C-b>",
preview_down = "<C-e>", preview_up = "<C-y>",
},
},
}open = "full" takes the current window, so <enter> opens the note in place and <c-o> comes back. The split variants leave the window you were reading in, and <enter> opens the note there — so the list stays on screen beside it.
The list opens ordered by priority, then by due date — what you flagged, then what runs out. <alt-s> cycles that to two other orderings, and <alt-r> flips whichever one is in force:
| Ordering | Reads as |
|---|---|
priority |
What you decided matters, then what runs out first. The default |
due |
What runs out first, whatever you decided about it |
added |
The order they were written down: the note's date, then position in the note. Captures append, so within one note this is capture order |
A task with no due date sorts last under due, not first — no due date means "not urgent", and a sentinel that sorted first would let undated tasks drown the ones that actually run out. The same goes for a note with no date under added.
The ordering shows in the prompt (Tasks (due, reversed)> ) so a short list reads as "ordered differently" rather than "all there is". The default ordering is left unsaid: a prompt that always carries a tag is one you stop reading.
Changing the order reopens the picker, carrying your query over — the entries move, what you typed to narrow them doesn't. Reversing points the list the other way; it does not scramble the steps of a job, which stay in the order they are written under the item they belong to.
A job with steps is written the way you'd write it anyway — a checkbox indented under another:
- [ ] (A) make the phi0.3mm probe #todo due:2026-07-31
- [x] draw it up and release the drawing
- [ ] send it out for machiningA checkbox nested under a task is a task too, and inherits require_tag from it. Deciding an item is yours is a decision about the whole item; re-tagging every step of it is bookkeeping with nothing to show for it. Inheritance only ever flows down from a checkbox that carries the tag, so a meeting note's action items for other people — nested checkboxes just the same — stay out of the list exactly as before.
The list keeps a subtask under the item it belongs to, and says how far along that item is:
- [ ] (A) make the phi0.3mm probe [1/2] [due 2026-07-31]
↳ send it out for machining
Subtasks sort with their parent rather than on their own priority — a (A) step of a (C) job stays where it can be read as a step, instead of being scattered to the top of the list on its own.
A task also carries the line it hangs off, when there is no parent row above it to read that from:
- [ ] how hard is it to pull the holder off
- [ ] build a jig that takes the hook and the weight pan #todo
build a jig that takes the hook and the weight pan ← how hard is it to pull the holder off
That happens when the line above is a plain bullet (no checkbox, so nothing to indent under), or when the parent task was filtered out of the view — an open step under a finished item, say. Either way the task line stops being a fragment you have to open the note to understand. The same context shows up in the inbox, where a checkbox is most likely to be missing the words that made it make sense.
If your notes are written in Japanese, fzf's own filter needs you to type Japanese to match them. <alt-/> lets you narrow by romaji instead: it asks for a query (seeded from whatever you had typed), turns it into a matcher, and reopens the picker showing only what matches — so kaigi surfaces 会議 without leaving your keyboard's Latin layout. Empty input clears the filter; the prompt gains (romaji) while one is active. The key only appears when something is installed that can do the conversion — it is an optional dependency, not required.
The same <alt-/> drives link insertion (:FzfKastenInsert) and a romaji content search in :FzfKastenSearchContent — that last one is where it pays off most, since note bodies are mostly Japanese while filenames often are not.
The note finder does it without a second key. In :FzfKastenFindNotes, a query beginning with / is romaji: type /kaigi and the list narrows to notes matching 会議. Anything else is fzf's own matching, untouched — nvmcfg still finds nvim/config/init.lua, and so do 'exact, !not and ^prefix. The header says so, with the example rather than a description of one:
prefix / for romaji: /kaigi → 会議
The / token is borrowed from fzf-jp-extension, which patches it into fzf itself; doing it outside keeps stock fzf. With no backend installed the header stays quiet and / has no meaning at all — it is a character like any other.
Ordinary typing never leaves fzf. A plain query is matched by fzf itself, with its own operators — 'exact, !not, ^prefix, $suffix all work, and no process is started for a keystroke. Only a query that starts with / is romaji, and fzf is told to ask about those and nothing else: change is unbound until you type / on an empty query, and unbound again the moment the query stops starting with one. A / typed inside a query is just a /, which paths are full of.
What answers a / query is a script fzfkasten writes next to a copy of the note index, which fzf runs itself: it asks the migemo for a pattern and hands it to rg. Neovim is not involved. Two things have to be true for that, and when either is not, the finder falls back to asking Neovim on every keystroke (vim.fn.matchfuzzy() for plain queries, the migemo in Lua for romaji):
- fzf 0.45 or newer, for its
transformaction. - a romaji backend fzf can run, which means ttyskk. kensaku runs on denops and a backend you passed as a table is Lua; neither can be run by a shell.
Opening the picker builds the index: one rg --files pass for the list and one rg pass for every heading in the collection, started together. The headings are then kept between openings, and only notes never seen before are read again — so opening it a second time costs the file walk alone.
Over 491 notes, and over the same collection on WSL2 on a corporate laptop, where all of this was measured:
| Linux (NVMe) | WSL2 (corporate laptop) | |
|---|---|---|
| opening it, cold | 15 ms | 455 ms |
| opening it again | 6 ms | the file walk alone |
| per keystroke, plain query | 0 | 0 |
per keystroke, /romaji |
26 ms | 58 ms |
The WSL2 column is why it is written this way. vim.fn.glob("**/*.md") — which walks the tree from inside Neovim, one directory at a time, .git included — took 6.2 seconds there against 11 ms on Linux; reading each note for its headings took another 3.5 s; and a fzf --filter per keystroke cost 166 ms against 4.5 ms. Nothing about that setup was unhealthy and none of it was visible — the picker simply opened ten seconds later and then answered a fifth of a second behind the keyboard.
What keeping the headings trades away is a heading edited by something that is not this Neovim: a git pull, another machine, Claude writing to a note in a pane. The file list is walked every time, so a new note is never missed; it is the heading text of a note already read that can lag, and at worst a romaji query does not reach a heading it should. Writing the note in this Neovim drops it from the cache at once, and the whole cache is thrown away after find.headings_stale_after seconds:
find = {
headings_stale_after = 300, -- raise it where reading the collection is slow
-- (455ms vs 42ms on WSL2); 0 reads them every time
}The romaji backend is asked once per keystroke while a / query is being typed, so its speed is the typing latency there. :checkhealth fzfkasten times it and says so.
Reaching 会議 from kaigi takes a migemo — a converter that reaches the kanji, which needs a reading-to-headword dictionary and not just a kana table. Two programs can do it, and fzfkasten will use either:
| what it is | what it costs | |
|---|---|---|
| ttyskk | ttyskk migemo, reading the SKK dictionaries already on the machine |
nothing beyond itself; ~20ms per query, once indexed |
| kensaku.vim | a denops plugin | Deno, plus a 2.1MB dictionary downloaded on first use |
romaji.backend decides. The default "auto" tries ttyskk first and falls back to kensaku, so a machine with either just works.
romaji.headings decides what a romaji query is matched against. Filenames alone are thin ground: a collection can be written entirely in Japanese and still be filed under ASCII names — of 464 notes measured, 32 had any Japanese in the filename against 135 that carried it in their headings, 1654 headings' worth. So a note matches on its path and its own headings by default. Set it to false for paths only. Bodies are never searched here — that is :FzfKastenSearchContent.
romaji = {
backend = "auto", -- or "ttyskk" / "kensaku" / false / a table of your own
ttyskk = { cmd = "ttyskk", limit = nil, timeout = 5000 },
}Naming one pins it: asked for ttyskk on a machine without it, <alt-/> stays hidden rather than quietly starting Deno instead. false turns the key off entirely. To supply your own, pass a table with available(), regex(romaji) (a \m Vim regex) and rg_regex(romaji) (a ripgrep pattern) — both flavours are needed, because the pickers filter in Lua while content search shells out to rg, and handing rg a Vim pattern does not fail, it matches nothing.
If you use ttyskk, build its reading index once:
ttyskk migemo --build-index
It takes about half a second and is not built for you. Without it, ttyskk migemo re-reads the SKK dictionaries on every call — 270ms against 20ms measured here — and the note finder calls it on every keystroke of a / query, so the picker appears to freeze while you type.
:checkhealth fzfkasten reports which backend answered and how long it took to answer, which is the number that decides whether /kaigi feels instant.
By default every checkbox is a task. If you also use checkboxes for things that aren't tasks — acceptance criteria in a spec, a packing list — you have two ways out, and they suit different habits.
Opt out per note, with tasks: false in its frontmatter:
---
title: Deployment spec
tasks: false
---
## Acceptance criteria
- [ ] clone works # not a taskOr opt in per section, by only collecting below task headings:
require("fzfkasten").setup({
tasks = { scope = "headings" }, -- only checkboxes under "# Tasks", "# ToDo", ...
})Prefer tasks: false when the non-tasks cluster in a few notes, and scope = "headings" when they're scattered. Note that scope = "headings" asks you to move a checkbox under a heading before it counts — a small copying step, which is exactly the kind of friction that kills task systems. Reach for it only if the opt-out isn't enough.
tasks = {
scope = "all", -- "all" | "headings"
-- Lua patterns matched against lowercased heading text (scope = "headings").
headings = { "^tasks?%f[%A]", "^to%-?dos?%f[%A]", "^タスク", "^やること" },
ignore = {
frontmatter_key = "tasks", -- `tasks: false` opts a note out; false disables
dirs = { "templates" }, -- directories (relative to `home`) never scanned
},
-- Skip notes older than N days; nil scans everything.
since_days = nil,
always = {}, -- notes always scanned regardless of `since_days`
capture_note = nil, -- where :FzfKastenTaskAdd appends; nil = first `always` entry
date_keys = { "date", "created" }, -- frontmatter keys holding a note's date
date = nil, -- function(path, lines, frontmatter) -> "YYYY-MM-DD"|nil
patterns = {
-- A ripgrep regex (not a Lua pattern), see "Redefining the syntax" below.
scan = [[^\s*[-*]\s+\[[ xX-]\]\s+]],
open = "^%s*[-*]%s+%[ %]%s+(.+)$",
done = "^%s*[-*]%s+%[[xX]%]%s+(.+)$",
cancelled = "^%s*[-*]%s+%[%-%]%s+(.+)$",
toggle = "^(%s*[-*]%s+%[)([ xX-])(%])", -- captures (before)(mark)(after)
priority = "^%((%u)%)%s+",
due = "due:(%d%d%d%d%-%d%d%-%d%d[T%d:]*)", -- ISO day, optional THH:MM
},
marks = { open = " ", done = "x", cancelled = "-" }, -- what `toggle` writes
new_checkbox = "- [ ] ", -- literal `:FzfKastenTaskTag` puts in front of prose
require_tag = nil, -- e.g. "todo": only #todo checkboxes are tasks
done_stamp = { -- written on completion, removed on reopen; false disables
format = " done:%Y-%m-%d %H:%M",
pattern = "%s*done:(%d%d%d%d%-%d%d%-%d%d %d%d:%d%d)",
},
cancel_stamp = { -- the same, for a task you dropped; false disables
format = " cancelled:%Y-%m-%d",
pattern = "%s*cancelled:(%d%d%d%d%-%d%d%-%d%d)",
},
cancel_strike = "~~", -- wrapped around a cancelled task's text; false disables
filter = nil, -- function(task) -> boolean; false drops the task
on_collect = nil, -- function(tasks) called after each collect
}Not every checkbox in your notes is a job for you. Meeting minutes record action items for other people; a spec's acceptance criteria are checkboxes that are nobody's task. require_tag settles it by asking you to say so:
tasks = { require_tag = "todo" } -- only `- [ ] ... #todo` is a task of mine## Minutes
- [ ] revise the manual (Alice) → not mine, never in my list
- [ ] send the quote #todo → mineTagging is a decision, not bookkeeping: writing #todo is the moment you accept the work. That is why the tag beats guessing from shape — "parenthesis means owner" looks tempting until you meet - [ ] (A) ship it, - [ ] fix the workflow (repairs) and - [ ] submit (due May), all parentheses and none an owner.
The obvious risk is forgetting the tag, so nothing is thrown away for lacking one. :FzfKastenTaskInbox lists exactly the checkboxes require_tag left out. Triage there with <ctrl-t>: the entry is tagged in its note and moves straight to the task list, cursor still in place, so a run of them takes one keypress each. An untagged task is waiting, not lost — which is what makes it safe to require the tag at all.
The inbox catches what you missed, but the cheaper moment is while you are still writing. :FzfKastenTaskTag raises the line under the cursor to a task, and it starts from wherever the line already is:
send the quote → - [ ] send the quote #todo
- send the quote → - [ ] send the quote #todo
- [ ] send the quote → - [ ] send the quote #todoRealising mid-sentence that a line is yours to do is the moment to say so, and prose is where that realisation lands — so the command bullets it, boxes it and tags it in one keystroke rather than three edits. It takes a range, which is what makes an old note tractable: select the meeting minutes you never triaged and :'<,'>FzfKastenTaskTag the lot.
It edits the buffer, not the file, so it works mid-edit on unsaved text — unlike the inbox's <ctrl-t>, which reaches into a note you may not have open. What it will not do is write a task the list would never show: headings, frontmatter, fenced examples and (under scope = "headings") anything outside a task section are left alone, because the same rules that decide what :FzfKastenTasks scans decide what this tags. A key that silently writes an invisible task would be worse than no key.
A tag you have to remember is a tag you will forget, so put it in the template you already type. With LuaSnip, a checkbox snippet that emits the tag costs nothing at the keyboard:
s("todo", fmt("- [ ] <> #todo", { i(0) }, { delimiters = "<>" }))Leave require_tag unset and every checkbox is a task, with no inbox to keep.
For anything the tag can't express, filter runs on every task with everything parsed, so it can key off due, priority, rel, date or done. Return false to drop; anything else keeps. A filter that raises keeps the task and warns once — losing work to a config error would be the worse failure.
done_stamp writes the time into the line as a task is completed, and removes it if the task is reopened:
- [ ] send the quote #todo @work due:2026-07-20
↓ <ctrl-x> in the picker, or :FzfKastenTaskToggle on the line
- [x] send the quote #todo @work due:2026-07-20 done:2026-07-16 14:32format is an os.date format and pattern must match what it writes, capturing the timestamp — that capture becomes task.done_at, and it is how the stamp gets stripped again. Set done_stamp = false to write nothing.
The stamp records when, never whether: the checkbox stays the only source of done-ness. It is tempting to keep the state in a tag instead — #todo becoming #done — but any other editor ticking the box knows nothing about your tags, and then the box says done while the tag says open, with no way to tell which is right. One state, one place.
Not everything you accept gets done; some of it stops being worth doing. Deleting the line would be the obvious move and the wrong one — that a job was once on your list is a fact about the project, and it is the only trace that you thought about it at all. <ctrl-d> in the picker, or :FzfKastenTaskCancel on the line, drops a task without losing it:
- [ ] (A) redraw the figures #todo due:2026-07-20
↓
- [-] (A) ~~redraw the figures #todo due:2026-07-20~~ cancelled:2026-07-17It leaves the task list the same way a completed one does, and the same command puts it back. Ask for it with collect({ cancelled = true }) when you want to know what you dropped.
A dropped task is not a finished one, so it does not become - [x]. That would put it in "what did I finish last week", which is a lie your own notes would tell you later; cancel_stamp is separate from done_stamp for the same reason. Cancelling something already done is refused rather than guessed at, since the two claims contradict each other.
The strikethrough is decoration, and the mark is the state — marks.cancelled alone decides, and if a stray ~~ disagrees the mark wins. It is there because [-] is not a checkbox to GitHub or a phone's markdown viewer, which show it as literal text; struck-through text still reads as dropped wherever the note is read, which matters when the notes are synced and read outside Neovim. Set cancel_strike = false for the mark alone — false rather than nil, for the reason described under Redefining the syntax.
Note where the wrap sits: inside the priority, outside the stamp. patterns.priority is anchored to the start of the task's text, so ~~(A) redraw~~ would hide the (A) for as long as the task stayed cancelled — and the cancelling is not itself cancelled, so the stamp stays out of the strike too.
<ctrl-x> on the wrong row is easy: the task is done, the list refreshes, and the row you meant is now where your cursor is. <alt-u> puts the line back, once per keypress, walking back through <ctrl-x>, <ctrl-d> and <ctrl-t> alike. :FzfKastenTaskUndo does the same after you have closed the picker.
Vim's u cannot do this, and looks like it can. The picker writes the note, not a buffer — usually a note you don't even have open. When you do have it open, u rolls the buffer back and leaves the note on disk as it was, so the task list still shows it done, the buffer now disagrees with the file, and nothing says why. That gap is what this key is for.
Edits you make in a note yourself — :FzfKastenTaskToggle, :FzfKastenTaskCancel, :FzfKastenTaskTag — are u's to undo, as any edit is, and are deliberately not recorded here. Two undo stacks over one edit would fight: u would put the line back in the buffer and this would put it back in the file the buffer no longer agrees with.
An undo that would overwrite an edit made since is refused rather than forced through. If the line has changed — by hand, from your phone, by anything — it says so and leaves it alone; your text is worth more than the undo.
To review what you finished, ask for completed tasks and read their stamps:
local done = require("fzfkasten").collect_tasks({ done = true })patterns and marks between them define what a task looks like, and all of it is yours to change. Three of the patterns work together and have to agree:
scanfinds which notes are worth reading. It is a ripgrep regex, not a Lua pattern — the two are different languages, so it can't be derived fromopen/donefor you. Keep it a superset of both, or set it tofalseto skip the pre-filter and read every note (slower, but it can't disagree with anything).open,doneandcancelleddecide what each line is, and capture the task's text.togglecaptures(before)(mark)(after)around the mark, andmarkssays what to write into it. Its mark class has to admit every mark inmarks, or the states it leaves out become unreachable. It also pins down where the checkbox ends and the text starts, which is how a task gets rewritten in place.
new_checkbox has to be redefined alongside them for the same reason scan does: it is the literal :FzfKastenTaskTag writes in front of prose, and a pattern matches many strings without saying which one to produce.
Taken together, they let you use a different notation end to end:
tasks = {
patterns = {
scan = [[^\s*[-*]\s+\([ xX-]\)\s+]], -- ripgrep regex
open = "^%s*[-*]%s+%( %)%s+(.+)$", -- - ( ) buy milk
done = "^%s*[-*]%s+%([xX]%)%s+(.+)$", -- - (x) buy milk
cancelled = "^%s*[-*]%s+%(%-%)%s+(.+)$", -- - (-) buy milk
toggle = "^(%s*[-*]%s+%()([ xX-])(%))",
priority = "^%[(%u)%]%s+", -- - ( ) [A] buy milk
due = "due:(%d%d%d%d%-%d%d%-%d%d[T%d:]*)",
},
marks = { open = " ", done = "x", cancelled = "-" },
new_checkbox = "- ( ) ", -- what `:FzfKastenTaskTag` writes
}Note scan = false rather than nil: setup() merges your table over the defaults, so a nil leaves the default in place. The same applies to any other option you want to switch off.
since_days earns its keep once your notes are a few years deep: old notes carry checkboxes you'll never revisit, and they drown the ones you will. Pair it with always for a standing list that shouldn't age out:
tasks = { since_days = 60, always = { "tasks/active.md" } }That same standing note is the natural target for :FzfKastenTaskAdd, which captures a new task to one fixed place — no note to open, no "does this belong to today?" to answer. It defaults to your first always entry, so the note you already keep always-scanned is where captures land and where they surface; set capture_note to point somewhere else. Bind it to a key and one press then a line is the whole capture; triage later with the inbox. If require_tag is set, the tag is added for you, so a capture is a task straight away rather than an inbox entry. The same capture is a keypress away from inside :FzfKastenTasks itself: press <alt-a> and a guided input takes the task text (seeded from whatever you had typed to filter), lets you pick tags from the ones your notes already use, and asks for a due date — so noticing a new task while working the list doesn't mean leaving it. (<alt-a>, not <ctrl-a>, which stays fzf's own jump-to-line-start.)
With require_tag set, since_days bounds the inbox only — a tagged task never ages out. Tagging it was a decision, and expiring that by date would drop it from the task list and from the inbox, leaving it in neither. since_days is there to keep old untriaged checkboxes from drowning the inbox, not to overrule you.
since_days needs to know when a note is from. Fzfkasten reads that from the filename (2026-07-15.md), then from the frontmatter keys in date_keys.
It never falls back to mtime. In a git-backed Zettelkasten — which is the point of syncing notes to your phone — every checkout rewrites mtime, so it records when the file arrived, not when the note was written. A since_days window built on it would drop real tasks on days you changed nothing.
A note whose date can't be determined is never aged out. Its tasks always show. Failing open is deliberate: an extra task in the list is a nuisance you can see, while a silently hidden one is a task you simply lose. Use tasks: false to quiet an undated note you don't want.
If your notes keep the date somewhere else, date reads it:
tasks = {
-- e.g. a "**Created**: 2026-04-30" line near the top of the body
date = function(path, lines, frontmatter)
for i = 1, math.min(10, #lines) do
local d = lines[i]:match("^%*%*Created%*%*:%s*(%d%d%d%d%-%d%d%-%d%d)")
if d then return d end
end
end,
}It runs before the filename and frontmatter; return nil to fall through to them.
on_collect receives the task list after each scan. The picker only exists inside Neovim, so this is the hook for getting the same list somewhere else — an aggregated index note you can read on your phone, a todo.txt, an external tracker:
tasks = {
on_collect = function(tasks)
local lines = { "# Open tasks", "" }
for _, t in ipairs(tasks) do
local note = vim.fn.fnamemodify(t.path, ":t:r")
table.insert(lines, string.format("- [[%s]] — %s", note, t.text))
end
vim.fn.writefile(lines, vim.fn.expand("~/notes/tasks/OPEN.md"))
end,
}Write the export without checkboxes, as above. A generated file is a view: a box in it invites a tick that the next scan will overwrite.
Each task is { text, done, priority, due, path, rel, lineno, date }. require("fzfkasten").collect_tasks(opts) returns the same list directly, and require("fzfkasten.tasks").toggle_at(path, lineno) flips one checkbox on disk.
Most people run Claude Code where they have always run long-lived processes: in a pane of a terminal multiplexer, next to the editor rather than inside it. Fzfkasten sends notes and prompts to that pane, by typing into it the way you would — through herdr's or tmux's CLI.
Changed in 0.2.0. This used to drive claudecode.nvim and an editor-embedded Claude terminal. That dependency is gone, along with
:FzfKastenClaudeToggle. What went with it is the editor protocol: the pane cannot see your editor, so a note becomes context by being named in the text, not by being open. Line-range selections and diffs written back into your buffers are not part of this; reaching the Claude you actually run, from nvim or over ssh, is.
Nothing to install beyond the multiplexer you already use:
require("fzfkasten").setup({
claude = {
enabled = true,
pane = {
via = "herdr", -- or "tmux"
target = nil, -- unset: the panes are listed and you pick one
},
-- Named prompts you send by name.
prompts = {
-- Open (or create) this week's weekly note, then type
-- "@/path/to/2026-W33.md /my-weekly-retro" into the Claude pane and
-- submit it -- e.g. to run one of your own skills against that note.
retro = { note = "weekly", text = "/my-weekly-retro" },
},
},
}):FzfKastenClaudePrompt <name>: Send a prompt registered inclaude.prompts. The prompt'snoteis opened here and named in the text, so Claude reads it as context.<Tab>completes the configured names.:FzfKastenClaudeSendBuffer: Put the current buffer to the pane, without submitting, so you can type the request after it.:FzfKastenClaudeSendSelection: The same, for the selected line range.:FzfKastenClaudePane: Pick which pane Claude is in, replacing the one picked earlier this session.
A note is a file, so it is named: the pane is sent @/abs/path (quoted,
"/abs/path with spaces.md", when the name has spaces in it, since Claude's @
ends at the first one). Claude reads the file itself, follows links out of it,
and can write back to it.
A buffer fzfkasten made and never wrote — the task list, a preview — has no file to name, so what it shows is pasted instead, under the buffer's name:
fzfkasten://tasks:
(A) draft the monthly report [due 2026-07-27] tasks/active.md:32
(B) write up the calibration [0/1] tasks/active.md:26
↳ read back the sensor logs lognote/2026-W30.md:14
The note and line at the end of each row are virtual text — on the screen, not
in the buffer's lines — and are taken along, so a pasted task list still says
where each task came from and Claude can go read those notes. This is a copy,
not a reference: Claude cannot write back into a view that was never a file.
pane.max_lines (500) caps how much is pasted; past it, send a selection.
A file buffer that has never been written is refused rather than pasted — save it, and it can be named like any other note.
With pane.target unset, the first send of each session lists the panes and
asks; the answer is remembered until nvim closes. herdr labels a pane with the
agent running in it and tmux reports the running command, so the list is
narrowed to the Claude panes when either can tell.
| Field | Meaning |
|---|---|
via |
"herdr" or "tmux" — whose CLI does the typing. Defaults to "herdr". |
target |
A herdr pane id or agent name ("w1:p2", "claude"), or a tmux target ("%3", "notes:1.2"). Unset means ask. |
host |
An ssh host to run that CLI on. Unset means this machine. |
cmd |
The executable. Defaults to via — give a full path when a non-interactive ssh would not find it. |
root |
Where home is on the host machine, when the two differ. |
paste |
Send the text as a paste rather than as typing. Defaults to true; see below. |
max_lines |
How much of a view may be pasted at once. Defaults to 500. |
The text goes in wrapped in the terminal's bracketed-paste markers (DEC mode
2004), and only the Return that submits it is sent as a keystroke. That is what
the text is — content from elsewhere rather than keys someone pressed — and
saying so is what makes it arrive intact. A prompt with newlines in it would
otherwise be submitted a line at a time, its first line running as a message of
its own; and anything that reads keystrokes on the way into the pane, such as an
input method, would otherwise interpret the characters instead of taking them
literally. Pasted content is passed through untouched by convention — the same
reason tmux offers paste-buffer -p. Set pane.paste = false when sending to
something that does not understand a paste.
Set host and the CLI runs over ssh, which is enough to work from an editor on
a laptop against the Claude on a workstation:
pane = {
via = "herdr",
host = "workstation",
cmd = "/home/me/.local/bin/herdr", -- a non-interactive ssh gets a shorter PATH
root = "/home/me/zettelkasten", -- where the collection sits over there
},Notes are named to Claude by their path on that machine, which means it reads its own copy of the collection — the same git clone, not the file you just edited. So before sending, fzfkasten looks at how the note stands with git and says what the far end would actually read:
- committed and pushed — sent without a word;
- committed but not pushed — offers to push here,
pull --ff-onlythere, then send; - uncommitted — says so, and sends only if you say to.
It never commits for you, and never merges on the far end. What to commit, and under what message, is not a decision to take on a note collection from a send keybinding.
Each entry under claude.prompts is a named string you send with
:FzfKastenClaudePrompt <name>:
| Field | Meaning |
|---|---|
text |
The string typed into the pane (required). |
note |
"weekly" | "daily" | "current" (or omit) — the note the prompt is about, named in the text so Claude reads it as context. weekly/daily are opened first, and created from their template if missing; "current" is the note you are in; omitting it names no note. |
submit |
Send a trailing Return so Claude runs it immediately. Defaults to true. |
A note is named as @/abs/path, or quoted ("/abs/path with spaces.md") when
its name has spaces in it, since Claude's @ ends at the first one.
This is deliberately generic: register whatever prompt (a slash command, a
question, a canned instruction) suits your workflow. With no prompts
configured the command simply lists that none are set.
{ "<leader>kc", "<cmd>FzfKastenClaudeSendBuffer<CR>", desc = "Name note to Claude" },
{ "<leader>kc", "<cmd>FzfKastenClaudeSendSelection<CR>", mode = "v", desc = "Name selection to Claude" },
{ "<leader>kC", "<cmd>FzfKastenClaudePane<CR>", desc = "Pick the Claude pane" },
{ "<leader>kr", "<cmd>FzfKastenClaudePrompt retro<CR>", desc = "Send retro prompt to Claude" },If claude.enabled is false, or the multiplexer's CLI is not there, the
commands show a warning and do nothing — fzfkasten continues to work normally.
:checkhealth fzfkasten reports which pane a send would go to.
To integrate with Google Calendar, you need to have gcalcli installed and configured. Then, you can enable it in the setup:
require("fzfkasten").setup({
notes = {
daily = {
use_external_cmd = true,
},
},
})This will append the output of gcalcli agenda --tsv to your new daily notes.
Image rendering in the note finder (find_notes) is delegated to fzf-lua, so any previewer it supports works here — fzfkasten just passes fzf.files through to it. Point fzf.files.previewer at your chosen backend:
require("fzfkasten").setup({
fzf = {
files = {
-- "builtin" uses fzf-lua's native previewer (text + basic image support
-- in terminals that can render images inline, e.g. Kitty, WezTerm).
-- Swap for a custom previewer like "bat", or a user-defined one that
-- shells out to `chafa`, `viu`, or `ueberzug` for richer image preview.
previewer = "builtin",
},
},
})Requirements for inline image preview:
- A terminal that can render images (Kitty, WezTerm, Ghostty, iTerm2, or any terminal with
ueberzug/chafa). fzf-lua's image-preview config set up — see fzf-lua's previewer docs for defining custom previewers.
Plain-text preview (Markdown syntax highlighting) works out of the box with previewer = "builtin" and requires no extra setup.
The task engine rewrites checkboxes in your notes in place — wrapping a strike
inside a priority, stamping a completion, stripping it again on reopen. That
string surgery is easy to get subtly wrong, so its pure helpers are pinned down
by a test suite under tests/, run with
plenary.nvim's busted harness.
Run it headlessly (needs plenary.nvim and fzf-lua installed wherever your
plugin manager keeps them):
nvim --headless --noplugin -u tests/minimal_init.lua \
-c "PlenaryBustedDirectory tests/ { minimal_init = 'tests/minimal_init.lua' }"or, with make available, simply make test. A green run exits 0; a failing
assertion prints the file, line and diff and exits 1.
CI runs the same suite from a clean checkout on Neovim stable and nightly
(.github/workflows/test.yml). That the tests pass in a working copy says
nothing about them passing for anyone who clones the repo — a .gitignore
pattern once swallowed tests/minimal_init.lua, and the suite could not run at
all from a clone, silently.
What the suite covers, and why in that order:
| Spec | Covers |
|---|---|
tasks_spec |
The string surgery — rewriting a mark, wrapping a strike inside a priority, stripping a stamp — plus nesting and the orderings |
writers_spec |
The paths that decide not to write: an unsaved buffer, a line edited since, a note that moved. Every refusal asserts the file is byte-for-byte unchanged |
tasklist_spec |
The task list buffer end to end, pressing the keys rather than calling the functions, so a mapping that failed to attach cannot pass |
health_spec |
:checkhealth in the states worth reporting — no notes directory, a missing template, nowhere to capture to |
writers_spec is the one that earns its keep. Everything else fails loudly; that
code fails silently, and it is all that stands between a mis-press and a lost
line.
When a new test passes first time, break the thing it covers and check it goes red. Three of these did not at first — one read its expected value out of the module it was testing, so it passed for any value that module held.
:FzfKastenRenameNote moves the note and retargets every link to it across the
collection. Every shape a link to it can take comes through:
| Before | After |
|---|---|
[[old]] |
[[new]] |
[[old|alias]] |
[[new|alias]] |
[[old#heading]] |
[[new#heading]] |
[[old#heading|alias]] |
[[new#heading|alias]] |
[[old.md]] |
[[new]] |
[[folder/old]] |
[[new]] |
The anchor and the alias are carried across untouched: renaming a note moves
neither the headings inside it nor the words you chose to call it by. The
directory and the extension are not, because the new name is where the note is
now and the path the link used to take is no longer true. Only whole names
match, so [[old-notes]] is not a link to old, and [[#top]] — an anchor
within the same note, with no name — belongs to nobody.
Following an anchored link puts the cursor on that heading. [[note#Results]]
opens the note at its ## Results, matched on the heading's own text rather
than a slug — that is what the link says, and you write it by reading the note,
not by guessing how its headings would be encoded. Capitalisation is ignored,
since a heading is prose and nobody recalls it. A heading that is not there
opens the note anyway and says so, because landing at the top otherwise looks
like it worked.
This is the widest write in the plugin, so two things about how it goes about it are worth knowing:
- The file moves first. Rewriting the links and then failing to move would point every one of them at a name that does not exist — a whole collection broken by a rename that never happened. A failure at the move changes nothing at all.
- A note you have open with unsaved changes is edited in its buffer, not on
disk. Writing the file underneath it would be overwritten by your next
:w, leaving that one note pointing at the old name, silently. Your:wnow carries both your edits and the new links.
It says how many notes it touched, since it rewrites files you do not have open.
:checkhealth fzfkasten reports what fzfkasten needs from outside itself: the
notes directory, fzf-lua and the fzf binary, ripgrep, the templates you
pointed it at, and where captured tasks will land. Those failures otherwise
surface as a picker that opens empty or a command that quietly does nothing,
which says nothing about which of them it was.
This project is licensed under the MIT License - see the LICENSE file for details.