From baf33c9b7c6b30a8a7d3bf4bf9f56a3e41fc2c22 Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 17:39:44 -0700 Subject: [PATCH 01/15] fix: hyphenated tag names in filters, add, and modify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TW3's expression parser reads the hyphen in a bare +tag token as a subtraction operator, so tags like +ais-research-taste broke three ways: filters errored with "Cannot subtract from a Boolean value" (rendering a silently empty buffer), `add` dumped the tag into the description, and `modify +tag` exited 2. - shell_export now routes parsed args through normalize_tag_filters (same rewrite tw_export already used): +t → tags.has:t, -t → tags.hasnt:t - fields_to_args emits one `tags:a,b` replacement arg instead of per-tag +a/-b deltas; this also fixes partial tag removal on buffer save, which +t deltas never applied - new taskmd.tw_change_tag(uuid, tag, remove) merges a single-tag delta into the full set and replaces; modify_tag picker and :TwInbox use it Verified against a real task 3.4.2 DB (unit + e2e specs included). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- lua/taskwarrior/inbox.lua | 7 +- lua/taskwarrior/modify.lua | 17 ++- lua/taskwarrior/taskmd.lua | 38 ++++++- tests/e2e/spec/hyphen_tag_e2e_spec.lua | 126 ++++++++++++++++++++++ tests/lua/spec/hyphen_tag_spec.lua | 142 +++++++++++++++++++++++++ 5 files changed, 322 insertions(+), 8 deletions(-) create mode 100644 tests/e2e/spec/hyphen_tag_e2e_spec.lua create mode 100644 tests/lua/spec/hyphen_tag_spec.lua diff --git a/lua/taskwarrior/inbox.lua b/lua/taskwarrior/inbox.lua index c60d8ec..793e390 100644 --- a/lua/taskwarrior/inbox.lua +++ b/lua/taskwarrior/inbox.lua @@ -112,8 +112,11 @@ function M.run(hours) action = function(cb) vim.ui.input({ prompt = "Tag (no +): " }, function(v) if v and v ~= "" then - local result = command.mutate({ short, "modify", "+" .. v }) - return cb(result.ok, result.output) + -- `modify +v` breaks on hyphenated tags (TW3 parses the hyphen + -- as subtraction) — use the merge-and-replace helper instead. + local ok, out = require("taskwarrior.taskmd").tw_change_tag( + short, (v:gsub("^%+", ""))) + return cb(ok, out) end cb(nil, "") end) diff --git a/lua/taskwarrior/modify.lua b/lua/taskwarrior/modify.lua index 91c94f2..4c2dc54 100644 --- a/lua/taskwarrior/modify.lua +++ b/lua/taskwarrior/modify.lua @@ -173,6 +173,19 @@ end -- user never has to remember Taskwarrior's attribute-value syntax. -- --------------------------------------------------------------------------- +-- Tag deltas can't go through `modify +t` / `modify -t` — TW3 mis-parses +-- hyphenated tag names (see taskmd.tw_change_tag). Route through the safe +-- merge-and-replace helper, keeping modify_field's notify/refresh contract. +local function modify_tag_delta(uuid, tag, remove) + local ok, out = require("taskwarrior.taskmd").tw_change_tag(uuid, tag, remove) + if ok then + notify("modify", "taskwarrior.nvim: " .. (remove and "-" or "+") .. tag) + refresh_all_task_buffers() + else + notify("error", "taskwarrior.nvim: modify failed\n" .. (out or ""), vim.log.levels.ERROR) + end +end + local function modify_field(uuid, spec) local parts, err = command.parse_args(spec) if not parts then @@ -295,10 +308,10 @@ function M.modify_tag() if not choice then return end if choice == "(custom…)" then vim.ui.input({ prompt = "Tag (no leading +): " }, function(v) - if v and v ~= "" then modify_field(uuid, "+" .. v) end + if v and v ~= "" then modify_tag_delta(uuid, (v:gsub("^%+", ""))) end end) else - modify_field(uuid, "+" .. choice) + modify_tag_delta(uuid, choice) end end) end diff --git a/lua/taskwarrior/taskmd.lua b/lua/taskwarrior/taskmd.lua index 117a2e4..3f81da8 100644 --- a/lua/taskwarrior/taskmd.lua +++ b/lua/taskwarrior/taskmd.lua @@ -328,6 +328,10 @@ end function M.shell_export(filter_str) local args, err = command.parse_args(filter_str) if not args then return nil, err end + -- Same normalization tw_export applies: without it, `+ais-research-taste` + -- is parsed by TW3 as (+ais) − (research) − (taste) and the export errors + -- with "Cannot subtract from a Boolean value". + args = normalize_tag_filters(normalize_duration_minutes(args)) table.insert(args, 1, "rc.json.array=on") args[#args + 1] = "export" local result = command.read(args, { ok_codes = { 0, 1 } }) @@ -361,13 +365,17 @@ local function fields_to_args(fields) local args = {} for key, val in pairs(fields) do if key == "tags" then + -- `tags:a,b` replacement instead of per-tag `+a +b`: TW3 mis-parses + -- `+foo-bar` (hyphen becomes a subtraction operator — on add the tag + -- silently lands in the description, on modify the command errors), + -- and replacement also applies partial removals, which `+t` deltas + -- never did. compute_diff always carries the FULL new tag set here. if type(val) == "table" then - for _, t in ipairs(val) do args[#args + 1] = "+" .. t end + args[#args + 1] = "tags:" .. table.concat(val, ",") end elseif key == "_removed_tags" then - if type(val) == "table" then - for _, t in ipairs(val) do args[#args + 1] = "-" .. t end - end + -- Obsolete: full-set `tags:` replacement above already covers removal. + -- Kept as an ignored key so older callers don't emit broken `-t` args. elseif key == "status" then -- skip elseif val == "" then @@ -419,6 +427,28 @@ function M.tw_modify(uuid, fields) return result.ok, result.output, result.code end +-- Append (or remove) a single tag safely. TW3 cannot parse `modify +foo-bar` +-- (hyphen becomes subtraction; the command errors or silently no-ops), so +-- read the task's current tag set, merge the delta, and replace via `tags:`. +-- Returns ok, output, code — same contract as tw_modify. +function M.tw_change_tag(uuid, tag, remove) + local current = M.shell_export("uuid:" .. uuid) + if not current or not current[1] then + return false, "could not read task " .. tostring(uuid) .. " to change its tags", 1 + end + local tags, found = {}, false + for _, t in ipairs(current[1].tags or {}) do + if t == tag then found = true end + if not (remove and t == tag) then tags[#tags + 1] = t end + end + if remove and not found then return true, "", 0 end + if not remove then + if found then return true, "", 0 end + tags[#tags + 1] = tag + end + return M.tw_modify(uuid, { tags = tags }) +end + local function simple_tw(verb) return function(uuid) local result = command.mutate({ uuid, verb }) diff --git a/tests/e2e/spec/hyphen_tag_e2e_spec.lua b/tests/e2e/spec/hyphen_tag_e2e_spec.lua new file mode 100644 index 0000000..6b35500 --- /dev/null +++ b/tests/e2e/spec/hyphen_tag_e2e_spec.lua @@ -0,0 +1,126 @@ +-- hyphen_tag_e2e_spec.lua — end-to-end regression shield for hyphenated tag +-- names against a real Taskwarrior 3.x CLI (tw task 3b9dfcab). +-- +-- TW3's expression parser mis-parses bare `+foo-bar` tokens (the hyphen is +-- read as subtraction). Before the fix: +-- * :TwFilter +ais-research-taste → "Cannot subtract from a Boolean value" +-- and the buffer silently rendered nothing +-- * capture `+foo-bar` → the tag landed inside the description text +-- * modify_tag / inbox "+tag" → exit 2, tag never applied +-- +-- Each scenario here drives the plugin path and asserts the observable +-- Taskwarrior state, not just the absence of an error. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") + +local function unique_hyphen_tag(prefix) + return string.format("%s-hyphen-%d-%d", prefix, vim.fn.getpid(), math.random(1, 1e9)) +end + +describe("e2e hyphenated tags (tw 3b9dfcab)", function() + it("shell_export matches a task filtered by +hyphen-tag", function() + local tag = unique_hyphen_tag("filter") + local uuid, ok = taskmd.tw_add("filter by hyphen tag", { tags = { tag } }) + assert.is_true(ok) + assert.is_true(uuid ~= "") + + local tasks = taskmd.shell_export("+" .. tag) + assert.is_table(tasks) + assert.are.same(1, #tasks) + assert.are.same(uuid, tasks[1].uuid) + assert.are.same({ tag }, tasks[1].tags) + end) + + it("shell_export excludes via -hyphen-tag", function() + local tag = unique_hyphen_tag("excl") + local uuid = taskmd.tw_add("excluded by hyphen tag", { tags = { tag } }) + local tasks = taskmd.shell_export("status:pending -" .. tag) + assert.is_table(tasks) + for _, t in ipairs(tasks) do + assert.is_true(t.uuid ~= uuid, "task with excluded tag leaked through") + end + end) + + it("tw_add stores hyphenated tags as tags, not description text", function() + local tag = unique_hyphen_tag("add") + local uuid, ok = taskmd.tw_add("clean description", { tags = { tag, "plain" } }) + assert.is_true(ok) + local t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same("clean description", t.description) + table.sort(t.tags) + local expected = { "plain", tag } + table.sort(expected) + assert.are.same(expected, t.tags) + end) + + it("tw_change_tag appends and removes a hyphenated tag", function() + local base = unique_hyphen_tag("base") + local extra = unique_hyphen_tag("extra") + local uuid = taskmd.tw_add("tag delta target", { tags = { base } }) + + assert.is_true(taskmd.tw_change_tag(uuid, extra)) + local t = taskmd.shell_export("uuid:" .. uuid)[1] + table.sort(t.tags) + local expected = { base, extra } + table.sort(expected) + assert.are.same(expected, t.tags) + + assert.is_true(taskmd.tw_change_tag(uuid, base, true)) + t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same({ extra }, t.tags) + end) + + it("task buffer opened with a +hyphen-tag filter renders the task", function() + local tag = unique_hyphen_tag("buf") + local uuid = taskmd.tw_add("visible in filtered buffer", { tags = { tag } }) + + vim.cmd("enew") + require("taskwarrior").open("+" .. tag) + vim.wait(200, function() return false end, 10) + + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + local found = false + for _, line in ipairs(lines) do + if line:find(uuid:sub(1, 8), 1, true) then found = true end + end + assert.is_true(found, "filtered buffer did not render the tagged task:\n" + .. table.concat(lines, "\n")) + end) + + it("saving a buffer that drops one of two tags applies the removal", function() + local keep = unique_hyphen_tag("keep") + local drop = unique_hyphen_tag("drop") + local uuid = taskmd.tw_add("partial tag removal", { tags = { keep, drop } }) + + vim.cmd("enew") + require("taskwarrior").open("uuid:" .. uuid) + vim.wait(200, function() return false end, 10) + local bufnr = vim.api.nvim_get_current_buf() + + -- Remove the `drop` tag from the rendered line, keep everything else. + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, line in ipairs(lines) do + if line:find(uuid:sub(1, 8), 1, true) then + lines[i] = line:gsub("%s*%+" .. vim.pesc(drop), "") + end + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + -- Non-interactive apply: auto-accept the confirm picker. + local orig_select = vim.ui.select + vim.ui.select = function(items, _, cb) cb(items[1], 1) end + vim.cmd("silent write") + vim.wait(2000, function() + local t = taskmd.shell_export("uuid:" .. uuid)[1] + return t and t.tags and #t.tags == 1 + end, 20) + vim.ui.select = orig_select + + local t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same({ keep }, t.tags, + "expected only the kept tag after save-applied removal") + end) +end) diff --git a/tests/lua/spec/hyphen_tag_spec.lua b/tests/lua/spec/hyphen_tag_spec.lua new file mode 100644 index 0000000..36adc86 --- /dev/null +++ b/tests/lua/spec/hyphen_tag_spec.lua @@ -0,0 +1,142 @@ +-- Regression spec for the hyphenated-tag class of bugs. +-- +-- TW3's expression parser treats `-` inside a bare `+tag` token as a +-- subtraction operator: +-- * filter `+ais-research-taste` → "Cannot subtract from a Boolean value" +-- * `add … +ais-research-taste` → tag silently lands in the description +-- * `modify +ais-research-taste` → exit 2 (or silent no-op with `--`) +-- +-- Fixes under test: +-- 1. shell_export routes parsed args through normalize_tag_filters +-- (`+t` → `tags.has:t`, `-t` → `tags.hasnt:t`, virtual tags verbatim). +-- 2. fields_to_args emits a single `tags:a,b` replacement arg instead of +-- per-tag `+a +b` deltas (also fixes partial tag removal on save). +-- 3. tw_change_tag merges a single-tag delta into the full set and +-- replaces, instead of `modify +t` / `modify -t`. + +local eq = assert.are.same + +local tm = require("taskwarrior.taskmd") +local command = require("taskwarrior.command") +local runtime = require("taskwarrior.runtime") + +describe("hyphen tags — fields_to_args replacement semantics", function() + it("emits one tags: arg for the full set", function() + local args = tm._fields_to_args({ tags = { "plain", "with-hyphens" } }) + eq({ "tags:plain,with-hyphens" }, args) + end) + + it("emits an empty tags: to clear all tags", function() + local args = tm._fields_to_args({ tags = {}, _removed_tags = { "old-tag" } }) + eq({ "tags:" }, args) + end) + + it("never emits +tag or -tag delta args", function() + local args = tm._fields_to_args({ + tags = { "keep-me" }, _removed_tags = { "drop-me" }, project = "p", + }) + for _, a in ipairs(args) do + assert.is_nil(a:match("^[%+%-]"), "unexpected delta arg: " .. a) + end + end) +end) + +describe("hyphen tags — shell_export filter normalization", function() + local orig_read, orig_executable + local captured + + before_each(function() + captured = nil + orig_read = command.read + orig_executable = vim.fn.executable + vim.fn.executable = function(_) return 1 end + runtime._reset_for_tests() + command.read = function(args, _) + captured = args + return { ok = true, output = "[]", code = 0 } + end + end) + + after_each(function() + command.read = orig_read + vim.fn.executable = orig_executable + runtime._reset_for_tests() + end) + + local function exported_args(filter) + tm.shell_export(filter) + assert.is_table(captured, "command.read was not invoked") + return captured + end + + it("rewrites +tag to tags.has:", function() + local args = exported_args("+ais-research-taste status:pending") + eq("tags.has:ais-research-taste", args[2]) + eq("status:pending", args[3]) + end) + + it("rewrites -tag to tags.hasnt:", function() + local args = exported_args("-ais-research-taste") + eq("tags.hasnt:ais-research-taste", args[2]) + end) + + it("keeps virtual tags verbatim", function() + local args = exported_args("+ACTIVE") + eq("+ACTIVE", args[2]) + end) +end) + +describe("hyphen tags — tw_change_tag merge-and-replace", function() + local orig_export, orig_modify + local modify_calls + + before_each(function() + modify_calls = {} + orig_export = tm.shell_export + orig_modify = tm.tw_modify + tm.tw_modify = function(uuid, fields) + modify_calls[#modify_calls + 1] = { uuid = uuid, fields = fields } + return true, "", 0 + end + end) + + after_each(function() + tm.shell_export = orig_export + tm.tw_modify = orig_modify + end) + + local function with_task_tags(tags) + tm.shell_export = function(_) + return { { uuid = "u-1", tags = tags } } + end + end + + it("appends a tag to the existing set", function() + with_task_tags({ "existing" }) + local ok = tm.tw_change_tag("u-1", "new-hyphen-tag") + assert.is_true(ok) + eq({ "existing", "new-hyphen-tag" }, modify_calls[1].fields.tags) + end) + + it("is a no-op when the tag is already present", function() + with_task_tags({ "already-there" }) + local ok = tm.tw_change_tag("u-1", "already-there") + assert.is_true(ok) + eq(0, #modify_calls) + end) + + it("removes a tag, keeping the rest", function() + with_task_tags({ "keep-this", "drop-this" }) + local ok = tm.tw_change_tag("u-1", "drop-this", true) + assert.is_true(ok) + eq({ "keep-this" }, modify_calls[1].fields.tags) + end) + + it("fails closed when the task cannot be read", function() + tm.shell_export = function(_) return nil end + local ok, out = tm.tw_change_tag("u-1", "anything") + assert.is_false(ok) + assert.is_truthy(out:match("could not read task")) + eq(0, #modify_calls) + end) +end) From fd7ea35b99c879695fa152b68b6c475f51601f39 Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 17:44:17 -0700 Subject: [PATCH 02/15] feat: echo a snippet of the added task in the capture notification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "added task" alone doesn't tell you whether the right thing went through (tw 978fb9d1) — include the first ~40 chars of the parsed description (ellipsized beyond that). The e2e spec also locks in that the capture float starts in insert mode (tw 0f0b9bbf), observed over RPC in a child nvim since :startinsert never engages inside an in-process headless spec. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- lua/taskwarrior/capture.lua | 14 ++- tests/e2e/spec/capture_flow_e2e_spec.lua | 128 +++++++++++++++++++++++ 2 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/spec/capture_flow_e2e_spec.lua diff --git a/lua/taskwarrior/capture.lua b/lua/taskwarrior/capture.lua index 89fad01..bcc7829 100644 --- a/lua/taskwarrior/capture.lua +++ b/lua/taskwarrior/capture.lua @@ -87,6 +87,16 @@ function M.open(refresh_fn) end) end + -- Echo the start of what was actually stored so the user can confirm the + -- right task went through (tw 978fb9d1) — "added task" alone doesn't tell + -- you whether your fields parsed or the text was mangled. + local function snippet(text, max) + max = max or 40 + text = vim.trim(text or "") + if vim.fn.strchars(text) <= max then return text end + return vim.fn.strcharpart(text, 0, max) .. "…" + end + local function submit(line) if not line or line == "" then return end @@ -107,7 +117,7 @@ function M.open(refresh_fn) if desc and desc ~= "" then local new_uuid, add_ok = tm.tw_add(desc, fields) if new_uuid and new_uuid ~= "" then - vim.notify("taskwarrior.nvim: added task") + vim.notify(('taskwarrior.nvim: added "%s"'):format(snippet(desc))) elseif add_ok then vim.notify( "taskwarrior.nvim: added task, but Taskwarrior did not report its UUID; not retrying", @@ -126,7 +136,7 @@ function M.open(refresh_fn) -- their typed content. local result = command.mutate({ "add", "--", line }) if result.ok then - vim.notify("taskwarrior.nvim: added task (unparsed)") + vim.notify(('taskwarrior.nvim: added (unparsed) "%s"'):format(snippet(line))) refresh_fn() else vim.notify("taskwarrior.nvim: add failed", vim.log.levels.ERROR) diff --git a/tests/e2e/spec/capture_flow_e2e_spec.lua b/tests/e2e/spec/capture_flow_e2e_spec.lua new file mode 100644 index 0000000..384708c --- /dev/null +++ b/tests/e2e/spec/capture_flow_e2e_spec.lua @@ -0,0 +1,128 @@ +-- capture_flow_e2e_spec.lua — drives the real quick-capture user journey +-- against a live task CLI. Covers two tw tasks: +-- * 0f0b9bbf — the add interface is a buffer that starts in insert mode +-- * 978fb9d1 — the post-add notification echoes the start of the task +-- so the user can confirm the right thing went through + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") + +local function open_capture() + require("taskwarrior").capture() + vim.wait(100, function() return false end, 10) + return vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() +end + +-- Fire the capture buffer's insert-mode mapping the way a user would. +local function press_enter(buf) + local maps = vim.api.nvim_buf_get_keymap(buf, "i") + for _, m in ipairs(maps) do + if m.lhs == "" and m.callback then + m.callback() + return true + end + end + return false +end + +describe("e2e quick-capture flow", function() + it("opens a floating buffer in insert mode (tw 0f0b9bbf)", function() + -- :startinsert only engages when control returns to the input loop, + -- which never happens inside this in-process spec — so the real UX is + -- observed over RPC in a child nvim whose main loop is idle between + -- requests. This proves a user invoking capture lands in insert mode. + local root = vim.fn.fnamemodify(debug.getinfo(1, "S").source:sub(2), ":h:h:h:h") + local sock = TMP .. "/capture-mode.sock" + local job = vim.fn.jobstart({ + "nvim", "--headless", "--listen", sock, "--cmd", "set rtp+=" .. root, + }) + assert.is_true(job > 0, "failed to spawn child nvim") + vim.wait(3000, function() return vim.fn.filereadable(sock) == 1 end, 20) + local chan = vim.fn.sockconnect("pipe", sock, { rpc = true }) + assert.is_true(chan > 0, "failed to connect to child nvim") + + vim.rpcrequest(chan, "nvim_exec_lua", + "require('taskwarrior').setup({}) require('taskwarrior').capture()", {}) + -- Give the child a main-loop turn so the pending startinsert engages. + vim.wait(300, function() + return vim.rpcrequest(chan, "nvim_get_mode").mode:sub(1, 1) == "i" + end, 20) + + local mode = vim.rpcrequest(chan, "nvim_get_mode").mode + local is_float = vim.rpcrequest(chan, "nvim_exec_lua", [[ + local cfg = vim.api.nvim_win_get_config(0) + return (cfg.relative ~= nil and cfg.relative ~= "") + and vim.bo[vim.api.nvim_get_current_buf()].buftype == "nofile" + ]], {}) + + vim.fn.chanclose(chan) + vim.fn.jobstop(job) + + assert.are.same("i", mode:sub(1, 1), + "capture window did not start in insert mode (child mode: " .. mode .. ")") + assert.is_true(is_float, "capture window is not a nofile float") + end) + + it("submitting shows a snippet of the added task and stores it (tw 978fb9d1)", function() + local buf, win = open_capture() + local desc = ("capture snippet check %d"):format(math.random(1, 1e9)) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { desc .. " project:capturetest" }) + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) messages[#messages + 1] = tostring(msg) end + + assert.is_true(press_enter(buf), "capture mapping not found") + vim.wait(3000, function() + local t = taskmd.shell_export("project:capturetest") + return t and #t > 0 + end, 20) + vim.wait(200, function() return false end, 10) + vim.notify = orig_notify + + -- Observable TW state: the task exists with parsed fields. + local tasks = taskmd.shell_export("project:capturetest") or {} + local stored + for _, t in ipairs(tasks) do + if t.description == desc then stored = t end + end + assert.is_truthy(stored, "captured task not found in export") + + -- Notification carries the first characters of the description. + local prefix = desc:sub(1, 20) + local mentioned = false + for _, m in ipairs(messages) do + if m:find(prefix, 1, true) then mentioned = true end + end + assert.is_true(mentioned, + "no notification contained the task snippet; got: " + .. vim.inspect(messages)) + pcall(vim.api.nvim_win_close, win, true) + end) + + it("truncates long descriptions in the notification", function() + local buf, win = open_capture() + local long = ("verylongword "):rep(10) .. "tail-marker" + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { long }) + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) messages[#messages + 1] = tostring(msg) end + assert.is_true(press_enter(buf), "capture mapping not found") + vim.wait(3000, function() return #messages > 0 end, 20) + vim.notify = orig_notify + + local added + for _, m in ipairs(messages) do + if m:find("added", 1, true) then added = m end + end + assert.is_truthy(added, "no 'added' notification; got " .. vim.inspect(messages)) + assert.is_truthy(added:find("…", 1, true), + "long description was not truncated with an ellipsis: " .. added) + assert.is_nil(added:find("tail-marker", 1, true), + "notification should not contain the tail of a long description") + pcall(vim.api.nvim_win_close, win, true) + end) +end) From 47f8a4103f7ac57c07afe0c8b3f30ad7ec04381d Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 17:45:52 -0700 Subject: [PATCH 03/15] feat: point the no-confirm apply summary at :TwUndo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit confirm = false already skips the save popup and applies immediately; the summary notification is then the user's only checkpoint, so make the revert path discoverable right there (tw fca82462). Documented the confirm=false → notify → :TwUndo workflow in README + :help, and added an e2e spec that saves without a picker, checks the summary + hint, and verifies :TwUndo actually reverts the mutation in Taskwarrior. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- README.md | 5 +- doc/taskwarrior.txt | 3 +- lua/taskwarrior/apply.lua | 8 ++ tests/e2e/spec/no_confirm_save_e2e_spec.lua | 130 ++++++++++++++++++++ 4 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 tests/e2e/spec/no_confirm_save_e2e_spec.lua diff --git a/README.md b/README.md index 570eefe..b68fa37 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,10 @@ Tasks use Taskwarrior-native syntax after the description: ```lua require("taskwarrior").setup({ on_delete = "done", -- "done" or "delete" when lines are removed - confirm = true, -- show confirmation dialog before applying + confirm = true, -- show confirmation dialog before applying. + -- false = apply immediately on :w; the summary + -- notification then points at :TwUndo, which + -- reverts everything the save just did sort = "urgency-", -- default sort (field+ for asc, field- for desc) group = nil, -- default group field (nil to disable) wrap = true, -- line-wrap task buffers (false = one line per task) diff --git a/doc/taskwarrior.txt b/doc/taskwarrior.txt index f79fdc9..1176f11 100644 --- a/doc/taskwarrior.txt +++ b/doc/taskwarrior.txt @@ -371,7 +371,8 @@ Full schema with defaults: > require("taskwarrior").setup({ on_delete = "done", -- or "delete" - confirm = true, + confirm = true, -- false = no popup: :w applies at once, + -- notifies a summary, :TwUndo reverts sort = "urgency-", group = nil, wrap = true, -- false = don't line-wrap task buffers diff --git a/lua/taskwarrior/apply.lua b/lua/taskwarrior/apply.lua index ff5b040..2a4f74c 100644 --- a/lua/taskwarrior/apply.lua +++ b/lua/taskwarrior/apply.lua @@ -338,6 +338,14 @@ function M.do_apply_and_refresh(bufnr, tmpfile, on_delete, refresh_fn, opts) if first and first ~= "" then msg = msg .. "\n" .. first end vim.notify(msg, vim.log.levels.ERROR) elseif (summary.action_count or 0) > 0 then + -- Without the confirm popup the save applies silently, so the summary + -- notification is the user's only checkpoint — make the undo path + -- discoverable right there (tw fca82462). + local config = require("taskwarrior.config") + if not config.options.confirm then + local prefix = config.options.command_prefix or "Tw" + msg = msg .. (" — :%sUndo to revert"):format(prefix) + end vim.notify(msg) end diff --git a/tests/e2e/spec/no_confirm_save_e2e_spec.lua b/tests/e2e/spec/no_confirm_save_e2e_spec.lua new file mode 100644 index 0000000..6dd126b --- /dev/null +++ b/tests/e2e/spec/no_confirm_save_e2e_spec.lua @@ -0,0 +1,130 @@ +-- no_confirm_save_e2e_spec.lua — the configurable save popup (tw fca82462). +-- +-- With setup({ confirm = false }) the Apply/Cancel picker must never appear: +-- :w applies immediately, the summary notification names what happened and +-- points at :TwUndo, and :TwUndo actually reverts the mutation in +-- Taskwarrior. Driven against the live task CLI seeded by tests/e2e/run.sh. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") + +describe("e2e no-confirm save (tw fca82462)", function() + local orig_confirm + + before_each(function() + local config = require("taskwarrior.config") + if not next(config.options) then require("taskwarrior").setup({}) end + orig_confirm = config.options.confirm + config.options.confirm = false + end) + + after_each(function() + require("taskwarrior.config").options.confirm = orig_confirm + end) + + it(":w applies without a picker, notifies summary + undo hint, undo reverts", function() + local marker = ("noconfirm %d"):format(math.random(1, 1e9)) + local uuid = taskmd.tw_add(marker, { project = "noconfirmtest" }) + assert.is_true(uuid ~= "") + + vim.cmd("enew") + require("taskwarrior").open("uuid:" .. uuid) + vim.wait(200, function() return false end, 10) + local bufnr = vim.api.nvim_get_current_buf() + + -- Edit the description in the buffer. + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, line in ipairs(lines) do + if line:find(marker, 1, true) then + lines[i] = line:gsub(vim.pesc(marker), marker .. " edited") + end + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + -- The picker must NOT be involved in this flow. + local orig_select = vim.ui.select + local select_called = false + vim.ui.select = function(items, _, cb) + select_called = true + cb(nil) + end + + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, ...) messages[#messages + 1] = tostring(msg) end + + vim.cmd("silent write") + vim.wait(3000, function() + local t = taskmd.shell_export("uuid:" .. uuid)[1] + return t and t.description == marker .. " edited" + end, 20) + + vim.notify = orig_notify + assert.is_false(select_called, + "confirm=false save still invoked vim.ui.select") + + local t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same(marker .. " edited", t.description, + "save did not apply the edit") + + local summary + for _, m in ipairs(messages) do + if m:find("Applied:", 1, true) then summary = m end + end + assert.is_truthy(summary, "no apply summary notification; got: " + .. vim.inspect(messages)) + assert.is_truthy(summary:find("~1 modified", 1, true), + "summary does not name the modify: " .. summary) + assert.is_truthy(summary:find("Undo to revert", 1, true), + "summary does not mention the undo path: " .. summary) + + -- :TwUndo (driven via apply.undo) reverts the modify. + vim.ui.select = function(items, _, cb) cb("Undo", 1) end + require("taskwarrior").undo() + vim.wait(3000, function() + local cur = taskmd.shell_export("uuid:" .. uuid)[1] + return cur and cur.description == marker + end, 20) + vim.ui.select = orig_select + + local reverted = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same(marker, reverted.description, + "undo did not revert the modify") + end) + + it("confirm=true keeps the popup (control)", function() + local marker = ("withconfirm %d"):format(math.random(1, 1e9)) + local uuid = taskmd.tw_add(marker, { project = "noconfirmtest" }) + require("taskwarrior.config").options.confirm = true + + vim.cmd("enew") + require("taskwarrior").open("uuid:" .. uuid) + vim.wait(200, function() return false end, 10) + local bufnr = vim.api.nvim_get_current_buf() + + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, line in ipairs(lines) do + if line:find(marker, 1, true) then + lines[i] = line:gsub(vim.pesc(marker), marker .. " edited") + end + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_select = vim.ui.select + local select_called = false + vim.ui.select = function(items, _, cb) + select_called = true + cb(nil) -- cancel + end + vim.cmd("silent write") + vim.wait(1000, function() return select_called end, 20) + vim.ui.select = orig_select + + assert.is_true(select_called, "confirm=true save never showed the picker") + local t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same(marker, t.description, + "cancelled confirm save must not mutate the task") + end) +end) From 2420d34bb7f71423d02bff438ede22fe6ad8734e Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 17:52:15 -0700 Subject: [PATCH 04/15] feat: Taskwarrior context support (:TwContext) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit :TwContext shows the active context and lists defined ones; a name activates it (task context ), "none" clears it (idempotent), and open task buffers refresh (tw c12b4cbd). TW 3.x applies contexts to reports but NOT to `export`, so the render path injects the active context's read filter itself. The injected tokens land in the rendered taskmd header, which keeps the save path on the same effective filter — the e2e spec proves saving a context-narrowed buffer never touches the tasks the context hides. uuid-targeted filters are exempt so opening one specific task can't come up empty. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- README.md | 1 + doc/taskwarrior.txt | 12 +++ lua/taskwarrior/buffer.lua | 8 ++ lua/taskwarrior/commands.lua | 18 +++++ lua/taskwarrior/context.lua | 116 ++++++++++++++++++++++++++++ lua/taskwarrior/init.lua | 1 + tests/e2e/spec/context_e2e_spec.lua | 116 ++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+) create mode 100644 lua/taskwarrior/context.lua create mode 100644 tests/e2e/spec/context_e2e_spec.lua diff --git a/README.md b/README.md index b68fa37..0ac539b 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,7 @@ These are bundled but require their own host plugins. | `:TwStart` / `:TwStop` | Start / stop active timer on task under cursor | | `:TwSave ` / `:TwLoad [name]` | Save / restore the current filter+sort+group as a named view | | `:TwReview` | Guided urgency walk through pending tasks | +| `:TwContext [name\|none]` | Show or set the Taskwarrior context (task buffers honor its read filter) | | `:TwDelegate [copy\|copy-command]` | Delegate task(s) to Claude in a popup form | | `:TwDiffPreview [on\|off\|toggle]` | Toggle live virt-text diff preview | | `:TwBurndown` | Pending-task burndown chart | diff --git a/doc/taskwarrior.txt b/doc/taskwarrior.txt index 1176f11..3174197 100644 --- a/doc/taskwarrior.txt +++ b/doc/taskwarrior.txt @@ -243,6 +243,18 @@ Visualisation commands. See |taskwarrior-views|. :TwSync Run `task sync` asynchronously with progress and error reporting. Offers a retry on failure. + *:TwContext* +:TwContext [name] Show or set the Taskwarrior context. With no + argument, shows the active context and lists the + defined ones. With a name (e.g. `work`), activates + that context — task buffers refresh and honor its + read filter (TW 3.x does not apply contexts to + `export`, so the plugin injects the filter itself). + `:TwContext none` clears it. Filters that target a + specific `uuid:` are never context-narrowed. + Define contexts in the CLI: + `task context define work project:work` + *:TwFloat* :TwFloat [filter] Open the task buffer in a centered floating window instead of a split. `q` to dismiss. diff --git a/lua/taskwarrior/buffer.lua b/lua/taskwarrior/buffer.lua index 70ab765..4b47fe0 100644 --- a/lua/taskwarrior/buffer.lua +++ b/lua/taskwarrior/buffer.lua @@ -76,6 +76,10 @@ local function render(filter, sort, group) if filter and filter ~= "" then for w in filter:gmatch("%S+") do table.insert(filter_args, w) end end + -- Honor the active Taskwarrior context (TW 3.x doesn't apply it to + -- `export`). The tokens land in the rendered header, so the save path + -- re-exports with the same effective filter — no false deletes. + vim.list_extend(filter_args, require("taskwarrior.context").filter_tokens(filter)) local tm = require("taskwarrior.taskmd") local ok, result = pcall(tm.render, { @@ -107,6 +111,8 @@ local function apply_custom_sort(bufnr) -- Export tasks to get full data for the custom function local filter = vim.b[bufnr].task_filter or "" local export_filter = filter ~= "" and filter or "status:pending" + local ctx = table.concat(require("taskwarrior.context").filter_tokens(filter), " ") + if ctx ~= "" then export_filter = export_filter .. " " .. ctx end local tasks = require("taskwarrior.taskmd").shell_export(export_filter) if not tasks then return end @@ -352,6 +358,8 @@ local function apply_virtual_text(bufnr) apply_empty_state(bufnr) local filter = vim.b[bufnr].task_filter or "" local export_filter = filter ~= "" and filter or "status:pending" + local ctx = table.concat(require("taskwarrior.context").filter_tokens(filter), " ") + if ctx ~= "" then export_filter = export_filter .. " " .. ctx end local tasks = require("taskwarrior.taskmd").shell_export(export_filter) if not tasks then return end diff --git a/lua/taskwarrior/commands.lua b/lua/taskwarrior/commands.lua index f8afe7e..56991c0 100644 --- a/lua/taskwarrior/commands.lua +++ b/lua/taskwarrior/commands.lua @@ -283,6 +283,24 @@ function M.setup(main, complete_filter) main.sync() end, { nargs = 0, desc = "Run `task sync` with progress and error handling" }) + -- Taskwarrior contexts (work, home, …). No args shows the active context + -- and the available ones; a name sets it; "none" clears it. + register("Context", function(o) + main.context(o.args) + end, { + nargs = "?", + desc = "Show or set the Taskwarrior context (name or 'none')", + complete = function(arg_lead) + local names = require("taskwarrior.context").list() + table.insert(names, "none") + local out = {} + for _, n in ipairs(names) do + if n:sub(1, #arg_lead) == arg_lead then table.insert(out, n) end + end + return out + end, + }) + register("Float", function(o) require("taskwarrior.buffer").open_float(o.args) end, { diff --git a/lua/taskwarrior/context.lua b/lua/taskwarrior/context.lua new file mode 100644 index 0000000..f4bab9b --- /dev/null +++ b/lua/taskwarrior/context.lua @@ -0,0 +1,116 @@ +-- taskwarrior/context.lua — Taskwarrior context support (tw c12b4cbd). +-- +-- `task context work` / `task context none` set a global context in the +-- user's .taskrc; reports honor it automatically. TW 3.x does NOT apply the +-- context to `export`, which is how this plugin reads everything — so the +-- render path injects the active context's read filter itself via +-- M.filter_tokens(). The injected tokens end up in the rendered taskmd +-- header, which keeps the save/apply path consistent with what was rendered +-- (no false external-delete conflicts when a context hides tasks). + +local M = {} +local command = require("taskwarrior.command") +local notify = require("taskwarrior.notify") + +-- Current context name, or nil when unset. +function M.current() + local result = command.read({ "_get", "rc.context" }) + if not result.ok then return nil end + local name = vim.trim(result.output or "") + -- Context names never contain whitespace — anything else is CLI chatter. + if name == "" or name:find("%s") then return nil end + return name +end + +-- All defined context names. +function M.list() + local result = command.read({ "_context" }) + if not result.ok then return {} end + local out = {} + for line in (result.output or ""):gmatch("[^\r\n]+") do + local v = vim.trim(line) + if v ~= "" and not v:find("%s") then out[#out + 1] = v end + end + return out +end + +-- Read filter of the named (default: active) context, or nil. +function M.read_filter(name) + name = name or M.current() + if not name then return nil end + local result = command.read({ "_get", "rc.context." .. name .. ".read" }) + local f = result.ok and vim.trim(result.output or "") or "" + if f == "" then + -- Contexts defined by TW <2.6 live in rc.context. without .read. + result = command.read({ "_get", "rc.context." .. name }) + f = result.ok and vim.trim(result.output or "") or "" + end + if f == "" then return nil end + return f +end + +-- Tokens to append to a render filter so task buffers honor the active +-- context. Parenthesized so `or`-filters compose with the rest. Skipped for +-- uuid-targeted filters: opening one specific task must never come up empty +-- because a context hides it. +function M.filter_tokens(existing_filter) + if type(existing_filter) == "string" and existing_filter:find("uuid[%.:=]") then + return {} + end + local f = M.read_filter() + if not f then return {} end + local tokens = { "(" } + for w in f:gmatch("%S+") do tokens[#tokens + 1] = w end + tokens[#tokens + 1] = ")" + return tokens +end + +local function refresh_all_task_buffers() + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(b) and vim.b[b].task_filter ~= nil then + pcall(function() require("taskwarrior.buffer").refresh_buf(b) end) + end + end +end + +-- Show the active context and the available ones. +function M.show() + local cur = M.current() + local names = M.list() + local lines = {} + if cur then + lines[1] = ("taskwarrior.nvim: context %s (%s)"):format(cur, M.read_filter(cur) or "?") + else + lines[1] = "taskwarrior.nvim: no context active" + end + if #names > 0 then + lines[#lines + 1] = "available: " .. table.concat(names, ", ") .. ", none" + else + lines[#lines + 1] = "none defined — create one with: task context define work project:work" + end + notify("view", table.concat(lines, "\n")) +end + +-- Set (or clear, with "none") the context, then refresh open task buffers. +function M.set(name) + name = vim.trim(name or "") + if name == "" then return M.show() end + -- `task context none` exits 2 when no context was set ("Context not + -- unset.") — clearing an already-clear context is a no-op, not an error. + local ok_codes = name == "none" and { 0, 1, 2 } or nil + local result = command.mutate({ "context", name }, { ok_codes = ok_codes }) + if not result.ok then + notify("error", "taskwarrior.nvim: context failed\n" .. (result.output or ""), + vim.log.levels.ERROR) + return + end + if name == "none" then + notify("view", "taskwarrior.nvim: context cleared") + else + notify("view", ("taskwarrior.nvim: context %s (%s)"):format( + name, M.read_filter(name) or "?")) + end + refresh_all_task_buffers() +end + +return M diff --git a/lua/taskwarrior/init.lua b/lua/taskwarrior/init.lua index 32d9404..403c4e7 100644 --- a/lua/taskwarrior/init.lua +++ b/lua/taskwarrior/init.lua @@ -181,6 +181,7 @@ function M.graph() require("taskwarrior.graph").open() end function M.inbox() require("taskwarrior.inbox").run() end function M.export(path) require("taskwarrior.export").write(path) end function M.sync() require("taskwarrior.sync").run() end +function M.context(name) require("taskwarrior.context").set(name) end -- Omnifunc bridge — capture buffer sets omnifunc to a v:lua expression that -- needs this method on the top-level require("taskwarrior") module. diff --git a/tests/e2e/spec/context_e2e_spec.lua b/tests/e2e/spec/context_e2e_spec.lua new file mode 100644 index 0000000..453324b --- /dev/null +++ b/tests/e2e/spec/context_e2e_spec.lua @@ -0,0 +1,116 @@ +-- context_e2e_spec.lua — Taskwarrior context support (tw c12b4cbd), driven +-- against the live task CLI seeded by tests/e2e/run.sh. +-- +-- TW 3.x applies contexts to reports but NOT to `export`, so the plugin +-- injects the active context's read filter into its render path. The +-- injected tokens are part of the rendered header, which is what keeps the +-- save path honest — the last test here is the safety property that saving +-- a context-narrowed buffer must never touch the tasks the context hides. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") +local context = require("taskwarrior.context") + +local function run_task(args) + return vim.fn.system("task rc.bulk=0 rc.confirmation=off rc.verbose=nothing " .. args) +end + +describe("e2e Taskwarrior contexts (tw c12b4cbd)", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + run_task("context define ctxwork project:ctxdemo") + local in_uuid = taskmd.tw_add("inside the context", { project = "ctxdemo" }) + local out_uuid = taskmd.tw_add("outside the context", { project = "ctxother" }) + assert(in_uuid ~= "" and out_uuid ~= "") + + before_each(function() + context.set("none") + end) + + after_each(function() + context.set("none") + end) + + it("set/current/list/read_filter round-trip", function() + assert.is_nil(context.current()) + context.set("ctxwork") + assert.are.same("ctxwork", context.current()) + assert.are.same("project:ctxdemo", context.read_filter()) + local names = context.list() + local found = false + for _, n in ipairs(names) do if n == "ctxwork" then found = true end end + assert.is_true(found, "ctxwork missing from context.list(): " .. vim.inspect(names)) + context.set("none") + assert.is_nil(context.current()) + end) + + it("task buffer honors the active context's read filter", function() + context.set("ctxwork") + vim.cmd("enew") + require("taskwarrior").open("") + vim.wait(200, function() return false end, 10) + local text = table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), "\n") + assert.is_truthy(text:find(in_uuid:sub(1, 8), 1, true), + "context task missing from buffer") + assert.is_nil(text:find(out_uuid:sub(1, 8), 1, true), + "task outside the context leaked into the buffer") + end) + + it("uuid-targeted filters are never context-narrowed", function() + context.set("ctxwork") + vim.cmd("enew") + require("taskwarrior").open("uuid:" .. out_uuid) + vim.wait(200, function() return false end, 10) + local text = table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), "\n") + assert.is_truthy(text:find(out_uuid:sub(1, 8), 1, true), + "uuid-filtered buffer came up empty under an active context") + end) + + it("saving a context-narrowed buffer does not touch hidden tasks", function() + context.set("ctxwork") + vim.cmd("enew") + require("taskwarrior").open("") + vim.wait(200, function() return false end, 10) + local bufnr = vim.api.nvim_get_current_buf() + + -- Edit the in-context task's description, then save with auto-accept. + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for i, line in ipairs(lines) do + if line:find(in_uuid:sub(1, 8), 1, true) then + lines[i] = line:gsub("inside the context", "inside the context edited") + end + end + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + + local orig_select = vim.ui.select + vim.ui.select = function(items, _, cb) cb(items[1], 1) end + vim.cmd("silent write") + vim.wait(3000, function() + local t = taskmd.shell_export("uuid:" .. in_uuid)[1] + return t and t.description == "inside the context edited" + end, 20) + vim.ui.select = orig_select + + local hidden = taskmd.shell_export("uuid:" .. out_uuid)[1] + assert.is_truthy(hidden, "hidden task vanished entirely") + assert.are.same("pending", hidden.status, + "task hidden by the context was mutated by the save") + local edited = taskmd.shell_export("uuid:" .. in_uuid)[1] + assert.are.same("inside the context edited", edited.description) + end) + + it(":TwContext command is registered with completion", function() + local prefix = require("taskwarrior.config").options.command_prefix or "Tw" + local cmds = vim.api.nvim_get_commands({}) + assert.is_truthy(cmds[prefix .. "Context"], ":" .. prefix .. "Context not registered") + vim.cmd(prefix .. "Context ctxwork") + vim.wait(200, function() return context.current() == "ctxwork" end, 10) + assert.are.same("ctxwork", context.current()) + vim.cmd(prefix .. "Context none") + vim.wait(200, function() return context.current() == nil end, 10) + assert.is_nil(context.current()) + end) +end) From aaba71d2b940ee1dc0561d8cef1973f61fbff705 Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 17:56:14 -0700 Subject: [PATCH 05/15] feat: annotations and field coloring in the quick-capture window The capture form was a single uncolored line (tw 14d722e8). Now: - lines below the first become annotations on the created task; opens one, still submits everything. capture_annotations = false opts out, capture_height sizes the form. - the capture buffer runs the task buffer's highlighter, so project:, priority:, due:, +tags and UDAs are colored as you type - every remaining `field:value` token gets the new neutral TaskField group, so a UDA never renders as plain description text, and the new field_colors option overrides any field by name (mirrors tag_colors). Clock times (09:30) and URL schemes are excluded. Also fixed: discard-confirm and its cursor restore only looked at line 1, so annotation text could be dropped without a prompt. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- README.md | 7 +- doc/taskwarrior.txt | 6 +- lua/taskwarrior/buffer.lua | 40 +++++- lua/taskwarrior/capture.lua | 72 ++++++++++- lua/taskwarrior/config.lua | 15 ++- lua/taskwarrior/validate.lua | 18 +++ tests/e2e/spec/capture_rich_e2e_spec.lua | 158 +++++++++++++++++++++++ 7 files changed, 307 insertions(+), 9 deletions(-) create mode 100644 tests/e2e/spec/capture_rich_e2e_spec.lua diff --git a/README.md b/README.md index 0ac539b..497c558 100644 --- a/README.md +++ b/README.md @@ -231,7 +231,12 @@ require("taskwarrior").setup({ icons = true, -- nerd font checkbox/header icons border_style = "rounded", -- "rounded" | "single" | "double" | "none" capture_width = nil, -- quick-capture width (nil = auto) - capture_height = 3, -- quick-capture height in lines + capture_height = 3, -- quick-capture height in lines; line 1 is the + -- task, lines below it become annotations + capture_annotations = true, -- false = ignore everything below line 1 + field_colors = {}, -- per-field highlight override, e.g. + -- { utility = "DiagnosticInfo" }. Fields not + -- listed still get the neutral TaskField group auto_backup = true, -- copy ~/.task to stdpath("data")/taskwarrior.nvim/backups/ before apply auto_backup_keep = 10, -- number of recent backups to retain delegate = { diff --git a/doc/taskwarrior.txt b/doc/taskwarrior.txt index 3174197..011187d 100644 --- a/doc/taskwarrior.txt +++ b/doc/taskwarrior.txt @@ -400,7 +400,9 @@ Full schema with defaults: > icons = true, border_style = "rounded", capture_width = nil, - capture_height = 3, + capture_height = 3, -- line 1 = task, lines below it become + -- annotations ( opens one) + capture_annotations = true, -- false = ignore lines below line 1 capture_confirm_close = true, -- prompt before discarding text on group_separator = true, animation = true, @@ -420,6 +422,8 @@ Full schema with defaults: > }, -- Per-tag highlight overrides (see |taskwarrior-tag-colors|). tag_colors = {}, -- e.g. { ["+urgent"] = "ErrorMsg" } + field_colors = {}, -- e.g. { utility = "DiagnosticInfo" }; + -- unlisted fields get `TaskField` -- Urgency breakpoints for virtual text + views (|taskwarrior-urgency-colors|). urgency_colors = { { threshold = 8, hl = "TaskPriorityH" }, diff --git a/lua/taskwarrior/buffer.lua b/lua/taskwarrior/buffer.lua index 4b47fe0..b523aaf 100644 --- a/lua/taskwarrior/buffer.lua +++ b/lua/taskwarrior/buffer.lua @@ -604,6 +604,10 @@ local function define_highlights() -- Overdue right-align pill uses an inverted-fg style so it pops against -- normal right-align virt-text. vim.api.nvim_set_hl(0, "TaskOverdueBadge",{ fg = "#1e1e2e", bg = "#f38ba8", bold = true }) + -- Catch-all for any other `field:value` (UDAs, depends:, until:, …) so no + -- field renders as plain description text. Per-field overrides go through + -- config.field_colors. + vim.api.nvim_set_hl(0, "TaskField", { link = "TaskSubtle" }) end -- Highlight patterns: { lua pattern, highlight group, is_prefix_match } @@ -696,6 +700,41 @@ local function highlight_line(bufnr, line_nr, line) pos = e + 1 end + local config = require("taskwarrior.config") + + -- Every remaining `field:value` token — UDAs (utility:20), depends:, + -- until:, and any field the specific patterns below don't cover. Painted + -- FIRST so the specific patterns overwrite it where they apply, and so + -- an unknown field never reads as plain description text. Per-field + -- overrides come from config.field_colors, keyed by field name. + local field_colors = config.options.field_colors or {} + pos = 1 + while true do + local s, e, name = line:find("([%w_]+):%S+", pos) + if not s then break end + local prev = s > 1 and line:sub(s - 1, s - 1) or "" + local value = line:sub(s + #name + 1, e) + -- Require a token boundary, reject numeric names ("12:30" is a clock + -- time) and `//` values (URL schemes) — neither is a Taskwarrior field. + if (prev == "" or prev:match("%s")) + and not name:match("^%d") + and not value:match("^//") then + local hl_group = "TaskField" + local override = field_colors[name] + if type(override) == "string" then + hl_group = override + elseif type(override) == "table" then + local gname = "TaskField_" .. name:gsub("[^%w]", "_") + pcall(vim.api.nvim_set_hl, 0, gname, override) + hl_group = gname + end + vim.api.nvim_buf_set_extmark(bufnr, hl_ns, line_nr, s - 1, { + end_col = e, hl_group = hl_group, + }) + end + pos = e + 1 + end + -- All other patterns for _, pat in ipairs(HL_PATTERNS) do pos = 1 @@ -712,7 +751,6 @@ local function highlight_line(bufnr, line_nr, line) -- Tags: +word, but only when the `+` is at start-of-line or preceded by a -- non-word character. Prevents "housing+food" from highlighting "+food". -- Honors config.tag_colors for per-tag overrides (e.g. +urgent → ErrorMsg). - local config = require("taskwarrior.config") local tag_colors = config.options.tag_colors or {} pos = 1 while true do diff --git a/lua/taskwarrior/capture.lua b/lua/taskwarrior/capture.lua index bcc7829..76ce2a5 100644 --- a/lua/taskwarrior/capture.lua +++ b/lua/taskwarrior/capture.lua @@ -45,6 +45,14 @@ function M.open(refresh_fn) vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "" }) vim.cmd("startinsert") + -- Color project:/+tag/due:/priority:/UDA fields as they're typed, using the + -- same highlighter the task buffer uses (tw 14d722e8). Re-runs on every + -- text change so the coloring tracks what you're typing. + local ok_buffer, tw_buffer = pcall(require, "taskwarrior.buffer") + if ok_buffer then + pcall(tw_buffer.setup_buf_syntax, buf) + end + -- Close is always deferred via vim.schedule: cmp's keymap solver (and other -- expr-mapping wrappers) can invoke our callbacks from inside a textlock -- context where nvim_win_close raises E565. Scheduling moves the close to @@ -65,8 +73,10 @@ function M.open(refresh_fn) local function close_with_confirm() vim.schedule(function() if config.options.capture_confirm_close ~= false then + -- Check every line, not just the first: annotation lines below it + -- are unsubmitted content too. local line = vim.api.nvim_buf_is_valid(buf) - and (vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or "") + and table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") or "" if line:match("%S") then -- vim.fn.confirm requires a non-textlock context; we're already @@ -76,7 +86,9 @@ function M.open(refresh_fn) -- Restore insert mode at the line's end so typing resumes naturally. if vim.api.nvim_win_is_valid(win) then vim.api.nvim_set_current_win(win) - vim.api.nvim_win_set_cursor(win, { 1, #line }) + local last = vim.api.nvim_buf_line_count(buf) + local last_text = vim.api.nvim_buf_get_lines(buf, last - 1, last, false)[1] or "" + vim.api.nvim_win_set_cursor(win, { last, #last_text }) vim.cmd("startinsert!") end return @@ -97,8 +109,38 @@ function M.open(refresh_fn) return vim.fn.strcharpart(text, 0, max) .. "…" end - local function submit(line) + -- Lines below the first become annotations on the new task (tw 14d722e8). + -- Set capture_annotations = false to ignore them instead. + local function annotate_extra_lines(uuid, extra) + if not uuid or uuid == "" or #extra == 0 then return 0 end + local done = 0 + for _, text in ipairs(extra) do + local result = command.mutate({ uuid, "annotate", "--", text }) + if result.ok then + done = done + 1 + else + vim.notify("taskwarrior.nvim: annotate failed\n" .. (result.output or ""), + vim.log.levels.ERROR) + end + end + return done + end + + local function extra_lines() + if config.options.capture_annotations == false then return {} end + if not vim.api.nvim_buf_is_valid(buf) then return {} end + local all = vim.api.nvim_buf_get_lines(buf, 1, -1, false) + local out = {} + for _, l in ipairs(all) do + local t = vim.trim(l) + if t ~= "" then out[#out + 1] = t end + end + return out + end + + local function submit(line, extra) if not line or line == "" then return end + extra = extra or {} -- Greedy-parse the line so utility:20, project:X, +tag, due:tom etc. -- become real fields even when they appear in the middle of free-form @@ -117,7 +159,13 @@ function M.open(refresh_fn) if desc and desc ~= "" then local new_uuid, add_ok = tm.tw_add(desc, fields) if new_uuid and new_uuid ~= "" then - vim.notify(('taskwarrior.nvim: added "%s"'):format(snippet(desc))) + local annotated = annotate_extra_lines(new_uuid, extra) + local msg = ('taskwarrior.nvim: added "%s"'):format(snippet(desc)) + if annotated > 0 then + msg = msg .. (" (+%d annotation%s)"):format( + annotated, annotated == 1 and "" or "s") + end + vim.notify(msg) elseif add_ok then vim.notify( "taskwarrior.nvim: added task, but Taskwarrior did not report its UUID; not retrying", @@ -157,14 +205,28 @@ function M.open(refresh_fn) return "" end local line = vim.api.nvim_buf_get_lines(buf, 0, 1, false)[1] or "" + local extra = extra_lines() -- Defer close + submit to escape any active textlock (nvim-cmp, etc.). vim.schedule(function() close() - submit(line) + submit(line, extra) end) return "" end, { buffer = buf, expr = true }) + -- / open a new line below for an annotation without + -- submitting — stays "submit everything" from any line. + local function open_annotation_line() + local last = vim.api.nvim_buf_line_count(buf) + vim.api.nvim_buf_set_lines(buf, last, last, false, { "" }) + if vim.api.nvim_win_is_valid(win) then + vim.api.nvim_win_set_cursor(win, { last + 1, 0 }) + end + vim.cmd("startinsert!") + end + vim.keymap.set("i", "", open_annotation_line, { buffer = buf }) + vim.keymap.set("i", "", open_annotation_line, { buffer = buf }) + vim.keymap.set("i", "", close_with_confirm, { buffer = buf }) vim.keymap.set("n", "", close_with_confirm, { buffer = buf }) vim.keymap.set("n", "q", close_with_confirm, { buffer = buf }) diff --git a/lua/taskwarrior/config.lua b/lua/taskwarrior/config.lua index 6bfd837..c4a5380 100644 --- a/lua/taskwarrior/config.lua +++ b/lua/taskwarrior/config.lua @@ -43,7 +43,13 @@ M.defaults = { icons = true, border_style = "rounded", -- border style for floating windows: "rounded", "single", "double", "none" capture_width = nil, -- quick-capture window width (nil = auto: min(80, 60% of editor)) - capture_height = 3, -- quick-capture window height (lines visible; task is still 1 line) + -- Quick-capture window height. Line 1 is the task; every line below it + -- becomes an annotation on save ( opens one). Raise this for a + -- roomier capture form. + capture_height = 3, + -- Turn the capture window's extra lines into annotations. Set false to + -- ignore everything below line 1. + capture_annotations = true, -- When the quick-capture window has unsubmitted text and the user presses -- , ask before discarding instead of closing silently. Set to false -- to restore the pre-1.4 always-close behavior. @@ -85,6 +91,13 @@ M.defaults = { -- nvim_set_hl (e.g. `{ fg = "#f38ba8", bold = true }`). -- tag_colors = { ["+urgent"] = "ErrorMsg", ["+someday"] = "Comment" } tag_colors = {}, + -- Per-field highlight overrides, keyed by field name (no colon). Applies + -- in task buffers AND the quick-capture window. Value is a highlight + -- group name or a table passed to nvim_set_hl. Any field not listed here + -- still gets the neutral `TaskField` group, so UDAs are never rendered + -- as plain description text. + -- field_colors = { utility = "DiagnosticInfo", depends = { fg = "#f38ba8" } } + field_colors = {}, -- Urgency color breakpoints used by task buffer virtual text and view -- renderers. Rows are evaluated top-to-bottom; the first row whose -- `threshold` is `<=` the urgency wins. The defaults reproduce the diff --git a/lua/taskwarrior/validate.lua b/lua/taskwarrior/validate.lua index 15fba07..732fa20 100644 --- a/lua/taskwarrior/validate.lua +++ b/lua/taskwarrior/validate.lua @@ -10,6 +10,7 @@ local KNOWN_KEYS = { "capture_key", "open_key", "filter_key", "sort_key", "group_key", "project_add_key", "filters", "projects", "icons", "border_style", "capture_width", "capture_height", "capture_confirm_close", + "capture_annotations", "field_colors", "group_separator", "animation", "clamp_cursor", "day_start_hour", "urgency_coefficients", "urgency_value_mappers", "custom_urgency", "auto_backup", "auto_backup_keep", @@ -47,6 +48,8 @@ local TOP_LEVEL_TYPES = { capture_width = "number", -- nil OK capture_height = "number", capture_confirm_close = "boolean", + capture_annotations = "boolean", + field_colors = "table", group_separator = "boolean", animation = "boolean", clamp_cursor = "boolean", @@ -281,6 +284,21 @@ function M.validate(opts) end end + -- 8b. Nested: field_colors — same value contract as tag_colors, keyed by + -- field name without the trailing colon. + if opts.field_colors ~= nil then + for field, val in pairs(opts.field_colors) do + if type(val) ~= "string" and type(val) ~= "table" then + error( + ("taskwarrior.nvim: field_colors['%s'] must be a string or table, got %s"):format( + tostring(field), type(val) + ), + 0 + ) + end + end + end + -- 9. Nested: urgency_colors — list of { threshold = number, hl = string|table }. if opts.urgency_colors ~= nil then for i, entry in ipairs(opts.urgency_colors) do diff --git a/tests/e2e/spec/capture_rich_e2e_spec.lua b/tests/e2e/spec/capture_rich_e2e_spec.lua new file mode 100644 index 0000000..0938838 --- /dev/null +++ b/tests/e2e/spec/capture_rich_e2e_spec.lua @@ -0,0 +1,158 @@ +-- capture_rich_e2e_spec.lua — the roomier, colored capture form (tw 14d722e8). +-- +-- Two properties, both observed rather than asserted-by-existence: +-- * lines below the first become real Taskwarrior annotations on save +-- (verified via `task export`), and capture_annotations = false opts out +-- * fields typed into the capture buffer get highlight extmarks — the +-- specific groups for known fields, the neutral TaskField group for +-- UDAs, and config.field_colors overrides on top + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") + +local function open_capture() + require("taskwarrior").capture() + vim.wait(100, function() return false end, 10) + return vim.api.nvim_get_current_buf(), vim.api.nvim_get_current_win() +end + +local function press_enter(buf) + for _, m in ipairs(vim.api.nvim_buf_get_keymap(buf, "i")) do + if m.lhs == "" and m.callback then + m.callback() + return true + end + end + return false +end + +-- Highlight groups present on a line, keyed by the text they cover. +local function highlights_on(buf, line_nr) + require("taskwarrior.buffer").update_highlights(buf) + local line = vim.api.nvim_buf_get_lines(buf, line_nr, line_nr + 1, false)[1] or "" + local marks = vim.api.nvim_buf_get_extmarks(buf, -1, { line_nr, 0 }, + { line_nr, -1 }, { details = true }) + local out = {} + for _, m in ipairs(marks) do + local d = m[4] or {} + if d.hl_group and d.end_col then + out[line:sub(m[3] + 1, d.end_col)] = d.hl_group + end + end + return out +end + +describe("e2e rich capture (tw 14d722e8)", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + local config = require("taskwarrior.config") + + it("extra lines become annotations on the created task", function() + local buf, win = open_capture() + local desc = ("annotated capture %d"):format(math.random(1, 1e9)) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + desc .. " project:annotest", + "first annotation line", + "", + "second annotation line", + }) + + assert.is_true(press_enter(buf)) + vim.wait(5000, function() + local t = (taskmd.shell_export("project:annotest") or {})[1] + return t and t.annotations and #t.annotations == 2 + end, 20) + pcall(vim.api.nvim_win_close, win, true) + + local tasks = taskmd.shell_export("project:annotest") or {} + local stored + for _, t in ipairs(tasks) do + if t.description == desc then stored = t end + end + assert.is_truthy(stored, "captured task not found") + local texts = {} + for _, a in ipairs(stored.annotations or {}) do + texts[#texts + 1] = a.description + end + table.sort(texts) + assert.are.same({ "first annotation line", "second annotation line" }, texts, + "extra capture lines did not become annotations") + end) + + it("capture_annotations = false ignores the extra lines", function() + local orig = config.options.capture_annotations + config.options.capture_annotations = false + + local buf, win = open_capture() + local desc = ("no annotations %d"):format(math.random(1, 1e9)) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + desc .. " project:noannotest", + "this line should be dropped", + }) + assert.is_true(press_enter(buf)) + vim.wait(5000, function() + local t = (taskmd.shell_export("project:noannotest") or {})[1] + return t ~= nil + end, 20) + vim.wait(300, function() return false end, 10) + config.options.capture_annotations = orig + pcall(vim.api.nvim_win_close, win, true) + + local stored + for _, t in ipairs(taskmd.shell_export("project:noannotest") or {}) do + if t.description == desc then stored = t end + end + assert.is_truthy(stored, "captured task not found") + assert.are.same(0, #(stored.annotations or {}), + "annotations were created despite capture_annotations = false") + end) + + it("colors known fields, tags, and UDAs in the capture buffer", function() + local buf, win = open_capture() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + "buy milk project:home priority:H +errand-run utility:20", + }) + local hls = highlights_on(buf, 0) + pcall(vim.api.nvim_win_close, win, true) + + assert.are.same("TaskProject", hls["project:home"], + "project: not colored in capture buffer: " .. vim.inspect(hls)) + assert.are.same("TaskPriorityH", hls["priority:H"], + "priority: not colored: " .. vim.inspect(hls)) + assert.are.same("TaskTag", hls["+errand-run"], + "hyphenated tag not colored: " .. vim.inspect(hls)) + assert.are.same("TaskField", hls["utility:20"], + "UDA field not colored with the neutral group: " .. vim.inspect(hls)) + end) + + it("honors config.field_colors overrides", function() + local orig = config.options.field_colors + config.options.field_colors = { utility = "ErrorMsg" } + + local buf, win = open_capture() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "task utility:20" }) + local hls = highlights_on(buf, 0) + config.options.field_colors = orig + pcall(vim.api.nvim_win_close, win, true) + + assert.are.same("ErrorMsg", hls["utility:20"], + "field_colors override not applied: " .. vim.inspect(hls)) + end) + + it("does not color clock times or URLs as fields", function() + local buf, win = open_capture() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "standup at 09:30 see https://x.dev" }) + local hls = highlights_on(buf, 0) + pcall(vim.api.nvim_win_close, win, true) + + for text, group in pairs(hls) do + assert.is_nil(text:match("^09:30"), + "clock time was colored as a field (" .. group .. ")") + assert.is_nil(text:match("^https:"), + "URL was colored as a field (" .. group .. ")") + end + end) +end) From 100edcd9f3b955749b176691afaf6e43b23e2178 Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 18:00:23 -0700 Subject: [PATCH 06/15] =?UTF-8?q?feat:=20:TwTable=20=E2=80=94=20vit-style?= =?UTF-8?q?=20configurable=20column=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One row per task with aligned columns so every data modality is visible at once (tw 6e9f911d), sorted by urgency (or custom_urgency). opens the task under the cursor in an editable :Tw buffer, r refreshes, q closes; the view itself is read-only. Columns come from the new table_columns option — field name or { field, label, width, align, format }. Omitting width on one column makes it absorb the leftover window width; cells longer than their column are ellipsized rather than overflowed. Unrecognised field names are read straight off the exported task, so UDA columns need no wiring. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- README.md | 10 + doc/taskwarrior.txt | 28 ++- lua/taskwarrior/commands.lua | 8 + lua/taskwarrior/config.lua | 12 ++ lua/taskwarrior/init.lua | 1 + lua/taskwarrior/table_view.lua | 265 +++++++++++++++++++++++++ lua/taskwarrior/validate.lua | 37 +++- lua/taskwarrior/views.lua | 5 + tests/e2e/spec/table_view_e2e_spec.lua | 142 +++++++++++++ 9 files changed, 506 insertions(+), 2 deletions(-) create mode 100644 lua/taskwarrior/table_view.lua create mode 100644 tests/e2e/spec/table_view_e2e_spec.lua diff --git a/README.md b/README.md index 497c558..9fbebfa 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ These are bundled but require their own host plugins. | `:TwSummary` | Per-project stats | | `:TwCalendar` | Tasks grouped by due date | | `:TwTags` | Tag-frequency view | +| `:TwTable [filter]` | Vit-style column table; columns configurable via `table_columns`. `` opens that task for editing | | `:TwProjectAdd [name]` / `:TwProjectRemove` / `:TwProjectList` | Auto-project mapping | | `:TwTutor [reset]` | Open the interactive tutorial; `reset` ends an active session and cleans up orphan temp dirs | | `:TwFeedback [last-error]` | Open the structured bug-report form; `last-error` pre-fills with the most recent ERROR captured by the plugin | @@ -234,6 +235,15 @@ require("taskwarrior").setup({ capture_height = 3, -- quick-capture height in lines; line 1 is the -- task, lines below it become annotations capture_annotations = true, -- false = ignore everything below line 1 + table_columns = nil, -- :TwTable columns; nil = built-in defaults. + -- Entries are field names or tables: + -- { field, label, width, align, format }. + -- Omit width on one column to let it absorb + -- the leftover space. Unknown fields fall + -- through to the task, so UDAs just work: + -- { "id", { field = "utility", width = 5, + -- align = "right" }, + -- "description", "tags" } field_colors = {}, -- per-field highlight override, e.g. -- { utility = "DiagnosticInfo" }. Fields not -- listed still get the neutral TaskField group diff --git a/doc/taskwarrior.txt b/doc/taskwarrior.txt index 011187d..0de9f07 100644 --- a/doc/taskwarrior.txt +++ b/doc/taskwarrior.txt @@ -129,7 +129,8 @@ Edit any line. `:w` to sync. That is the whole loop. *:TwSummary* *:TwCalendar* *:TwTags* -Visualisation commands. See |taskwarrior-views|. + *:TwTable* +Visualisation commands. See |taskwarrior-views| and |taskwarrior-table|. *:TwProjectAdd* *:TwProjectRemove* @@ -618,9 +619,34 @@ are supported. :TwSummary Per-project counts + horizontal bar chart. :TwCalendar Tasks grouped by due date. :TwTags Tag frequency. +:TwTable [filter] Vit-style column table of tasks. All read from `task export` — no external charting tools. + *taskwarrior-table* +:TwTable renders one row per task with aligned columns, sorted by urgency +(or `custom_urgency` when set). `` opens the task under the cursor in +an editable :Tw buffer, `r` refreshes, `q` closes. The view itself is +read-only. + +Columns come from the `table_columns` option. Each entry is a field name, +or a table: > + + table_columns = { + "id", + { field = "utility", label = "Util", width = 5, align = "right" }, + { field = "description" }, -- no width: absorbs leftover space + { field = "tags", label = "Count", width = 6, + format = function(value, task) return tostring(#(value or {})) end }, + } +< +`width` omitted on exactly one column makes it take the remaining window +width; cells longer than their column are ellipsized, never overflowed. +Field names not recognised as built-ins are read straight off the exported +task, so UDA columns need no extra wiring. `format(value, task)` renders +the cell yourself. Leave `table_columns` nil for the built-in defaults +(ID, urgency, priority, project, due, description, tags). + ============================================================================== 10. SAVED VIEWS *taskwarrior-saved-views* diff --git a/lua/taskwarrior/commands.lua b/lua/taskwarrior/commands.lua index 56991c0..825fd4a 100644 --- a/lua/taskwarrior/commands.lua +++ b/lua/taskwarrior/commands.lua @@ -201,6 +201,14 @@ function M.setup(main, complete_filter) views.tags() end, { nargs = 0, desc = "Show tag distribution" }) + register("Table", function(o) + require("taskwarrior.table_view").open(o.args) + end, { + nargs = "*", + desc = "Show tasks as a configurable column table", + complete = function(arg_lead) return complete_filter(arg_lead) end, + }) + -- Structured feedback buffer. Default config: GitHub-issue + clipboard -- always available; HTTP "Send" only when feedback_endpoint is set. -- :Feedback open the empty form diff --git a/lua/taskwarrior/config.lua b/lua/taskwarrior/config.lua index c4a5380..4c07350 100644 --- a/lua/taskwarrior/config.lua +++ b/lua/taskwarrior/config.lua @@ -54,6 +54,18 @@ M.defaults = { -- , ask before discarding instead of closing silently. Set to false -- to restore the pre-1.4 always-close behavior. capture_confirm_close = true, + -- Columns for the :TwTable view (vit-style). Each entry is a field name, + -- or a table { field, label, width, align, format }. `width` omitted on + -- exactly one column makes it absorb the leftover width (usually + -- description). `format = function(value, task) -> string` renders the + -- cell yourself. Unknown fields fall through to the exported task, so + -- UDA columns need no extra wiring: + -- table_columns = { + -- "id", { field = "utility", label = "Util", width = 5, align = "right" }, + -- { field = "description" }, "tags", + -- } + -- nil = the built-in default columns (see table_view.DEFAULT_COLUMNS). + table_columns = nil, group_separator = true, -- show separator lines between groups animation = true, -- enable open/transition animations clamp_cursor = true, -- clamp cursor before UUID comment (prevents invisible cursor movement) diff --git a/lua/taskwarrior/init.lua b/lua/taskwarrior/init.lua index 403c4e7..6c63e8f 100644 --- a/lua/taskwarrior/init.lua +++ b/lua/taskwarrior/init.lua @@ -182,6 +182,7 @@ function M.inbox() require("taskwarrior.inbox").run() end function M.export(path) require("taskwarrior.export").write(path) end function M.sync() require("taskwarrior.sync").run() end function M.context(name) require("taskwarrior.context").set(name) end +function M.table_view(f) require("taskwarrior.table_view").open(f) end -- Omnifunc bridge — capture buffer sets omnifunc to a v:lua expression that -- needs this method on the top-level require("taskwarrior") module. diff --git a/lua/taskwarrior/table_view.lua b/lua/taskwarrior/table_view.lua new file mode 100644 index 0000000..d6bd934 --- /dev/null +++ b/lua/taskwarrior/table_view.lua @@ -0,0 +1,265 @@ +-- taskwarrior/table_view.lua — :TwTable, a vit-style column view (tw 6e9f911d). +-- +-- Renders tasks as aligned columns so every data modality (id, urgency, +-- project, priority, due, tags, description, UDAs) is visible at once. +-- Columns come from config.table_columns, so users can add UDA columns, +-- reorder, relabel, resize, or plug in their own formatter. +-- +-- Read-only by design: this is a *view*. Editing still happens in the :Tw +-- markdown buffer, which opens for the task under the cursor. + +local M = {} + +local views = require("taskwarrior.views") + +-- Columns rendered when config.table_columns is unset. +M.DEFAULT_COLUMNS = { + { field = "id", label = "ID", width = 4, align = "right" }, + { field = "urgency", label = "Urg", width = 5, align = "right" }, + { field = "priority", label = "P", width = 1 }, + { field = "project", label = "Project", width = 14 }, + { field = "due", label = "Due", width = 10 }, + { field = "description", label = "Description" }, -- no width = takes the rest + { field = "tags", label = "Tags", width = 18 }, +} + +local function tw_date_to_ymd(val) + if type(val) ~= "string" then return nil end + local y, mo, d = val:match("^(%d%d%d%d)(%d%d)(%d%d)T") + if y then return ("%s-%s-%s"):format(y, mo, d) end + return val:match("^%d%d%d%d%-%d%d%-%d%d") and val:sub(1, 10) or nil +end + +-- Raw cell text for one column of one task. Unknown fields fall through to +-- the task table, so UDA columns work with no extra configuration. +local function cell_value(task, col) + local field = col.field + local raw = task[field] + if col.format then + local ok, out = pcall(col.format, raw, task) + if ok then return tostring(out or "") end + return "" + end + if field == "id" then + return task.id and task.id ~= 0 and tostring(task.id) or "" + elseif field == "uuid" then + return (task.uuid or ""):sub(1, 8) + elseif field == "urgency" then + return ("%.1f"):format(tonumber(task.urgency) or 0) + elseif field == "tags" then + return type(raw) == "table" and table.concat(raw, ",") or "" + elseif field == "annotations" then + return type(raw) == "table" and #raw > 0 and ("+" .. #raw) or "" + elseif field == "status" and task.start then + return "started" + elseif field == "due" or field == "scheduled" or field == "wait" + or field == "entry" or field == "until" or field == "end" then + return tw_date_to_ymd(raw) or (raw and tostring(raw) or "") + end + if raw == nil then return "" end + if type(raw) == "table" then return table.concat(raw, ",") end + return tostring(raw) +end + +local function pad(text, width, align) + local w = vim.fn.strdisplaywidth(text) + if w > width then + return width > 1 and (vim.fn.strcharpart(text, 0, width - 1) .. "…") + or vim.fn.strcharpart(text, 0, width) + end + local fill = string.rep(" ", width - w) + if align == "right" then return fill .. text end + return text .. fill +end + +-- Normalize a user column spec (string or table) into the internal form. +local function normalize_columns(cols) + local out = {} + for _, c in ipairs(cols or {}) do + if type(c) == "string" then + out[#out + 1] = { field = c, label = c:sub(1, 1):upper() .. c:sub(2) } + elseif type(c) == "table" and c.field then + out[#out + 1] = { + field = c.field, + label = c.label or (c.field:sub(1, 1):upper() .. c.field:sub(2)), + width = c.width, + align = c.align, + format = c.format, + } + end + end + return out +end + +-- Resolve widths: explicit width wins; otherwise size to content, and give +-- any leftover space to the flexible column (the first without a width). +local function resolve_widths(columns, tasks, total_width) + local widths, flex = {}, nil + for i, col in ipairs(columns) do + if col.width then + widths[i] = col.width + else + local w = vim.fn.strdisplaywidth(col.label) + for _, t in ipairs(tasks) do + w = math.max(w, vim.fn.strdisplaywidth(cell_value(t, col))) + end + widths[i] = w + if not flex then flex = i end + end + end + if flex then + local used = 0 + for i, w in ipairs(widths) do + if i ~= flex then used = used + w + 1 end + end + -- Always clamp to the leftover room, never to the content width: a + -- 300-char description must not push the row past the window edge. + -- 10 columns is the floor so the cell stays readable when the fixed + -- columns have eaten the whole budget (the row then overflows by + -- design rather than rendering an unusable 1-char cell). + local room = total_width - used - 2 + widths[flex] = math.max(10, math.min(widths[flex], room)) + end + return widths +end + +local function urgency_hl(value) + local u = tonumber(value) or 0 + if u >= 8 then return "TaskViewUrgHigh" end + if u >= 4 then return "TaskViewUrgMed" end + return "TaskViewUrgLow" +end + +-- Highlight group for a rendered cell, or nil for default text. +local function cell_hl(col, text, task) + local f = col.field + if f == "urgency" then return urgency_hl(task.urgency) end + if f == "project" then return text ~= "" and "TaskViewProject" or nil end + if f == "tags" then return text ~= "" and "TaskViewTag" or nil end + if f == "priority" then + if text == "H" then return "TaskViewUrgHigh" end + if text == "M" then return "TaskViewUrgMed" end + return text ~= "" and "TaskViewUrgLow" or nil + end + if f == "due" then + local ymd = tw_date_to_ymd(task.due) + if not ymd then return nil end + if ymd < os.date("!%Y-%m-%d") then return "TaskViewOverdue" end + if ymd == os.date("!%Y-%m-%d") then return "TaskViewToday" end + return "TaskViewDate" + end + return nil +end + +-- Build the buffer content. Returned separately from the window plumbing so +-- tests can assert on the geometry without opening anything. +-- Returns lines, highlights, row_uuids (buffer line index → task uuid). +function M.build(tasks, opts) + opts = opts or {} + local config = require("taskwarrior.config") + local columns = normalize_columns( + opts.columns or config.options.table_columns or M.DEFAULT_COLUMNS) + local total = opts.width or vim.o.columns + local widths = resolve_widths(columns, tasks, total) + + local lines, highlights, row_uuids = {}, {}, {} + + local title = ("taskwarrior.nvim — Table (%s)"):format(opts.filter or "status:pending") + lines[1] = title + highlights[#highlights + 1] = { 0, 0, #title, "TaskViewTitle" } + + local header_cells = {} + for i, col in ipairs(columns) do + header_cells[#header_cells + 1] = pad(col.label, widths[i], col.align) + end + local header = " " .. table.concat(header_cells, " ") + lines[#lines + 1] = header + highlights[#highlights + 1] = { #lines - 1, 0, #header, "TaskViewHeader" } + + local sep = " " .. string.rep("─", math.max(1, vim.fn.strdisplaywidth(header) - 2)) + lines[#lines + 1] = sep + highlights[#highlights + 1] = { #lines - 1, 0, #sep, "TaskViewSeparator" } + + for _, task in ipairs(tasks) do + local parts, col_byte = {}, 2 + local line_hls = {} + for i, col in ipairs(columns) do + local raw = cell_value(task, col) + local text = pad(raw, widths[i], col.align) + local hl = cell_hl(col, vim.trim(text), task) + if hl then + line_hls[#line_hls + 1] = { col_byte, col_byte + #text, hl } + end + parts[#parts + 1] = text + col_byte = col_byte + #text + 1 + end + lines[#lines + 1] = " " .. table.concat(parts, " ") + local li = #lines - 1 + for _, h in ipairs(line_hls) do + highlights[#highlights + 1] = { li, h[1], h[2], h[3] } + end + row_uuids[li] = task.uuid + end + + if #tasks == 0 then + lines[#lines + 1] = " No tasks match this filter." + end + + lines[#lines + 1] = "" + local hint = " open task in a :Tw buffer · r refresh · q close" + lines[#lines + 1] = hint + highlights[#highlights + 1] = { #lines - 1, 0, #hint, "TaskViewHint" } + + return lines, highlights, row_uuids +end + +--- :TwTable [filter] — open (or refresh) the table view. +function M.open(filter) + filter = (filter and filter ~= "") and filter or "status:pending" + local row_uuids = {} + + local function do_render() + local tasks = require("taskwarrior.taskmd").shell_export(filter) + if not tasks then + require("taskwarrior.notify")("error", + "taskwarrior.nvim: table view — export failed", vim.log.levels.ERROR) + return + end + local config = require("taskwarrior.config") + local custom = config.options.custom_urgency + table.sort(tasks, function(a, b) + local ua = custom and (tonumber(custom(a)) or 0) or (tonumber(a.urgency) or 0) + local ub = custom and (tonumber(custom(b)) or 0) or (tonumber(b.urgency) or 0) + if ua == ub then + return (a.description or "") < (b.description or "") + end + return ua > ub + end) + + local lines, highlights, uuids = M.build(tasks, { filter = filter }) + row_uuids = uuids + local bufnr = views._open_scratch( + "taskwarrior.nvim Table", lines, highlights, do_render) + + vim.keymap.set("n", "", function() + local row = vim.api.nvim_win_get_cursor(0)[1] - 1 + local uuid = row_uuids[row] + if not uuid then + require("taskwarrior.notify")("warn", + "taskwarrior.nvim: no task on this line", vim.log.levels.WARN) + return + end + require("taskwarrior").open("uuid:" .. uuid) + end, { buffer = bufnr, noremap = true, silent = true, + desc = "taskwarrior.nvim: open this task in a :Tw buffer" }) + + vim.keymap.set("n", "r", do_render, { buffer = bufnr, noremap = true, + silent = true, desc = "taskwarrior.nvim: refresh the table view" }) + + return bufnr + end + + return do_render() +end + +return M diff --git a/lua/taskwarrior/validate.lua b/lua/taskwarrior/validate.lua index 732fa20..6eae6f5 100644 --- a/lua/taskwarrior/validate.lua +++ b/lua/taskwarrior/validate.lua @@ -10,7 +10,7 @@ local KNOWN_KEYS = { "capture_key", "open_key", "filter_key", "sort_key", "group_key", "project_add_key", "filters", "projects", "icons", "border_style", "capture_width", "capture_height", "capture_confirm_close", - "capture_annotations", "field_colors", + "capture_annotations", "field_colors", "table_columns", "group_separator", "animation", "clamp_cursor", "day_start_hour", "urgency_coefficients", "urgency_value_mappers", "custom_urgency", "auto_backup", "auto_backup_keep", @@ -50,6 +50,7 @@ local TOP_LEVEL_TYPES = { capture_confirm_close = "boolean", capture_annotations = "boolean", field_colors = "table", + table_columns = "table", -- nil OK group_separator = "boolean", animation = "boolean", clamp_cursor = "boolean", @@ -284,6 +285,40 @@ function M.validate(opts) end end + -- 8c. Nested: table_columns — each entry is a field name or a table with + -- a `field` key; width must be a positive number when present. + if opts.table_columns ~= nil then + for i, col in ipairs(opts.table_columns) do + if type(col) == "table" then + if type(col.field) ~= "string" or col.field == "" then + error( + ("taskwarrior.nvim: table_columns[%d].field must be a non-empty string"):format(i), + 0 + ) + end + if col.width ~= nil and (type(col.width) ~= "number" or col.width < 1) then + error( + ("taskwarrior.nvim: table_columns[%d].width must be a number >= 1"):format(i), + 0 + ) + end + if col.format ~= nil and type(col.format) ~= "function" then + error( + ("taskwarrior.nvim: table_columns[%d].format must be a function"):format(i), + 0 + ) + end + elseif type(col) ~= "string" then + error( + ("taskwarrior.nvim: table_columns[%d] must be a string or table, got %s"):format( + i, type(col) + ), + 0 + ) + end + end + end + -- 8b. Nested: field_colors — same value contract as tag_colors, keyed by -- field name without the trailing colon. if opts.field_colors ~= nil then diff --git a/lua/taskwarrior/views.lua b/lua/taskwarrior/views.lua index 4484ed8..8e5ed12 100644 --- a/lua/taskwarrior/views.lua +++ b/lua/taskwarrior/views.lua @@ -107,6 +107,11 @@ local function open_scratch(name, lines, highlights, render_fn) return buf end +-- Exported for taskwarrior.table_view, which is a view like the others but +-- lives in its own module because it carries user-configurable columns. +M._open_scratch = open_scratch +M._define_view_highlights = define_view_highlights + local function tw_date_to_ymd(val) if not val then return nil end if val:match("^%d%d%d%d%d%d%d%dT") then diff --git a/tests/e2e/spec/table_view_e2e_spec.lua b/tests/e2e/spec/table_view_e2e_spec.lua new file mode 100644 index 0000000..f72ab6c --- /dev/null +++ b/tests/e2e/spec/table_view_e2e_spec.lua @@ -0,0 +1,142 @@ +-- table_view_e2e_spec.lua — :TwTable, the vit-style column view (tw 6e9f911d). +-- +-- Asserts the rendered geometry (columns actually align, nothing overflows +-- the window width), that config.table_columns really drives the layout +-- including UDA and custom-format columns, and that the view's opens +-- the right task in an editable :Tw buffer. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") +local table_view = require("taskwarrior.table_view") + +-- Split a rendered row on the 2-space left margin + single-space gutters is +-- ambiguous; instead assert alignment by comparing display columns of the +-- header cells against each row's cells at the same byte offsets. +local function display_width(s) return vim.fn.strdisplaywidth(s) end + +describe("e2e :TwTable (tw 6e9f911d)", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + local config = require("taskwarrior.config") + + local uuid = taskmd.tw_add("table view target", { + project = "tabledemo", priority = "H", tags = { "tbl-one", "tbl-two" }, + }) + assert(uuid ~= "") + + it("renders aligned rows that fit the window width", function() + local tasks = taskmd.shell_export("project:tabledemo") or {} + assert.is_true(#tasks > 0) + local lines = table_view.build(tasks, { filter = "project:tabledemo", width = 100 }) + + for i, line in ipairs(lines) do + assert.is_true(display_width(line) <= 100, + ("line %d overflows the 100-col budget (%d): %q") + :format(i, display_width(line), line)) + end + + -- Header, separator, then one row per task. + assert.is_truthy(lines[2]:find("Description", 1, true), "no Description header") + assert.is_truthy(lines[3]:find("─", 1, true), "no separator row") + local row + for _, l in ipairs(lines) do + if l:find("table view target", 1, true) then row = l end + end + assert.is_truthy(row, "task row missing:\n" .. table.concat(lines, "\n")) + assert.is_truthy(row:find("tabledemo", 1, true), "project cell missing") + assert.is_truthy(row:find("tbl%-one,tbl%-two"), "tags cell missing") + + -- Columns align: the description header and the description cell start + -- at the same display column. + local header = lines[2] + local hdr_col = display_width(header:sub(1, header:find("Description", 1, true) - 1)) + local cell_col = display_width(row:sub(1, row:find("table view target", 1, true) - 1)) + assert.are.same(hdr_col, cell_col, + "description column is not aligned with its header") + end) + + it("honors config.table_columns including UDA and format columns", function() + local orig = config.options.table_columns + config.options.table_columns = { + { field = "description", width = 20 }, + { field = "project", label = "Proj", width = 10 }, + { field = "tags", label = "Count", width = 6, + format = function(v) return tostring(#(v or {})) end }, + } + local tasks = taskmd.shell_export("project:tabledemo") or {} + local lines = table_view.build(tasks, { filter = "project:tabledemo", width = 80 }) + config.options.table_columns = orig + + assert.is_truthy(lines[2]:find("Proj", 1, true), "custom label missing from header") + assert.is_truthy(lines[2]:find("Count", 1, true), "format column missing from header") + assert.is_nil(lines[2]:find("Urg", 1, true), + "default columns leaked into a custom layout") + + local row + for _, l in ipairs(lines) do + if l:find("table view target", 1, true) then row = l end + end + assert.is_truthy(row, "task row missing under custom columns") + -- The format function turned the tag list into its count. + assert.is_truthy(row:find("2", 1, true), "format column did not render the count") + assert.is_nil(row:find("tbl-one", 1, true), + "raw tag value rendered despite a format function") + end) + + it("truncates over-long cells with an ellipsis instead of overflowing", function() + local long = taskmd.tw_add(("wordy "):rep(40) .. "end", { project = "tablewide" }) + local tasks = taskmd.shell_export("uuid:" .. long) or {} + local lines = table_view.build(tasks, { filter = "uuid", width = 100 }) + for _, line in ipairs(lines) do + assert.is_true(display_width(line) <= 100, + ("long description overflowed: %d cols"):format(display_width(line))) + end + local row + for _, l in ipairs(lines) do + if l:find("wordy", 1, true) then row = l end + end + assert.is_truthy(row, "long-description row missing") + assert.is_truthy(row:find("…", 1, true), "long cell was not ellipsized") + end) + + it(":TwTable opens a view buffer and opens that task for editing", function() + local prefix = config.options.command_prefix or "Tw" + assert.is_truthy(vim.api.nvim_get_commands({})[prefix .. "Table"], + ":" .. prefix .. "Table not registered") + + vim.cmd(prefix .. "Table project:tabledemo") + vim.wait(500, function() return false end, 10) + local view_buf = vim.api.nvim_get_current_buf() + assert.are.same("taskwarrior_view", vim.bo[view_buf].filetype) + assert.is_false(vim.bo[view_buf].modifiable, "table view should be read-only") + + -- Put the cursor on the task row, then fire . + local lines = vim.api.nvim_buf_get_lines(view_buf, 0, -1, false) + local row_nr + for i, l in ipairs(lines) do + if l:find("table view target", 1, true) then row_nr = i end + end + assert.is_truthy(row_nr, "task row not found in the opened view") + vim.api.nvim_win_set_cursor(0, { row_nr, 0 }) + + local cr + for _, m in ipairs(vim.api.nvim_buf_get_keymap(view_buf, "n")) do + if m.lhs == "" and m.callback then cr = m.callback end + end + assert.is_truthy(cr, " keymap missing from the table view") + cr() + vim.wait(500, function() + return vim.b[vim.api.nvim_get_current_buf()].task_filter ~= nil + end, 10) + + local task_buf = vim.api.nvim_get_current_buf() + assert.are.same("uuid:" .. uuid, vim.b[task_buf].task_filter, + " did not open the task under the cursor") + local text = table.concat(vim.api.nvim_buf_get_lines(task_buf, 0, -1, false), "\n") + assert.is_truthy(text:find("table view target", 1, true), + "opened buffer does not contain the task") + end) +end) From 67c88d68c10d19454866137c974880e19f5e330e Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 18:18:19 -0700 Subject: [PATCH 07/15] docs: changelog entries for contexts, table view, capture, hyphen tags Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- CHANGELOG.md | 69 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index da70314..0718e7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,75 @@ this project follows [Semantic Versioning](https://semver.org/). ## [Unreleased] — v1.5.0 +### Added + +- **`:TwContext [name|none]`** — set or show the Taskwarrior context + (work, home, …). Task buffers honor the active context's read filter + and refresh when it changes. Taskwarrior 3.x applies contexts to + reports but *not* to `export`, which is how the plugin reads + everything, so the render path injects the read filter itself; the + injected tokens land in the rendered header, keeping the save path on + the same effective filter. `uuid:`-targeted filters are never + context-narrowed. Completion lists defined contexts plus `none`. +- **`:TwTable [filter]`** — vit-style column view: one row per task with + aligned columns, sorted by urgency (or `custom_urgency`). `` opens + the task under the cursor in an editable `:Tw` buffer, `r` refreshes, + `q` closes; the view itself is read-only. Columns come from the new + `table_columns` option — a field name or + `{ field, label, width, align, format }`. Omitting `width` on one + column lets it absorb the leftover window width; over-long cells + ellipsize instead of overflowing. Unrecognised field names are read + straight off the exported task, so UDA columns need no extra wiring. +- **Capture annotations** — lines below the first in the quick-capture + window become annotations on the created task. `` opens an + annotation line; `` still submits everything. New + `capture_annotations` option (default `true`) opts out; + `capture_height` sizes the form. +- **Capture field coloring** — the quick-capture buffer now runs the task + buffer's highlighter, so `project:`, `priority:`, `due:`, `+tags` and + UDAs are colored as you type. +- **`field_colors` option** — per-field highlight overrides keyed by + field name, mirroring `tag_colors`. Any `field:value` token not + otherwise styled now gets the new neutral `TaskField` highlight group, + so a UDA never renders as plain description text. Clock times + (`09:30`) and URL schemes are excluded from field matching. +- The quick-capture confirmation now echoes the first ~40 characters of + the stored description (plus an annotation count), so you can tell at a + glance that the right task went through and its fields parsed. + +### Fixed + +- **Hyphenated tag names** (`+ais-research-taste` and friends). + Taskwarrior 3's expression parser reads the hyphen in a bare `+tag` + token as a subtraction operator, which broke three separate paths: + filters failed with `Cannot subtract from a Boolean value` and rendered + a silently empty buffer; `task add … +foo-bar` put the tag in the + *description* text; and `modify +foo-bar` exited non-zero without + applying. Fixes: + - `shell_export` now routes parsed filter args through the same + `+t` → `tags.has:t` / `-t` → `tags.hasnt:t` rewrite `tw_export` + already used (virtual tags such as `+ACTIVE` pass through verbatim). + - Task creation and buffer saves emit a single `tags:a,b` replacement + instead of per-tag `+a` / `-b` deltas. + - New `taskmd.tw_change_tag(uuid, tag, remove)` merges a single-tag + delta into the full set and replaces; used by the `gm` tag picker and + `:TwInbox`. +- **Partial tag removal on save** — removing one tag from a task line in + the markdown buffer and saving now actually applies the removal. The + old per-tag `+t` delta args never did. +- `` in the quick-capture window only checked line 1 for unsubmitted + text, so annotation lines could be discarded without a prompt; the + cursor restore after "don't discard" also always jumped back to line 1. +- `task context none` exits non-zero when no context is set; clearing an + already-clear context is now treated as the no-op it is instead of + surfacing an error. + +### Changed + +- With `confirm = false`, the apply summary notification now points at + `:TwUndo` — without the popup that notification is the only checkpoint, + so the revert path is named where you'll see it. + ### Removed — Python backend - `bin/taskmd` (the optional Python CLI) — removed. Taskwarrior.nvim has From dad480fb4547b0809d64ea2ed61fe46d34958f36 Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 18:35:08 -0700 Subject: [PATCH 08/15] feat: :TwRepairTags, context define/delete, table width fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from testing PR #10 against real data. :TwRepairTags — the parser fix stops NEW tasks from losing their tags, but tasks added before it still have the literal +tag text sitting in their description with no tag created, so they stay unfindable. This moves those tokens back into real tags. It previews every change in a tab and confirms before writing, defaults to status:pending, and skips a +tag written inside quotes (that's someone describing a tag, not a misparse — e.g. a bug report about the filter failing). :TwContext — bare :TwContext now clears the context (the common case); `show` prints the summary. Added `define [name] [filter]` and `delete [name]`, both prompting for anything omitted, so contexts can be managed without dropping to the CLI. :TwTable — column widths were computed from vim.o.columns, which overcounts by the number/sign gutter, so the widest rows wrapped past the right edge. Now measured from the window's text area (width - textoff), with a re-render on resize. Capture annotations — the key is now configurable via capture_annotation_key, defaulting to plus . only reaches Neovim from terminals speaking CSI-u (in tmux, only with extended-keys on), so binding it alone would silently submit instead. Dropped the insert-mode binding, which shadowed Vim's built-in. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- CHANGELOG.md | 26 +++- README.md | 10 +- doc/taskwarrior.txt | 43 ++++-- lua/taskwarrior/capture.lua | 25 +++- lua/taskwarrior/commands.lua | 50 ++++++- lua/taskwarrior/config.lua | 11 ++ lua/taskwarrior/context.lua | 83 ++++++++++- lua/taskwarrior/repair_tags.lua | 186 ++++++++++++++++++++++++ lua/taskwarrior/table_view.lua | 40 ++++- lua/taskwarrior/validate.lua | 27 +++- tests/e2e/spec/context_e2e_spec.lua | 46 ++++++ tests/e2e/spec/repair_tags_e2e_spec.lua | 103 +++++++++++++ tests/lua/spec/repair_tags_spec.lua | 77 ++++++++++ 13 files changed, 698 insertions(+), 29 deletions(-) create mode 100644 lua/taskwarrior/repair_tags.lua create mode 100644 tests/e2e/spec/repair_tags_e2e_spec.lua create mode 100644 tests/lua/spec/repair_tags_spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 0718e7c..4d45e3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ this project follows [Semantic Versioning](https://semver.org/). ### Added +- **`:TwRepairTags [filter]`** — repairs the damage the hyphenated-tag bug + left behind. Tasks added before the fix had the literal `+foo-bar` text + filed into their *description* with no tag created, so they are + unfindable by `+foo-bar` even after the parser fix. This strips those + tokens out and adds the real tags. Every proposed change is shown in a + preview tab and confirmed before anything is written; the default scope + is `status:pending`, and a `+tag` written inside quotes is treated as a + mention and left alone. - **`:TwContext [name|none]`** — set or show the Taskwarrior context (work, home, …). Task buffers honor the active context's read filter and refresh when it changes. Taskwarrior 3.x applies contexts to @@ -26,10 +34,14 @@ this project follows [Semantic Versioning](https://semver.org/). ellipsize instead of overflowing. Unrecognised field names are read straight off the exported task, so UDA columns need no extra wiring. - **Capture annotations** — lines below the first in the quick-capture - window become annotations on the created task. `` opens an - annotation line; `` still submits everything. New + window become annotations on the created task. `` or `` + opens an annotation line; `` still submits everything. New `capture_annotations` option (default `true`) opts out; - `capture_height` sizes the form. + `capture_height` sizes the form. The keys are configurable via + `capture_annotation_key` — `` only reaches Neovim from terminals + speaking the CSI-u / kitty keyboard protocol (and inside tmux only with + `set -g extended-keys on`), which is why the portable `` is bound + alongside it. - **Capture field coloring** — the quick-capture buffer now runs the task buffer's highlighter, so `project:`, `priority:`, `due:`, `+tags` and UDAs are colored as you type. @@ -69,6 +81,14 @@ this project follows [Semantic Versioning](https://semver.org/). already-clear context is now treated as the no-op it is instead of surfacing an error. +- `:TwTable` sized its columns against `vim.o.columns`, which overcounts + by the width of the number/sign gutter — the widest rows wrapped past + the right edge. Widths now come from the window's actual text area + (`width - textoff`), and the view re-renders on window resize. +- The quick-capture window bound insert-mode `` for annotation lines, + shadowing Vim's built-in one-shot-normal-command. Removed; see + `capture_annotation_key`. + ### Changed - With `confirm = false`, the apply summary notification now points at diff --git a/README.md b/README.md index 9fbebfa..7730205 100644 --- a/README.md +++ b/README.md @@ -163,7 +163,8 @@ These are bundled but require their own host plugins. | `:TwStart` / `:TwStop` | Start / stop active timer on task under cursor | | `:TwSave ` / `:TwLoad [name]` | Save / restore the current filter+sort+group as a named view | | `:TwReview` | Guided urgency walk through pending tasks | -| `:TwContext [name\|none]` | Show or set the Taskwarrior context (task buffers honor its read filter) | +| `:TwContext [name\|show\|define\|delete]` | Taskwarrior context: bare clears it, `` activates, `define`/`delete` manage them | +| `:TwRepairTags [filter]` | Move `+tag` text stuck in descriptions back into real tags (previews every change first) | | `:TwDelegate [copy\|copy-command]` | Delegate task(s) to Claude in a popup form | | `:TwDiffPreview [on\|off\|toggle]` | Toggle live virt-text diff preview | | `:TwBurndown` | Pending-task burndown chart | @@ -235,6 +236,13 @@ require("taskwarrior").setup({ capture_height = 3, -- quick-capture height in lines; line 1 is the -- task, lines below it become annotations capture_annotations = true, -- false = ignore everything below line 1 + capture_annotation_key = nil,-- insert-mode key(s) opening an annotation + -- line. Default binds and . + -- only reaches Neovim from terminals + -- speaking CSI-u (in tmux you also need + -- `set -g extended-keys on`); works + -- almost everywhere, which is why both are + -- bound by default table_columns = nil, -- :TwTable columns; nil = built-in defaults. -- Entries are field names or tables: -- { field, label, width, align, format }. diff --git a/doc/taskwarrior.txt b/doc/taskwarrior.txt index 0de9f07..87081de 100644 --- a/doc/taskwarrior.txt +++ b/doc/taskwarrior.txt @@ -245,16 +245,39 @@ Visualisation commands. See |taskwarrior-views| and |taskwarrior-table|. error reporting. Offers a retry on failure. *:TwContext* -:TwContext [name] Show or set the Taskwarrior context. With no - argument, shows the active context and lists the - defined ones. With a name (e.g. `work`), activates - that context — task buffers refresh and honor its - read filter (TW 3.x does not apply contexts to - `export`, so the plugin injects the filter itself). - `:TwContext none` clears it. Filters that target a - specific `uuid:` are never context-narrowed. - Define contexts in the CLI: - `task context define work project:work` +:TwContext Clear the active context (same as `none`). +:TwContext {name} Activate that context. Task buffers refresh and + honor its read filter — TW 3.x does not apply + contexts to `export`, so the plugin injects the + filter itself. Filters targeting a specific + `uuid:` are never context-narrowed. +:TwContext show Show the active context and list defined ones. +:TwContext define [name] [filter] + Define a context, e.g. + :TwContext define work project:work + :TwContext define deep +focus or priority:H + Prompts for anything you leave out, then offers to + activate it. +:TwContext delete [name] + Delete a context definition (prompts with a picker + when the name is omitted). + + *:TwRepairTags* +:TwRepairTags [filter] + Repair tasks whose `+tag` text ended up inside the + description. Taskwarrior 3 parses the hyphen in a + bare `+foo-bar` token as subtraction, so tasks + added before this was fixed had the literal text + filed into the description and the tag never + created — making them unfindable by `+foo-bar`. + This strips those tokens out of the description + and adds the real tags. + + Shows every proposed change in a preview tab and + asks before writing anything. Defaults to + `status:pending`; pass a filter to widen the scope + (e.g. `status:completed`). A `+tag` written inside + quotes is treated as a mention and left alone. *:TwFloat* :TwFloat [filter] Open the task buffer in a centered floating window diff --git a/lua/taskwarrior/capture.lua b/lua/taskwarrior/capture.lua index 76ce2a5..7b34a03 100644 --- a/lua/taskwarrior/capture.lua +++ b/lua/taskwarrior/capture.lua @@ -214,8 +214,16 @@ function M.open(refresh_fn) return "" end, { buffer = buf, expr = true }) - -- / open a new line below for an annotation without - -- submitting — stays "submit everything" from any line. + -- Open a new line below for an annotation without submitting — stays + -- "submit everything" from any line. + -- + -- Key choice matters more than it looks: only reaches Neovim from + -- terminals that speak the CSI-u / kitty keyboard protocol, and inside + -- tmux only with `set -g extended-keys on`. Everywhere else it arrives + -- indistinguishable from a plain and would silently submit. + -- is sent as ESC-prefixed CR by virtually every terminal, so it is the + -- portable default; both are bound by default and + -- capture_annotation_key overrides the whole set. local function open_annotation_line() local last = vim.api.nvim_buf_line_count(buf) vim.api.nvim_buf_set_lines(buf, last, last, false, { "" }) @@ -224,8 +232,17 @@ function M.open(refresh_fn) end vim.cmd("startinsert!") end - vim.keymap.set("i", "", open_annotation_line, { buffer = buf }) - vim.keymap.set("i", "", open_annotation_line, { buffer = buf }) + local anno_keys = config.options.capture_annotation_key + if anno_keys == nil then anno_keys = { "", "" } end + if type(anno_keys) == "string" then anno_keys = { anno_keys } end + for _, key in ipairs(anno_keys) do + if key ~= "" then + vim.keymap.set("i", key, open_annotation_line, { + buffer = buf, + desc = "taskwarrior.nvim: new annotation line", + }) + end + end vim.keymap.set("i", "", close_with_confirm, { buffer = buf }) vim.keymap.set("n", "", close_with_confirm, { buffer = buf }) diff --git a/lua/taskwarrior/commands.lua b/lua/taskwarrior/commands.lua index 825fd4a..cf7b0df 100644 --- a/lua/taskwarrior/commands.lua +++ b/lua/taskwarrior/commands.lua @@ -201,6 +201,16 @@ function M.setup(main, complete_filter) views.tags() end, { nargs = 0, desc = "Show tag distribution" }) + -- Repair tasks whose `+tag` text ended up in the description (the + -- hyphenated-tag misparse). Previews every change before writing. + register("RepairTags", function(o) + require("taskwarrior.repair_tags").run(o.args) + end, { + nargs = "*", + desc = "Move +tag text stuck in descriptions back into real tags (previews first)", + complete = function(arg_lead) return complete_filter(arg_lead) end, + }) + register("Table", function(o) require("taskwarrior.table_view").open(o.args) end, { @@ -291,18 +301,42 @@ function M.setup(main, complete_filter) main.sync() end, { nargs = 0, desc = "Run `task sync` with progress and error handling" }) - -- Taskwarrior contexts (work, home, …). No args shows the active context - -- and the available ones; a name sets it; "none" clears it. + -- Taskwarrior contexts (work, home, …). + -- :TwContext clear the active context + -- :TwContext activate it + -- :TwContext show active context + the defined ones + -- :TwContext define [name] [filter] create one (prompts for anything + -- not supplied) + -- :TwContext delete [name] remove a definition (prompts if omitted) register("Context", function(o) - main.context(o.args) + local ctx = require("taskwarrior.context") + local sub, rest = o.args:match("^(%S+)%s*(.*)$") + if sub == "define" then + local name, filter = rest:match("^(%S+)%s*(.*)$") + ctx.define(name, filter) + elseif sub == "delete" then + ctx.delete(rest) + else + main.context(o.args) + end end, { - nargs = "?", - desc = "Show or set the Taskwarrior context (name or 'none')", - complete = function(arg_lead) + nargs = "*", + desc = "Taskwarrior context: bare = clear, = activate, show/define/delete", + complete = function(arg_lead, cmd_line) + -- After `define`/`delete`, complete context names rather than verbs. + local words = vim.split(cmd_line or "", "%s+") + local sub = words[2] local names = require("taskwarrior.context").list() - table.insert(names, "none") + local candidates + if sub == "delete" and #words > 2 then + candidates = names + elseif sub == "define" or (sub == "delete" and #words > 3) then + candidates = {} + else + candidates = vim.list_extend({ "none", "show", "define", "delete" }, names) + end local out = {} - for _, n in ipairs(names) do + for _, n in ipairs(candidates) do if n:sub(1, #arg_lead) == arg_lead then table.insert(out, n) end end return out diff --git a/lua/taskwarrior/config.lua b/lua/taskwarrior/config.lua index 4c07350..e373fd6 100644 --- a/lua/taskwarrior/config.lua +++ b/lua/taskwarrior/config.lua @@ -50,6 +50,17 @@ M.defaults = { -- Turn the capture window's extra lines into annotations. Set false to -- ignore everything below line 1. capture_annotations = true, + -- Insert-mode key(s) that open a new annotation line in the capture + -- window without submitting. String or list of strings; false/"" disables. + -- Default binds both and . + -- + -- only reaches Neovim from terminals speaking the CSI-u / kitty + -- keyboard protocol — and inside tmux only with `set -g extended-keys on` + -- (plus `set -as terminal-features '*:extkeys'`). Without that it arrives + -- as a plain and submits. (Alt+Enter) is sent as an + -- ESC-prefixed CR by virtually every terminal, which is why it's in the + -- default set. + capture_annotation_key = nil, -- When the quick-capture window has unsubmitted text and the user presses -- , ask before discarding instead of closing silently. Set to false -- to restore the pre-1.4 always-close behavior. diff --git a/lua/taskwarrior/context.lua b/lua/taskwarrior/context.lua index f4bab9b..270a05b 100644 --- a/lua/taskwarrior/context.lua +++ b/lua/taskwarrior/context.lua @@ -91,10 +91,91 @@ function M.show() notify("view", table.concat(lines, "\n")) end +--- Define a context. `filter` is a Taskwarrior filter expression; when +--- omitted the user is prompted for it. Prompts for the name too when nil. +function M.define(name, filter) + local function do_define(n, f) + n, f = vim.trim(n or ""), vim.trim(f or "") + if n == "" or f == "" then return end + if n == "none" then + notify("error", "taskwarrior.nvim: 'none' is reserved — pick another name", + vim.log.levels.ERROR) + return + end + local args = { "context", "define", n } + -- The filter is one CLI argument per token, same as any other filter. + local parsed = command.parse_args(f) + if not parsed then + notify("error", "taskwarrior.nvim: unparseable context filter", + vim.log.levels.ERROR) + return + end + vim.list_extend(args, parsed) + local result = command.mutate(args) + if not result.ok then + notify("error", "taskwarrior.nvim: context define failed\n" .. (result.output or ""), + vim.log.levels.ERROR) + return + end + notify("view", ("taskwarrior.nvim: context %s defined (%s)"):format(n, f)) + -- Offer to switch to it right away — defining one you don't then use is + -- almost never what you meant. + vim.ui.select({ "Activate now", "Leave inactive" }, { + prompt = ("Activate context %s?"):format(n), + }, function(choice) + if choice == "Activate now" then M.set(n) end + end) + end + + if name == nil or vim.trim(name) == "" then + vim.ui.input({ prompt = "Context name: " }, function(n) + if not n or vim.trim(n) == "" then return end + vim.ui.input({ prompt = "Filter (e.g. project:work or +work): " }, function(f) + do_define(n, f) + end) + end) + return + end + if filter == nil or vim.trim(filter) == "" then + vim.ui.input({ prompt = ("Filter for context %s: "):format(name) }, function(f) + do_define(name, f) + end) + return + end + do_define(name, filter) +end + +--- Delete a context definition (prompts to pick one when name is omitted). +function M.delete(name) + local function do_delete(n) + local result = command.mutate({ "context", "delete", n }) + if not result.ok then + notify("error", "taskwarrior.nvim: context delete failed\n" .. (result.output or ""), + vim.log.levels.ERROR) + return + end + notify("view", ("taskwarrior.nvim: context %s deleted"):format(n)) + refresh_all_task_buffers() + end + + if name and vim.trim(name) ~= "" then return do_delete(vim.trim(name)) end + local names = M.list() + if #names == 0 then + notify("warn", "taskwarrior.nvim: no contexts defined", vim.log.levels.WARN) + return + end + vim.ui.select(names, { prompt = "Delete which context?" }, function(choice) + if choice then do_delete(choice) end + end) +end + -- Set (or clear, with "none") the context, then refresh open task buffers. +-- Bare `:TwContext` clears — the common case is "get me out of this +-- context"; use `:TwContext show` for the read-only summary. function M.set(name) name = vim.trim(name or "") - if name == "" then return M.show() end + if name == "" then name = "none" end + if name == "show" then return M.show() end -- `task context none` exits 2 when no context was set ("Context not -- unset.") — clearing an already-clear context is a no-op, not an error. local ok_codes = name == "none" and { 0, 1, 2 } or nil diff --git a/lua/taskwarrior/repair_tags.lua b/lua/taskwarrior/repair_tags.lua new file mode 100644 index 0000000..81239ba --- /dev/null +++ b/lua/taskwarrior/repair_tags.lua @@ -0,0 +1,186 @@ +-- taskwarrior/repair_tags.lua — :TwRepairTags. +-- +-- Repairs the damage left by the hyphenated-tag bug: Taskwarrior 3 parses +-- the hyphen in a bare `+foo-bar` token as a subtraction operator, so +-- `task add "… +taco-tuesday"` silently filed the literal text +-- "+taco-tuesday" into the DESCRIPTION instead of creating a tag. Tasks +-- added that way are unfindable by `+taco-tuesday` because the tag never +-- existed. (The same happened via the plugin's literal-add fallback for +-- non-hyphenated tags.) +-- +-- This moves those tokens back where they belong: strip `+token` from the +-- description, add `token` to the task's tags. +-- +-- It NEVER writes without showing you every proposed change first. Two +-- deliberate conservatisms, because a description legitimately may contain +-- a `+`: +-- * a `+token` wrapped in quotes is skipped — that is someone *writing +-- about* a tag ("searching for \"+ais-research-taste\" fails"), not a +-- mis-parsed one; +-- * the default scope is pending tasks. Completed and deleted history is +-- rarely worth rewriting; pass an explicit filter to include it. + +local M = {} + +local command = require("taskwarrior.command") +local notify = require("taskwarrior.notify") + +-- A tag token: `+` then a tag-legal name, at a word boundary. Mirrors the +-- boundary rule the buffer highlighter uses so "housing+food" is not a tag. +local TOKEN = "%+([A-Za-z_][%w_%-]*)" + +local function is_quoted(text, start_idx) + local prev = start_idx > 1 and text:sub(start_idx - 1, start_idx - 1) or "" + local after_end = text:find("%s", start_idx) or (#text + 1) + local following = text:sub(after_end - 1, after_end - 1) + return (prev == '"' or prev == "'" or prev == "`") + or (following == '"' or following == "'" or following == "`") +end + +--- Find the repairs a task needs. Returns nil when it needs none. +--- Exposed for testing. +function M.plan_for(task) + local desc = task.description or "" + local existing = {} + for _, t in ipairs(task.tags or {}) do existing[t] = true end + + local found, spans = {}, {} + local pos = 1 + while true do + local s, e, name = desc:find(TOKEN, pos) + if not s then break end + local prev = s > 1 and desc:sub(s - 1, s - 1) or "" + -- Word boundary before the `+`, and not a quoted mention. + if (prev == "" or not prev:match("[%w_]")) and not is_quoted(desc, s) then + if not existing[name] and not vim.tbl_contains(found, name) then + found[#found + 1] = name + end + spans[#spans + 1] = { s, e } + end + pos = e + 1 + end + if #found == 0 then return nil end + + -- Rebuild the description without the token spans, right to left so the + -- earlier indices stay valid. + local new_desc = desc + for i = #spans, 1, -1 do + new_desc = new_desc:sub(1, spans[i][1] - 1) .. new_desc:sub(spans[i][2] + 1) + end + new_desc = new_desc:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") + if new_desc == "" then + -- The description was nothing but tags — keep the tag text as the + -- description rather than creating a task with no description at all. + new_desc = desc + end + + local tags = { unpack(task.tags or {}) } + vim.list_extend(tags, found) + return { + uuid = task.uuid, + old_description = desc, + new_description = new_desc, + added_tags = found, + tags = tags, + } +end + +--- Scan `filter` (default pending) and return the list of planned repairs. +function M.scan(filter) + filter = (filter and filter ~= "") and filter or "status:pending" + local tasks = require("taskwarrior.taskmd").shell_export(filter) + if not tasks then return nil end + local plans = {} + for _, t in ipairs(tasks) do + local plan = M.plan_for(t) + if plan then plans[#plans + 1] = plan end + end + return plans +end + +local function preview_lines(plans, filter) + local lines = { + ("taskwarrior.nvim — tag repair preview (%s)"):format(filter), + ("%d task(s) would be changed. Nothing has been written yet."):format(#plans), + "", + } + for i, p in ipairs(plans) do + lines[#lines + 1] = ("%d. %s"):format(i, p.uuid:sub(1, 8)) + lines[#lines + 1] = (" tags + %s"):format(table.concat(p.added_tags, ", ")) + lines[#lines + 1] = (" before %s"):format(p.old_description) + lines[#lines + 1] = (" after %s"):format(p.new_description) + lines[#lines + 1] = "" + end + lines[#lines + 1] = "Quoted mentions (\"+tag\") are deliberately left alone." + return lines +end + +local function apply(plans) + local taskmd = require("taskwarrior.taskmd") + local ok_count, failures = 0, {} + for _, p in ipairs(plans) do + local ok, out = taskmd.tw_modify(p.uuid, { + description = p.new_description, + tags = p.tags, + }) + if ok then + ok_count = ok_count + 1 + else + failures[#failures + 1] = ("%s: %s"):format(p.uuid:sub(1, 8), out or "") + end + end + return ok_count, failures +end + +--- :TwRepairTags [filter] — preview, confirm, then repair. +function M.run(filter) + filter = (filter and filter ~= "") and filter or "status:pending" + local plans = M.scan(filter) + if not plans then + notify("error", "taskwarrior.nvim: repair scan failed", vim.log.levels.ERROR) + return + end + if #plans == 0 then + notify("view", ("taskwarrior.nvim: no tags to repair in `%s`"):format(filter)) + return + end + + -- Show every change in a scratch buffer before asking. The picker prompt + -- alone can't carry this much detail, and this is a destructive rewrite of + -- data the user did not ask us to touch. + local buf = vim.api.nvim_create_buf(false, true) + vim.bo[buf].buftype = "nofile" + vim.api.nvim_buf_set_lines(buf, 0, -1, false, preview_lines(plans, filter)) + vim.bo[buf].modifiable = false + vim.cmd("tab split") + vim.api.nvim_win_set_buf(0, buf) + local preview_win = vim.api.nvim_get_current_win() + + vim.schedule(function() + vim.ui.select({ "Apply repairs", "Cancel" }, { + prompt = ("Repair %d task(s)?"):format(#plans), + }, function(choice) + if vim.api.nvim_win_is_valid(preview_win) then + pcall(vim.api.nvim_win_close, preview_win, true) + end + if choice ~= "Apply repairs" then + notify("view", "taskwarrior.nvim: repair cancelled — nothing written") + return + end + local ok_count, failures = apply(plans) + local msg = ("taskwarrior.nvim: repaired %d/%d task(s)"):format(ok_count, #plans) + if #failures > 0 then + notify("error", msg .. "\n" .. table.concat(failures, "\n"), vim.log.levels.ERROR) + else + notify("view", msg .. " — :TwUndo reverts one at a time") + end + for _, b in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_valid(b) and vim.b[b].task_filter ~= nil then + pcall(function() require("taskwarrior.buffer").refresh_buf(b) end) + end + end + end) + end) +end + +return M diff --git a/lua/taskwarrior/table_view.lua b/lua/taskwarrior/table_view.lua index d6bd934..64fbfb6 100644 --- a/lua/taskwarrior/table_view.lua +++ b/lua/taskwarrior/table_view.lua @@ -213,6 +213,19 @@ function M.build(tasks, opts) return lines, highlights, row_uuids end +-- Usable text width of a window: its total width minus the gutter (number, +-- sign and fold columns). `vim.o.columns` alone overcounts by the gutter, +-- which pushed the widest rows past the right edge and wrapped them. +local function text_width(win) + if win and vim.api.nvim_win_is_valid(win) then + local info = vim.fn.getwininfo(win)[1] + if info and info.width and info.textoff then + return math.max(20, info.width - info.textoff) + end + end + return vim.o.columns +end + --- :TwTable [filter] — open (or refresh) the table view. function M.open(filter) filter = (filter and filter ~= "") and filter or "status:pending" @@ -236,11 +249,23 @@ function M.open(filter) return ua > ub end) - local lines, highlights, uuids = M.build(tasks, { filter = filter }) + -- First pass sizes against the current window; the view may open in a + -- new tab whose gutter differs, so re-measure once the window exists + -- and re-render if the usable width actually changed. + local width = text_width(vim.api.nvim_get_current_win()) + local lines, highlights, uuids = M.build(tasks, { filter = filter, width = width }) row_uuids = uuids local bufnr = views._open_scratch( "taskwarrior.nvim Table", lines, highlights, do_render) + local view_win = vim.fn.bufwinid(bufnr) + local actual = text_width(view_win ~= -1 and view_win or nil) + if actual ~= width then + lines, highlights, uuids = M.build(tasks, { filter = filter, width = actual }) + row_uuids = uuids + views._open_scratch("taskwarrior.nvim Table", lines, highlights, do_render) + end + vim.keymap.set("n", "", function() local row = vim.api.nvim_win_get_cursor(0)[1] - 1 local uuid = row_uuids[row] @@ -256,6 +281,19 @@ function M.open(filter) vim.keymap.set("n", "r", do_render, { buffer = bufnr, noremap = true, silent = true, desc = "taskwarrior.nvim: refresh the table view" }) + -- Column widths are computed against the window, so re-render when the + -- window changes size. clear = true keeps this to one autocmd per buffer + -- no matter how many times we re-render. + vim.api.nvim_create_augroup("TaskwarriorTable_" .. bufnr, { clear = true }) + vim.api.nvim_create_autocmd({ "VimResized", "WinResized" }, { + group = "TaskwarriorTable_" .. bufnr, + callback = function() + if not vim.api.nvim_buf_is_valid(bufnr) then return true end + if vim.fn.bufwinid(bufnr) == -1 then return end + do_render() + end, + }) + return bufnr end diff --git a/lua/taskwarrior/validate.lua b/lua/taskwarrior/validate.lua index 6eae6f5..aebbdb9 100644 --- a/lua/taskwarrior/validate.lua +++ b/lua/taskwarrior/validate.lua @@ -10,7 +10,8 @@ local KNOWN_KEYS = { "capture_key", "open_key", "filter_key", "sort_key", "group_key", "project_add_key", "filters", "projects", "icons", "border_style", "capture_width", "capture_height", "capture_confirm_close", - "capture_annotations", "field_colors", "table_columns", + "capture_annotations", "capture_annotation_key", "field_colors", + "table_columns", "group_separator", "animation", "clamp_cursor", "day_start_hour", "urgency_coefficients", "urgency_value_mappers", "custom_urgency", "auto_backup", "auto_backup_keep", @@ -285,6 +286,30 @@ function M.validate(opts) end end + -- 8d. capture_annotation_key — string, list of strings, or false. + if opts.capture_annotation_key ~= nil and opts.capture_annotation_key ~= false then + local v = opts.capture_annotation_key + if type(v) == "table" then + for i, k in ipairs(v) do + if type(k) ~= "string" then + error( + ("taskwarrior.nvim: capture_annotation_key[%d] must be a string, got %s"):format( + i, type(k) + ), + 0 + ) + end + end + elseif type(v) ~= "string" then + error( + ("taskwarrior.nvim: capture_annotation_key must be a string, list, or false, got %s"):format( + type(v) + ), + 0 + ) + end + end + -- 8c. Nested: table_columns — each entry is a field name or a table with -- a `field` key; width must be a positive number when present. if opts.table_columns ~= nil then diff --git a/tests/e2e/spec/context_e2e_spec.lua b/tests/e2e/spec/context_e2e_spec.lua index 453324b..ef61218 100644 --- a/tests/e2e/spec/context_e2e_spec.lua +++ b/tests/e2e/spec/context_e2e_spec.lua @@ -113,4 +113,50 @@ describe("e2e Taskwarrior contexts (tw c12b4cbd)", function() vim.wait(200, function() return context.current() == nil end, 10) assert.is_nil(context.current()) end) + + it("bare :TwContext clears the active context", function() + local prefix = require("taskwarrior.config").options.command_prefix or "Tw" + context.set("ctxwork") + assert.are.same("ctxwork", context.current()) + vim.cmd(prefix .. "Context") + vim.wait(500, function() return context.current() == nil end, 10) + assert.is_nil(context.current(), "bare :TwContext did not clear the context") + end) + + it("define creates a context and delete removes it", function() + local name = ("ctxmade%d"):format(math.random(1, 1e6)) + -- define offers to activate; decline so this test only checks definition. + local orig_select = vim.ui.select + vim.ui.select = function(_, _, cb) cb("Leave inactive") end + context.define(name, "project:ctxdemo or +ctx-hyphen-tag") + vim.wait(2000, function() + return context.read_filter(name) ~= nil + end, 20) + vim.ui.select = orig_select + + assert.are.same("project:ctxdemo or +ctx-hyphen-tag", context.read_filter(name), + "context filter was not stored verbatim") + local names = context.list() + assert.is_true(vim.tbl_contains(names, name), + "defined context missing from list: " .. vim.inspect(names)) + + -- It actually works as a context. + context.set(name) + assert.are.same(name, context.current()) + context.set("none") + + context.delete(name) + vim.wait(2000, function() return context.read_filter(name) == nil end, 20) + assert.is_nil(context.read_filter(name), "context was not deleted") + assert.is_false(vim.tbl_contains(context.list(), name), + "deleted context still listed") + end) + + it("define refuses the reserved name 'none'", function() + local before = context.list() + context.define("none", "project:whatever") + vim.wait(300, function() return false end, 10) + assert.are.same(#before, #context.list(), + "defining a context named 'none' should be refused") + end) end) diff --git a/tests/e2e/spec/repair_tags_e2e_spec.lua b/tests/e2e/spec/repair_tags_e2e_spec.lua new file mode 100644 index 0000000..2d07ab1 --- /dev/null +++ b/tests/e2e/spec/repair_tags_e2e_spec.lua @@ -0,0 +1,103 @@ +-- repair_tags_e2e_spec.lua — :TwRepairTags against a live Taskwarrior CLI. +-- +-- Seeds the exact damage the hyphenated-tag bug produced (tag text stuck in +-- the description, tag never created), runs the repair, and asserts the task +-- becomes findable by the tag afterwards. Also asserts the two refusals that +-- keep this safe: a quoted mention is untouched, and Cancel writes nothing. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") +local repair = require("taskwarrior.repair_tags") + +-- Seed a task the way the bug did: the +tag lives in the description text +-- and no tag exists. `add --` forces a literal description. +local function seed_damaged(description) + local out = vim.fn.system({ + "task", "rc.bulk=0", "rc.confirmation=off", "rc.verbose=new-uuid", + "add", "--", description, + }) + return out:match("[0-9a-fA-F%-]+%-[0-9a-fA-F%-]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+%-[0-9a-fA-F]+") +end + +describe("e2e :TwRepairTags", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + + it("makes a damaged task findable by its tag again", function() + local tag = ("repair-me-%d"):format(math.random(1, 1e9)) + local uuid = seed_damaged("buy groceries +" .. tag) + assert.is_truthy(uuid, "seed failed") + + -- Precondition: this is exactly the reported symptom. + local before = taskmd.shell_export("+" .. tag) or {} + assert.are.same(0, #before, "seed did not reproduce the bug") + local seeded = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same("buy groceries +" .. tag, seeded.description) + + local orig_select = vim.ui.select + vim.ui.select = function(_, _, cb) cb("Apply repairs") end + repair.run("uuid:" .. uuid) + vim.wait(5000, function() + local t = taskmd.shell_export("uuid:" .. uuid)[1] + return t and t.tags and vim.tbl_contains(t.tags, tag) + end, 20) + vim.ui.select = orig_select + + local after = taskmd.shell_export("+" .. tag) or {} + assert.are.same(1, #after, "task is still not findable by its tag") + assert.are.same(uuid, after[1].uuid) + assert.are.same("buy groceries", after[1].description, + "tag text was not stripped from the description") + end) + + it("leaves a quoted mention alone", function() + local tag = ("quoted-%d"):format(math.random(1, 1e9)) + local uuid = seed_damaged(('filtering by "+%s" fails'):format(tag)) + local plans = repair.scan("uuid:" .. uuid) + assert.are.same({}, plans, + "a quoted tag mention must not be planned for repair") + end) + + it("Cancel writes nothing", function() + local tag = ("cancel-me-%d"):format(math.random(1, 1e9)) + local uuid = seed_damaged("do not touch +" .. tag) + + local orig_select = vim.ui.select + vim.ui.select = function(_, _, cb) cb("Cancel") end + repair.run("uuid:" .. uuid) + vim.wait(1000, function() return false end, 20) + vim.ui.select = orig_select + + local t = taskmd.shell_export("uuid:" .. uuid)[1] + assert.are.same("do not touch +" .. tag, t.description, + "Cancel must not modify the description") + assert.are.same(0, #(t.tags or {}), "Cancel must not add tags") + end) + + it("preserves tags the task already had", function() + local keep = ("kept-%d"):format(math.random(1, 1e9)) + local add = ("added-%d"):format(math.random(1, 1e9)) + local uuid = seed_damaged("mixed tags +" .. add) + -- Give it a real tag through the (now correct) plugin path. + assert.is_true(taskmd.tw_change_tag(uuid, keep)) + + local orig_select = vim.ui.select + vim.ui.select = function(_, _, cb) cb("Apply repairs") end + repair.run("uuid:" .. uuid) + vim.wait(5000, function() + local t = taskmd.shell_export("uuid:" .. uuid)[1] + return t and t.tags and #t.tags == 2 + end, 20) + vim.ui.select = orig_select + + local t = taskmd.shell_export("uuid:" .. uuid)[1] + local tags = { unpack(t.tags or {}) } + table.sort(tags) + local expected = { keep, add } + table.sort(expected) + assert.are.same(expected, tags, "existing tag was lost during repair") + end) +end) diff --git a/tests/lua/spec/repair_tags_spec.lua b/tests/lua/spec/repair_tags_spec.lua new file mode 100644 index 0000000..b8bea89 --- /dev/null +++ b/tests/lua/spec/repair_tags_spec.lua @@ -0,0 +1,77 @@ +-- Unit spec for the tag-repair planner (:TwRepairTags). +-- +-- The planner rewrites descriptions, so its false-positive behaviour is the +-- part that matters: a description that merely *mentions* a tag in quotes +-- (a bug report, a note about a filter) must be left completely alone. + +local eq = assert.are.same +local repair = require("taskwarrior.repair_tags") + +describe("repair_tags.plan_for", function() + it("moves a hyphenated tag out of the description", function() + local p = repair.plan_for({ + uuid = "u1", description = "Taco Tuesday — grocery shop +taco-tuesday", + }) + assert.is_truthy(p) + eq("Taco Tuesday — grocery shop", p.new_description) + eq({ "taco-tuesday" }, p.added_tags) + eq({ "taco-tuesday" }, p.tags) + end) + + it("handles several tokens anywhere in the line", function() + local p = repair.plan_for({ + uuid = "u2", description = "+email connect michael with shon +followup", + }) + eq("connect michael with shon", p.new_description) + eq({ "email", "followup" }, p.added_tags) + end) + + it("merges with tags the task already has", function() + local p = repair.plan_for({ + uuid = "u3", description = "ship it +extra", tags = { "work" }, + }) + eq("ship it", p.new_description) + eq({ "extra" }, p.added_tags) + eq({ "work", "extra" }, p.tags) + end) + + it("leaves a task alone when the tag text is already a real tag", function() + -- Nothing is broken here: the task IS findable by +urgent. Rewriting a + -- description purely to de-duplicate text is risk without benefit, so + -- the planner stays out of it. + assert.is_nil(repair.plan_for({ + uuid = "u3b", description = "ship it +urgent", tags = { "work", "urgent" }, + })) + end) + + it("leaves a QUOTED mention alone (the bug-report case)", function() + local p = repair.plan_for({ + uuid = "u4", + description = 'when searching with TwFilter and "+ais-research-taste" it fails', + }) + assert.is_nil(p, "a quoted tag mention must not be rewritten") + end) + + it("ignores a + that is not at a word boundary", function() + assert.is_nil(repair.plan_for({ uuid = "u5", description = "housing+food budget" })) + assert.is_nil(repair.plan_for({ uuid = "u6", description = "sell SPY + SPYG today" })) + end) + + it("returns nil when there is nothing to repair", function() + assert.is_nil(repair.plan_for({ + uuid = "u7", description = "ordinary task", tags = { "work" }, + })) + end) + + it("keeps the original description when it is nothing but tags", function() + local p = repair.plan_for({ uuid = "u8", description = "+ais-research-taste" }) + eq({ "ais-research-taste" }, p.added_tags) + eq("+ais-research-taste", p.new_description, + "a tags-only description must not become empty") + end) + + it("collapses the whitespace left behind by a removed token", function() + local p = repair.plan_for({ uuid = "u9", description = "call +email bob now" }) + eq("call bob now", p.new_description) + end) +end) From 578dfe2beaf8b640cd640fddd33bc0429a57188c Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 18:42:36 -0700 Subject: [PATCH 09/15] refactor: tag repair is a module call, not a :Tw command The repair runs once, ever, to clean up damage from the TW3 hyphen misparse. A registered command would sit in :Tw forever for every user in exchange for a flow almost nobody runs twice. Invoke it explicitly instead: :lua require("taskwarrior.repair_tags").run() Documented under Data safety in the README and |taskwarrior-repair| in the help file. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- CHANGELOG.md | 18 +++++++++-------- README.md | 12 ++++++++++- doc/taskwarrior.txt | 35 ++++++++++++++++++--------------- lua/taskwarrior/commands.lua | 14 +++++-------- lua/taskwarrior/repair_tags.lua | 9 +++++++-- 5 files changed, 52 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d45e3e..0491f2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,14 +8,16 @@ this project follows [Semantic Versioning](https://semver.org/). ### Added -- **`:TwRepairTags [filter]`** — repairs the damage the hyphenated-tag bug - left behind. Tasks added before the fix had the literal `+foo-bar` text - filed into their *description* with no tag created, so they are - unfindable by `+foo-bar` even after the parser fix. This strips those - tokens out and adds the real tags. Every proposed change is shown in a - preview tab and confirmed before anything is written; the default scope - is `status:pending`, and a `+tag` written inside quotes is treated as a - mention and left alone. +- **One-time tag repair** — + `:lua require("taskwarrior.repair_tags").run()` repairs the damage the + hyphenated-tag bug left behind. Tasks added before the fix had the + literal `+foo-bar` text filed into their *description* with no tag + created, so they stay unfindable by `+foo-bar` even after the parser + fix. This strips those tokens out and adds the real tags. Every + proposed change is shown in a preview tab and confirmed before anything + is written; the default scope is `status:pending`, and a `+tag` written + inside quotes is treated as a mention and left alone. Intentionally not + a `:Tw*` command — it is run once, not routinely. - **`:TwContext [name|none]`** — set or show the Taskwarrior context (work, home, …). Task buffers honor the active context's read filter and refresh when it changes. Taskwarrior 3.x applies contexts to diff --git a/README.md b/README.md index 7730205..f5a3990 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,6 @@ These are bundled but require their own host plugins. | `:TwSave ` / `:TwLoad [name]` | Save / restore the current filter+sort+group as a named view | | `:TwReview` | Guided urgency walk through pending tasks | | `:TwContext [name\|show\|define\|delete]` | Taskwarrior context: bare clears it, `` activates, `define`/`delete` manage them | -| `:TwRepairTags [filter]` | Move `+tag` text stuck in descriptions back into real tags (previews every change first) | | `:TwDelegate [copy\|copy-command]` | Delegate task(s) to Claude in a popup form | | `:TwDiffPreview [on\|off\|toggle]` | Toggle live virt-text diff preview | | `:TwBurndown` | Pending-task burndown chart | @@ -309,6 +308,17 @@ Run `:checkhealth taskwarrior` to verify your setup (Neovim version, Taskwarrior By default (`auto_backup = true`), the plugin copies your Taskwarrior data directory to `stdpath("data")/taskwarrior.nvim/backups//` immediately before any apply. The ten newest backups are kept; older ones are pruned. Disable with `auto_backup = false` in `setup()`. +### One-time tag repair + +Taskwarrior 3 parses the hyphen in a bare `+foo-bar` token as a subtraction operator. Tasks added before this was fixed had the literal `+foo-bar` text filed into their **description** with no tag ever created — so they stay invisible to `+foo-bar` even after upgrading. If that happened to you, run the repair once: + +```vim +:lua require("taskwarrior.repair_tags").run() " pending tasks +:lua require("taskwarrior.repair_tags").run("status:completed") +``` + +It shows every proposed change in a preview tab and asks before writing anything. A `+tag` written inside quotes is treated as a mention and left alone. There is deliberately no `:Tw*` command for this — you run it once, not daily. + ## Help Run `:help taskwarrior.nvim` inside Neovim for the full reference, or read [`doc/taskwarrior.txt`](doc/taskwarrior.txt). `:checkhealth taskwarrior` verifies your setup. diff --git a/doc/taskwarrior.txt b/doc/taskwarrior.txt index 87081de..8a95726 100644 --- a/doc/taskwarrior.txt +++ b/doc/taskwarrior.txt @@ -262,22 +262,25 @@ Visualisation commands. See |taskwarrior-views| and |taskwarrior-table|. Delete a context definition (prompts with a picker when the name is omitted). - *:TwRepairTags* -:TwRepairTags [filter] - Repair tasks whose `+tag` text ended up inside the - description. Taskwarrior 3 parses the hyphen in a - bare `+foo-bar` token as subtraction, so tasks - added before this was fixed had the literal text - filed into the description and the tag never - created — making them unfindable by `+foo-bar`. - This strips those tokens out of the description - and adds the real tags. - - Shows every proposed change in a preview tab and - asks before writing anything. Defaults to - `status:pending`; pass a filter to widen the scope - (e.g. `status:completed`). A `+tag` written inside - quotes is treated as a mention and left alone. + *taskwarrior-repair* +One-time tag repair ~ + +Taskwarrior 3 parses the hyphen in a bare `+foo-bar` token as a +subtraction operator, so tasks added before this was fixed had the +literal text filed into their DESCRIPTION and the tag was never created +— leaving them unfindable by `+foo-bar` even after upgrading. To move +those tokens back into real tags: > + + :lua require("taskwarrior.repair_tags").run() + :lua require("taskwarrior.repair_tags").run("status:completed") +< +Every proposed change is shown in a preview tab and confirmed before +anything is written. The default scope is `status:pending`; pass a +filter to widen it. A `+tag` written inside quotes is treated as a +mention (someone describing a tag) and left alone. + +There is deliberately no `:Tw` command for this — it is run once, not +routinely, and would otherwise sit in the command list forever. *:TwFloat* :TwFloat [filter] Open the task buffer in a centered floating window diff --git a/lua/taskwarrior/commands.lua b/lua/taskwarrior/commands.lua index cf7b0df..e286317 100644 --- a/lua/taskwarrior/commands.lua +++ b/lua/taskwarrior/commands.lua @@ -201,15 +201,11 @@ function M.setup(main, complete_filter) views.tags() end, { nargs = 0, desc = "Show tag distribution" }) - -- Repair tasks whose `+tag` text ended up in the description (the - -- hyphenated-tag misparse). Previews every change before writing. - register("RepairTags", function(o) - require("taskwarrior.repair_tags").run(o.args) - end, { - nargs = "*", - desc = "Move +tag text stuck in descriptions back into real tags (previews first)", - complete = function(arg_lead) return complete_filter(arg_lead) end, - }) + -- NOTE: the one-time tag repair (taskwarrior.repair_tags) is deliberately + -- NOT registered here. It is run once, ever, to clean up damage from the + -- TW3 hyphen misparse; a permanent :Tw* slot would clutter the command + -- list for every user forever. Invoke it explicitly: + -- :lua require("taskwarrior.repair_tags").run() register("Table", function(o) require("taskwarrior.table_view").open(o.args) diff --git a/lua/taskwarrior/repair_tags.lua b/lua/taskwarrior/repair_tags.lua index 81239ba..c2f4dec 100644 --- a/lua/taskwarrior/repair_tags.lua +++ b/lua/taskwarrior/repair_tags.lua @@ -1,4 +1,9 @@ --- taskwarrior/repair_tags.lua — :TwRepairTags. +-- taskwarrior/repair_tags.lua — one-time tag repair. +-- +-- Deliberately NOT a :Tw* command: you run this once, ever, and a permanent +-- command slot would clutter the list for every user. Invoke it directly: +-- :lua require("taskwarrior.repair_tags").run() +-- :lua require("taskwarrior.repair_tags").run("status:completed") -- -- Repairs the damage left by the hyphenated-tag bug: Taskwarrior 3 parses -- the hyphen in a bare `+foo-bar` token as a subtraction operator, so @@ -132,7 +137,7 @@ local function apply(plans) return ok_count, failures end ---- :TwRepairTags [filter] — preview, confirm, then repair. +--- Preview, confirm, then repair. `filter` defaults to pending tasks. function M.run(filter) filter = (filter and filter ~= "") and filter or "status:pending" local plans = M.scan(filter) From 963e6bd16d19c667c788b1789b515761a073a8ac Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 18:46:11 -0700 Subject: [PATCH 10/15] feat: task buffers open on the first task, no leading blank rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The taskmd header comment is metadata, not content, but it still cost two screen rows: `conceal` blanks a line without removing it, and render put a spacer underneath. Tasks therefore started on the third row. - hide the header row outright with extmark `conceal_lines` (Neovim 0.11+, feature-probed rather than version-compared; older Neovim keeps the previous blank-row behaviour) - drop the spacer line after the header in render() - park the cursor on the first task on open, in both the split and float paths — with the header row gone, line 1 is not somewhere the cursor should sit Verified by screen geometry, not extmark presence: winline() == 1 for the cursor on the first task proves the header occupies no row. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- CHANGELOG.md | 6 ++ lua/taskwarrior/buffer.lua | 46 ++++++++++- lua/taskwarrior/taskmd.lua | 5 +- tests/e2e/spec/buffer_layout_e2e_spec.lua | 93 +++++++++++++++++++++++ 4 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/spec/buffer_layout_e2e_spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index 0491f2d..ea18076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,12 @@ this project follows [Semantic Versioning](https://semver.org/). ### Changed +- Task buffers now start at the top: the taskmd header comment is hidden + outright via extmark `conceal_lines` (Neovim 0.11+) instead of leaving a + blank concealed row, the spacer line beneath it is gone, and the cursor + opens on the first task rather than the header. On Neovim < 0.11 the + header still renders as one blank row (`conceal_lines` does not exist + there), but the spacer and cursor changes apply. - With `confirm = false`, the apply summary notification now points at `:TwUndo` — without the popup that notification is the only checkpoint, so the revert path is named where you'll see it. diff --git a/lua/taskwarrior/buffer.lua b/lua/taskwarrior/buffer.lua index b523aaf..cebb892 100644 --- a/lua/taskwarrior/buffer.lua +++ b/lua/taskwarrior/buffer.lua @@ -194,6 +194,22 @@ end -- --------------------------------------------------------------------------- local hl_ns = vim.api.nvim_create_namespace("taskwarrior_hl") + +-- Whether this Neovim understands extmark `conceal_lines` (0.11+), which +-- hides a line's screen row outright rather than blanking it. Probed once +-- against a throwaway buffer — the option is silently ignored on older +-- versions in some builds and errors in others, so feature-test rather +-- than version-compare. +local HAS_CONCEAL_LINES = (function() + local probe_buf = vim.api.nvim_create_buf(false, true) + local probe_ns = vim.api.nvim_create_namespace("taskwarrior_conceal_probe") + vim.api.nvim_buf_set_lines(probe_buf, 0, -1, false, { "probe" }) + local ok = pcall(vim.api.nvim_buf_set_extmark, probe_buf, probe_ns, 0, 0, { + conceal_lines = "", + }) + pcall(vim.api.nvim_buf_delete, probe_buf, { force = true }) + return ok +end)() local vt_ns = vim.api.nvim_create_namespace("taskwarrior_vt") -- Paint the checkbox visuals on a single line: a conceal extmark over the @@ -638,10 +654,12 @@ local function highlight_line(bufnr, line_nr, line) -- the tasks themselves. Filter/sort/group are shown via statusline if the -- user configures it, and :TaskHelp lists the active settings. if line:match("^", filter_str, sort_spec, group_part, udas_part, now_iso()) - local lines = { header, "" } + -- No blank line after the header: the header itself is concealed in the + -- task buffer, so a spacer under it just pushes the first task down two + -- rows for no visual benefit. Group headers below bring their own spacing. + local lines = { header } if group_field then local groups = {} local order = {} diff --git a/tests/e2e/spec/buffer_layout_e2e_spec.lua b/tests/e2e/spec/buffer_layout_e2e_spec.lua new file mode 100644 index 0000000..b5c1dff --- /dev/null +++ b/tests/e2e/spec/buffer_layout_e2e_spec.lua @@ -0,0 +1,93 @@ +-- buffer_layout_e2e_spec.lua — the task buffer starts at the top. +-- +-- The taskmd header comment is metadata, not content: it must cost zero +-- screen rows, and the first task must be drawn on the window's top line +-- with the cursor already on it. +-- +-- Asserting the extmark exists is NOT enough here — `conceal` blanks a row +-- but still occupies it, while `conceal_lines` (Neovim 0.11+) removes it. +-- The observable difference is `winline()`, so that is what's checked. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") +local buffer = require("taskwarrior.buffer") + +local has_conceal_lines = pcall(function() + local b = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(b, 0, -1, false, { "x" }) + local ok = pcall(vim.api.nvim_buf_set_extmark, b, + vim.api.nvim_create_namespace("probe"), 0, 0, { conceal_lines = "" }) + pcall(vim.api.nvim_buf_delete, b, { force = true }) + assert(ok) +end) + +describe("e2e task buffer layout", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + + local uuid = taskmd.tw_add("layout probe task", { project = "layoutdemo" }) + assert(uuid ~= "") + + it("renders no blank spacer between the header and the first task", function() + local out = taskmd.render({ filter = { "project:layoutdemo" }, sort = "urgency-" }) + local lines = vim.split(out, "\n", { plain = true }) + assert.is_truthy(lines[1]:match("^= 1 and row <= count, + ("cursor row %d outside buffer of %d lines"):format(row, count)) + end) + + it("the float path also opens on the first task", function() + vim.cmd("enew") + require("taskwarrior.buffer").open_float("project:layoutdemo") + vim.wait(300, function() return false end, 10) + + local row = vim.api.nvim_win_get_cursor(0)[1] + local line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] or "" + assert.is_truthy(line:match("^%- %["), + ("float cursor landed on a non-task line (row %d): %q"):format(row, line)) + vim.cmd("close") + end) +end) From 18bc1e3030e6f900f3d63761d42999cb02a1dcce Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sat, 15 Aug 2026 19:02:17 -0700 Subject: [PATCH 11/15] feat: pick finite choices from a list instead of typing them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sort order, grouping, context, saved views and reports each have a small known answer set, so none of them should require remembering syntax like `urgency-`. All five now open a picker; new tc (context), tv (saved view) and tr (report) join the existing ts / tg, and a bare :TwSort opens the picker instead of erroring. Built on vim.ui.select so the list renders through whatever picker the user already configured (dressing/telescope, snacks, fzf-lua) and inherits its fuzzy matching, degrading to Neovim's built-in list. The active value is marked, and the context/report pickers show each entry's filter. The filter prompt stays free text — a Taskwarrior filter is not a finite set. Sort specs and group fields lived in three copies (two completion callbacks plus the command); they now come from taskwarrior.choices. Two real bugs surfaced while testing this, both fixed: - sorting by a field only SOME tasks have (priority, due, project) raised "attempt to compare two boolean values" and failed the entire render — the comparator compared two is-missing booleans with `<`. Missing values now sort last in both directions. - priority sorted alphabetically, so `priority-` ("most important first", matching urgency- beside it) listed L above H. Now ranked H > M > L. Also: :TwLoad with no argument silently did nothing, because the command passes "" and `if name then` is true for the empty string. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- CHANGELOG.md | 23 +++ README.md | 7 +- doc/taskwarrior.txt | 34 +++- lua/taskwarrior/buffer.lua | 38 ++-- lua/taskwarrior/choices.lua | 52 +++++ lua/taskwarrior/commands.lua | 33 +-- lua/taskwarrior/config.lua | 6 + lua/taskwarrior/context.lua | 24 +++ lua/taskwarrior/init.lua | 38 ++-- lua/taskwarrior/pick.lua | 52 +++++ lua/taskwarrior/report.lua | 13 +- lua/taskwarrior/saved_views.lua | 6 +- lua/taskwarrior/taskmd.lua | 19 +- lua/taskwarrior/validate.lua | 16 ++ tests/e2e/spec/pickers_e2e_spec.lua | 224 +++++++++++++++++++++ tests/lua/spec/sort_missing_field_spec.lua | 76 +++++++ 16 files changed, 600 insertions(+), 61 deletions(-) create mode 100644 lua/taskwarrior/choices.lua create mode 100644 lua/taskwarrior/pick.lua create mode 100644 tests/e2e/spec/pickers_e2e_spec.lua create mode 100644 tests/lua/spec/sort_missing_field_spec.lua diff --git a/CHANGELOG.md b/CHANGELOG.md index ea18076..e118105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,8 +56,31 @@ this project follows [Semantic Versioning](https://semver.org/). the stored description (plus an annotation count), so you can tell at a glance that the right task went through and its fields parsed. +- **Pickers for every finite choice.** Anything with a small known answer + set is now chosen from a list instead of typed from memory: sort order + (`ts`), grouping (`tg`), context (`tc`, new), + saved views (`tv`, new) and reports (`tr`, new). The + active value is marked in the list, and the context/report pickers show + each entry's filter so you can tell them apart. Built on `vim.ui.select`, + so fuzzy matching comes from whatever picker you already use + (dressing/telescope, snacks, fzf-lua) and it degrades to Neovim's + built-in list otherwise. Bare `:TwSort` now opens the picker instead of + erroring. New `context_key` / `view_key` / `report_key` options, each + disablable with `false`. + ### Fixed +- **Sorting by a field only some tasks have** (`:TwSort priority-`, + `due+`, `project-`) raised `attempt to compare two boolean values` and + failed the whole render. The comparator compared two "is this side + missing?" booleans with `<`, which Lua rejects. Missing values now sort + last in both directions. +- **Priority sorted alphabetically rather than by importance**, so + `priority-` — which reads as "most important first", like `urgency-` + beside it — listed `L` above `H`. Priority is now ranked H > M > L. +- `:TwLoad` with no argument silently did nothing instead of offering the + saved-view picker: the command passes `""`, which is truthy in Lua, so + it fell through to the "load view named ''" path. - **Hyphenated tag names** (`+ais-research-taste` and friends). Taskwarrior 3's expression parser reads the hyphen in a bare `+tag` token as a subtraction operator, which broke three separate paths: diff --git a/README.md b/README.md index f5a3990..fcb0b3f 100644 --- a/README.md +++ b/README.md @@ -190,9 +190,12 @@ These are bundled but require their own host plugins. | `ta` | Quick-capture (global, works from any buffer) | | `tt` | Open task buffer (global) | | `tf` | Change filter (in task buffer) | -| `ts` | Change sort (in task buffer) | -| `tg` | Change group (in task buffer) | +| `ts` | Pick sort order (in task buffer) | +| `tg` | Pick grouping (in task buffer) | | `tpa` | Register cwd as a project | +| `tc` | Pick the Taskwarrior context (global) | +| `tv` | Pick a saved view (global) | +| `tr` | Pick a named report (global) | ## Metadata syntax diff --git a/doc/taskwarrior.txt b/doc/taskwarrior.txt index 8a95726..0257cec 100644 --- a/doc/taskwarrior.txt +++ b/doc/taskwarrior.txt @@ -381,9 +381,34 @@ Buffer-local (inside a task buffer): ~ << Prepend to description (|:TwPrepend|). yt Duplicate task (|:TwDuplicate|). dD Purge task (irreversible, confirms). - tf Change filter interactively. - ts Change sort interactively. - tg Change grouping interactively. + tf Change filter (free text, completes). + ts Pick sort order. + tg Pick grouping. + +Global (work from any buffer): + + tt Open the task buffer. + ta Quick-capture a task. + tc Pick the Taskwarrior context. + tv Pick a saved view. + tr Pick a named report. + tpa Register cwd as a project. + tF Report a bug (|:TwFeedback|). + + *taskwarrior-pickers* +Anything with a small, known set of answers is chosen from a list rather +than typed: sort order, grouping, context, saved views and reports. The +active value is marked with `●`, and the context and report pickers show +each entry's filter alongside its name. + +These use |vim.ui.select|, so they render through whichever picker you +have configured — dressing.nvim/telescope, snacks, fzf-lua — and inherit +its fuzzy matching. With no such plugin installed you get Neovim's +built-in numbered list. + +The filter prompt is deliberately NOT a picker: a Taskwarrior filter is +open-ended, so it stays free text with completion over your real +projects, tags and fields. ============================================================================== 7. SYNTAX *taskwarrior-syntax* @@ -422,6 +447,9 @@ Full schema with defaults: > sort_key = "ts", group_key = "tg", project_add_key = "tpa", + context_key = "tc", -- pick a context + view_key = "tv", -- pick a saved view + report_key = "tr", -- pick a named report filters = {}, projects = {}, icons = true, diff --git a/lua/taskwarrior/buffer.lua b/lua/taskwarrior/buffer.lua index cebb892..df94944 100644 --- a/lua/taskwarrior/buffer.lua +++ b/lua/taskwarrior/buffer.lua @@ -1061,33 +1061,31 @@ function M.setup_buf_keymaps(bufnr) end, { buffer = bufnr, noremap = true, silent = true, desc = "taskwarrior.nvim: Change filter" }) end - -- Buffer-local sort key + -- Buffer-local sort key. Finite set → picker, not a free-text prompt. if config2.options.sort_key then vim.keymap.set("n", config2.options.sort_key, function() - local ok, input = pcall(vim.fn.input, { - prompt = "Sort: ", - default = vim.b[bufnr].task_sort or "urgency-", - completion = "customlist,v:lua.require'taskwarrior'._complete_sort", - }) - if not ok or input == nil then return end - vim.b[bufnr].task_sort = input - M.refresh_buf(bufnr) - vim.notify("taskwarrior.nvim: sort → " .. input) + require("taskwarrior.pick").select( + require("taskwarrior.choices").SORTS, + { prompt = "Sort by:", current = vim.b[bufnr].task_sort or "urgency-" }, + function(value) + vim.b[bufnr].task_sort = value + M.refresh_buf(bufnr) + vim.notify("taskwarrior.nvim: sort → " .. value) + end) end, { buffer = bufnr, noremap = true, silent = true, desc = "taskwarrior.nvim: Change sort" }) end - -- Buffer-local group key + -- Buffer-local group key. if config2.options.group_key then vim.keymap.set("n", config2.options.group_key, function() - local ok, input = pcall(vim.fn.input, { - prompt = "Group by (empty=none): ", - default = vim.b[bufnr].task_group or "", - completion = "customlist,v:lua.require'taskwarrior'._complete_group", - }) - if not ok or input == nil then return end - vim.b[bufnr].task_group = (input ~= "" and input ~= "none") and input or nil - M.refresh_buf(bufnr) - vim.notify("taskwarrior.nvim: group → " .. (input ~= "" and input or "(none)")) + require("taskwarrior.pick").select( + require("taskwarrior.choices").GROUPS, + { prompt = "Group by:", current = vim.b[bufnr].task_group or "none" }, + function(value) + vim.b[bufnr].task_group = (value ~= "none") and value or nil + M.refresh_buf(bufnr) + vim.notify("taskwarrior.nvim: group → " .. value) + end) end, { buffer = bufnr, noremap = true, silent = true, desc = "taskwarrior.nvim: Change grouping" }) end diff --git a/lua/taskwarrior/choices.lua b/lua/taskwarrior/choices.lua new file mode 100644 index 0000000..1e4e29b --- /dev/null +++ b/lua/taskwarrior/choices.lua @@ -0,0 +1,52 @@ +-- taskwarrior/choices.lua — the finite option sets, in one place. +-- +-- Sort specs and group fields were previously spelled out three times each +-- (command completion, input() completion, and now the pickers), which is +-- how they drift. Everything that offers these choices reads them here. +-- +-- Each entry is { value, label } — `value` is what Taskwarrior/the buffer +-- variable wants, `label` is what a human should see in a picker. + +local M = {} + +M.SORTS = { + { value = "urgency-", label = "urgency ↓ (most urgent first)" }, + { value = "urgency+", label = "urgency ↑ (least urgent first)" }, + { value = "due+", label = "due ↑ (soonest first)" }, + { value = "due-", label = "due ↓ (latest first)" }, + { value = "priority-", label = "priority ↓ (H → L)" }, + { value = "priority+", label = "priority ↑ (L → H)" }, + { value = "project+", label = "project A→Z" }, + { value = "project-", label = "project Z→A" }, + { value = "description+", label = "description A→Z" }, +} + +M.GROUPS = { + { value = "none", label = "none (flat list)" }, + { value = "project", label = "project" }, + { value = "priority", label = "priority" }, + { value = "status", label = "status" }, + { value = "tag", label = "tag" }, +} + +-- Plain value lists, for cmdline completion. +local function values(set) + local out = {} + for _, entry in ipairs(set) do out[#out + 1] = entry.value end + return out +end + +function M.sort_values() return values(M.SORTS) end +function M.group_values() return values(M.GROUPS) end + +--- Filter a value list by a completion prefix. +function M.complete(set, arg_lead) + arg_lead = arg_lead or "" + local out = {} + for _, v in ipairs(values(set)) do + if v:sub(1, #arg_lead) == arg_lead then out[#out + 1] = v end + end + return out +end + +return M diff --git a/lua/taskwarrior/commands.lua b/lua/taskwarrior/commands.lua index e286317..376718a 100644 --- a/lua/taskwarrior/commands.lua +++ b/lua/taskwarrior/commands.lua @@ -70,19 +70,24 @@ function M.setup(main, complete_filter) main.undo() end, { nargs = 0, desc = "Undo last save" }) + -- No argument opens the picker (previously this was an error), so both + -- `:TwSort due+` and a bare `:TwSort` are usable. register("Sort", function(cmd_opts) + if cmd_opts.args == "" then + local bufnr = vim.api.nvim_get_current_buf() + require("taskwarrior.pick").select( + require("taskwarrior.choices").SORTS, + { prompt = "Sort by:", current = vim.b[bufnr].task_sort or "urgency-" }, + function(value) main.sort(value) end) + return + end main.sort(cmd_opts.args) end, { - nargs = 1, - desc = "Change task sort order (e.g. due+, urgency-)", + nargs = "?", + desc = "Change task sort order (no argument opens a picker)", complete = function(arg_lead) - local fields = { "urgency-", "urgency+", "due+", "due-", "priority-", - "priority+", "project+", "project-", "description+" } - local results = {} - for _, f in ipairs(fields) do - if f:sub(1, #arg_lead) == arg_lead then table.insert(results, f) end - end - return results + local choices = require("taskwarrior.choices") + return choices.complete(choices.SORTS, arg_lead) end, }) @@ -90,14 +95,10 @@ function M.setup(main, complete_filter) main.group(cmd_opts.args) end, { nargs = "?", - desc = "Change task grouping (e.g. project, tag, none)", + desc = "Change task grouping (project, tag, none; see also tg)", complete = function(arg_lead) - local fields = { "project", "priority", "status", "tag", "none" } - local results = {} - for _, f in ipairs(fields) do - if f:sub(1, #arg_lead) == arg_lead then table.insert(results, f) end - end - return results + local choices = require("taskwarrior.choices") + return choices.complete(choices.GROUPS, arg_lead) end, }) diff --git a/lua/taskwarrior/config.lua b/lua/taskwarrior/config.lua index e373fd6..c2dceab 100644 --- a/lua/taskwarrior/config.lua +++ b/lua/taskwarrior/config.lua @@ -31,6 +31,12 @@ M.defaults = { sort_key = "ts", -- buffer-local keybind to change sort (nil to disable) group_key = "tg", -- buffer-local keybind to change grouping (nil to disable) project_add_key = "tpa", -- global keybind to register cwd as a project (nil to disable) + -- Global keybinds that open a picker over a finite set. All use + -- vim.ui.select, so they inherit whatever picker (and fuzzy matching) + -- you already use. Set any to nil/false to disable. + context_key = "tc", -- pick the Taskwarrior context + view_key = "tv", -- pick a saved view + report_key = "tr", -- pick a named report filters = {}, -- named filter presets: { { key = "", filter = "filter_str", label = "label" }, ... } projects = {}, -- directory-to-project mapping: { ["/path/to/dir"] = "project_name", ... } -- Icon mode. Auto-detects nerd-font availability via `vim.g.have_nerd_font` diff --git a/lua/taskwarrior/context.lua b/lua/taskwarrior/context.lua index 270a05b..203b13b 100644 --- a/lua/taskwarrior/context.lua +++ b/lua/taskwarrior/context.lua @@ -91,6 +91,30 @@ function M.show() notify("view", table.concat(lines, "\n")) end +--- Fuzzy-pick a context to activate. Lists every defined context with its +--- read filter, marks the active one, and offers "none" to clear plus a +--- "define new" escape hatch so an empty context list is not a dead end. +function M.pick() + local names = M.list() + local current = M.current() + local entries = { { value = "none", label = "none (clear context)" } } + for _, name in ipairs(names) do + entries[#entries + 1] = { + value = name, + label = ("%-10s%s"):format(name, M.read_filter(name) or ""), + } + end + entries[#entries + 1] = { value = "\0define", label = "+ define a new context…" } + + require("taskwarrior.pick").select(entries, { + prompt = "Context:", + current = current or "none", + }, function(value) + if value == "\0define" then return M.define() end + M.set(value) + end) +end + --- Define a context. `filter` is a Taskwarrior filter expression; when --- omitted the user is prompted for it. Prompts for the name too when nil. function M.define(name, filter) diff --git a/lua/taskwarrior/init.lua b/lua/taskwarrior/init.lua index 6c63e8f..b6e9432 100644 --- a/lua/taskwarrior/init.lua +++ b/lua/taskwarrior/init.lua @@ -223,22 +223,13 @@ function M._complete_modify(_arg_lead, cmd_line, _cursor_pos) end function M._complete_sort(arg_lead, _cmd_line, _cursor_pos) - local fields = { "urgency-", "urgency+", "due+", "due-", "priority-", - "priority+", "project+", "project-", "description+" } - local results = {} - for _, f in ipairs(fields) do - if f:sub(1, #arg_lead) == arg_lead then table.insert(results, f) end - end - return results + local choices = require("taskwarrior.choices") + return choices.complete(choices.SORTS, arg_lead) end function M._complete_group(arg_lead, _cmd_line, _cursor_pos) - local fields = { "project", "priority", "status", "tag", "none" } - local results = {} - for _, f in ipairs(fields) do - if f:sub(1, #arg_lead) == arg_lead then table.insert(results, f) end - end - return results + local choices = require("taskwarrior.choices") + return choices.complete(choices.GROUPS, arg_lead) end M.api = {} @@ -295,6 +286,27 @@ function M.setup(opts) end, vim.tbl_extend("force", gopts, { desc = "taskwarrior.nvim: Register cwd as project" })) end + -- Finite-choice pickers. Each is a small known set, so it gets a picker + -- rather than a remembered-syntax prompt. + local function map_picker(key, fn, desc) + if key and key ~= false and key ~= "" then + vim.keymap.set("n", key, fn, + vim.tbl_extend("force", gopts, { desc = "taskwarrior.nvim: " .. desc })) + end + end + + map_picker(config.options.context_key, function() + require("taskwarrior.context").pick() + end, "Pick Taskwarrior context") + + map_picker(config.options.view_key, function() + M.view_load() + end, "Pick a saved view") + + map_picker(config.options.report_key, function() + M.report() + end, "Pick a named report") + -- Easy-feedback global keymap (issue #2 → v1.4.1). Default tF. -- Set feedback_key = false to disable. local feedback_cfg = config.options.feedback or {} diff --git a/lua/taskwarrior/pick.lua b/lua/taskwarrior/pick.lua new file mode 100644 index 0000000..baf7ebc --- /dev/null +++ b/lua/taskwarrior/pick.lua @@ -0,0 +1,52 @@ +-- taskwarrior/pick.lua — one way to choose from a finite set. +-- +-- Everything with a small, known set of answers (sort spec, grouping, +-- context, saved view, report) goes through here instead of a free-text +-- `vim.fn.input`. Typing `urgency-` from memory is not a feature. +-- +-- Built on `vim.ui.select`, deliberately: that routes through whatever +-- picker the user already configured — telescope/dressing, snacks, fzf-lua +-- — so fuzzy matching comes from their own tooling and matches the rest of +-- their editor. With no backend installed it degrades to Neovim's built-in +-- numbered list, which still works. + +local M = {} + +--- Choose from `entries` ({ value, label } tables, or plain strings). +--- opts.prompt — picker prompt. +--- opts.current — value to mark as active. +--- on_choice(value, entry) fires only on a real selection (never on cancel). +function M.select(entries, opts, on_choice) + opts = opts or {} + local items = {} + for _, e in ipairs(entries) do + if type(e) == "string" then + items[#items + 1] = { value = e, label = e } + else + items[#items + 1] = e + end + end + if #items == 0 then + require("taskwarrior.notify")("warn", + opts.empty_message or "taskwarrior.nvim: nothing to choose from", + vim.log.levels.WARN) + return + end + + vim.ui.select(items, { + prompt = opts.prompt or "Select:", + format_item = function(item) + local label = item.label or item.value + -- Mark the active entry so the picker doubles as "what is set now?". + if opts.current ~= nil and item.value == opts.current then + return "● " .. label + end + return " " .. label + end, + }, function(choice) + if not choice then return end + on_choice(choice.value, choice) + end) +end + +return M diff --git a/lua/taskwarrior/report.lua b/lua/taskwarrior/report.lua index 4fd3b0c..a249cae 100644 --- a/lua/taskwarrior/report.lua +++ b/lua/taskwarrior/report.lua @@ -35,9 +35,16 @@ end -- threaded from init.lua (so we don't introduce a circular require). function M.open(name, open_fn) if not name or name == "" then - vim.ui.select(M.names(), { prompt = "Report:" }, function(choice) - if choice then M.open(choice, open_fn) end - end) + local entries = {} + for _, n in ipairs(M.names()) do + local r = M.reports[n] + entries[#entries + 1] = { + value = n, + label = ("%-12s%s"):format(n, (r and r.filter) or ""), + } + end + require("taskwarrior.pick").select(entries, { prompt = "Report:" }, + function(choice) M.open(choice, open_fn) end) return end local report = M.reports[name] diff --git a/lua/taskwarrior/saved_views.lua b/lua/taskwarrior/saved_views.lua index cef7803..4eb4c6a 100644 --- a/lua/taskwarrior/saved_views.lua +++ b/lua/taskwarrior/saved_views.lua @@ -92,7 +92,9 @@ function M.load(name, open_fn, refresh_fn) end vim.notify(string.format("taskwarrior.nvim: loaded view %q", chosen)) end - if name then + -- `:TwLoad` with no argument passes "" (truthy in Lua), which used to fall + -- into finish("") and silently do nothing. Empty means "ask me". + if name and name ~= "" then finish(name) else local names = M.list_names() @@ -100,7 +102,7 @@ function M.load(name, open_fn, refresh_fn) vim.notify("taskwarrior.nvim: no saved views", vim.log.levels.WARN) return end - vim.ui.select(names, { prompt = "Load view:" }, finish) + require("taskwarrior.pick").select(names, { prompt = "Load view:" }, finish) end end diff --git a/lua/taskwarrior/taskmd.lua b/lua/taskwarrior/taskmd.lua index 1e9681f..ef2b51a 100644 --- a/lua/taskwarrior/taskmd.lua +++ b/lua/taskwarrior/taskmd.lua @@ -1166,11 +1166,26 @@ function M.render(args) local descending = sort_spec:sub(-1) == "-" local sort_field = sort_spec:gsub("[+-]$", "") + -- Priority is ordinal, not alphabetical: comparing the letters put "L" + -- above "H" descending, so `priority-` listed the LEAST important tasks + -- first — the opposite of what the spec means (and of `urgency-` beside + -- it). Rank them so the numeric branch below does the right thing. + local PRIORITY_RANK = { H = 3, M = 2, L = 1 } + local function sort_value(task) + local v = task[sort_field] + if sort_field == "priority" and type(v) == "string" then + return PRIORITY_RANK[v:upper()] + end + return v + end table.sort(tasks, function(a, b) - local va, vb = a[sort_field], b[sort_field] + local va, vb = sort_value(a), sort_value(b) local ma, mb = va == nil, vb == nil if ma and mb then return false end - if ma ~= mb then return ma < mb end -- missing sorts last (ma=true → larger) + -- Exactly one side is missing → it sorts last, regardless of direction. + -- (`ma < mb` was a boolean comparison, which Lua rejects outright: any + -- sort on a field some tasks lack — priority, due, project — errored.) + if ma ~= mb then return mb end if type(va) == "number" and type(vb) == "number" then if descending then return va > vb else return va < vb end end diff --git a/lua/taskwarrior/validate.lua b/lua/taskwarrior/validate.lua index aebbdb9..6aea7da 100644 --- a/lua/taskwarrior/validate.lua +++ b/lua/taskwarrior/validate.lua @@ -9,6 +9,7 @@ local KNOWN_KEYS = { "on_delete", "confirm", "sort", "group", "fields", "wrap", "capture_key", "open_key", "filter_key", "sort_key", "group_key", "project_add_key", "filters", "projects", "icons", + "context_key", "view_key", "report_key", "border_style", "capture_width", "capture_height", "capture_confirm_close", "capture_annotations", "capture_annotation_key", "field_colors", "table_columns", @@ -42,6 +43,8 @@ local TOP_LEVEL_TYPES = { sort_key = "string", -- nil OK group_key = "string", -- nil OK project_add_key = "string", -- nil OK + -- context_key / view_key / report_key accept `false` to disable, so they + -- are checked separately below rather than as plain strings. filters = "table", projects = "table", icons = "boolean", @@ -174,6 +177,19 @@ function M.validate(opts) -- 2. Top-level type checks. check_types(opts, TOP_LEVEL_TYPES, nil) + -- Picker keymaps: a keystring, or false to disable. + for _, key in ipairs({ "context_key", "view_key", "report_key" }) do + local v = opts[key] + if v ~= nil and v ~= false and type(v) ~= "string" then + error( + ("taskwarrior.nvim: setup key '%s' must be a string or false, got %s"):format( + key, type(v) + ), + 0 + ) + end + end + -- feedback_endpoint: boolean false OR a string URL. local fe = opts.feedback_endpoint if fe ~= nil and fe ~= false and type(fe) ~= "string" then diff --git a/tests/e2e/spec/pickers_e2e_spec.lua b/tests/e2e/spec/pickers_e2e_spec.lua new file mode 100644 index 0000000..9f1004d --- /dev/null +++ b/tests/e2e/spec/pickers_e2e_spec.lua @@ -0,0 +1,224 @@ +-- pickers_e2e_spec.lua — finite choices are chosen, not typed. +-- +-- Sort spec, grouping, context, saved view and report all have small known +-- answer sets, so each is driven by a picker rather than a free-text prompt. +-- These tests stub vim.ui.select (standing in for whatever backend the user +-- has — dressing/telescope, snacks, fzf-lua) and assert the *effect* of a +-- selection, not merely that a picker appeared. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") +local context = require("taskwarrior.context") + +-- Stub the picker: `chooser(labels)` returns the index to select, or nil to +-- cancel. Captures the rendered labels so tests can assert what was offered. +local function with_picker(chooser, fn) + local orig = vim.ui.select + local seen = {} + vim.ui.select = function(items, opts, cb) + local labels = {} + for i, item in ipairs(items) do + labels[i] = opts.format_item and opts.format_item(item) or tostring(item) + end + seen = { labels = labels, items = items, prompt = opts.prompt } + local idx = chooser(labels, items) + if idx == nil then return cb(nil) end + cb(items[idx], idx) + end + local ok, err = pcall(fn) + vim.ui.select = orig + if not ok then error(err) end + return seen +end + +-- Fire a normal-mode keymap by its description. Matching on `desc` rather +-- than `lhs` because Neovim stores the mapping with already +-- expanded to the current mapleader, which the spec cannot assume. +local function press(bufnr, desc_fragment) + local function search(maps) + for _, m in ipairs(maps) do + if m.callback and m.desc and m.desc:find(desc_fragment, 1, true) then + m.callback() + return true + end + end + return false + end + if bufnr and bufnr ~= 0 and search(vim.api.nvim_buf_get_keymap(bufnr, "n")) then + return true + end + if bufnr and search(vim.api.nvim_buf_get_keymap(bufnr, "n")) then return true end + return search(vim.api.nvim_get_keymap("n")) +end + +describe("e2e finite-choice pickers", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + local config = require("taskwarrior.config") + + taskmd.tw_add("picker probe alpha", { project = "pickdemo", priority = "H" }) + taskmd.tw_add("picker probe beta", { project = "pickdemo" }) + + local function open_buf() + vim.cmd("enew") + require("taskwarrior").open("project:pickdemo") + vim.wait(300, function() return false end, 10) + return vim.api.nvim_get_current_buf() + end + + it("sort key opens a picker and applies the chosen spec", function() + local bufnr = open_buf() + local seen = with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("due ↑", 1, true) then return i end + end + end, function() + assert.is_true(press(bufnr, "Change sort"), "sort key not mapped") + vim.wait(500, function() return vim.b[bufnr].task_sort == "due+" end, 10) + end) + + assert.are.same("due+", vim.b[bufnr].task_sort, + "picker selection did not change the buffer's sort") + -- The active spec is marked, so the picker also answers "what's set now?". + local marked = false + for _, l in ipairs(seen.labels) do + if l:sub(1, 3) == "● " or l:find("●", 1, true) then marked = true end + end + assert.is_true(marked, "no current-value marker in the sort picker") + end) + + it("group key opens a picker and applies the chosen field", function() + local bufnr = open_buf() + with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("project", 1, true) then return i end + end + end, function() + assert.is_true(press(bufnr, "Change grouping"), "group key not mapped") + vim.wait(500, function() return vim.b[bufnr].task_group == "project" end, 10) + end) + assert.are.same("project", vim.b[bufnr].task_group) + + -- Choosing "none" clears it, rather than setting a literal group. + with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("none", 1, true) then return i end + end + end, function() + press(bufnr, "Change grouping") + vim.wait(500, function() return vim.b[bufnr].task_group == nil end, 10) + end) + assert.is_nil(vim.b[bufnr].task_group, "'none' should clear grouping") + end) + + it("context key picks a context and activates it", function() + vim.fn.system("task rc.bulk=0 rc.confirmation=off rc.verbose=nothing " .. + "context define pickctx project:pickdemo") + context.set("none") + + local seen = with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("pickctx", 1, true) then return i end + end + end, function() + assert.is_true(press(0, "Pick Taskwarrior context"), "context key not mapped") + vim.wait(2000, function() return context.current() == "pickctx" end, 20) + end) + + assert.are.same("pickctx", context.current(), + "picker selection did not activate the context") + -- The picker shows each context's filter, so you can tell them apart. + local shows_filter = false + for _, l in ipairs(seen.labels) do + if l:find("project:pickdemo", 1, true) then shows_filter = true end + end + assert.is_true(shows_filter, "context picker does not show read filters") + context.set("none") + end) + + it("context picker offers 'none' and a define escape hatch", function() + local seen = with_picker(function() return nil end, function() + press(0, "Pick Taskwarrior context") + end) + local has_none, has_define = false, false + for _, l in ipairs(seen.labels) do + if l:find("clear context", 1, true) then has_none = true end + if l:find("define a new context", 1, true) then has_define = true end + end + assert.is_true(has_none, "context picker has no 'none' entry") + assert.is_true(has_define, "context picker has no define escape hatch") + end) + + it("cancelling a picker changes nothing", function() + local bufnr = open_buf() + vim.b[bufnr].task_sort = "urgency-" + with_picker(function() return nil end, function() + press(bufnr, "Change sort") + vim.wait(300, function() return false end, 10) + end) + assert.are.same("urgency-", vim.b[bufnr].task_sort, + "cancelling the picker must not change the sort") + end) + + it("bare :TwSort opens the picker instead of erroring", function() + local bufnr = open_buf() + local prefix = config.options.command_prefix or "Tw" + with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("priority ↓", 1, true) then return i end + end + end, function() + vim.cmd(prefix .. "Sort") + vim.wait(500, function() return vim.b[bufnr].task_sort == "priority-" end, 10) + end) + assert.are.same("priority-", vim.b[bufnr].task_sort) + end) + + it("report picker opens the chosen report", function() + local seen = with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("overdue", 1, true) then return i end + end + end, function() + assert.is_true(press(0, "Pick a named report"), "report key not mapped") + vim.wait(1000, function() + return (vim.b[vim.api.nvim_get_current_buf()].task_filter or ""):find("OVERDUE") ~= nil + end, 20) + end) + + local shows_filter = false + for _, l in ipairs(seen.labels) do + if l:find("status:pending", 1, true) then shows_filter = true end + end + assert.is_true(shows_filter, "report picker does not show each report's filter") + local filter = vim.b[vim.api.nvim_get_current_buf()].task_filter or "" + assert.is_truthy(filter:find("OVERDUE"), + "overdue report did not open; filter = " .. filter) + end) + + it("saved-view picker loads the chosen view (and no-arg :TwLoad asks)", function() + local bufnr = open_buf() + vim.b[bufnr].task_sort = "due+" + require("taskwarrior").view_save("pickview") + vim.wait(300, function() return false end, 10) + + vim.cmd("enew") + with_picker(function(labels) + for i, l in ipairs(labels) do + if l:find("pickview", 1, true) then return i end + end + end, function() + assert.is_true(press(0, "Pick a saved view"), "view key not mapped") + vim.wait(1000, function() + return vim.b[vim.api.nvim_get_current_buf()].task_filter ~= nil + end, 20) + end) + + local loaded = vim.api.nvim_get_current_buf() + assert.are.same("project:pickdemo", vim.b[loaded].task_filter, + "saved view did not load its filter") + end) +end) diff --git a/tests/lua/spec/sort_missing_field_spec.lua b/tests/lua/spec/sort_missing_field_spec.lua new file mode 100644 index 0000000..5e9f610 --- /dev/null +++ b/tests/lua/spec/sort_missing_field_spec.lua @@ -0,0 +1,76 @@ +-- Regression spec: sorting by a field that only SOME tasks have. +-- +-- The comparator used to do `return ma < mb` on two booleans ("is this side +-- missing?"), which Lua rejects with "attempt to compare two boolean +-- values". Any sort on a partially-populated field — priority, due, project +-- — therefore blew up the whole render with a Lua error instead of sorting. +-- Real databases always have such fields, so this hit `:TwSort priority-` +-- almost immediately. + +local eq = assert.are.same +local tm = require("taskwarrior.taskmd") + +-- Task descriptions in render order. The rendered line carries the fields +-- too (`has high priority:H`), so cut at the first metadata token. +local function descriptions(out) + local names = {} + for line in out:gmatch("[^\n]+") do + local d = line:match("^%- %[.%]%s+(.-)%s* M > L), not alphabetical. Sorting the letters + -- put "L" above "H" descending, so `priority-` — which reads as "most + -- important first", matching `urgency-` — listed the least important + -- tasks first. + it("orders priority by importance, not alphabetically", function() + eq({ "has high", "has low", "no priority" }, render("priority-")) + eq({ "has low", "has high", "no priority" }, render("priority+")) + end) + + it("handles every field missing without erroring", function() + local names = render("project-") + eq(3, #names) + end) +end) From 3eb263702937414fab9a747b6dd14d85c863d32d Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sun, 16 Aug 2026 14:36:15 -0700 Subject: [PATCH 12/15] fix: keep the header row when a filter matches nothing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding the header row with conceal_lines also hides anything anchored to it — a concealed-away line draws no virt_lines (verified directly against Neovim 0.11.6). The empty-state hint IS virt_lines on the header, so an empty filter rendered a completely blank window with no explanation: exactly the issue #5 symptom the hint was added to prevent. With no tasks there is nothing to pull to the top anyway, so the header row now stays whenever the buffer holds no task lines, and is removed only when there is content to lift. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- lua/taskwarrior/buffer.lua | 18 ++++++++-- tests/e2e/spec/buffer_layout_e2e_spec.lua | 41 +++++++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/lua/taskwarrior/buffer.lua b/lua/taskwarrior/buffer.lua index df94944..7c29430 100644 --- a/lua/taskwarrior/buffer.lua +++ b/lua/taskwarrior/buffer.lua @@ -648,7 +648,7 @@ local function is_overdue(date_str) end -- Apply highlights to a single line -local function highlight_line(bufnr, line_nr, line) +local function highlight_line(bufnr, line_nr, line, has_tasks) -- Header comment line — concealed entirely. Users don't need to see the -- metadata, only -- the tasks themselves. Filter/sort/group are shown via statusline if the @@ -658,7 +658,13 @@ local function highlight_line(bufnr, line_nr, line) -- `conceal_lines` (Neovim 0.11+) removes the row entirely instead of -- leaving a blank one, so the first task sits on the top line. Older -- Neovim keeps the previous behaviour: an empty concealed row. - if HAS_CONCEAL_LINES then opts.conceal_lines = "" end + -- + -- EXCEPT when there are no tasks: the empty-state hint ("No tasks match + -- filter …") is virt_lines anchored to this header, and removing the + -- host row removes the hint with it — leaving a completely blank + -- buffer, which is the exact symptom issue #5 was about. With no tasks + -- there is nothing to pull up to the top anyway, so keep the row. + if HAS_CONCEAL_LINES and has_tasks then opts.conceal_lines = "" end vim.api.nvim_buf_set_extmark(bufnr, hl_ns, line_nr, 0, opts) return end @@ -800,8 +806,14 @@ end local function update_highlights(bufnr) vim.api.nvim_buf_clear_namespace(bufnr, hl_ns, 0, -1) local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + -- Whether the buffer holds any task at all decides if the header row can + -- be removed outright — see the header branch in highlight_line. + local has_tasks = false + for _, line in ipairs(lines) do + if uuid_from_line(line) or line:match("^%- %[") then has_tasks = true; break end + end for i, line in ipairs(lines) do - highlight_line(bufnr, i - 1, line) + highlight_line(bufnr, i - 1, line, has_tasks) end end diff --git a/tests/e2e/spec/buffer_layout_e2e_spec.lua b/tests/e2e/spec/buffer_layout_e2e_spec.lua index b5c1dff..d0289eb 100644 --- a/tests/e2e/spec/buffer_layout_e2e_spec.lua +++ b/tests/e2e/spec/buffer_layout_e2e_spec.lua @@ -68,6 +68,47 @@ describe("e2e task buffer layout", function() "occupies space (winline = " .. vim.fn.winline() .. ")") end) + -- Hiding the header row outright also hides anything anchored to it: + -- `conceal_lines` suppresses that line's `virt_lines` (verified directly + -- against Neovim 0.11.6 — a concealed-away line draws no virtual lines). + -- The empty-state hint IS virt_lines on the header, so concealing the row + -- in an empty buffer leaves nothing on screen at all: a blank window with + -- no explanation, which is precisely the issue #5 symptom the hint exists + -- to prevent. With no tasks there is nothing to pull to the top anyway, + -- so the row must stay. + it("keeps the header row when there are no tasks, so the hint can render", function() + vim.cmd("enew") + require("taskwarrior").open("project:definitely-no-such-project-here") + vim.wait(300, function() return false end, 10) + local bufnr = vim.api.nvim_get_current_buf() + + local hint = vim.api.nvim_buf_get_extmarks(bufnr, + vim.api.nvim_create_namespace("taskwarrior_empty_state"), 0, -1, + { details = true }) + assert.are.same(1, #hint, "empty-state hint extmark is missing") + assert.is_truthy(hint[1][4].virt_lines, "hint carries no virt_lines") + + local header = vim.api.nvim_buf_get_extmarks(bufnr, + vim.api.nvim_create_namespace("taskwarrior_hl"), { 0, 0 }, { 0, -1 }, + { details = true }) + assert.is_true(#header > 0, "header extmark is missing") + assert.is_nil(header[1][4].conceal_lines, + "header row was concealed away in an empty buffer — that hides the " .. + "empty-state hint with it, leaving a blank window") + end) + + it("still hides the header row when tasks ARE present", function() + if not has_conceal_lines then return end + vim.cmd("enew") + require("taskwarrior").open("project:layoutdemo") + vim.wait(300, function() return false end, 10) + local header = vim.api.nvim_buf_get_extmarks(vim.api.nvim_get_current_buf(), + vim.api.nvim_create_namespace("taskwarrior_hl"), { 0, 0 }, { 0, -1 }, + { details = true }) + assert.are.same("", header[1][4].conceal_lines, + "header row should be removed entirely when there are tasks to show") + end) + it("cursor_to_first_task tolerates a buffer with no tasks", function() vim.cmd("enew") require("taskwarrior").open("project:definitely-no-such-project-here") From 256b4c47beffa7a5d70c9d2404290723cb9c5cdc Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sun, 16 Aug 2026 14:43:15 -0700 Subject: [PATCH 13/15] fix: keep the cursor off the concealed header row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hiding the header row put the first task on the top screen row, but line 1 still existed and was navigable. `gg`, `:1`, `k` from the first task, or a restored cursor position all parked the cursor on a row the user cannot see, while the first task LOOKED selected — so typing edited the header and raised "header is read-only", apparently in response to editing the first task. CursorMoved/CursorMovedI now bounce the cursor from the header onto the first task line, preserving the column. Exempt when there is nothing to bounce to (an empty filter keeps the header row visible) and in visual mode, where moving the cursor would silently reshape the selection; the existing read-only guard still covers that path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- lua/taskwarrior/buffer.lua | 31 ++++++++++++++++++++ tests/e2e/spec/buffer_layout_e2e_spec.lua | 35 +++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/lua/taskwarrior/buffer.lua b/lua/taskwarrior/buffer.lua index 7c29430..54dd232 100644 --- a/lua/taskwarrior/buffer.lua +++ b/lua/taskwarrior/buffer.lua @@ -1209,6 +1209,37 @@ function M.setup_buf_autocmds(bufnr, on_write_fn) end, }) + -- Keep the cursor off the header line. The header row is concealed away + -- entirely (see highlight_line), so the first task is drawn on the top + -- screen row — which means `gg`, `:1`, or a restored cursor position puts + -- you on a line you cannot see while the first task LOOKS selected. + -- Typing there edits the header and earns a "header is read-only" + -- warning that appears to come from editing the first task. + -- + -- Visual mode is exempt: moving the cursor would silently reshape the + -- user's selection. The read-only guard below still covers that case. + vim.api.nvim_create_autocmd({ "CursorMoved", "CursorMovedI" }, { + buffer = bufnr, + group = group, + callback = function() + if not vim.api.nvim_buf_is_valid(bufnr) then return true end + if vim.fn.mode():match("[vV\22]") then return end + local pos = vim.api.nvim_win_get_cursor(0) + if pos[1] ~= 1 then return end + local first = vim.api.nvim_buf_get_lines(bufnr, 0, 1, false)[1] or "" + if not first:match("^ comment), -- restore it on the next TextChanged event. The cached header lives on the -- buffer (vim.b) so :TaskFilter/:TaskSort/:TaskRefresh can update it when diff --git a/tests/e2e/spec/buffer_layout_e2e_spec.lua b/tests/e2e/spec/buffer_layout_e2e_spec.lua index d0289eb..75a4aaf 100644 --- a/tests/e2e/spec/buffer_layout_e2e_spec.lua +++ b/tests/e2e/spec/buffer_layout_e2e_spec.lua @@ -109,6 +109,41 @@ describe("e2e task buffer layout", function() "header row should be removed entirely when there are tasks to show") end) + -- `gg`, `:1` or a restored cursor position would otherwise park the cursor + -- on the concealed header row, where the first task LOOKS selected but any + -- keystroke edits the header and trips its read-only guard. Reported as + -- "editing my first task warns that the header is read-only". + -- + -- Driven with `doautocmd CursorMoved` rather than feedkeys: CursorMoved is + -- raised by the main loop's cursor check, which nvim_feedkeys() does not + -- reach in a headless spec. The real-keystroke path was confirmed + -- separately against a live child Neovim. + it("bounces the cursor off the header line onto the first task", function() + vim.cmd("enew") + require("taskwarrior").open("project:layoutdemo") + vim.wait(300, function() return false end, 10) + + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.cmd("doautocmd CursorMoved") + + local row = vim.api.nvim_win_get_cursor(0)[1] + assert.is_true(row >= 2, + "cursor stayed on the concealed header row — typing there edits the header") + local line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] or "" + assert.is_truthy(line:match("^%- %["), "did not land on a task line: " .. line) + end) + + it("does not bounce when the buffer has no tasks to bounce to", function() + vim.cmd("enew") + require("taskwarrior").open("project:definitely-no-such-project-here") + vim.wait(300, function() return false end, 10) + + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + vim.cmd("doautocmd CursorMoved") + assert.are.same(1, vim.api.nvim_win_get_cursor(0)[1], + "with no tasks the header is the only line — nowhere to bounce") + end) + it("cursor_to_first_task tolerates a buffer with no tasks", function() vim.cmd("enew") require("taskwarrior").open("project:definitely-no-such-project-here") From eb1af7c96e04ead253f05fd27cf97f48f21fb746 Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sun, 16 Aug 2026 14:51:29 -0700 Subject: [PATCH 14/15] fix: `u` must not undo the render itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Populating a task buffer is not a user edit, but it was recorded as one. Pressing u in a freshly opened :Tw therefore reverted the population and left an empty buffer — which the save path reads as "every task in this filter was removed" and offers to mark them all done. The header guard restoring line 1 on top of that produced the reported sequence: blank screen, "header is read-only", then a prompt to delete everything. set_buf_lines now applies the render with undolevels = -1 (:h undolevels), making it non-undoable. The user's own edits still undo normally. Pre-existing: reproduced on main (7 lines -> 1 line on u), not introduced by this branch. The regression spec asserts what a SAVE would do after u, not just the line count — the destructive part is what matters. Verified it fails against the unfixed set_buf_lines (3 of 5 assertions). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- lua/taskwarrior/buffer.lua | 14 ++- tests/e2e/spec/undo_render_e2e_spec.lua | 115 ++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 tests/e2e/spec/undo_render_e2e_spec.lua diff --git a/lua/taskwarrior/buffer.lua b/lua/taskwarrior/buffer.lua index 54dd232..e4dcf59 100644 --- a/lua/taskwarrior/buffer.lua +++ b/lua/taskwarrior/buffer.lua @@ -62,7 +62,19 @@ function M.set_buf_lines(bufnr, text) if lines[#lines] == "" then table.remove(lines) end - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) + -- Render is not a user edit, so it must not be undoable. Otherwise `u` in a + -- freshly opened buffer reverts the population itself and leaves the empty + -- buffer behind — which the save path reads as "every task was deleted", + -- and duly offers to delete them all. Setting undolevels to -1 across the + -- change is the documented way to make it non-undoable (:h undolevels); + -- the user's own edits afterwards still undo normally. + vim.api.nvim_buf_call(bufnr, function() + local saved = vim.api.nvim_get_option_value("undolevels", { buf = bufnr }) + vim.api.nvim_set_option_value("undolevels", -1, { buf = bufnr }) + local ok, err = pcall(vim.api.nvim_buf_set_lines, bufnr, 0, -1, false, lines) + vim.api.nvim_set_option_value("undolevels", saved, { buf = bufnr }) + if not ok then error(err) end + end) end -- --------------------------------------------------------------------------- diff --git a/tests/e2e/spec/undo_render_e2e_spec.lua b/tests/e2e/spec/undo_render_e2e_spec.lua new file mode 100644 index 0000000..c2c03f6 --- /dev/null +++ b/tests/e2e/spec/undo_render_e2e_spec.lua @@ -0,0 +1,115 @@ +-- undo_render_e2e_spec.lua — `u` must not undo the render itself. +-- +-- Populating a task buffer is not a user edit. When it was undoable, `u` in +-- a freshly opened :Tw reverted the population and left an empty buffer — +-- which the save path reads as "every task in this filter was removed" and +-- duly offers to mark them all done/deleted. Reported as: pressing u gives a +-- blank screen, a header read-only warning, and a prompt to delete +-- everything. +-- +-- The important assertion is the last one: what a SAVE would do after `u`. +-- Line counts alone would not have caught the destructive part. + +local TMP = os.getenv("TASKWARRIOR_E2E_TMP") +assert(TMP and TMP ~= "", "TASKWARRIOR_E2E_TMP not set — run via tests/e2e/run.sh") + +local taskmd = require("taskwarrior.taskmd") + +local function planned_actions(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local tmpfile = vim.fn.tempname() + vim.fn.writefile(lines, tmpfile) + local res = taskmd.apply({ file = tmpfile, dry_run = true, on_delete = "done" }) + vim.fn.delete(tmpfile) + return res.actions or {}, res +end + +describe("e2e undo does not revert the render", function() + if not next(require("taskwarrior.config").options) then + require("taskwarrior").setup({}) + end + + taskmd.tw_add("undo probe one", { project = "undodemo" }) + taskmd.tw_add("undo probe two", { project = "undodemo" }) + + local function open() + vim.cmd("enew") + require("taskwarrior").open("project:undodemo") + vim.wait(300, function() return false end, 10) + return vim.api.nvim_get_current_buf() + end + + it("leaves the buffer intact when undoing a fresh open", function() + local bufnr = open() + local before = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.is_true(#before >= 3, "fixture did not render both tasks") + + vim.cmd("silent! undo") + vim.wait(200, function() return false end, 10) + + local after = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + assert.are.same(before, after, + "`u` on a fresh buffer changed its contents — the render was undoable") + end) + + it("keeps the buffer unmodified after undoing a fresh open", function() + local bufnr = open() + vim.cmd("silent! undo") + vim.wait(200, function() return false end, 10) + assert.is_false(vim.bo[bufnr].modified, + "undo dirtied a buffer the user never edited") + end) + + it("a save after undo would touch nothing — no mass delete", function() + local bufnr = open() + vim.cmd("silent! undo") + vim.wait(200, function() return false end, 10) + + local actions = planned_actions(bufnr) + assert.are.same(0, #actions, + "saving after `u` would run " .. #actions .. + " action(s) — undo must not stage a mass mutation: " .. vim.inspect(actions)) + end) + + it("still undoes the user's own edits", function() + local bufnr = open() + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + local target + for i, l in ipairs(lines) do + if l:find("undo probe one", 1, true) then target = i end + end + assert.is_truthy(target, "probe task not rendered") + + -- A real, undoable user edit (not nvim_buf_set_lines, which the render + -- path deliberately makes non-undoable). + vim.api.nvim_win_set_cursor(0, { target, 0 }) + vim.cmd("normal! AZZZEDIT") + assert.is_truthy( + (vim.api.nvim_buf_get_lines(bufnr, target - 1, target, false)[1] or ""):find("ZZZEDIT"), + "test edit did not land") + + vim.cmd("silent! undo") + vim.wait(200, function() return false end, 10) + + local restored = vim.api.nvim_buf_get_lines(bufnr, target - 1, target, false)[1] or "" + assert.is_nil(restored:find("ZZZEDIT", 1, true), + "user's own edit was not undone — undo is now too aggressive") + assert.are.same(#lines, vim.api.nvim_buf_line_count(bufnr), + "undoing a user edit collapsed the buffer") + end) + + it("survives a refresh followed by undo", function() + local bufnr = open() + local before = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + require("taskwarrior.buffer").refresh_buf(bufnr) + vim.wait(300, function() return false end, 10) + vim.cmd("silent! undo") + vim.wait(200, function() return false end, 10) + + local actions = planned_actions(bufnr) + assert.are.same(0, #actions, + "undo after a refresh staged " .. #actions .. " action(s)") + assert.are.same(#before, vim.api.nvim_buf_line_count(bufnr), + "undo after a refresh changed the buffer size") + end) +end) From e46c79c1d9d8ae2851b23031c5f59bd020168b40 Mon Sep 17 00:00:00 2001 From: MattHandzel Date: Sun, 16 Aug 2026 15:30:50 -0700 Subject: [PATCH 15/15] test: green both suites end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four separate reasons the suites were red; none of them a product bug. - issue_fixes lint required >= 4 v:lua completion specs. Converting sort and group to pickers removed two (pickers need no v:lua indirection), leaving 3. Floor lowered with a note that changing it is the intended review signal. - the icons e2e test asserted `icons = true` forces nerd-font glyphs, contradicting both config.lua's documented contract and the passing unit test in icons_spec. `true` means auto-detect; `"force-nf"` is the opt-in. The test predated that escape hatch. Now covers both. - :TaskSync's e2e test waited for the "syncing" PROGRESS message, so it restored vim.notify before the async failure arrived; the ERROR then escaped to stderr and made headless Neovim exit non-zero. Waits for a terminal state now. - undo_render_e2e_spec seeded project:undodemo, colliding with the existing :TaskUndo test's fixture (specs share one Neovim instance), which made that test read the wrong task. Renamed. Both suites now exit 0. Worth noting the e2e runner had been exiting non-zero on main for a while — CI only runs the unit suite, so it went unnoticed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx --- tests/e2e/spec/context_e2e_spec.lua | 15 ++++++++++++ tests/e2e/spec/e2e_spec.lua | 31 +++++++++++++++++++------ tests/e2e/spec/undo_render_e2e_spec.lua | 6 ++--- tests/lua/spec/issue_fixes_spec.lua | 9 ++++++- 4 files changed, 50 insertions(+), 11 deletions(-) diff --git a/tests/e2e/spec/context_e2e_spec.lua b/tests/e2e/spec/context_e2e_spec.lua index ef61218..aed4a45 100644 --- a/tests/e2e/spec/context_e2e_spec.lua +++ b/tests/e2e/spec/context_e2e_spec.lua @@ -154,9 +154,24 @@ describe("e2e Taskwarrior contexts (tw c12b4cbd)", function() it("define refuses the reserved name 'none'", function() local before = context.list() + -- The refusal is an intentional ERROR-level notify. Captured rather than + -- allowed through: an ERROR notify in headless Neovim writes to stderr + -- and makes the process exit non-zero, which would fail the suite for a + -- message the test is specifically asserting we DO emit. + local messages = {} + local orig_notify = vim.notify + vim.notify = function(msg, _lvl, _o) messages[#messages + 1] = tostring(msg) end context.define("none", "project:whatever") vim.wait(300, function() return false end, 10) + vim.notify = orig_notify + assert.are.same(#before, #context.list(), "defining a context named 'none' should be refused") + local refused = false + for _, m in ipairs(messages) do + if m:find("reserved", 1, true) then refused = true end + end + assert.is_true(refused, + "expected an explanatory refusal; got: " .. vim.inspect(messages)) end) end) diff --git a/tests/e2e/spec/e2e_spec.lua b/tests/e2e/spec/e2e_spec.lua index c7cf55d..cede022 100644 --- a/tests/e2e/spec/e2e_spec.lua +++ b/tests/e2e/spec/e2e_spec.lua @@ -888,10 +888,16 @@ describe("e2e :TaskSync without a server", function() stub_select("cancel") capture_notify() require("taskwarrior.sync").run() - vim.wait(3000, function() + -- Wait for a TERMINAL state, not the "syncing" progress message. sync is + -- async (jobstart), so returning as soon as progress appears restored + -- vim.notify before the real failure landed — the ERROR then escaped to + -- stderr, which makes headless Neovim exit non-zero and fails the whole + -- e2e run for a message this test exists to assert. + vim.wait(5000, function() for _, e in ipairs(_notify_log) do - if e.msg:match("sync failed") or e.msg:match("sync complete") - or e.msg:match("syncing") then return true end + if e.msg:match("sync failed") or e.msg:match("sync complete") then + return true + end end return false end, 50) @@ -1829,12 +1835,23 @@ describe("e2e taskwarrior.icons", function() require("taskwarrior.config").options.icons = nil end) - it("icons=true (default) → NF glyph regardless of have_nerd_font", function() - -- Default semantics: `true` is an explicit opt-in to nerd-font, not a - -- request for detection. Avoids the common "I have NF in my terminal - -- but never set vim.g.have_nerd_font" gotcha. + -- `true` means AUTO-DETECT, not "force nerd-font". Shipping U+F* glyphs to + -- a terminal without a nerd font renders tofu, so the default has to be + -- safe; `"force-nf"` is the opt-in for users whose detection is broken. + -- This test previously asserted the opposite, contradicting both the + -- documented contract in config.lua and the passing unit test in + -- icons_spec.lua ("returns nil when icons = true (auto) and no nerd font"). + -- It predated the force-nf escape hatch. + it("icons=true (default) → ASCII when no nerd font is detected", function() vim.g.have_nerd_font = nil require("taskwarrior.config").options.icons = true + assert.equals("H", icons.get("priority_h")) + require("taskwarrior.config").options.icons = nil + end) + + it("icons='force-nf' → NF glyph regardless of have_nerd_font", function() + vim.g.have_nerd_font = nil + require("taskwarrior.config").options.icons = "force-nf" assert.equals("󰜷", icons.get("priority_h")) require("taskwarrior.config").options.icons = nil end) diff --git a/tests/e2e/spec/undo_render_e2e_spec.lua b/tests/e2e/spec/undo_render_e2e_spec.lua index c2c03f6..bf9a425 100644 --- a/tests/e2e/spec/undo_render_e2e_spec.lua +++ b/tests/e2e/spec/undo_render_e2e_spec.lua @@ -29,12 +29,12 @@ describe("e2e undo does not revert the render", function() require("taskwarrior").setup({}) end - taskmd.tw_add("undo probe one", { project = "undodemo" }) - taskmd.tw_add("undo probe two", { project = "undodemo" }) + taskmd.tw_add("undo probe one", { project = "undorenderdemo" }) + taskmd.tw_add("undo probe two", { project = "undorenderdemo" }) local function open() vim.cmd("enew") - require("taskwarrior").open("project:undodemo") + require("taskwarrior").open("project:undorenderdemo") vim.wait(300, function() return false end, 10) return vim.api.nvim_get_current_buf() end diff --git a/tests/lua/spec/issue_fixes_spec.lua b/tests/lua/spec/issue_fixes_spec.lua index b763087..33c4d27 100644 --- a/tests/lua/spec/issue_fixes_spec.lua +++ b/tests/lua/spec/issue_fixes_spec.lua @@ -262,7 +262,14 @@ describe("lint — completion= v:lua references resolve", function() ("%s references v:lua taskwarrior.%s which is not a function"):format(path, name)) end end - assert.is_true(checked >= 4, "expected to find completion specs, found " .. checked) + -- Floor guards against the scan silently matching nothing (pattern rot + -- would turn this test into a no-op that always passes). It is the + -- current number of string-based completion specs, so converting one to + -- a picker means lowering it deliberately — which is the review signal + -- we want. Currently: _capture_omnifunc, _complete_filter, + -- _complete_modify. (Sort and group used to be here; they are pickers + -- now, which needs no v:lua indirection at all.) + assert.is_true(checked >= 3, "expected to find completion specs, found " .. checked) end) end)