Inspired by a video by Coding with Sphere, I started working on simple to do plugin, embedded into Neovim. The premise was fairly simple: a buffer I can open in any project to read and edit my todo list.

My workflow with things like this, is get the minimum viable product, so the first step was already done by Coding with Sphere. Using their setup as a jumping off point, I adjusted the border and added a few autocmds to make sure that when I resize the window, the pop-up is resized and remains centered.
For this, I needed to move some of the action to a new function which I called refresh_window. This function also handles reassigning my keymaps and autocmds. More on that later!
local function refresh_window()
local buf = state.buf or 0
state.cmdid = vim.api.nvim_create_autocmd("VimResized", {
buffer = state.buf,
callback = function()
vim.api.nvim_win_set_config(state.win, win_config(state.opts))
end,
})
vim.api.nvim_buf_set_keymap(buf, "n", "q", "", {
-- quit the window
})
vim.api.nvim_buf_set_keymap(buf, "n", "m", "", {
-- mini the window
})
vim.api.nvim_buf_set_keymap(buf, "n", "<M-n>", "", {
-- next note
})
vim.api.nvim_buf_set_keymap(buf, "n", "<M-p>", "", {
-- previous note
})
vim.api.nvim_buf_set_keymap(buf, "n", "f", "", {
-- fullscreen the window
})
end
This is paired with my win_config function, which handles setting up the window configuration in a clean functio function, which handles setting up the window configuration in a clean function:
local function win_config(opts)
local basew = vim.o.columns
local baseh = vim.o.lines
local width = state.sticky and 40 or math.min(basew, opts.width)
local height = state.sticky and 10 or math.min(baseh - 5, opts.height)
local col = state.sticky and vim.o.columns - width - 2 or (basew - width) / 2
local row = state.sticky and 1 or math.max(((baseh - height) / 2) - 2, 1)
return {
relative = "editor",
width = width,
height = height,
col = col,
row = row,
border = opts.border,
title = { { " Frankly ", "Normal" } },
title_pos = "center",
footer = { { " Next [meta-n] - Previous [meta-p] - Fullscreen [f] - Quit [q] ", "Normal" } },
footer_pos = "center",
}
end
It's a bit of a mouthful, but basically all we do here is return a nice table of all the things needed for the window to be drawn. This lets us spam this whenever needed to update the positioning of the window.

Now that that was working, I wanted to adapt it some more. For me personally, I liked to use the dated notes in Obsidian, so my next idea was to work with dated notes. The flow would be: open the window > it shows today's note > if no today note, parse previous note and remove all except the unchecked items and their headings > save as today's note > show today's note.
I thought this would be a good time to learn some Treesitter queries and just get a better understanding of how it works.
This is a bit of a weird file as I kinda don't know what I'm doing, but it does work:
local ts = vim.treesitter
local query = ts.query
local M = {}
local function read_file(path)
local f = io.open(path, "r")
if not f then
return nil
end
local content = f:read("*a")
f:close()
return content
end
function M.parse_todos(file)
local usefile = file or ""
local content = read_file(file)
if not content then
print("Failed to read file:", file)
return
end
local lang = "markdown"
local parser = ts.get_string_parser(content, lang)
local tree = parser:parse()[1]
local root = tree:root()
local q = query.parse(
lang,
[[
(section
(atx_heading
[(atx_h1_marker) (atx_h2_marker) (atx_h3_marker)] @level
(inline) @heading)
(list
(list_item
(task_list_marker_unchecked) @unchecked_marker
(paragraph (inline) @task))))
]]
)
local results = {}
local current_heading = nil
local heading_level = nil
local current_tasks = {}
table.insert(results, "# Todo")
for id, node in q:iter_captures(root, content, 0, -1) do
local name = q.captures[id]
local text = ts.get_node_text(node, content)
if name == "level" then
heading_level = #text -- number of '#' characters
elseif name == "heading" then
-- Flush previous group if needed
if current_heading and #current_tasks > 0 then
table.insert(results, string.rep("#", heading_level or 1) .. " " .. current_heading)
table.insert(results, "")
for _, task in ipairs(current_tasks) do
table.insert(results, task)
end
table.insert(results, "")
end
-- Start new group
current_heading = text
current_tasks = {}
elseif name == "task" then
table.insert(current_tasks, "- [ ] " .. text)
table.insert(results, "")
end
end
-- Final flush after loop
if current_heading and #current_tasks > 0 then
table.insert(results, string.rep("#", heading_level or 1) .. " " .. current_heading)
table.insert(results, "")
for _, task in ipairs(current_tasks) do
table.insert(results, task)
end
end
-- clean double empty lines
local i = 2
while i <= #results do
if results[i] == results[i - 1] then
table.remove(results, i)
else
i = i + 1
end
end
return results
end
return M
I broke this out to a new file just for ease of use, so it's easier to just pass it the file and get back the data we want to keep.
As I said above, it will strip down the file to only give us the leftover todos, so this:
# Todo
## Work
- [x] Reroute network cables
## General
- [ ] Go and pick up order
- [x] Write birthday card
will give us back:
# Todo
## General
- [ ] Go and pick up order
This lets us keep a nice record of tasks from previous days, and keep our current todo nice and clean.
Given that I made this specifically work with multiple files, I thought it would be helpful to make it possible to walk through all of the todo files in the folder. This was fairly painless, with the exception of needed to basically reassign everything to the new file as we walk, as most of them are buffer specific keymaps and autocmds.
M.walk_files = function(dir)
local tdir = state.target_dir
local cwdContent = vim.split(vim.fn.glob(tdir .. "/*"), "\n", { trimempty = true })
state.file_index = state.file_index + dir
state.file_index = math.min(#cwdContent, state.file_index)
state.file_index = math.max(1, state.file_index)
local new_path = cwdContent[state.file_index]
local buf = vim.fn.bufnr(new_path, true)
if buf == -1 then
buf = vim.api.nvim_create_buf(false, false)
vim.api.nvim_buf_set_name(buf, new_path)
end
vim.bo[buf].swapfile = false
state.buf = buf
if state.win and vim.api.nvim_win_is_valid(state.win) then
vim.api.nvim_win_set_buf(state.win, buf)
end
refresh_window()
end
I have a state table that stores all of the "persistent" things related to the window and buffers, which just helps me pull those all around the module and work with them, so we can just update state.buf and refresh the window.
The last thing I wanted to add, was a keymap to send this to a little window in the top right corner, to keep it there if needed. This was fairly simple, just setting state.sticky to true and using that in our refresh_window to set the size and position accordingly. All of the keymaps I ended up having are these:
local function refresh_window()
local buf = state.buf or 0
state.cmdid = vim.api.nvim_create_autocmd("VimResized", {
buffer = state.buf,
callback = function()
vim.api.nvim_win_set_config(state.win, win_config(state.opts))
end,
})
vim.api.nvim_buf_set_keymap(buf, "n", "q", "", {
noremap = true,
silent = true,
callback = function()
if vim.api.nvim_get_option_value("modified", { buf = buf }) then
vim.notify("Unsaved changes!", vim.log.levels.WARN)
else
vim.api.nvim_win_close(0, true)
state.sticky = false
state.win = nil
state.buf = nil
end
end,
})
vim.api.nvim_buf_set_keymap(buf, "n", "m", "", {
noremap = true,
silent = true,
callback = function()
vim.api.nvim_clear_autocmds({ buffer = state.buf })
state.sticky = true
vim.api.nvim_win_set_config(state.win, win_config(state.opts))
refresh_window()
end,
})
vim.api.nvim_buf_set_keymap(buf, "n", "<M-n>", "", {
noremap = true,
silent = true,
callback = function()
vim.api.nvim_clear_autocmds({ buffer = buf })
M.walk_files(1)
end,
})
vim.api.nvim_buf_set_keymap(buf, "n", "<M-p>", "", {
noremap = true,
silent = true,
callback = function()
vim.api.nvim_clear_autocmds({ buffer = buf })
M.walk_files(-1)
end,
})
vim.api.nvim_buf_set_keymap(buf, "n", "f", "", {
noremap = true,
silent = true,
callback = function()
state.sticky = false
state.win = nil
state.buf = nil
local fp = vim.api.nvim_buf_get_name(0)
vim.api.nvim_win_close(0, true)
vim.cmd("e " .. fp)
end,
})
end
Download it, fork it, have fun!
You have made it to the bottom.
▄▄▄▄ ▗▞▀▜▌█ ▄ ▗▞▀▚▖ ▄▄▄ ▄▄▄ ▄▄▄▄ ▗▞▀▚▖ ■ ▐▌ ▄ ▄▄▄▄
█ █ █ ▝▚▄▟▌█▄▀ ▐▛▀▀▘ ▀▄▄ █ █ █ █ █ ▐▛▀▀▘▗▄▟▙▄▖▐▌ ▄ █ █
█ █ █ ▀▄ ▝▚▄▄▖ ▄▄▄▀ ▀▄▄▄▀ █ █ ▝▚▄▄▖ ▐▌ ▐▛▀▚▖█ █ █
█ █ ▐▌ ▐▌ ▐▌█ ▗▄▖
▐▌ ▐▌ ▐▌
▝▀▜▌
▐▙▄▞▘
Copyright (c) 2025 Luke Van. All Rights Reserved.