README
¶
taskr
A fast, keyboard-driven task manager for the terminal — built with Go and Bubble Tea.
Features
- Tasks — add, complete, delete, rename, set priority, size (S/M/L), due dates, start dates
- Sequencing engine — a weighted score (deadline + priority + momentum + size + age) decides the next-best task automatically; cycle
sto switch between Sequence / Due / Size sort. The Score column reads as a percentage of the current field (100% = the highest-scoring pending task), so it says how close to the top something is instead of quoting an unbounded number. Tune the weights in Settings (Relaxed / Balanced / Intense for each dimension), and presswon any task to see the points behind the percentage, their causes, the margins to the rows either side, and the moments the ranking moves on its own - Dependency-aware ordering — a task that blocks others inherits their urgency, so the prerequisite for an urgent task surfaces right above it (critical-path behaviour). In the list,
↥marks a blocker (something depends on it) and↧marks a blocked task (waiting on an unfinished dependency) - Calendar — per-day activity timeline with project/tag roll-ups and a tracked-time heatmap; edit or delete entries in place
- Projects — group tasks, Gantt timeline view.
enterdrills into a project's tasks, where the task keys (ddone,ttrack,ppriority,rrename,xdelete,enterdetails) all work;astarts a new task already in that project,xon the project row clears the grouping off its tasks - Tags — tag tasks, rename/merge/delete globally, and work the tag's tasks in place:
enterdrills into them with the same task keys as the Tasks tab,aadds a task already carrying the tag,fshows the tag's tasks on the Tasks tab as a filter - Board — kanban view of your pending tasks: one column per stage, the last of which holds the completed ones. Every column name is yours to define, that last one included — edit them in Settings → "Board columns" (comma-separated), or in
"stages"insettings.json(see Data for where that lives); default Backlog / In progress / Review / Done. Renaming a column takes its cards with it. The last column is done-ness itself rather than a stage you can assign, so calling it "Shipped" changes the heading and nothing else: a card moved into it is completed exactly likedwould, and neither the Stage row nor--stagecan put a task there.←/→switch columns,H/Lmove the selected card between stages — into the last column completes the task, out of it reopens it (confirmed)./filters the cards with the same grammar the Tasks tab uses, so#tag,@projector plain text narrows every column at once. With more stages than fit — ten columns need a 204-column terminal — the board becomes a window that scrolls:←/→move the focus and bring the view along one column at a time, and the panel title says which slice you are on (Workflow ‹ 3–8/11 ›). Below three visible columns it falls back to the stacked list instead, which shows every stage at once. A task's stage is also editable from the detail pane:←/→on the Stage row move it between columns, the way the arrows change a value in Settings. Not using kanban at all? Settings → "Kanban board" removes the tab and the field together. Alsotaskr edit <ref> --stage <name>from the CLI - Stats — productivity overview with an activity heatmap
- Time tracking — start/stop a timer per task (
t), live elapsed display, runaway-timer guard - Detail view — per-task comments, dependencies, subtasks, notes (opens
$EDITOR), plus a live score breakdown so you can see why a task ranks where it does - Search — live filter across tasks, projects and tags; fuzzy title matching (notes are matched as a substring) plus field filters (
#tag @project p:high due:<fri overdue); the Stats tab follows the active filter, so#tagscopes every stat to that tag - Command palette —
ctrl+kfinds any action by name (fuzzy, ranked) and shows the key that performs it, so nothing is gated behind remembering a letter; an action belonging to another tab switches there first - Undo — multi-level undo for all mutations
- Settings — three sequencing-bias knobs, theme, language (English / Dansk / Deutsch — the quick-add and search keywords speak it too), board columns, version, in-app self-update (tab 7)
Installation
macOS with Homebrew (recommended):
brew install iliorn/tap/taskr
This builds the tagged source and installs the taskr terminal command. Update
it with brew update && brew upgrade taskr; Taskr detects Homebrew installations
and will point you to that command instead of trying to overwrite Homebrew's
managed files.
From source:
git clone https://github.com/iliorn/taskr
cd taskr
go mod tidy
go build -ldflags "-X main.appVersion=$(git describe --tags --abbrev=0)" -o taskr .
mv taskr ~/.local/bin/ # or anywhere on your PATH
With Go:
go install github.com/Iliorn/taskr@latest
Builds from source on any platform Go supports, including architectures the
release page doesn't carry. It reports the module version it was installed at
(taskr --version), because the Go toolchain stamps that into the binary even
though no release tag was baked in with -ldflags.
This is also the install with the strongest integrity guarantee. Go verifies
every module against sum.golang.org, a public append-only transparency log —
a hash that has been recorded there cannot be changed afterwards, not even by
me. The release binaries carry a SHA256SUMS file, which is a weaker promise:
it proves your download matches what the release workflow published, not that
the publisher was honest. See SECURITY.md for
what each check does and does not cover.
Verifying a release binary:
curl -LO https://github.com/Iliorn/taskr/releases/latest/download/SHA256SUMS
sha256sum -c SHA256SUMS --ignore-missing
That proves your download matches what the release published. To also prove who published it, verify the build provenance — every release binary is signed by the release workflow through Sigstore and recorded in a public transparency log:
gh attestation verify taskr --repo Iliorn/taskr
It names the workflow, commit and run that produced the file, and it is not something a stolen token can forge after the fact. See SECURITY.md for what each check covers.
Release builds pass -trimpath with CGO_ENABLED=0 and the Go version pinned
in go.mod, so they are reproducible: check out the tag, run the same
go build the release workflow does, and the
hashes should match.
Arch Linux (AUR):
yay -S taskr-bin # or: paru -S taskr-bin
Installs the published binary along with the shell completions and the man
page. The PKGBUILD is generated by the release workflow and attached to each
release, so its checksums always match the release it describes.
Windows with Scoop:
scoop install https://github.com/Iliorn/taskr/releases/latest/download/taskr.json
That URL always resolves to the newest release, so it never needs updating.
Upgrade with scoop update taskr.
Pre-built binary (Linux / Windows):
Download the latest release from the Releases page:
| Asset | Platform |
|---|---|
taskr |
Linux x64 |
taskr-linux-arm64 |
Linux arm64 (Raspberry Pi, ARM servers/VMs) |
taskr.exe |
Windows x64 |
SHA256SUMS |
checksums for the three binaries |
taskr.json |
Scoop manifest (Windows) |
PKGBUILD |
Arch Linux package recipe |
Verify a download with sha256sum --check --ignore-missing SHA256SUMS.
On Windows, notes editing uses EDITOR if set (setx EDITOR hx), falling back to notepad. On Linux and Windows, self-update (Settings tab → "Update to latest release") downloads the release asset straight from the GitHub API — no extra tooling required — and verifies its SHA-256 against the SHA256SUMS published with the release before installing it. It fails closed: a release without that file, without an entry for your platform's binary, or a download that hashes to something else installs nothing. (That is an integrity check, not a signature — it catches a truncated download or a proxy rewriting the response, not someone who can edit the release itself.) macOS and other Homebrew installations are pointed at brew upgrade taskr instead, since the binary there is a managed file.
Usage
taskr
Keyboard shortcuts
| Key | Action |
|---|---|
a |
Add task |
d |
Toggle done |
t |
Start/stop time tracking |
D |
Set / clear the due date (same prompt as the detail field) |
r |
Rename |
x / del |
Delete |
n |
Edit notes in $EDITOR |
f |
Focus mode (today + overdue) |
h |
Toggle history |
s |
Cycle sort: Sequence → Due → Size |
w |
Why this rank — the points behind the percentage, their causes, the margins either side, what moves it next |
/ |
Search / filter |
enter |
Open detail view |
u |
Undo |
↑/↓ or j/k |
Move the cursor (every list, the detail pane, the board, the calendar, settings) |
tab / shift+tab |
Next / previous tab |
1–7 |
Jump straight to a tab (7 = Settings) |
ctrl+k |
Command palette — find any action by name |
? |
Show all shortcuts (/ filters them) |
On the Tags and Projects tabs, enter walks in one level at a time — row → its tasks → the selected task's detail — and esc walks back out the same way. While you're inside, the row-level keys act on the task under the cursor, exactly as they do on the Tasks tab.
Custom keybindings
Every binding carries an action id, so rebinding one is a line in
settings.json — ~/.config/taskr/settings.json on a new Linux install, ~/.taskr/settings.json on an older one (taskr doctor prints the path):
{
"keys": {
"done": "D",
"search": "s",
"sort": "/"
}
}
The keys are action ids (the full list is the ? overlay — every row with a
single-key binding), the values are keys. A rebind moves the action: the old key
stops working, so it is free for something else, and the footer hints, the help
overlay and the command palette all show the new key. Entries that name an
unknown action, use a key that isn't a single key, or collide with another
binding in the same view are ignored with a warning rather than leaving the
action unreachable — as is ctrl+c, which always quits. Bindings written as a
pair or a range (←/→, H/L) can't be rebound.
Quick-add syntax
Buy groceries #shopping due:friday p:high size:s @personal
Supports #tag, due:date, p:high/medium/low, size:s/m/l, @project inline when adding a task. Typing # or @ offers your existing tags/projects (most recently used first) as chips under the field — tab inserts the highlighted one, ↑/↓ pick a different one — so you don't have to remember the exact spelling. Projects whose name contains a space aren't offered, since quick-add tokenises on whitespace; add those from the detail pane's @ picker. Tags are lowercase slugs; whitespace entered in the tag editor is normalized to - (for example, Deep Work becomes #deep-work).
Search syntax
The / filter tokenises on whitespace and ANDs the tokens together, reusing the quick-add vocabulary:
@work p:high due:<friday # high-priority Work tasks due before Friday
#urgent overdue # overdue tasks tagged urgent
grcrs # fuzzy title match → "Buy groceries"
Supported tokens: #tag, @project, p:high/medium/low, due:<date / due:>date / due:date (<=/>= too), and the bare keyword overdue. Anything left over fuzzy-matches the title (a subsequence, so dply finds "Deploy release" — every letter must appear in order, which is why grcrs finds "Buy groceries" and grcry does not) or matches the notes as a plain substring — fuzzy matching over a whole note would hit almost anything.
Date formats
today · tomorrow · next week · monday · 15-06-25 · +3d · +2w · +1m
Typing in your own language
With the interface set to Dansk or Deutsch, the quick-add and search grammars
accept that language's words too — frist:imorgen p:høj størrelse:lille,
fällig:freitag p:hoch überfällig — and the help overlay and the input hints
advertise those spellings rather than the English ones. The words come from the
same translation table the interface renders from, so what is on screen is what
parses.
English keeps working everywhere, whatever the language is set to, and only input is localized: recurrence rules, tags and every other stored value stay in their canonical English form, so installs in different languages sync without translating anything. The CLI stays English on both sides — its help and output are English, so its input is too.
CLI
taskr ships with a small command-line surface for scripting. Bare taskr still launches the TUI; pass a subcommand to drop into CLI mode:
taskr add "Buy milk" --size=s --due=tomorrow --p=high --tag=shopping
taskr list # pending top-level tasks (table)
taskr list --json --focus # JSON, today + overdue only
taskr list --stale=30d --sort=idle --wide # backlog review: nothing touched in a month,
# longest-untouched first, with AGE and IDLE columns
taskr list --unblocked-since=14d # tasks freed recently: every blocker done, the last one this fortnight
taskr search RAM --word # whole-word match ("RAM" won't match "Ramte"); --re for a regexp
taskr top -n=5 # top 5 by sequence score (percent of the current field)
taskr show milk # full detail (incl. score breakdown + subtask IDs)
taskr why milk # why it ranks there: each factor's cause, the margins to the
# tasks either side, and when the ranking shifts on its own
taskr edit milk --p=high --add-tag=urgent --due=tomorrow
taskr edit deploy --add-dep=sign-off # depend on another task (refused if it would loop)
taskr edit deploy --remove-dep=sign-off
taskr edit a1b2 c3d4 e5f6 --project=hoth # one change across several tasks (--title stays single-ref)
taskr done milk # mark a task done
taskr reopen milk # move it back to pending (the counterpart to done)
taskr delete milk # soft delete (alias: taskr rm)
taskr subtask milk "find receipt" # create a subtask of "milk"
taskr add "Deploy release" --depends="sign-off" # block the new task until "sign-off" is done
taskr start milk # start the time tracker
taskr stop # stop the running tracker (no ref needed)
taskr comment milk "blocked on review"
taskr comment milk --edit=1 "still blocked, asked Sam"
taskr comment milk --delete=2
taskr stats # one-line summary
taskr stats --tag=work # same, scoped to tasks carrying a tag (also --project / --search)
taskr stats --seq # sequence miss analysis: which score dimension buried the
# tasks you finished anyway, plus a bias-tuning hint
taskr stats --format=waybar # Waybar-shaped JSON for a status-bar widget
taskr export > backup.json # versioned JSON snapshot of every live task
taskr export --include-done > full.json # include completed tasks
taskr import backup.json # merge an export file into the local store
taskr import - < backup.json # same, reading from stdin
taskr completion fish > ~/.config/fish/completions/taskr.fish
taskr man > ~/.local/share/man/man1/taskr.1
taskr help
The --json output is a contract
list, search, top, show, why, add, tags, projects, doctor,
stats --format=json|waybar and export all emit machine-readable JSON, and
that shape is treated as an interface, not an implementation detail:
- Fields are not renamed or removed in a patch or minor release. New fields may be added at any time, so parse defensively — read the keys you need and ignore the rest.
- A removal or rename waits for a major version and is called out in the changelog.
- The shape of every one of those commands is pinned by golden files
(
testdata/json_contract/) that a test compares on every CI run, so a field cannot disappear through a refactor without someone deciding to let it. The test reports exactly which key paths appeared and vanished.
Two shapes worth knowing about when you script against them: timestamps are
always present (Go's omitempty does not apply to time values, so an unset
date is the zero time 0001-01-01T00:00:00Z, not a missing key), and size is
absent for medium-sized tasks, which is the default.
Shell completion and man page
taskr completion bash|zsh|fish prints a completion script; taskr man prints a
roff man page. Both are generated from the same command table the CLI dispatches
on, so they can't fall behind a new subcommand or flag — a test compares the
table against the flags each command actually defines. Task refs complete from
the live store (via taskr list), so tab-completing taskr done offers your
open tasks.
# bash
taskr completion bash > /etc/bash_completion.d/taskr
# zsh
taskr completion zsh > "${fpath[1]}/_taskr"
# fish
taskr completion fish > ~/.config/fish/completions/taskr.fish
# man page
taskr man > ~/.local/share/man/man1/taskr.1
Task references can be either a UUID prefix (60b9) or a case-insensitive substring of the title (milk). ID-prefix takes precedence so scripts stay deterministic. Ambiguous references fail with exit code 2 and list every match with its short ID for easy disambiguation:
$ taskr done milk
title "milk" matches 2 tasks:
21a164e1 Buy milk
2ffe832a Buy more milk
Flags can appear before or after the reference. taskr top --json and taskr show --json are the recommended hooks for scripts and other tools. The CLI reads the same settings.json as the TUI, so ranking matches your current bias personality.
The TUI and CLI share the SQLite store. Concurrent reads are safe; writes serialize via SQLite's busy-timeout. A running TUI watches the data directory and live-reloads when the CLI (or a sync from another device) mutates the database, so scripted changes show up without restarting — a reload is briefly deferred while you're mid-edit so it can't clobber in-flight input.
Export / import
taskr export writes a versioned JSON envelope to stdout:
{
"version": 1,
"exported_at": "2026-07-10T12:00:00Z",
"tasks": [ ... ]
}
Each task in tasks is a todo.Todo object with these key fields:
| Field | Type | Notes |
|---|---|---|
id |
string (UUID) | stable identifier, used as the merge key |
title |
string | |
status |
int | 0 = pending, 1 = done |
priority |
int | 0 = low, 1 = medium, 2 = high |
size |
int | 0 = medium, 1 = small, 2 = large |
created_at / modified_at |
RFC 3339 | LWW conflict resolution uses modified_at |
due_date |
RFC 3339 (omitempty) | |
project |
string (omitempty) | |
tags |
string array (omitempty) | |
dependencies |
string array (omitempty) | IDs of tasks this one is blocked by |
comments |
array (omitempty) | each has id, text, created_at |
deleted / deleted_at |
bool / RFC 3339 | soft-delete tombstones for sync |
parent_id |
string (omitempty) | set on subtasks |
taskr import <file> (or taskr import - for stdin) merges the file's tasks into
the local store using the same tombstone-aware, idempotent merge engine that powers
device sync. Import never replaces the store — it folds the imported tasks in —
so running export | import is always safe and importing the same file twice is a
no-op. Both the versioned envelope and a legacy bare JSON array are accepted.
When something feels slow
On Windows, a keystroke arriving after a pause used to wait up to 16 ms
before taskr heard about it: Bubble Tea reads the console by polling it in a
loop with a 16 ms sleep between attempts, and during a burst the loop spins
instead of sleeping — so the first key felt late and the rest did not. taskr
now opens CONIN$ as its own file, which selects Bubble Tea's other input
path: a blocking read of the console's escape-sequence stream that returns the
moment a key arrives. TASKR_WIN_CONSOLE_INPUT=1 goes back to the polling
reader if the escape-sequence path ever misbehaves on a particular console.
Because resize events only arrive on the polling path, the Windows build asks
the console for its size four times a second instead.
If input still feels laggy, try TASKR_NO_WATCH=1 taskr. The filesystem watcher is the only thing
taskr does continuously against the operating system — a watch on the data directory
that wakes on every write, including the app's own — and it is the first
variable worth removing. If that fixes it, the cost is in the watch (a synced
or network home directory, an antivirus scanning the database on every write);
the trade-off is that the TUI stops noticing taskr add from another shell
until it next reloads.
If it doesn't, measure:
TASKR_TRACE=1 taskr writes one line per frame to trace.log in the state directory
(TASKR_TRACE=/path/to/file picks another location). Each line carries the
wall clock, the gap since the previous frame, how long Update and View
took, the GC cycle count, and which message caused it:
# time gap_ms update_ms view_ms gc msg
12:53:42.841 1.0 0.066 0.915 8 key down
12:53:42.863 15.9 0.641 1.632 9 main.reloadedMsg
Quitting writes a summary, which is usually the whole report:
# summary over 412 frames (ms)
# update p50 0.090 p95 0.140 max 1.900
# view p50 0.900 p95 1.300 max 4.100
That separates the three things a delay can be: our own compute (update_ms
or view_ms is large), a garbage collection (the frame is slow and gc
moved), or everything outside the app — terminal, ssh, the input reader — in
which case the frame is fast and the time sits in gap_ms. Tracing is off
unless the variable is set, and a full buffer drops entries rather than
slowing the loop.
Data
Tasks live in a SQLite database (WAL mode). Where it and the rest of taskr's files go depends on what it finds, in this order:
TASKR_HOME, if set — everything in that one directory. For people who would rather have a single path to back up than a tidy split.~/.taskr/, if that directory already exists — every version before v1.32 put everything there, and it keeps working exactly as it did. Nothing moves, nothing is migrated. Move the directory yourself if you want the layout below.- Otherwise the platform convention:
| Linux / BSD | macOS | Windows | |
|---|---|---|---|
Config — settings.json, sync.json |
$XDG_CONFIG_HOME, default ~/.config/taskr |
~/Library/Application Support/taskr |
%APPDATA%\taskr |
Data — tasks.db |
$XDG_DATA_HOME, default ~/.local/share/taskr |
~/Library/Application Support/taskr |
%LOCALAPPDATA%\taskr |
| State — undo history, sync state, logs | $XDG_STATE_HOME, default ~/.local/state/taskr |
~/Library/Application Support/taskr |
%LOCALAPPDATA%\taskr |
Cache — the $EDITOR scratch file |
$XDG_CACHE_HOME, default ~/.cache/taskr |
~/Library/Caches/taskr |
%LOCALAPPDATA%\taskr |
An explicitly exported XDG_* variable wins on every platform, including macOS
and Windows — if you set it, you meant it. taskr doctor prints the directories
it resolved, which is the quickest way to see which of the three rules applied.
On first launch a legacy tasks.json next to the database is imported and then
left in place as a backup.
A store that a newer taskr has already migrated will not open with an older
binary — it says which schema it found and which one it knows, rather than
coming up on an empty list over a full database. Update taskr, or restore the
tasks.db-pre-migration-*.bak the upgrade wrote next to it.
If taskr ever crashes, it writes crash-<timestamp>.log to the state directory
above — the stack trace plus the build, platform, terminal size and what was on
screen — and flushes any edits the 300ms save debounce still owed before it
exits. The newest five are kept. Attaching one to an issue is the whole bug
report.
Sync
taskr can sync tasks across devices through a small self-hosted server — one authoritative merge point, no third-party service. The same binary is both client and server.
Run a server
On the machine that should host the canonical store (e.g. a home server reachable over Tailscale/LAN):
taskr serve --listen 100.x.y.z:8765 --token "$(openssl rand -hex 32)"
# or: TASKR_SYNC_TOKEN=… taskr serve --listen 100.x.y.z:8765
A token is mandatory — taskr refuses to run unauthenticated. --listen
defaults to 127.0.0.1:8765; bind to a Tailscale/LAN address (or put it behind a
reverse proxy for TLS) to reach it from other devices. The server persists to its
own tasks.db and exposes:
POST /v1/sync— full-snapshot sync (Bearer token)GET /v1/health— liveness checkGET /v1/events— Server-Sent Events "doorbell" so clients pull in real time
To keep it running, wrap it in a systemd --user unit with the token in an
EnvironmentFile (mode 600) and enable lingering.
Point a client at it
taskr sync --url http://100.x.y.z:8765 --token "<token>" --save
--save writes the URL + token to sync.json in the config directory so future syncs need no
flags; TASKR_SYNC_URL / TASKR_SYNC_TOKEN work too. Once configured, the TUI
auto-syncs (on launch/exit, on a periodic tick, and live via SSE), and CLI
mutations sync best-effort in the background. Set "auto_sync": false in
sync.json to require manual taskr sync. The local SQLite store is always the
source of truth — network failures never block the UI.
You can also manage all of this from the Settings tab: toggle auto-sync, edit the server URL/token inline (token masked), run "Sync now", and (v1.17+) flip the local instance into server mode.
How merge works
Sync is UUID-keyed with last-writer-wins on scalars (by ModifiedAt), union of
child collections (comments/time-entries) by UUID, and soft-delete
tombstones so a deletion propagates instead of the row reappearing. Edit-vs-delete
conflicts surface as a brief toast, and the losing version is appended to
sync.log in the state directory for recovery. Clock-based LWW assumes roughly synced clocks
(NTP); only tasks sync, not settings.json.
Contributing
See CONTRIBUTING.md for the build/test commands and the conventions worth knowing before a pull request; ARCHITECTURE.md is the architecture tour. Notable changes are in CHANGELOG.md.
License
MIT
Source Files
¶
- atomicfile.go
- board.go
- cache.go
- cli.go
- cli_diagnose.go
- cli_refs.go
- cli_suggest.go
- cli_sync_recover.go
- completion.go
- constants.go
- crash.go
- export.go
- faststyle.go
- focus.go
- helpers.go
- input.go
- input_other.go
- keymap.go
- keys.go
- lang.go
- lang_input.go
- lastadded.go
- layout.go
- main.go
- migration_002.go
- migration_011.go
- migrator.go
- model.go
- model_layout.go
- palette.go
- paths.go
- repository.go
- selectors.go
- sequence.go
- sequence_explain.go
- serve.go
- storage.go
- storage_sqlite.go
- store.go
- styles.go
- suggest.go
- syncauto.go
- syncclient.go
- synclive.go
- syncserver.go
- syncsettings.go
- syncstore.go
- synctoken.go
- taskops.go
- timerrecover.go
- trace.go
- undo_persist.go
- update.go
- update_board.go
- update_detail.go
- update_modes.go
- version.go
- view.go
- view_board.go
- view_calendar.go
- view_detail.go
- view_explain.go
- view_lists.go
- view_preview.go
- watcher.go
Directories
¶
| Path | Synopsis |
|---|---|
|
Package tasksync is taskr's cross-device sync engine: the pure merge fold (Merge), the wire protocol (Request/Response, PostSync), the HTTP endpoint (Server), the real-time change push (Hub, Listener), and conflict detection (DroppedLocalEdits).
|
Package tasksync is taskr's cross-device sync engine: the pure merge fold (Merge), the wire protocol (Request/Response, PostSync), the HTTP endpoint (Server), the real-time change push (Hub, Listener), and conflict detection (DroppedLocalEdits). |
|
Package todo is taskr's framework-free domain layer: the Todo type and its mutations (toggle, tags, timers, subtasks, comments, time entries).
|
Package todo is taskr's framework-free domain layer: the Todo type and its mutations (toggle, tags, timers, subtasks, comments, time entries). |