intuiTUIve gaps: taskwarrior-tui¶
Round 1: taskwarrior-tui¶
examples/tasks.rs
rebuilds taskwarrior-tui,
the Taskwarrior front end, on intuiTUIve with no framework change. It is
2,582 lines (after rustfmt): about 920 are the domain (times and
Taskwarrior's date names, the task model and its JSON, urgency as
Taskwarrior computes it, the store, the filter and modification syntax),
460 the reports (columns, sorting, Taskwarrior's default colour rules,
task info, task projects, task timesheet as text), 515 the app's
state and actions (the command bar's modes, completion, marks, undo), 530
the views and keys, and the rest the app, the sample tasks and main.
task is not installed here, so the tasks live behind a Store trait
(load, commit as one undoable change, undo, contexts,
set_context, new_uuid), implemented by JsonlStore: a directory with
tasks.jsonl (each task as task export writes it, through rs-rich-ext's
data JSON parser, so the example needs --features rich-ext/data),
undo.jsonl (each change's tasks as they were before it) and taskrc
(context.NAME=FILTER, context=NAME). Ids are the working set's,
renumbered on each load as Taskwarrior's are. The tests
(tests/taskwarrior.rs,
781 lines, 27 tests; tests/tasks.rs was taken by the background-task
tests) fill a store in a temporary directory with a fixed "now" (Monday
2026-10-19 12:00 UTC) and drive the app headless, checking the screen,
its styles and the files.
What is there, with taskwarrior-tui's layout, strings, colours and default keys:
- The navigation bar:
Tasks Projects Timesheet Calendar, reversed, the view in bold,next [none](report and context) at the right;]and[change view. - The task report: the
nextreport's columns (ID, Active, Age, P, Project, Tag, Due relative, Description with[n]annotations, Urg), the empty ones dropped as taskwarrior-tui does, sorted by urgency,•on the selected row; Taskwarrior's default colour rules in their precedence (activergb555 on rgb410, overduecolor9, due and due todaycolor1, taggedcolor2, the priorities' greys), alternate rowson gray2, the selection bold, the header underlined. j/k, J/K (a page), g/G. - The details under the report (50/50, a rule on top):
task info's Name/Value rows, annotations under the description, virtual tags, and the urgency's terms; Ctrl+E/Ctrl+Y scroll,zhides them,\puts them beside the report. - The command bar: two rows, the title reversed (
Filter Tasks,Add Task,Modify Task 1,Annotate Tasks 2,1,Jump to Task), red when it fails./filters as you type (Esc puts the filter back),aadds,llogs,mmodifies (filled with the task's description, tags, project, priority and due date),Aannotates,:jumps to an id; Tab completesproject:values, tags, priorities, statuses, date names and the attribute names, cycling. Errors (Additional text must be provided.,Cannot locate task id 42,No undo information available) in a box until a key is pressed, the bar's titleError. - Actions:
ddone,xdelete (Delete Task 1?/Press <x> to confirm or <q> to abort.),sstart/stop,ttoggles+next, H/M/L/N the priority,yduplicates,uundo (asked:Run `task undo`?),rreloads. The selection follows the task when its urgency moves it. - Marks:
vmarks,Vmarks or clears all; marked rows show✔, and every action (and the titles,Delete Tasks 2,1,3?) takes the marked tasks as one change, which one undo reverts. - The filter syntax (a subset of Taskwarrior's):
project:x(and its subprojects),project:(none),+tag/-tagwith the virtual tags (+OVERDUE,+DUE,+ACTIVE,+TODAY,+TAGGED, …),status:,priority:,due.before:/due.after:, ids (2,4-5), words (in the description or an annotation). Modifications take the same attributes, and dates as Taskwarrior names them (today,tomorrow,eod,eow,sow,eom,eoy, weekday names, ISO dates and times,3d,2w). - The context menu (
c) and the report menu (R): a box withContext (type to filter)over a table (Name, Definition, Active), typing filters, ↑/↓ and Enter pick; reportsnext,list,active,overdue,completed,all, each with its columns and sort. - Projects (
task projects: the tree with counts and(none)), Timesheet (the last four weeks' completed and started tasks) and Calendar: the year, as many months across as fit (width / 24), centred, weeks from Sunday, the report's due dates in their tasks' colours, today bold; j/k a year, J/K ten. - Help (
?): the framework's, from the bindings' descriptions, sectioned by mode.
Left out: e (edit in $EDITOR), ! (shell command), the user shortcuts
1–9, the command history (Up/Down, [n / m] in the title), the
completion pop-up and ghost text (Tab cycles in place instead), task
log's auto-inserted quotes, recurrence, dependencies, waiting and
scheduled tasks, user-defined attributes, keyword colour rules, and
taskwarrior-tui's config (uda.taskwarrior-tui.*). Choices: delete and
undo ask first (taskwarrior-tui's prompt-on-delete and
prompt-on-undo; its defaults are off and Taskwarrior asks itself), done
does not (its default). A due date with no colour rule is underlined on
the calendar, where taskwarrior-tui leaves it plain, so every due date
shows. The months' titles carry the year (October 2026, not
October): month always draws it.
Bespoke, and why: no Widget of its own. What stands in for missing
components is counted below: the command bar (a text over an input
that is disabled_when it is not typing, the modes' keys by in_mode),
the completion (candidates and cycling over the input's signal), the
marks written into the ID cell, the selection following a task by uuid
after each change, the menus (an input over a table in a modal), the
year (twelve month nodes in a grid, how many across read from a
RectHandle), and task info and task projects laid out as text. The
date parsing, urgency, filters and the store are the domain, which a
Taskwarrior plugin would hold.
New gaps and bugs¶
| Gap or bug | Needed for | API sketch or repro | Where |
|---|---|---|---|
A table cannot show marks: selection_markers and marker are a list's only, so the marks are written into the ID cell (✔ 2), and the highlight symbol is fixed (taskwarrior-tui's is ⦿/⦾ on a marked/unmarked selected row while marking) |
v/V, bulk actions |
TableOptions::selection_markers(sel, \|i\| key), .marker(\|i\| style), .symbol_for(\|i\| markup) |
core |
A table has no cursor_by: a change of urgency re-sorts the report, so reload finds the selected uuid again by hand, as gitui's Files did for its tree |
the selection following the task (taskwarrior-tui's selection_fix) |
TableOptions::cursor_by(\|i\| key), as list has |
core |
A table's columns are fixed when it is made: hiding the empty ones (taskwarrior-tui's simplify_table) and switching reports rebuild the table in a switch keyed by the visible set, and the focus target's id changes with each build (kept in a Signal<Cell<Option<NodeId>>> written from the builder) |
the report's columns, the report menu | table_columns(\|\| Vec<Column>, rows, sel, options); Column::hide_when_empty() |
core |
No completion on an input: no pop-up of candidates, no ghost text (taskwarrior-tui has both, Textual's Suggester and Bubbles' textinput suggestions have the ghost text); Tab is bound on the input and cycles candidates by rewriting the signal |
project:, tags, dates in the command bar |
input(sig).complete(\|text, caret\| Vec<Completion>) with CompleteOptions::popup().ghost().cycle_keys("tab", "shift+tab"); Completion { replace: Range, text, detail } |
core |
No history on an input: Up/Down through earlier entries matching the start of the line, [3 / 7] in a title |
the command bar (taskwarrior-tui keeps one per action) | input(sig).history(Signal<Vec<String>>), .history_status() -> Memo<Option<(usize, usize)>> |
core |
Dates read only as ISO: Date: FromStr takes 2026-10-07; Taskwarrior's names (today, eow, monday, 3d) are the example's parse_date |
due dates typed in add/modify/filter | rs-rich-interact: Date::parse_relative(word, today, WeekStart) -> Option<Date>, DateTime::parse_relative(word, now), and a date_names() list for completion |
core (rs-rich-interact) |
No local time: Date, Time and DateTime are UTC only. The example takes a fixed offset from rs-rich-ext's fs::local_time(now, "%z") (chrono, behind local-time), which is wrong across a change of summer time |
every date shown or parsed | rs-rich-interact: DateTime::now_local(), DateTime::from_unix(secs, Zone::Local), to_unix() |
core (rs-rich-interact) |
month always titles a month October 2026 in calendar.title (bold); taskwarrior-tui titles it October and bolds only today's month. CalendarOptions are fixed when the node is made, so a style cannot follow the year shown |
the Calendar view | CalendarOptions::title(MonthTitle::Name \| NameYear \| None), .month_style_with(\|first_of_month\| style) |
core |
A node built later by a switch has a new id: moving the focus to "the report" needs the id kept from the builder; autofocus only works while nothing else holds the focus |
the focus back on the report after a prompt, a menu or a view change | cx.focus_within(container_id) (its first focusable node), or switch(..).keep_focus() |
core |
A Taskwarrior backend: task is not installed here, so the clone has its own store. A plugin would run task export/import/modify/undo with rc.confirmation=off, read _projects, _tags, context and show (colours, reports, urgency coefficients), and watch the data directory (taskwarrior-tui polls task); taskchampion (Taskwarrior 3's storage crate) is not in the lockfile |
everything taskwarrior-tui does with task |
plugin taskwarrior::Task::open(rc) implementing the example's Store, reports(), colour_rules(); under it, core's jobs (a process with output) and the watch feature's watch_path |
plugin |
Friction with the existing APIs¶
- The command bar is three nodes and a mode. A
textfor the title (in aswitchso an error can restyle the whole row, asstyleis fixed), aninputthat isdisabled_whenthe mode is not typing (so Tab and the focus skip it), andin_modebindings for each prompt. Prompts (Press <x> to confirm or <q> to abort.) are text written into the input's signal.in_modeworked well for the prompts' keys;xmeans delete in one mode and confirm in the next with no clash. - Live filtering needs a watch. The input sets
cmd; awatchcopies it to the filter while the mode is Filter, and Esc puts the old filter back.input(..).on_change(..)would say it where the input is made (PromptOptions::on_changeexists forcx.prompt). - Pages need the table's height. J/K move a page as taskwarrior-tui's
do; the table's own PgUp/PgDn cannot be called, so the example reads a
RectHandleof the report area and halves it when the details are under it. ATableHandle(page_down(),rows_in_view()) would do. - The focus survives a rebuilt table by luck of the Tab order. When
the report's columns change (the only active task done, the details
moved), the old table leaves and the focus goes to the next focusable
node, which is the new table only because the input is disabled and
the details are
no_focus(). factshas no header and no hanging lines.task infois aName/Valuetable with the annotations under the description;factsbolds its keys and has no header, so it is formatted as text in ascroll_with, whose offset Ctrl+E/Ctrl+Y move.- A filterable table is two nodes. The menus are an
inputover atablein a modal, ↑/↓ bound on the column to move the table's selection while the input has the focus.fuzzy_listis one column; taskwarrior-tui's menus have three. - The year is a grid of months laid out by hand. How many fit across
is read from a
RectHandlein amemo, the grid rebuilt in aswitch, centred between two flexible blanks. - Escaping. Every cell and line from the data is escaped (a filter's
[none]in the navigation bar was read as a tag until it was). - What worked without friction:
tabswith theme styles for a reversed bar,table_with'srow_stylefor the colour rules andsymbol/HighlightSpacing::Alwaysfor the indicator,multi_selectfor the marks,cx.modalandon_any_keyfor the error box,CalendarOptions::eventsreading the report (each month redraws when the tasks change), andhelp_withfrom the bindings' sections.
Components that would help¶
| Component | What it would replace or enable | API sketch | Where | Value |
|---|---|---|---|---|
| A query language over records | the example's Filter (~150 lines): attribute terms, tags, words, ids; Taskwarrior's or, xor, parentheses and modifiers (.before, .has, .startswith) are not supported; bottom's process search was the same shape |
core: query::parse(text, &Schema) -> Query over a Record trait (field(name) -> Value, tags(), text()), with and/or/not/parens, name:value, name.modifier:value, +tag/-tag, typed comparisons; query.matches(&record), errors with a position; plugin: Taskwarrior's syntax and virtual tags on it |
core + plugin | high |
| A command line | the command bar (title, modes, inline confirm, error state, completion and history) | core: command_bar(state) with cmd.prompt(title, initial, on_submit), cmd.confirm(question, key, on_yes), cmd.error(text); vim's : line, lazygit's and k9s's prompts |
core | high |
| Input completion and history | Tab cycling by hand, no pop-up, no history | as in the gaps; Textual's Suggester, Bubbles' textinput.SetSuggestions, rustyline's completer |
core | high |
| Natural dates and local time | parse_date, the offset trick |
as in the gaps; the chrono-english/two_timer crates do this (not in the lockfile) |
core (rs-rich-interact) | high |
| A year view with an agenda | the grid of months, and the tasks of a day (taskwarrior-tui shows none; calcurse, khal and Textual apps do) | core: year_view(year, YearOptions::new().across(Auto).events(..)), agenda(\|date\| Vec<markup>, day); plugin: none |
core | medium |
| A table with marks, keyed cursor and dynamic columns | the ID-cell marks, reload's uuid search, the switch per column set |
as in the gaps; Textual's DataTable keys rows and adds/removes columns |
core | high |
| A transaction log for undo | the store's undo.jsonl |
core: UndoLog<C> (push(change), undo() -> Option<C>, redo, a limit, serialisable by the app); history(signal) covers values in memory only |
core | low |
| A Taskwarrior plugin | the Store, urgency, reports and colour rules read from task show |
as in the gaps | plugin | high |
| A tree with columns | task projects as text: a tree of projects with a count column |
core: tree_table(columns, items, selected) (tui-tree-widget plus a table) |
core | low |
Round 2: taskwarrior-tui on round 6¶
The clone is rebuilt on round 6. It is 2,412 lines (from 2,582) and does
more: Taskwarrior's or, not and parentheses in filters, a completion
pop-up with ghost text, a history per action, local time across a change
of summer time, ⦿/⦾ while marking, months titled as taskwarrior-tui
titles them. The tests are 807 lines, 27 tests; all pass.
What was replaced, and by what¶
| Hand-built in round 1 | Now | Lines |
|---|---|---|
Filter and Term (~150 lines): attribute terms, tags, words, ids; no or, xor or parentheses |
Taskwarrior's syntax as a query::Grammar in the example (fields, pro/proj/pri aliases, project: by prefix, status:/priority: readers, dates through dates, ids as words matchers, lenient) over a TaskRecord implementing Record (its has_tag answers the virtual tags); query::parse and Query::matches |
−70 |
parse_date (~60 lines) and DATE_NAMES |
dates::parse_with(.., DateOptions::new().week_start(6)), dates::NAMES for completion |
−70 |
local_offset() (a fixed offset from %z, wrong across summer time) and offset: i64 everywhere |
rich_interact::datetime::Zone (Zone::Local in the app, Zone::Utc/Fixed in the tests), DateTime::from_unix/to_unix/format |
−20 |
The command bar: a text title in a switch, an input disabled_when it was not typing, in_mode prompts writing Press <x> to confirm… into the input's signal, a watch copying the input to the filter, the input's and the table's ids kept in Signal<Cell<Option<NodeId>>> to move the focus, the Mode enum |
command_line_with(cmd, CommandLineOptions::new().two_rows()): a Mode per action (title, initial, complete, history, on_submit, on_change for the filter as you type, on_cancel putting it back), confirm_keys(.., "x", ..) and "u" for delete and undo; the focus goes back by itself |
−110 |
Tab cycling through candidates by rewriting the input's signal (Completion state, complete()) |
Mode::complete with word_before and Completion::new: the pop-up, Tab/↓, Enter, and ghost text |
−20 |
| No history | Mode::history("add"), one per action, as taskwarrior-tui keeps them (new test) |
+1 |
The report table rebuilt in a switch keyed by the visible column set and report, its id written out of the builder |
one table_with: columns_with (the report's columns), columns_shown (the non-empty ones), its id kept once |
−15 |
reload finding the selected uuid again after each change |
Node::cursor_by(\|i\| uuid) on the table |
−10 |
Marks only in the ID cell, • always |
symbol_for(2, ..): ⦿ on a marked selected row, ⦾ on an unmarked one, while marking (the ✔ in the ID cell stays: taskwarrior-tui draws it there) |
+8 |
J/K pages from a RectHandle of the report area, halved when the details are under it |
TableOptions::layout(&layout), layout.rows_in_view() |
−8 |
The details' layouts in a switch that rebuilt the table with them |
shown_when on two details panes (beside, under); the table is built once |
−5 |
The calendar: a RectHandle, a memo of how many fit, a grid of twelve months in a switch, two flexible blanks to centre it, the year as a text |
year_view_with (YearOptions::marks, heading_style bold only for this year), MonthTitle::Name, month_style_with bolding today's month |
−25 |
Tests changed, each where the component's behaviour is the better one
or the more faithful: filters with or, not and parentheses now pass
(the old test asserted +a or +b was an error); someday is
9999-12-30 as Taskwarrior reads it (was no date); the bad-filter test
types (+bills (an unclosed parenthesis) since or is valid now; the
months are October, not October 2026; the selected marked row starts
⦿ ✔ 1; the completion test drives the pop-up (Tab, Tab, Enter) and
reads the ghost text, and checks ↑ in the add history; the filter row
shows the ghost text while typing (status:pending project:home.bills
after typing project:home); while typing, the red title is the
command line's error style over the whole row ([bold red]Filter Tasks
…[/] where it was a bold title on a red row).
Still hand-built, and why¶
- The closed bar's two rows. A closed
command_lineshows a message on its line row only, and nothing on its title row, where taskwarrior-tui showsFilter Tasksover the filter (andErroroverPress any key to continue.). Acolumnof twotexts is layered over the command line while it is closed, and its title row also while it asks (the title over the question,Delete Task 1?). - Whether it is typing or asking.
CommandLine::is_openis both; the app keeps atypingsignal (set on open, cleared in each mode'son_submit/on_canceland on failure) and anaskingtitle. - The red title while typing a bad filter. A mode's title is fixed
at open and has one style; the filter mode's
on_changecallscmd.error("Filter Tasks")so the error style draws the title. Keys that change nothing (an arrow) clear the error until the next edit. - The menus (an
inputover a three-columntablein a modal): still no filterable table. task info,task projects,task timesheetas text, the modification syntax (words,modify), urgency, reports, colour rules and the store: the Taskwarrior domain (round 1's plugin).
New gaps¶
| Gap | Needed for | API sketch | Where |
|---|---|---|---|
| A two-row command line's closed look: a title row and a line row while idle | Filter Tasks over the filter, Error over Press any key…, with no layer |
CommandLine::idle(title_markup, line_markup) or message_with(\|\| (title, line)) |
core |
| A mode's title that follows state and a title style of its own (an error look that keeps the title) | the red Filter Tasks while the filter does not parse; [3 / 7] (the history's position) in the title, as taskwarrior-tui shows |
Mode::title_with(\|\| markup), Mode::invalid_when(\|text\| bool) styling the title with command.error; InputHistory::status() read from the title |
core |
| A question's title in a two-row bar, and a callback for no | Delete Task 1? over Press <x> to confirm…; clearing state when the answer is no |
confirm_with(cx, ConfirmOptions::new(question).title(t).keys("x").on_no(f), on_yes) |
core |
| Which state the line is in | the app's own typing signal |
CommandLine::is_typing() / is_asking() (reactive) |
core |
Zone is not re-exported (dates is): the example names rich_interact::datetime::Zone |
any app showing local times | intuituive::datetime::Zone (or widgets::Zone) |
core |
A quote-aware word splitter: the query's tokenizer is private, so the modification syntax ("Sweep the porch" project:home) keeps its own words |
Taskwarrior's add/modify, any command line with quoted arguments |
query::words(text) -> Vec<(String, Range<usize>)> |
core |
| A filterable table (round 1): the menus are an input over a table with ↑/↓ bound on the column | the context and report menus | table_with(..).filter_by(query: Signal<String>, \|row\| text), or fuzzy_table |
core |
Bugs found¶
Fixed in 0.0.21 (2a36216): the focus move is made
before the modal opens; the test is in tests/round7_bugs.rs, and the
clone no longer focuses the report itself. The clone's own case needed a
second fix in the same commit: the report, focused by a watch before it
was laid out, had no focus path, so the command line kept no node to
give the focus back to.
- A modal opened after
cx.focus(x)in the same handler gives the focus back to the node focused before, notx. The command line closes on a failed add (cmd.close(cx)focuses the report) and the error modal opens: when it pops, the focus goes to the command line, closed and no longer focusable, and the report's keys are dead. The clone focuses the report itself after the pop. Minimal failing test:
use intuituive::prelude::*;
#[test]
fn a_modal_opened_after_a_focus_change_gives_the_focus_back_to_the_new_node() {
let app = App::new(|| {
let said = signal(String::new());
let a = label("a").focusable().on_key("x", move |_| said.set("a".into()));
let a_id = a.id();
let b = label("b").focusable().autofocus().on_key("m", move |cx| {
cx.focus(a_id);
cx.modal(Size::Fixed(6), Size::Fixed(1), || {
label("modal").on_any_key(|cx, _| {
cx.pop();
true
})
});
});
column([a.fixed(1), b.fixed(1), text!("{said}").fixed(1)])
});
let mut d = app.driver(10, 3);
d.press("m space x");
// Fails: "" (the focus went back to b). Without the modal, "m x" gives "a".
assert_eq!(d.screen().plain()[2].trim_end(), "a");
}
Components that would help¶
| Component | What it would replace or enable | API sketch | Where | Value |
|---|---|---|---|---|
| The two-row command line's closed look and state reads | the layered overlay, typing, asking, the error hack (~50 lines) |
as in the gaps | core | high |
| A Taskwarrior plugin (round 1) | the store, urgency, reports, colour rules, the modification syntax; its grammar would be the example's grammar() |
as in round 1 | plugin | high |
| A filterable table | menu (~60 lines) |
as above | core | medium |
| A tree with columns (round 1) | task projects as text |
tree_table |
core | low |