dextui
A two-pane terminal UI for browsing and triaging dex tasks — task tree on the left, full detail on the right.

Nerd Font glyphs; the default set works in any terminal. Regenerate with
scripts/screenshot.sh.
dex is a CLI task tracker. It is excellent at writing tasks and at being driven
by an agent, but reading a tree of them means running dex list again and again.
dextui is the reading half: one screen, always current.
It refreshes itself whenever the store changes — including when an agent edits tasks underneath you — without moving your selection, collapsing the tree, or interrupting a dialog you have open. That is the whole point of it, and the rule the code is built around.
Requirements
- dex on your
PATH. Every read and write goes through it, so its validation and any GitHub/Shortcut sync you have configured still run.dex --versionshould print something. - Rust 1.85 or newer to build (this crate is edition 2024).
rustupfrom rustup.rs is the usual way in. - A real terminal. Piping it somewhere gets you an explanation and exit 1.
Use
dextui selftestto see the data without one. - Optional: a Nerd Font if you want the fancier glyph set. The default works in any terminal.
Install
That puts dextui in ~/.cargo/bin, which needs to be on your PATH.
Try it in 30 seconds
You do not need any tasks of your own yet. This seeds a throwaway store with a realistic tree covering every state, and prints the directory it made:
&&
Press ? for help, j/k to move, Tab to switch panes, q to quit. rm -rf
the directory when you are done with it.
Then run it somewhere real:
&&
dextui reads the store for the current directory, so run it from the project
whose tasks you want. If the tasks look wrong, dex dir tells you which store is
actually in use — outside a git repo, dex falls back to a shared global one.
Reading the display
| marker | state |
|---|---|
◇ |
todo |
⠋ |
in progress — and it spins, so you can find it |
◆ |
done |
× |
blocked |
The hollow shape fills in as a task completes. In progress breaks the family because it is the one state that is happening. The colours — yellow, blue, green, red — are dex's own, so the two tools never disagree about what a task is, and only the marker is coloured, never the task name.
██▌░░░░ 1/7on a parent is subtree progress: done, in flight, untouched. Computed from the unfiltered list, so hiding completed tasks does not zero it.4hon an in-progress task is how long it has been in flight. Only in-progress tasks get one, so it stays a signal rather than noise.- The header counts what you can act on:
2 active · 5 ready · 1 blocked. Ready means unstarted with nothing in its way. A parent with unfinished children is neither ready nor blocked — you cannot pick up an epic — so those three deliberately do not add up to the total outstanding. - The selected row is marked by a
┃in the left margin rather than a highlight bar, so the status colours stay readable on it.
Narrow the terminal and the header sheds what carries least first; which project you are in survives all of it.
Zoom: one pane at a time
On a narrow terminal the split gives way to a single pane, with [1] [2] tabs in
the header showing where you are. This is what makes dextui usable on a phone
— over SSH from Termius or Blink, in Termux, or in any terminal on a small
screen, where two panes would leave no room for either.
Press z to zoom at any width — handy on a wide screen when you want a long
description full-width. Below single_pane_below columns (80 by default) it
zooms on its own; z still overrides that either way, and your choice sticks
until you press it again. Set the value to 0 to always split.
| key | does |
|---|---|
z |
zoom / unzoom |
1 2 |
jump to the tree / the detail |
enter, → |
open the detail (→ from a task with no subtasks) |
← tab |
back to the tree |
The tabs are clickable, and the header sheds the same way the wide one does — but the tabs are reserved before any of that, so the way back is never the thing that disappears.
Keys
Press ? in the app for this list at any time.
| key | does |
|---|---|
↑ ↓ j k |
move, or scroll the focused pane |
→ ← h l |
expand/collapse, or scroll sideways |
g G |
first / last |
tab |
switch pane (the focused one has the brighter border) |
enter |
open the detail pane |
1 2 |
jump straight to the tree / the detail |
z |
zoom — one pane at a time |
- + |
collapse / expand all |
/ |
search names and descriptions |
f |
cycle filter — pending / active / all |
o O |
cycle sort / reverse it |
w |
toggle wrapping |
Ctrl-R |
refresh now (it refreshes itself; this is the escape hatch) |
, |
edit the config in $EDITOR (created if missing, reloaded on save) |
? |
help |
q esc |
quit |
Acting on the selected task:
| key | does |
|---|---|
s |
start |
c |
complete (prompts for a result) |
r |
rename |
e |
edit the description in $EDITOR |
n |
new top-level task |
a |
new subtask of the selection |
d |
delete, with confirmation |
The header is clickable. Click a word in [ all pending active ] to switch
filter, or the sort label to cycle it — right-click the sort label to reverse it,
the same pair as o and O. When the terminal is too narrow to show the whole
menu, clicking the one filter name it does show cycles instead.
Mouse: drag the divider to resize the panes; the wheel or a trackpad drag scrolls whichever pane is under the pointer, content moving with your fingers in both; click selects. Mouse capture means the terminal no longer does its own text selection — hold Shift to select and copy as usual.
Wrapping vs wide tables. Wrapping and sideways scrolling are mutually
exclusive: wrapping removes the overflow there would be anything to scroll to.
Prose wants wrap on; a table wider than the pane wants w to turn it off, after
which h/l reach the rest.
Descriptions
Rendered as markdown — headings, lists, tables, fenced code — but written however you like. A single newline stays a line break and leading indentation is preserved, so a plain-text description that is not markdown at all still looks the way you typed it.
e opens the description in $EDITOR (then VISUAL, then vi). Quitting
without changing anything writes nothing, so it will not touch updated_at.
Configuration
Entirely optional — dextui works with no config at all. Three ways to get a file to edit:
Add -l (or --local / --project) to act on the project file instead:
Or press , inside the app, which opens the global file in $EDITOR, creating
it from the template first if it does not exist, and reloading it when you save.
Layered defaults < global < project < environment:
| layer | path |
|---|---|
| global | ~/.config/dextui/config.toml |
| project | .dextui.toml at the git root |
= "priority" # priority | updated | created | name
= false # flips it: newest→oldest, updated→stalest
= "pending" # pending | active | all
= true
= "unicode" # nerd | unicode | ascii
= true # spin the in-progress marker
= 80 # below this width, one pane at a time (0 = always split)
A project file need only mention what it changes:
# .dextui.toml — this repo has wide tables
= false
Both files are read-only to the app — w, o, O and f change only the
current run, so nothing you toggle is written back over a file you hand-edited.
Set animate = false if you would rather nothing moved. dextui only redraws when
something changes, so with it off the app costs nothing at all while you are not
touching it — and with it on, only while a task is actually running.
DEXTUI_ICONS overrides the icon tier for a single run.
Command line
Subcommands follow dex's own shape, including -l/-g.
dextui Run the TUI (default)
dextui config Show the config paths and a commented template
dextui config init Write a config template
dextui config edit Open a config in $EDITOR, creating it if needed
dextui icons List the glyph tiers
dextui selftest Print the data pipeline as text (no TUI)
-h, --help Show help
-V, --version Show the version
-g, --global Act on ~/.config/dextui/config.toml (default)
-l, --local, --project Act on .dextui.toml at the git root
--force With `config init`, overwrite an existing file
Troubleshooting
"this needs a real terminal" — it draws a full-screen interface, so it cannot
render into a pipe, a file, or a job with no terminal attached. dextui selftest
prints the same data as text.
"dex is required" — every read and write goes through the dex CLI. If the message says dex is at a path but could not be started, dex itself is fine and its interpreter is not: dex is a Node script, and a node upgrade can move the runtime out from under it. Reinstalling dex under the current node fixes it.
Wrong tasks — dex resolves its store from the working directory, and falls
back to a global store outside a git repo. dex dir shows which one is in use.
Tofu (□) instead of icons — icons = "nerd" without a patched font. Use
unicode, or ascii if that still looks wrong. dextui icons shows all three.
A setting seems ignored — dextui config shows which files were found; an
unknown key or value is reported in the status bar at startup.
A build error mentioning edition2024 — your Rust is older than 1.85.
rustup update.
Scope
In: browse, search, filter, start, complete, edit, create, subtask, delete.
Out, because the CLI already does them well: sync, import, export, plan,
archive, and multi-project views. dextui shows the current directory's store.
Development
cargo run preserves your working directory, which matters here: dex resolves
its store from the cwd, so running it from another project browses that project's
tasks. cargo install copies the binary, so re-run it to pick up changes.
CLAUDE.md documents the conventions and the traps worth knowing before changing anything — several are non-obvious and were expensive to find.
License
MIT.