feat: add cancelled task status with configurable state chars

Problem: the task lifecycle only has `pending`, `wip`, `blocked`, and
`done`. There is no way to mark a task as abandoned. Additionally,
state characters (`>`, `=`) are hardcoded rather than reading from
`config.icons`, so customizing them has no effect on rendering or
parsing.

Solution: add a `cancelled` status with default state char `c`, `g/`
keymap, `PendingCancelled` highlight, filter predicate, and archive
support. Unify state chars by making `state_char()`, `parse_buffer()`,
and `infer_status()` read from `config.icons`. Change defaults to
mnemonic chars: `w` (wip), `b` (blocked), `c` (cancelled).
This commit is contained in:
Barrett Ruth 2026-03-12 20:15:35 -04:00
parent b2456580b5
commit cee291a20b
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
9 changed files with 109 additions and 45 deletions

View file

@ -347,6 +347,7 @@ Default buffer-local keys: ~
`gr` Prompt for a recurrence pattern (`recur`) `gr` Prompt for a recurrence pattern (`recur`)
`gw` Toggle work-in-progress status (`wip`) `gw` Toggle work-in-progress status (`wip`)
`gb` Toggle blocked status (`blocked`) `gb` Toggle blocked status (`blocked`)
`g/` Toggle cancelled status (`cancelled`)
`gf` Prompt for filter predicates (`filter`) `gf` Prompt for filter predicates (`filter`)
`<Tab>` Switch between category / queue view (`view`) `<Tab>` Switch between category / queue view (`view`)
`gz` Undo the last `:w` save (`undo`) `gz` Undo the last `:w` save (`undo`)
@ -470,6 +471,12 @@ old keys to `false`: >lua
Toggle blocked status for the task under the cursor. Toggle blocked status for the task under the cursor.
If the task is already `blocked`, reverts to `pending`. If the task is already `blocked`, reverts to `pending`.
*<Plug>(pending-cancelled)*
<Plug>(pending-cancelled)
Toggle cancelled status for the task under the cursor.
If the task is already `cancelled`, reverts to `pending`.
Toggling on a `done` task switches it to `cancelled`.
*<Plug>(pending-priority-up)* *<Plug>(pending-priority-up)*
<Plug>(pending-priority-up) <Plug>(pending-priority-up)
Increment the priority level for the task under the cursor, clamped Increment the priority level for the task under the cursor, clamped
@ -537,14 +544,15 @@ Category view (default): ~ *pending-view-category*
Tasks are grouped under their category header. Categories appear in the Tasks are grouped under their category header. Categories appear in the
order tasks were added unless `category_order` is set (see order tasks were added unless `category_order` is set (see
|pending-config|). Blank lines separate categories. Within each category, |pending-config|). Blank lines separate categories. Within each category,
tasks are sorted by status (wip → pending → blocked → done), then by tasks are sorted by status (wip → pending → blocked → done → cancelled), then by
priority, then by insertion order. Category sections are foldable with priority, then by insertion order. Category sections are foldable with
`zc` and `zo`. `zc` and `zo`.
Queue view: ~ *pending-view-queue* Queue view: ~ *pending-view-queue*
A flat list of all tasks sorted by a configurable tiebreak chain A flat list of all tasks sorted by a configurable tiebreak chain
(default: status → priority → due → order → id). See (default: status → priority → due → order → id). See
`view.queue.sort` in |pending-config| for customization. Category `view.queue.sort` in |pending-config| for customization. Status
order: wip → pending → blocked → done → cancelled. Category
names are shown as right-aligned virtual text alongside the due date names are shown as right-aligned virtual text alongside the due date
virtual text so tasks remain identifiable across categories. The virtual text so tasks remain identifiable across categories. The
buffer is named `pending://queue`. buffer is named `pending://queue`.
@ -581,6 +589,8 @@ Available predicates: ~
`blocked` Show only tasks with status `blocked`. `blocked` Show only tasks with status `blocked`.
`cancelled` Show only tasks with status `cancelled`.
`clear` Special value for |:Pending-filter| — clears the active filter `clear` Special value for |:Pending-filter| — clears the active filter
and shows all tasks. and shows all tasks.
@ -778,6 +788,7 @@ loads: >lua
move_up = 'K', move_up = 'K',
wip = 'gw', wip = 'gw',
blocked = 'gb', blocked = 'gb',
cancelled = 'g/',
}, },
sync = { sync = {
gcal = {}, gcal = {},
@ -967,17 +978,21 @@ Fields: ~
See |pending-gcal|, |pending-gtasks|, |pending-s3|. See |pending-gcal|, |pending-gtasks|, |pending-s3|.
{icons} (table) *pending.Icons* {icons} (table) *pending.Icons*
Icon characters displayed in the buffer. The Icon characters used for rendering and parsing
{pending}, {done}, {priority}, {wip}, and task checkboxes. The {pending}, {done},
{blocked} characters appear inside brackets {priority}, {wip}, {blocked}, and {cancelled}
(`[icon]`) as an overlay on the checkbox. The characters determine what is written inside
{category} character prefixes both header lines brackets (`[icon]`) in the buffer text and how
and EOL category labels. Fields: status is inferred on `:w`. Each must be a
single character. The {category} character
prefixes header lines and EOL category labels.
Fields:
{pending} Pending task character. Default: ' ' {pending} Pending task character. Default: ' '
{done} Done task character. Default: 'x' {done} Done task character. Default: 'x'
{priority} Priority task character. Default: '!' {priority} Priority task character. Default: '!'
{wip} Work-in-progress character. Default: '>' {wip} Work-in-progress character. Default: 'w'
{blocked} Blocked task character. Default: '=' {blocked} Blocked task character. Default: 'b'
{cancelled} Cancelled task character. Default: 'c'
{due} Due date prefix. Default: '.' {due} Due date prefix. Default: '.'
{recur} Recurrence prefix. Default: '~' {recur} Recurrence prefix. Default: '~'
{category} Category prefix. Default: '#' {category} Category prefix. Default: '#'
@ -1034,6 +1049,10 @@ PendingWip Applied to the checkbox icon of work-in-progress tasks.
PendingBlocked Applied to the checkbox icon and text of blocked tasks. PendingBlocked Applied to the checkbox icon and text of blocked tasks.
Default: links to `DiagnosticError`. Default: links to `DiagnosticError`.
*PendingCancelled*
PendingCancelled Applied to the checkbox icon and text of cancelled tasks.
Default: links to `NonText`.
*PendingPriority* *PendingPriority*
PendingPriority Applied to the checkbox icon of priority 1 tasks. PendingPriority Applied to the checkbox icon of priority 1 tasks.
Default: links to `DiagnosticWarn`. Default: links to `DiagnosticWarn`.
@ -1603,8 +1622,8 @@ with cached data and updates extmarks when the fetch completes.
State pull: ~ State pull: ~
Requires `forge.close = true`. After fetching, if the remote issue/PR Requires `forge.close = true`. After fetching, if the remote issue/PR
is closed or merged and the local task is pending/wip/blocked, the task is is closed or merged and the local task is pending/wip/blocked (not cancelled),
automatically marked as done. Disabled by default. One-way: local status the task is automatically marked as done. Disabled by default. One-way: local status
changes do not push back to the forge. changes do not push back to the forge.
Highlight groups: ~ Highlight groups: ~
@ -1632,7 +1651,7 @@ Task fields: ~
{id} (integer) Unique, auto-incrementing task identifier. {id} (integer) Unique, auto-incrementing task identifier.
{description} (string) Task text as shown in the buffer. {description} (string) Task text as shown in the buffer.
{status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`, {status} (string) `'pending'`, `'wip'`, `'blocked'`, `'done'`,
or `'deleted'`. `'cancelled'`, or `'deleted'`.
{category} (string) Category name. Defaults to `default_category`. {category} (string) Category name. Defaults to `default_category`.
{priority} (integer) Priority level: `0` (none), `1``3` (or up to {priority} (integer) Priority level: `0` (none), `1``3` (or up to
`max_priority`). Higher values sort first. `max_priority`). Higher values sort first.

View file

@ -146,6 +146,14 @@ local function apply_inline_row(bufnr, row, m, icons)
hl_group = 'PendingDone', hl_group = 'PendingDone',
invalidate = true, invalidate = true,
}) })
elseif m.status == 'cancelled' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, col_start, {
end_col = #line,
hl_group = 'PendingCancelled',
invalidate = true,
})
elseif m.status == 'blocked' then elseif m.status == 'blocked' then
local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or '' local line = vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false)[1] or ''
local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0 local col_start = line:find('/%d+/') and select(2, line:find('/%d+/')) or 0
@ -160,10 +168,12 @@ local function apply_inline_row(bufnr, row, m, icons)
local icon, icon_hl local icon, icon_hl
if m.status == 'done' then if m.status == 'done' then
icon, icon_hl = icons.done, 'PendingDone' icon, icon_hl = icons.done, 'PendingDone'
elseif m.status == 'cancelled' then
icon, icon_hl = icons.cancelled, 'PendingCancelled'
elseif m.status == 'wip' then elseif m.status == 'wip' then
icon, icon_hl = icons.wip or '>', 'PendingWip' icon, icon_hl = icons.wip, 'PendingWip'
elseif m.status == 'blocked' then elseif m.status == 'blocked' then
icon, icon_hl = icons.blocked or '=', 'PendingBlocked' icon, icon_hl = icons.blocked, 'PendingBlocked'
elseif m.priority and m.priority >= 3 then elseif m.priority and m.priority >= 3 then
icon, icon_hl = icons.priority, 'PendingPriority3' icon, icon_hl = icons.priority, 'PendingPriority3'
elseif m.priority and m.priority == 2 then elseif m.priority and m.priority == 2 then
@ -216,11 +226,14 @@ local function infer_status(line)
if not ch then if not ch then
return nil return nil
end end
if ch == 'x' then local icons = config.get().icons
if ch == icons.done then
return 'done' return 'done'
elseif ch == '>' then elseif ch == icons.cancelled then
return 'cancelled'
elseif ch == icons.wip then
return 'wip' return 'wip'
elseif ch == '=' then elseif ch == icons.blocked then
return 'blocked' return 'blocked'
end end
return 'pending' return 'pending'
@ -573,6 +586,7 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingPriority3', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingWip', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true }) vim.api.nvim_set_hl(0, 'PendingBlocked', { link = 'DiagnosticError', default = true })
vim.api.nvim_set_hl(0, 'PendingCancelled', { link = 'NonText', default = true })
vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true }) vim.api.nvim_set_hl(0, 'PendingRecur', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true }) 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, 'PendingForge', { link = 'DiagnosticInfo', default = true })

View file

@ -11,6 +11,7 @@
---@field priority string ---@field priority string
---@field wip string ---@field wip string
---@field blocked string ---@field blocked string
---@field cancelled string
---@field due string ---@field due string
---@field recur string ---@field recur string
---@field category string ---@field category string
@ -80,6 +81,7 @@
---@field blocked? string|false ---@field blocked? string|false
---@field priority_up_visual? string|false ---@field priority_up_visual? string|false
---@field priority_down_visual? string|false ---@field priority_down_visual? string|false
---@field cancelled? string|false
---@class pending.CategoryViewConfig ---@class pending.CategoryViewConfig
---@field order? string[] ---@field order? string[]
@ -162,6 +164,7 @@ local defaults = {
move_up = 'K', move_up = 'K',
wip = 'gw', wip = 'gw',
blocked = 'gb', blocked = 'gb',
cancelled = 'g/',
priority_up = '<C-a>', priority_up = '<C-a>',
priority_down = '<C-x>', priority_down = '<C-x>',
priority_up_visual = 'g<C-a>', priority_up_visual = 'g<C-a>',
@ -192,8 +195,9 @@ local defaults = {
pending = ' ', pending = ' ',
done = 'x', done = 'x',
priority = '!', priority = '!',
wip = '>', wip = 'w',
blocked = '=', blocked = 'b',
cancelled = 'c',
due = '.', due = '.',
recur = '~', recur = '~',
category = '#', category = '#',

View file

@ -43,14 +43,17 @@ function M.parse_buffer(lines)
table.insert(result, { type = 'blank', lnum = i }) table.insert(result, { type = 'blank', lnum = i })
elseif id or body then elseif id or body then
local stripped = body:match('^- %[.?%] (.*)$') or body local stripped = body:match('^- %[.?%] (.*)$') or body
local state_char = body:match('^- %[(.-)%]') or ' ' local icons = config.get().icons
local priority = state_char == '!' and 1 or 0 local state_char = body:match('^- %[(.-)%]') or icons.pending
local priority = state_char == icons.priority and 1 or 0
local status local status
if state_char == 'x' then if state_char == icons.done then
status = 'done' status = 'done'
elseif state_char == '>' then elseif state_char == icons.cancelled then
status = 'cancelled'
elseif state_char == icons.wip then
status = 'wip' status = 'wip'
elseif state_char == '=' then elseif state_char == icons.blocked then
status = 'blocked' status = 'blocked'
else else
status = 'pending' status = 'pending'
@ -177,7 +180,7 @@ function M.apply(lines, s, hidden_ids)
end end
if entry.status and task.status ~= entry.status then if entry.status and task.status ~= entry.status then
task.status = entry.status task.status = entry.status
if entry.status == 'done' then if entry.status == 'done' or entry.status == 'cancelled' then
task['end'] = now task['end'] = now
else else
task['end'] = nil task['end'] = nil

View file

@ -47,7 +47,7 @@ function M._recompute_counts()
local today_str = os.date('%Y-%m-%d') --[[@as string]] local today_str = os.date('%Y-%m-%d') --[[@as string]]
for _, task in ipairs(get_store():active_tasks()) do for _, task in ipairs(get_store():active_tasks()) do
if task.status ~= 'done' and task.status ~= 'deleted' then if task.status ~= 'done' and task.status ~= 'deleted' and task.status ~= 'cancelled' then
pending = pending + 1 pending = pending + 1
if task.priority > 0 then if task.priority > 0 then
priority = priority + 1 priority = priority + 1
@ -173,6 +173,11 @@ local function compute_hidden_ids(tasks, predicates)
visible = false visible = false
break break
end end
elseif pred == 'cancelled' then
if task.status ~= 'cancelled' then
visible = false
break
end
end end
end end
if not visible then if not visible then
@ -368,6 +373,9 @@ function M._setup_buf_mappings(bufnr)
blocked = function() blocked = function()
M.toggle_status('blocked') M.toggle_status('blocked')
end, end,
cancelled = function()
M.toggle_status('cancelled')
end,
priority_up = function() priority_up = function()
M.increment_priority() M.increment_priority()
end, end,
@ -843,7 +851,7 @@ function M.prompt_date()
end) end)
end end
---@param target_status 'wip'|'blocked' ---@param target_status 'wip'|'blocked'|'cancelled'
---@return nil ---@return nil
function M.toggle_status(target_status) function M.toggle_status(target_status)
local bufnr = buffer.bufnr() local bufnr = buffer.bufnr()
@ -869,7 +877,7 @@ function M.toggle_status(target_status)
return return
end end
if task.status == target_status then if task.status == target_status then
s:update(id, { status = 'pending' }) s:update(id, { status = 'pending', ['end'] = vim.NIL })
else else
s:update(id, { status = target_status }) s:update(id, { status = target_status })
end end
@ -1187,7 +1195,7 @@ function M.archive(arg)
log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks)) log.debug(('archive: days=%d cutoff=%s total_tasks=%d'):format(days, cutoff, #tasks))
local count = 0 local count = 0
for _, task in ipairs(tasks) do for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then if (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') and task['end'] then
if task['end'] < cutoff then if task['end'] < cutoff then
count = count + 1 count = count + 1
end end
@ -1208,7 +1216,7 @@ function M.archive(arg)
function() function()
local kept = {} local kept = {}
for _, task in ipairs(tasks) do for _, task in ipairs(tasks) do
if (task.status == 'done' or task.status == 'deleted') and task['end'] then if (task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled') and task['end'] then
if task['end'] < cutoff then if task['end'] < cutoff then
goto skip goto skip
end end

View file

@ -1,6 +1,6 @@
local config = require('pending.config') local config = require('pending.config')
---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked' ---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked'|'cancelled'
---@alias pending.RecurMode 'scheduled'|'completion' ---@alias pending.RecurMode 'scheduled'|'completion'
---@class pending.TaskExtra ---@class pending.TaskExtra
@ -331,7 +331,7 @@ function Store:update(id, fields)
end end
end end
task.modified = now task.modified = now
if fields.status == 'done' or fields.status == 'deleted' then if fields.status == 'done' or fields.status == 'deleted' or fields.status == 'cancelled' then
task['end'] = task['end'] or now task['end'] = task['end'] or now
end end
return task return task

View file

@ -177,6 +177,7 @@ function M.push()
and ( and (
task.status == 'done' task.status == 'done'
or task.status == 'deleted' or task.status == 'deleted'
or task.status == 'cancelled'
or (task.status == 'pending' and not task.due) or (task.status == 'pending' and not task.due)
) )

View file

@ -71,21 +71,24 @@ local function compute_forge_spans(task, prefix_len)
end end
---@type table<string, integer> ---@type table<string, integer>
local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3 } local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3, cancelled = 4 }
---@param task pending.Task ---@param task pending.Task
---@return string ---@return string
local function state_char(task) local function state_char(task)
local icons = config.get().icons
if task.status == 'done' then if task.status == 'done' then
return 'x' return icons.done
elseif task.status == 'cancelled' then
return icons.cancelled
elseif task.status == 'wip' then elseif task.status == 'wip' then
return '>' return icons.wip
elseif task.status == 'blocked' then elseif task.status == 'blocked' then
return '=' return icons.blocked
elseif task.priority > 0 then elseif task.priority > 0 then
return '!' return icons.priority
end end
return ' ' return icons.pending
end end
---@param tasks pending.Task[] ---@param tasks pending.Task[]
@ -204,7 +207,7 @@ function M.category_view(tasks)
by_cat[cat] = {} by_cat[cat] = {}
done_by_cat[cat] = {} done_by_cat[cat] = {}
end end
if task.status == 'done' or task.status == 'deleted' then if task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled' then
table.insert(done_by_cat[cat], task) table.insert(done_by_cat[cat], task)
else else
table.insert(by_cat[cat], task) table.insert(by_cat[cat], task)
@ -279,7 +282,11 @@ function M.category_view(tasks)
status = task.status, status = task.status,
category = cat, category = cat,
priority = task.priority, priority = task.priority,
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, overdue = task.status ~= 'done'
and task.status ~= 'cancelled'
and task.due ~= nil
and parse.is_overdue(task.due)
or nil,
recur = task.recur, recur = task.recur,
forge_spans = compute_forge_spans(task, prefix_len), forge_spans = compute_forge_spans(task, prefix_len),
}) })
@ -299,7 +306,7 @@ function M.priority_view(tasks)
local done = {} local done = {}
for _, task in ipairs(tasks) do for _, task in ipairs(tasks) do
if task.status == 'done' then if task.status == 'done' or task.status == 'cancelled' then
table.insert(done, task) table.insert(done, task)
else else
table.insert(pending, task) table.insert(pending, task)
@ -322,7 +329,7 @@ function M.priority_view(tasks)
for _, task in ipairs(all) do for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/' local prefix = '/' .. task.id .. '/'
local state = task.status == 'done' and 'x' or (task.priority > 0 and '!' or ' ') local state = state_char(task)
local line = prefix .. '- [' .. state .. '] ' .. task.description local line = prefix .. '- [' .. state .. '] ' .. task.description
local prefix_len = #prefix + #('- [' .. state .. '] ') local prefix_len = #prefix + #('- [' .. state .. '] ')
table.insert(lines, line) table.insert(lines, line)
@ -334,7 +341,11 @@ function M.priority_view(tasks)
status = task.status, status = task.status,
category = task.category, category = task.category,
priority = task.priority, priority = task.priority,
overdue = task.status ~= 'done' and task.due ~= nil and parse.is_overdue(task.due) or nil, overdue = task.status ~= 'done'
and task.status ~= 'cancelled'
and task.due ~= nil
and parse.is_overdue(task.due)
or nil,
show_category = true, show_category = true,
recur = task.recur, recur = task.recur,
forge_ref = task._extra and task._extra._forge_ref or nil, forge_ref = task._extra and task._extra._forge_ref or nil,

View file

@ -246,7 +246,7 @@ end, {
used[word] = true used[word] = true
end end
local candidates = local candidates =
{ 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked' } { 'clear', 'overdue', 'today', 'priority', 'done', 'pending', 'wip', 'blocked', 'cancelled' }
local store = require('pending.store') local store = require('pending.store')
local s = store.new(store.resolve_path()) local s = store.new(store.resolve_path())
s:load() s:load()
@ -394,6 +394,10 @@ vim.keymap.set('n', '<Plug>(pending-blocked)', function()
require('pending').toggle_status('blocked') require('pending').toggle_status('blocked')
end) end)
vim.keymap.set('n', '<Plug>(pending-cancelled)', function()
require('pending').toggle_status('cancelled')
end)
vim.keymap.set('n', '<Plug>(pending-priority-up)', function() vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
require('pending').increment_priority() require('pending').increment_priority()
end) end)