Skip to content

feat: hyphen-tag fix, contexts, table view, richer capture - #10

Merged
MattHandzel merged 15 commits into
mainfrom
agent/tasklist-batch
Aug 16, 2026
Merged

feat: hyphen-tag fix, contexts, table view, richer capture#10
MattHandzel merged 15 commits into
mainfrom
agent/tasklist-batch

Conversation

@MattHandzel

Copy link
Copy Markdown
Owner

Works through the seven open Taskwarrior tasks tagged/projected task_nvim / taskwarrior_nvim.

Based on agent/centralize-taskwarrior-commands (PR #9), since it builds on the centralized command.read/command.mutate boundary.

Fixes

Hyphenated tag names (3b9dfcab — "TwFilter with +ais-research-taste fails to pull something up")

TW3's expression parser reads the hyphen in a bare +tag token as subtraction, which broke three separate paths:

path before
:TwFilter +ais-research-taste Cannot subtract from a Boolean value → silently empty buffer
task add … +foo-bar tag landed inside the description
modify +foo-bar exit 2, tag never applied
  • shell_export now runs parsed args through normalize_tag_filters (the rewrite tw_export already used): +ttags.has:t, -ttags.hasnt:t, virtual tags verbatim.
  • fields_to_args emits one tags:a,b replacement instead of per-tag +a/-b deltas. This also fixes partial tag removal on save, which the +t deltas never applied.
  • 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.

Features

:TwContext (c12b4cbd — contexts like work/home)

Shows the active context, sets one by name, none clears it (idempotent — task context none exits 2 when already clear). TW 3.x applies contexts to reports but not to export, so the render path injects the context's read filter itself. Those tokens land in the rendered taskmd header, so the save path re-exports 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 task can't come up empty.

:TwTable (6e9f911d — vit-like configurable table view)

One row per task, aligned columns, urgency-sorted (honors custom_urgency). <CR> opens the task under the cursor in an editable :Tw buffer, r refreshes, q closes; the view is read-only. Columns come from the new table_columns option — a field name or { field, label, width, align, format }. Omit width on one column and it absorbs the leftover window width; over-long cells ellipsize rather than overflow. Unrecognised field names are read straight off the exported task, so UDA columns need no wiring.

Richer capture window (14d722e8 — bigger, colored capture form)

  • Lines below the first become annotations on the created task; <C-CR> opens one, <CR> still submits everything. capture_annotations = false opts out; capture_height sizes the form.
  • The capture buffer now runs the task buffer's highlighter, so project:, priority:, due:, +tags and UDAs color as you type.
  • Every remaining field:value token gets the new neutral TaskField group — 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: the <Esc> discard-confirm and its cursor restore only looked at line 1, so annotation text could be dropped without a prompt.

Add confirmation shows the task (978fb9d1)

added task didn't tell you whether the right thing went through — the notification now echoes the first ~40 chars of the parsed description, plus an annotation count when there is one.

Save popup / undo (fca82462)

confirm = false already skipped the popup and applied immediately; the summary notification is then the only checkpoint, so it now points at :TwUndo. Documented the confirm=false → notify → undo workflow.

0f0b9bbf (capture starts in insert mode) was already the behavior — it now has a regression test.

Verification

Every change is covered by specs driving the real task CLI (3.4.2) against an isolated TASKDATA, plus a unit spec for the pure tag-normalization logic.

tests/lua/bootstrap.sh          32 pass, 0 fail
tests/e2e/run.sh
  hyphen_tag_e2e_spec            6 pass
  capture_flow_e2e_spec          3 pass
  no_confirm_save_e2e_spec       2 pass
  context_e2e_spec               5 pass
  capture_rich_e2e_spec          5 pass
  table_view_e2e_spec            4 pass
  external_changes_spec          8 pass
  command_live_spec              5 pass
  e2e_spec                      94 pass, 1 fail (pre-existing)

Two pre-existing failures on this branch, both unrelated to these changes:

  • e2e_spec "icons=true → NF glyph" — fails identically on a clean tree.
  • capture_relative_dates_spec "due:today" — the assertion compares a UTC-now date prefix against a local-midnight timestamp, so it fails whenever the run happens between UTC midnight and local midnight.

Three of the e2e specs caught real bugs during development, all fixed here: the table view's flex column kept its content width when the fixed columns had eaten the budget (300-char rows), the generic field highlighter colored https:// as a field, and task context none exits 2 when no context is set.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx

MattHandzel and others added 7 commits August 15, 2026 17:39
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
"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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
:TwContext shows the active context and lists defined ones; a name
activates it (task context <name>), "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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
The capture form was a single uncolored line (tw 14d722e8). Now:

- lines below the first become annotations on the created task; <C-CR>
  opens one, <CR> 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: <Esc> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
One row per task with aligned columns so every data modality is visible
at once (tw 6e9f911d), sorted by urgency (or custom_urgency). <CR> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
MattHandzel and others added 8 commits August 15, 2026 18:35
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 <M-CR> plus <C-CR>. <C-CR> 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 <C-o> binding, which shadowed Vim's built-in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
The repair runs once, ever, to clean up damage from the TW3 hyphen
misparse. A registered command would sit in :Tw<Tab> 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
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 <leader>tc (context),
<leader>tv (saved view) and <leader>tr (report) join the existing
<leader>ts / <leader>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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EkK2z7n2ziYSsXnGbmX8Fx
@MattHandzel
MattHandzel changed the base branch from agent/centralize-taskwarrior-commands to main August 16, 2026 22:32
@MattHandzel
MattHandzel merged commit 6bd7420 into main Aug 16, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant