Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ require("taskwarrior").setup({
confirm = true, -- show confirmation dialog before applying
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)
fields = nil, -- fields to show (nil = all)
capture_key = "<leader>ta", -- global quick-capture (nil to disable)
open_key = "<leader>tt", -- global open-task-buffer (nil to disable)
Expand Down
1 change: 1 addition & 0 deletions doc/taskwarrior.txt
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,7 @@ Full schema with defaults: >
confirm = true,
sort = "urgency-",
group = nil,
wrap = true, -- false = don't line-wrap task buffers
fields = nil,
capture_key = "<leader>ta",
open_key = "<leader>tt",
Expand Down
22 changes: 14 additions & 8 deletions lua/taskwarrior/apply.lua
Original file line number Diff line number Diff line change
Expand Up @@ -194,14 +194,20 @@ function M.on_write(bufnr, refresh_fn, do_apply_fn)
prompt = string.format("Apply %d change(s)?", #actions)
end

vim.ui.select(choices, { prompt = prompt }, function(choice)
if not choice or choice == "Cancel" then
vim.notify("taskwarrior.nvim: cancelled")
vim.fn.delete(tmpfile)
return
end
local apply_force = choice == "Apply force (overwrite external changes)"
do_apply_fn(bufnr, tmpfile, on_delete, { force = apply_force })
-- Deferred via vim.schedule: on_write runs inside the BufWriteCmd
-- handler, and float-based vim.ui.select backends (dressing, snacks,
-- telescope, …) opened from an autocmd context can't take focus — the
-- picker renders but the cursor stays in the task buffer (issue #3).
vim.schedule(function()
vim.ui.select(choices, { prompt = prompt }, function(choice)
if not choice or choice == "Cancel" then
vim.notify("taskwarrior.nvim: cancelled")
vim.fn.delete(tmpfile)
return
end
local apply_force = choice == "Apply force (overwrite external changes)"
do_apply_fn(bufnr, tmpfile, on_delete, { force = apply_force })
end)
end)
else
if not force then
Expand Down
59 changes: 35 additions & 24 deletions lua/taskwarrior/buffer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -112,13 +112,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 cmd = string.format("task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", export_filter)
local out, ok = run(cmd)
if not ok or not out or out == "" then return end
local json_start = out:find("%[")
if json_start and json_start > 1 then out = out:sub(json_start) end
local parsed_ok, tasks = pcall(vim.fn.json_decode, out)
if not parsed_ok or type(tasks) ~= "table" then return end
local tasks = require("taskwarrior.taskmd").shell_export(export_filter)
if not tasks then return end

-- Apply multiplicative urgency coefficients (same logic as taskmd.lua render).
-- Non-numeric values go through user-configurable mappers.
Expand Down Expand Up @@ -332,23 +327,38 @@ end

M._relative_date = relative_date -- exported for e2e testing

local es_ns = vim.api.nvim_create_namespace("taskwarrior_empty_state")

-- Visible empty-state hint. A zero-task render is just the header comment,
-- which conceallevel=3 hides entirely — the user sees a blank buffer with no
-- explanation of why, and refresh looks like a no-op (issue #5). Rendered as
-- virt_lines, not buffer text, so `:w` round-trips untouched.
local function apply_empty_state(bufnr)
vim.api.nvim_buf_clear_namespace(bufnr, es_ns, 0, -1)
for _, line in ipairs(vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)) do
if uuid_from_line(line) then return end
end
local cfg_ok, cfg = pcall(require, "taskwarrior.config")
local prefix = (cfg_ok and cfg.options.command_prefix) or "Tw"
local filter = vim.b[bufnr].task_filter
local shown = (filter and filter ~= "") and filter or "(all pending)"
pcall(vim.api.nvim_buf_set_extmark, bufnr, es_ns, 0, 0, {
virt_lines = {
{ { "", "Comment" } },
{ { " No tasks match filter: " .. shown, "Comment" } },
{ { (" :%sFilter <expr> to change it · :%sAdd to create a task"):format(prefix, prefix), "Comment" } },
},
})
end
M._apply_empty_state = apply_empty_state -- exported for testing

local function apply_virtual_text(bufnr)
vim.api.nvim_buf_clear_namespace(bufnr, vt_ns, 0, -1)
apply_empty_state(bufnr)
local filter = vim.b[bufnr].task_filter or ""
local export_filter = filter ~= "" and filter or "status:pending"
local cmd = string.format(
"task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", export_filter)
local out, ok = run(cmd)
if not ok or not out or out == "" then return end

-- Handle warnings before JSON
local json_start = out:find("%[")
if json_start and json_start > 1 then
out = out:sub(json_start)
end

local parsed_ok, tasks = pcall(vim.fn.json_decode, out)
if not parsed_ok or type(tasks) ~= "table" then return end
local tasks = require("taskwarrior.taskmd").shell_export(export_filter)
if not tasks then return end

local config = require("taskwarrior.config")
local icons = require("taskwarrior.icons")
Expand Down Expand Up @@ -1107,7 +1117,8 @@ function M.setup_buf_autocmds(bufnr, on_write_fn)
-- Wrap prevents horizontal-scroll disorientation on j/k with long task
-- lines. Without wrap, curswant preservation causes the viewport to
-- shift horizontally, making it look like j "doesn't work".
vim.wo[0].wrap = true
-- Overridable via setup{ wrap = false } (issue #3).
vim.wo[0].wrap = require("taskwarrior.config").options.wrap ~= false
end,
})

Expand Down Expand Up @@ -1252,7 +1263,7 @@ function M.open_task_buf(filter_str, on_write_fn, detect_project_fn)
vim.api.nvim_win_set_buf(0, b)
vim.wo[0].conceallevel = 3
vim.wo[0].concealcursor = "nvic"
vim.wo[0].wrap = true
vim.wo[0].wrap = config.options.wrap ~= false
-- Reserve two sign cells for priority + status glyphs. `auto:2` only
-- shows the column when a sign is present, so tasks with no priority
-- or status don't waste horizontal space.
Expand Down Expand Up @@ -1286,7 +1297,7 @@ function M.open_task_buf(filter_str, on_write_fn, detect_project_fn)
vim.api.nvim_win_set_buf(0, bufnr)
vim.wo[0].conceallevel = 3
vim.wo[0].concealcursor = "nvic"
vim.wo[0].wrap = true
vim.wo[0].wrap = config.options.wrap ~= false

-- Set name safely — wipe stale buffer with same name if needed
local buf_name = "Tasks: " .. filter_str
Expand Down Expand Up @@ -1350,7 +1361,7 @@ function M.open_float(filter_str)
})
vim.wo[win].conceallevel = 3
vim.wo[win].concealcursor = "nvic"
vim.wo[win].wrap = true
vim.wo[win].wrap = config.options.wrap ~= false

vim.keymap.set("n", "q", function()
pcall(vim.api.nvim_win_close, win, true)
Expand Down
5 changes: 5 additions & 0 deletions lua/taskwarrior/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ M.defaults = {
confirm = true, -- show confirmation dialog before applying
sort = "urgency-", -- default sort
group = nil, -- default group field (nil to disable)
-- Line wrap in task buffers/floats. Default true: without wrap, curswant
-- preservation on long task lines makes j/k shift the viewport
-- horizontally ("j doesn't work"). Set false for one-line-per-task
-- (issue #3); long lines then extend past the right edge.
wrap = true,
fields = nil, -- fields to show (nil = all)
capture_key = "<leader>ta", -- global keybind for quick capture (nil to disable)
open_key = "<leader>tt", -- global keybind to open task buffer (nil to disable)
Expand Down
24 changes: 4 additions & 20 deletions lua/taskwarrior/delegate.lua
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,11 @@ function M.delegate_one()
vim.notify("taskwarrior.nvim: no UUID on this line", vim.log.levels.WARN)
return
end
local out, ok = run(
string.format("task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", short_uuid))
if not ok or not out or out == "" then
local tasks = require("taskwarrior.taskmd").shell_export(short_uuid)
if not tasks or #tasks == 0 then
vim.notify("taskwarrior.nvim: failed to export task", vim.log.levels.ERROR)
return
end
local json_start = out:find("%[")
if json_start and json_start > 1 then out = out:sub(json_start) end
local parsed_ok, tasks = pcall(vim.fn.json_decode, out)
if not parsed_ok or type(tasks) ~= "table" or #tasks == 0 then
vim.notify("taskwarrior.nvim: failed to parse task", vim.log.levels.ERROR)
return
end
return tasks[1], short_uuid
end

Expand Down Expand Up @@ -213,19 +205,11 @@ function M.collect(range)
end

local filter = table.concat(short_uuids, " ")
local out, ok = run(
string.format("task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", filter))
if not ok or not out or out == "" then
local tasks = require("taskwarrior.taskmd").shell_export(filter)
if not tasks or #tasks == 0 then
vim.notify("taskwarrior.nvim: failed to export tasks", vim.log.levels.ERROR)
return nil
end
local json_start = out:find("%[")
if json_start and json_start > 1 then out = out:sub(json_start) end
local parsed_ok, tasks = pcall(vim.fn.json_decode, out)
if not parsed_ok or type(tasks) ~= "table" or #tasks == 0 then
vim.notify("taskwarrior.nvim: failed to parse tasks", vim.log.levels.ERROR)
return nil
end

-- Preserve the order in which short_uuids appeared in the buffer.
local by_short = {}
Expand Down
10 changes: 2 additions & 8 deletions lua/taskwarrior/graph.lua
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,8 @@ end

function M.render(filter)
filter = filter or "status:pending"
local cmd = string.format(
"task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", filter)
local out, ok = run(cmd)
if not ok or not out or out == "" then return nil end
local js = out:find("%[")
if js and js > 1 then out = out:sub(js) end
local parsed_ok, tasks = pcall(vim.fn.json_decode, out)
if not parsed_ok or type(tasks) ~= "table" then return nil end
local tasks = require("taskwarrior.taskmd").shell_export(filter)
if not tasks then return nil end

local lines = {
"# taskwarrior.nvim — dependency graph",
Expand Down
10 changes: 2 additions & 8 deletions lua/taskwarrior/inbox.lua
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,12 @@ function M.run(hours)
-- Taskwarrior's entry.after: expects an ISO-like date. `now-Nh` is simpler.
local filter = string.format(
"status:pending entry.after:now-%dh project: -TAGGED", hours)
local cmd = string.format(
"task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", filter)
local out, ok = run(cmd)
if not ok then
local tasks = require("taskwarrior.taskmd").shell_export(filter)
if not tasks then
require("taskwarrior.notify")("error",
"taskwarrior.nvim: failed to fetch inbox", vim.log.levels.ERROR)
return
end
local js = out:find("%[")
if js and js > 1 then out = out:sub(js) end
local parsed_ok, tasks = pcall(vim.fn.json_decode, out)
if not parsed_ok or type(tasks) ~= "table" then tasks = {} end

-- Guard: also filter client-side, because TW's `project:` (empty) filter
-- can be finicky across TW versions.
Expand Down
38 changes: 26 additions & 12 deletions lua/taskwarrior/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -199,9 +199,31 @@ function M._setup_commands()
end

-- Completion callbacks (ArgLead, CmdLine, CursorPos) -> list of strings
function M._complete_filter(arg_lead, cmd_line, _cursor_pos)
local words = vim.split(cmd_line, "%s+")
return complete_filter(words[#words] or "")
--
-- These back vim.fn.input() prompts, where <Tab> replaces the ENTIRE typed
-- text with the chosen candidate (input() has no per-word completion). Each
-- candidate must therefore carry the untouched leading tokens as a prefix —
-- returning bare last-token candidates wipes the rest of the filter
-- (issue #4).
local function complete_last_token(cmd_line, complete_fn)
local prefix, last = cmd_line:match("^(.*%s)(%S*)$")
if not prefix then prefix, last = "", cmd_line end
local results = complete_fn(last or "")
if prefix ~= "" then
for i, r in ipairs(results) do results[i] = prefix .. r end
end
return results
end

function M._complete_filter(_arg_lead, cmd_line, _cursor_pos)
return complete_last_token(cmd_line or "", complete_filter)
end

-- Completion for the `gm` modify prompt (buffer.lua). Modify args share the
-- filter vocabulary (+tag, project:, priority:, due:, …). Registered as
-- "custom," completion, so return newline-separated matches.
function M._complete_modify(_arg_lead, cmd_line, _cursor_pos)
return table.concat(complete_last_token(cmd_line or "", complete_filter), "\n")
end

function M._complete_sort(arg_lead, _cmd_line, _cursor_pos)
Expand All @@ -228,15 +250,7 @@ M.api = {}
M.api.export = function(filter_args)
filter_args = filter_args or {}
local filter_str = type(filter_args) == "table" and table.concat(filter_args, " ") or filter_args
local cmd = string.format("task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export",
filter_str)
local out, ok = run(cmd)
if not ok or not out or out == "" then return {} end
local json_start = out:find("%[")
if json_start and json_start > 1 then out = out:sub(json_start) end
local parsed_ok, tasks = pcall(vim.fn.json_decode, out)
if not parsed_ok or type(tasks) ~= "table" then return {} end
return tasks
return require("taskwarrior.taskmd").shell_export(filter_str) or {}
end

M.api.get_task_on_cursor = function()
Expand Down
27 changes: 6 additions & 21 deletions lua/taskwarrior/modify.lua
Original file line number Diff line number Diff line change
Expand Up @@ -141,13 +141,8 @@ end
-- ---------------------------------------------------------------------------

local function get_task_json(uuid)
local out, ok = run(string.format(
"task rc.bulk=0 rc.confirmation=off rc.json.array=on %s export", uuid))
if not ok or not out or out == "" then return nil end
local json_start = out:find("%[")
if json_start and json_start > 1 then out = out:sub(json_start) end
local parsed_ok, arr = pcall(vim.fn.json_decode, out)
if not parsed_ok or type(arr) ~= "table" or not arr[1] then return nil end
local arr = require("taskwarrior.taskmd").shell_export(uuid)
if not arr or not arr[1] then return nil end
return arr[1]
end

Expand Down Expand Up @@ -201,13 +196,8 @@ end

--- Collect existing project names from pending tasks (de-duplicated, sorted).
local function existing_projects()
local out, ok = run(
"task rc.bulk=0 rc.confirmation=off rc.json.array=on status:pending export")
if not ok or not out or out == "" then return {} end
local json_start = out:find("%[")
if json_start and json_start > 1 then out = out:sub(json_start) end
local parsed_ok, tasks = pcall(vim.fn.json_decode, out)
if not parsed_ok or type(tasks) ~= "table" then return {} end
local tasks = require("taskwarrior.taskmd").shell_export("status:pending")
if not tasks then return {} end
local seen, names = {}, {}
for _, t in ipairs(tasks) do
if t.project and not seen[t.project] then
Expand All @@ -221,13 +211,8 @@ end

--- Collect existing tags from pending tasks.
local function existing_tags()
local out, ok = run(
"task rc.bulk=0 rc.confirmation=off rc.json.array=on status:pending export")
if not ok or not out or out == "" then return {} end
local json_start = out:find("%[")
if json_start and json_start > 1 then out = out:sub(json_start) end
local parsed_ok, tasks = pcall(vim.fn.json_decode, out)
if not parsed_ok or type(tasks) ~= "table" then return {} end
local tasks = require("taskwarrior.taskmd").shell_export("status:pending")
if not tasks then return {} end
local seen, names = {}, {}
for _, t in ipairs(tasks) do
for _, tag in ipairs(t.tags or {}) do
Expand Down
8 changes: 2 additions & 6 deletions lua/taskwarrior/statusline.lua
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,8 @@ end

local function compute()
local today = os.date("!%Y-%m-%d")
local out = vim.fn.system(
"task rc.bulk=0 rc.confirmation=off rc.json.array=on status:pending export")
local json_start = out:find("%[")
if json_start and json_start > 1 then out = out:sub(json_start) end
local ok, tasks = pcall(vim.fn.json_decode, out)
if not ok or type(tasks) ~= "table" then return "" end
local tasks = require("taskwarrior.taskmd").shell_export("status:pending")
if not tasks then return "" end

local overdue = 0
local next_due, next_due_date, next_desc
Expand Down
Loading
Loading