feat: add markdown detail buffer for task notes (#162)

Problem: tasks only have a one-line description. There is no way to
attach extended notes, checklists, or context to a task.

Solution: add `ge` keymap to open a `pending://task/<id>` markdown
buffer that replaces the task list in the same split. The buffer shows
a read-only metadata header (status, priority, category, due,
recurrence) rendered via extmarks, a `---` separator, and editable
notes below. `:w` saves notes to a new top-level `notes` field on the
task stored in the single `tasks.json`. `q` returns to the task list.
This commit is contained in:
Barrett Ruth 2026-03-13 08:22:04 -04:00
parent ba8b550b8c
commit 20506af849
7 changed files with 298 additions and 0 deletions

View file

@ -348,6 +348,7 @@ Default buffer-local keys: ~
`gw` Toggle work-in-progress status (`wip`)
`gb` Toggle blocked status (`blocked`)
`g/` Toggle cancelled status (`cancelled`)
`ge` Open markdown detail buffer for task notes (`edit_notes`)
`gf` Prompt for filter predicates (`filter`)
`<Tab>` Switch between category / queue view (`view`)
`gz` Undo the last `:w` save (`undo`)
@ -487,6 +488,12 @@ old keys to `false`: >lua
Decrement the priority level for the task under the cursor, clamped
at 0. Default key: `<C-x>`.
*<Plug>(pending-edit-notes)*
<Plug>(pending-edit-notes)
Open the markdown detail buffer for the task under the cursor.
Shows a read-only metadata header and editable notes below a `---`
separator. Press `q` to return to the task list. Default key: `ge`.
*<Plug>(pending-open-line)*
<Plug>(pending-open-line)
Insert a correctly-formatted blank task line below the cursor.
@ -557,6 +564,29 @@ Queue view: ~ *pending-view-queue*
virtual text so tasks remain identifiable across categories. The
buffer is named `pending://queue`.
==============================================================================
DETAIL BUFFER *pending-detail-buffer*
Press `ge` (or `keymaps.edit_notes`) on a task to open a markdown detail
buffer named `pending://task/<id>`. The buffer replaces the task list in
the same split.
Layout: ~
Line 1: `# <description>` (task description as heading)
Lines 2-3: Read-only metadata (status, priority, category, due,
recurrence) rendered as virtual text overlays
Line 4: `---` separator
Line 5+: Free-form markdown notes (editable)
The metadata header is not editable — it is rendered via extmarks on
empty buffer lines. To change metadata, return to the task list and use
the normal keymaps or `:Pending edit`.
Write (`:w`) saves the notes content (everything below the `---`
separator) to the `notes` field in the task store. Press `q` to return
to the task list.
==============================================================================
FILTERS *pending-filters*
@ -789,6 +819,7 @@ loads: >lua
wip = 'gw',
blocked = 'gb',
cancelled = 'g/',
edit_notes = 'ge',
},
sync = {
gcal = {},
@ -1651,6 +1682,7 @@ Task fields: ~
{entry} (string) ISO 8601 UTC timestamp of creation.
{modified} (string) ISO 8601 UTC timestamp of last modification.
{end} (string) ISO 8601 UTC timestamp of completion or deletion.
{notes} (string) Free-form markdown notes (from detail buffer).
{order} (integer) Relative ordering within a category.
Any field not in the list above is preserved in `_extra` and written back on

View file

@ -584,6 +584,7 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'PendingDetailMeta', { link = 'Comment', default = true })
end
---@return string
@ -805,4 +806,213 @@ function M.open()
return task_bufnr
end
local ns_detail = vim.api.nvim_create_namespace('pending_detail')
local DETAIL_SEPARATOR = '---'
---@type integer?
local _detail_bufnr = nil
---@type integer?
local _detail_task_id = nil
---@return integer?
function M.detail_bufnr()
return _detail_bufnr
end
---@return integer?
function M.detail_task_id()
return _detail_task_id
end
---@param bufnr integer
---@param task pending.Task
---@return nil
local function apply_detail_extmarks(bufnr, task)
vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1)
local icons = config.get().icons
local parts = {}
local status_label = task.status or 'pending'
local icon_char = icons[status_label] or icons.pending
table.insert(parts, { 'Status: [' .. icon_char .. '] ' .. status_label, 'PendingDetailMeta' })
if task.priority and task.priority > 0 then
table.insert(parts, { ' ', 'Normal' })
table.insert(
parts,
{ 'Priority: ' .. string.rep(icons.priority, task.priority), 'PendingDetailMeta' }
)
end
local line2 = {}
if task.category then
table.insert(line2, { 'Category: ' .. task.category, 'PendingDetailMeta' })
end
if task.due then
if #line2 > 0 then
table.insert(line2, { ' ', 'Normal' })
end
local due_label = task.due
local y, mo, d = task.due:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)')
if y then
local t = os.time({
year = tonumber(y) --[[@as integer]],
month = tonumber(mo) --[[@as integer]],
day = tonumber(d) --[[@as integer]],
})
due_label = os.date(config.get().date_format, t) --[[@as string]]
end
table.insert(line2, { 'Due: ' .. due_label, 'PendingDetailMeta' })
end
if task.recur then
if #line2 > 0 then
table.insert(line2, { ' ', 'Normal' })
end
table.insert(line2, { 'Recur: ' .. task.recur, 'PendingDetailMeta' })
end
if #parts > 0 then
vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 1, 0, {
virt_text = parts,
virt_text_pos = 'overlay',
})
end
if #line2 > 0 then
vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 2, 0, {
virt_text = line2,
virt_text_pos = 'overlay',
})
end
end
---@param task_id integer
---@return integer? bufnr
function M.open_detail(task_id)
if not _store then
return nil
end
if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then
if _detail_task_id == task_id then
return _detail_bufnr
end
vim.api.nvim_buf_delete(_detail_bufnr, { force = true })
_detail_bufnr = nil
_detail_task_id = nil
end
local task = _store:get(task_id)
if not task then
log.warn('task not found: ' .. task_id)
return nil
end
setup_highlights()
local bufnr = vim.api.nvim_create_buf(true, false)
vim.api.nvim_buf_set_name(bufnr, 'pending://task/' .. task_id)
vim.bo[bufnr].buftype = 'acwrite'
vim.bo[bufnr].filetype = 'markdown'
vim.bo[bufnr].swapfile = false
local lines = {
'# ' .. task.description,
'',
'',
DETAIL_SEPARATOR,
}
local notes = task.notes or ''
if notes ~= '' then
for note_line in (notes .. '\n'):gmatch('(.-)\n') do
table.insert(lines, note_line)
end
else
table.insert(lines, '')
end
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].modified = false
apply_detail_extmarks(bufnr, task)
local winid = task_winid
if winid and vim.api.nvim_win_is_valid(winid) then
vim.api.nvim_win_set_buf(winid, bufnr)
end
vim.wo[winid].conceallevel = 0
vim.wo[winid].foldmethod = 'manual'
vim.wo[winid].foldenable = false
_detail_bufnr = bufnr
_detail_task_id = task_id
local separator_row = 3
local cursor_row = separator_row + 2
local total = vim.api.nvim_buf_line_count(bufnr)
if cursor_row > total then
cursor_row = total
end
pcall(vim.api.nvim_win_set_cursor, winid, { cursor_row, 0 })
return bufnr
end
---@return nil
function M.close_detail()
if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then
vim.api.nvim_buf_delete(_detail_bufnr, { force = true })
end
_detail_bufnr = nil
_detail_task_id = nil
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then
vim.api.nvim_win_set_buf(task_winid, task_bufnr)
set_win_options(task_winid)
M.render(task_bufnr)
end
end
end
---@return nil
function M.save_detail()
if not _detail_bufnr or not _detail_task_id or not _store then
return
end
local task = _store:get(_detail_task_id)
if not task then
log.warn('task was deleted')
M.close_detail()
return
end
local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false)
local sep_row = nil
for i, line in ipairs(lines) do
if line == DETAIL_SEPARATOR then
sep_row = i
break
end
end
local notes_text = ''
if sep_row and sep_row < #lines then
local note_lines = {}
for i = sep_row + 1, #lines do
table.insert(note_lines, lines[i])
end
notes_text = table.concat(note_lines, '\n')
notes_text = notes_text:gsub('%s+$', '')
end
if notes_text == '' then
_store:update(_detail_task_id, { notes = vim.NIL })
else
_store:update(_detail_task_id, { notes = notes_text })
end
_store:save()
vim.bo[_detail_bufnr].modified = false
local updated = _store:get(_detail_task_id)
if updated then
apply_detail_extmarks(_detail_bufnr, updated)
end
end
return M

View file

@ -82,6 +82,7 @@
---@field priority_up_visual? string|false
---@field priority_down_visual? string|false
---@field cancelled? string|false
---@field edit_notes? string|false
---@class pending.CategoryViewConfig
---@field order? string[]
@ -163,6 +164,7 @@ local defaults = {
wip = 'gw',
blocked = 'gb',
cancelled = 'g/',
edit_notes = 'ge',
priority_up = '<C-a>',
priority_down = '<C-x>',
priority_up_visual = 'g<C-a>',

View file

@ -401,6 +401,9 @@ function M._setup_buf_mappings(bufnr)
open_line_above = function()
buffer.open_line(true)
end,
edit_notes = function()
M.open_detail()
end,
}
for name, fn in pairs(actions) do
@ -888,6 +891,46 @@ function M.toggle_status(target_status)
end
end
---@return nil
function M.open_detail()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local detail_bufnr = buffer.open_detail(id)
if not detail_bufnr then
return
end
local group = vim.api.nvim_create_augroup('PendingDetail', { clear = true })
vim.api.nvim_create_autocmd('BufWriteCmd', {
group = group,
buffer = detail_bufnr,
callback = function()
buffer.save_detail()
end,
})
local km = require('pending.config').get().keymaps
vim.keymap.set('n', km.close or 'q', function()
vim.api.nvim_del_augroup_by_name('PendingDetail')
buffer.close_detail()
end, { buffer = detail_bufnr })
end
---@param direction 'up'|'down'
---@return nil
function M.move_task(direction)

View file

@ -24,6 +24,7 @@ local config = require('pending.config')
---@field entry string
---@field modified string
---@field end? string
---@field notes? string
---@field order integer
---@field _extra? pending.TaskExtra
@ -93,6 +94,7 @@ local known_fields = {
entry = true,
modified = true,
['end'] = true,
notes = true,
order = true,
}
@ -124,6 +126,9 @@ local function task_to_table(task)
if task['end'] then
t['end'] = task['end']
end
if task.notes then
t.notes = task.notes
end
if task.order and task.order ~= 0 then
t.order = task.order
end
@ -150,6 +155,7 @@ local function table_to_task(t)
entry = t.entry,
modified = t.modified,
['end'] = t['end'],
notes = t.notes,
order = t.order or 0,
_extra = {},
}

View file

@ -341,6 +341,7 @@ function M.priority_view(tasks)
forge_ref = task._extra and task._extra._forge_ref or nil,
forge_cache = task._extra and task._extra._forge_cache or nil,
forge_spans = compute_forge_spans(task, prefix_len),
has_notes = task.notes ~= nil and task.notes ~= '',
})
end

View file

@ -407,6 +407,10 @@ vim.keymap.set('n', '<Plug>(pending-cancelled)', function()
require('pending').toggle_status('cancelled')
end)
vim.keymap.set('n', '<Plug>(pending-edit-notes)', function()
require('pending').open_detail()
end)
vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
require('pending').increment_priority()
end)