A local-first terminal kanban board with one provider-neutral workflow for
Jira, Linear, Asana, ClickUp, Monday.com, Plane, Trello, GitHub, and
Shortcut. Remote work items become editable Markdown; kbn sync reconciles
local edits with the selected provider.
Requires Python 3.11 or newer.
pip install pykantui # or: pipx install pykantui
kbn demoTo work on pykantui itself, install it editable from a clone:
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install -e ".[dev]"
.\.venv\Scripts\kbn.exe demopython -m venv .venv
./.venv/bin/python -m pip install -e ".[dev]"
./.venv/bin/kbn demo1. Look around first. demo opens a throwaway board with sample cards
and touches nothing on disk — drive it with the keys from
Driving the board, then ctrl+q to leave:
kbn demo2. Connect your tracker. init is one guided session: pick your provider
(Jira, Linear, Asana, ClickUp, Monday, Plane, Trello, GitHub, or Shortcut),
paste its API token, choose a project and a folder. It writes one Markdown
file per work item and stores the token outside the workspace:
kbn init3. Work. Open the board, edit cards in the TUI or the Markdown files in any editor, and sync when you are ready — every outbound change is shown for review and sent only after you confirm:
kbn # open the board for the workspace you are in
kbn sync # review what would change, confirm, push, then pullThat is the whole loop. The rest of this page is reference:
| Command | What it opens |
|---|---|
kbn demo |
a throwaway board with sample tasks |
kbn init |
create a Markdown workspace from any supported provider |
kbn |
the board for the current workspace |
kbn sync |
reconcile workspace Markdown and its provider |
kbn show |
the board as plain text, no TUI |
kbn board |
your local, tracker-free board |
| Flag | Effect |
|---|---|
--movement adjacent|jump |
how H/L behave |
--no-confirm |
apply column moves without the confirmation dialog |
--theme NAME |
any Textual theme; saved to config.json |
--edges round|square |
corner style for every border at once; saved |
--locale auto|en|es|… |
interface language, one of twenty; saved to config.json |
Token setup per provider is documented in .env.example, and
Provider workspaces covers the workspace lifecycle in
depth.
- Row actions: n / e / d for local edits
- Push path: F5 → confirm → sync status/progress dialog
- Move path: H/L then confirm dialog (or jump mode flow)
- Menu path: open/close Rows component menus and run actions inline
- Approval/status indicators: held items, conflicts, and sync status are visible
- Card movement is routed through the same confirmation and sync flow
- Push/menu/close cycles keep state in the same progress and notification model
- Use this view to validate move and approval behavior before provider writes
- Split behavior: [ / ] and \\ panel control
- Open/close menu while moving or editing cards
- Status and approval: explicit signals for queued/held/ready-to-apply work
- Install and Quick start
- Driving the board — keys, movement, confirmation, collapsing
- The top bar — search, filtering, sorting
- Shaping the board — columns as configuration
- Cards from the command line
- Backends and Provider workspaces
- Where things are stored
- Languages
- How it is built — layout, actions, the write path
- Develop and Recording the demo
Navigation and movement are deliberately different keys: lowercase moves the cursor, uppercase moves the card.
| Key | Effect |
|---|---|
h j k l / arrows |
move focus (wraps; empty columns are skipped) |
H / L |
move the focused card left / right across columns |
J / K |
reorder the card within its column |
enter |
commit a pending move (jump mode) |
n / e / d |
new / edit / delete |
i |
flash the cards blocking this one |
v / double-click |
open the card: dates, dependencies, description, provider fields |
, / right-click / click ▾ |
the column dropdown |
z / Z |
collapse the focused column / expand every column |
m |
toggle adjacent ↔ jump movement mode |
c |
toggle the move confirmation |
r |
reload from the backend |
/ |
jump to search |
F2 |
cycle the top bar |
ctrl+q |
quit |
Mouse drag works too, with the drop position taken from card midpoints.
- adjacent —
H/Lcommits to the neighbouring column immediately. - jump —
H/Lhighlights a candidate column and waits 1.2 s forenter. PressingH/Lagain walks the highlight further, so crossing three columns is one backend write instead of three. That matters against Jira, where each write is an HTTP round-trip.
Changing a card's column asks first. enter/y approves, escape/n
cancels. Nothing is written until you approve, so cancelling leaves both the
board and the store untouched.
Move this card?
Wire up the Jira backend
Ready → Doing
[ Move ] [ Cancel ]
The dialog names the side effect when there is one: moving into the finish column marks the task finished, into the reset column clears the dates, and on a read-only backend the move writes to Jira.
It applies to column moves only. J/K reordering does not ask, and neither
does a move the dependency gate is going to refuse — you get the "blocked"
toast instead of a pointless question.
Turn it off with c at runtime or --no-confirm at launch.
z shrinks the focused card's column to a 5-cell strip showing the count and
the name read downward; the columns left open share the freed width. Click the
« in a header to collapse, click the strip to reopen, or press Z to expand
everything.
╭──────────────────────────╮╭──────────────────────────╮╭───╮
│ Ready « ││ Doing « ││ » │
│ Wire up the Jira backend ││ Read the reference clone ││ │
│ Add a settings screen ││ ││ 1 │
│ Ship 0.1.0 ││ ││ │
│ ││ ││ D │
│ ││ ││ O │
│ ││ ││ N │
│ ││ ││ E │
╰──────────────────────────╯╰──────────────────────────╯╰───╯
A collapsed column is hidden, not closed:
- It stays a move target.
Linto a collapsed Done still files the card and the strip's count goes up. Focus stays on a visible card rather than following the card somewhere you cannot see. - Navigation skips it.
h/lstep over collapsed columns. - Its cards are untouched. Nothing is archived or dropped.
- The last open column will not collapse — you would be left with no board.
The JSON backend persists the state, so a board you left with Done collapsed opens that way. Jira keeps it for the session only.
One bar, three levels. F2 cycles them, or click the caret at the right.
≡ 20 cards ▾ collapsed
≡ search… Filter Sort Columns View Help 20 cards ▾ toolbar
≡ search… Filter Sort Columns View Help 20 cards ▴ expanded
┌ Provider scope ─┐ ┌ Workflow state ─┐ ┌ Assignee/owner ─┐ ┌ Type, when supported ─┐
┌ Provider ID ─┐ ┌ From ──┐ ┌ Until ─┐ ┌ Sort ─┐ Jira only: [ ] Sprint ┌ JQL ─┐ [Search]
┌ State ────┐ ┌ Saved ────┐ ⇵ Reverse + Save Clear New card Refresh
The level you leave it at is saved and comes back next time. The count shows
at every level, reading 9 of 27 · overdue when something is filtering — a
filter you forgot about is never invisible, even with the bar collapsed.
Provider boxes come from that provider's typed contract. Trello says Board/List/Member/Card ID; Shortcut says Workflow/Workflow State/Owner/Story Type; Jira alone adds Sprint and JQL. Unsupported boxes are absent. Jira's live query controls remain visible but disabled in an offline Markdown workspace.
Every dropdown has a shortcut that opens the panel and jumps straight to it:
| Key | Field | Key | Field | Key | Field |
|---|---|---|---|---|---|
p |
Provider scope | w |
Provider ID | o |
Sort |
t |
Type, when supported | f |
From | g |
Saved |
s |
Workflow state | u |
Until | x |
Sprint, Jira only |
a |
Assignee/owner | y |
Local state | q |
JQL, Jira only |
| Group | What it matches |
|---|---|
| Search | title and description, ignoring case |
| State | blocked · unblocked · overdue · due today · no due date · has notes |
| Provider | only fields declared by that tracker: assignee/owner, type, priority, labels/tags |
| Saved | your named combinations, stored in config.json |
Conditions are cumulative: Overdue plus Has notes means both, not either.
Clear resets the filter and the sort together.
Blocked is computed once per refresh from the whole task list rather than asked per card, so filtering a Jira board is one request, not one per card.
Manual · Title · Due · Age · Priority, with a Reverse toggle. Sorting is a view: it never writes positions, so the order you arranged by hand survives underneath and comes back exactly when you pick Manual again.
The trade is that J/K reordering is disabled while a sort is on — there is
nowhere for it to write. The binding greys out rather than failing on press.
Columns are configuration, not code. They live in one file that both backends read, so a card means the same thing locally and in Jira:
kbn columns # what the board looks like now
kbn columns add Blocked --after "In Progress" --statuses "BLOCKED, ON ICE"
kbn columns count 8 # grow or shrink to 8 visible columns
kbn columns move Done 1 # reorder
kbn columns rename Waiting "On Hold"
kbn columns role finish Shipped # which column means finished
kbn columns remove Waiting # its cards move left; use --move-to to choose
kbn columns hide Archive # keep it as a target without showing it
kbn columns reset --yes # back to the defaults below| Command | What it does |
|---|---|
list |
columns in order, with roles, hidden flags and Jira statuses |
add NAME |
--after to place it, --statuses to map Jira, --hidden to start hidden |
rename COL NAME |
rename in place |
remove COL |
delete it; cards move to --move-to, or the first column |
move COL N |
put it at 1-based position N |
count N |
grow with Column N placeholders, or shrink from the right |
role reset|start|finish [COL] |
set which column stamps dates; omit COL to clear |
statuses COL "A, B" |
Jira statuses landing here; "" clears |
show COL / hide COL |
visibility without deleting |
reset --yes |
restore the defaults |
Columns are addressed by id (#3), name ("Needs Review") or 1-based
position — whichever is handier. Nothing assumes a column count: one column
works, twelve works.
Boards already open pick changes up on r. Run kbn columns add ... in one
terminal, press r in another, and the board rebuilds with the new shape. No
restart, however many are open.
Roles are stored as column ids, not positions, so reordering never silently
changes which column means "done". Deleting a role column clears the role rather
than leaving it dangling. config.json is meant to be edited by hand, so a
value that is not one of ours falls back to the default instead of raising — a
typo in the file is never the reason the board will not open.
Columns share the available width while they fit and stop shrinking at 20 cells, after which the board scrolls sideways. That floor is why a ten- or twelve-column board stays usable in an 80-column terminal. Collapsing a column hands its width back to the rest.
The starting shape, written out on first run from
core/workflows.py:
| # | Column | Jira statuses | Effect on landing |
|---|---|---|---|
| 1 | To Do | BACKLOG, TO DO |
clears the start and finish dates |
| 2 | In Progress | IN PROGRESS |
stamps the start date |
| 3 | Needs Review | NEEDS REVIEW |
nothing |
| 4 | Waiting | NEEDS MORE INFO, WAITING ON HOLD, WAITING OR ON HOLD |
nothing |
| 5 | Done | DONE, CANCEL |
stamps the finish date |
| 6 | Archive | — | hidden by default |
Needs Review is a stage of the work, so it sits in the flow between In Progress and Done. Waiting is a parked state — blocked on someone else, or missing information — which is why it is not on the straight line to Done.
Neither stamps a date. Work under review or on hold is not un-started and not finished, so a card in either keeps the start date it already had and picks straight back up when it moves on.
The dependency gate only guards In Progress and Done. A blocked card can sit in Needs Review or Waiting; it cannot be claimed as started or done.
kbn task add "Write the docs" # one card in the first column
kbn task add Task --count 30 --column "To Do" # Task 01 .. Task 30
kbn task add "Ship it" --column Done --description "the details"
kbn task rm 4 7 9 # delete by id
kbn task clear "To Do" --yes # empty a columnCounts are zero-padded to the width of the total, so 30 cards come out
Task 01 … Task 30 and sort in the order you meant. Columns are addressed the
same way as in kbn columns. Editing a card is still the TUI's job (e).
Backend is the whole contract: four abstract
methods plus optional writes. Anything store-specific rides in Task.metadata
rather than becoming a domain field.
| Backend | Writable | Reorder | Query | Notes |
|---|---|---|---|---|
json |
yes | yes | no | default; one readable file per board |
| provider workspace | yes | provider-specific | provider-specific | local Markdown plus registry provider |
Every capability only some stores have is a method with a default on the base
class, not an attribute the UI goes looking for. The app asks the backend
questions — writable, supports_reorder, supports_query, query_text(),
sprint_only() — and never rummages through its attributes to guess.
All remote services use the same commands and lifecycle:
kbn init # choose a provider, project, and folder
kbn init --type jira # start directly with Jira
kbn init --list-types # list every registered provider
kbn sync # compare, confirm, push local edits, then pull
kbn # open the current workspace boardkbn init keeps its logo, spinner, provider picker, folder picker, progress,
and final board inside one TUI session. Provider field definitions supply the
wizard labels and validation, so adding a provider does not add another CLI
command.
Each workspace contains .pykantui/project.json and one Markdown file per work
item. Credentials live in the global auth.json, outside the workspace.
api/ owns the shared HTTP transport, bounded read retries, pagination, error
translation, and response caching. Each providers/<name>/client.py owns that
service's authentication; its provider package owns endpoints and field
mapping. See Provider architecture.
Jira still preserves its real behavior inside providers/jira: Jira Cloud
basic authentication, JQL search, board column discovery, issue creation,
editable fields, comments, and workflow transitions. It no longer requires a
separate SDK or a Jira-only kbn jira path.
| File | What |
|---|---|
%LOCALAPPDATA%\pykantui\board.json |
the local board's cards |
%LOCALAPPDATA%\pykantui\config.json |
columns, roles, saved filters, theme, locale |
%LOCALAPPDATA%\pykantui\auth.json |
credentials for all providers |
<workspace>\.pykantui\project.json |
provider/project metadata; no secrets |
<workspace>\.pykantui\cache\ |
ignored provider response cache |
<workspace>\.git\ |
local-only history checkpoints; never pushed by pykantui |
On Linux and macOS the base is $XDG_DATA_HOME or ~/.local/share.
PYKANTUI_HOME overrides the lot, which is how the tests and the demo board
never touch anything real. Every write goes to a temp file and is renamed over
the target, so an interrupted save leaves the old file intact rather than half
of a new one.
src/pykantui/
cli/ argument parsing and dispatch, nothing else
commands/ one module per provider-neutral kbn subcommand
api/ shared HTTP transport, retry policy, cache, pagination and errors
config/ where data lives (paths.py) and the saved board shape (board.py)
core/ board logic no screen or store owns: actions, filters, workflows
models/ the domain objects and the enums they are built from
pages/ full-screen views pushed over the board: detail, edit, menu, confirm
providers/ one package per service: field contract and endpoint mapping
sync/ local JSON and provider-workspace Backend adapters
tracker/ provider protocol, registry and provider-neutral remote models
tui/ the app, its themes, and the widgets on the board
tools/ dev scripts: gif and screenshot rendering
Dependencies run one way. models depends on nothing, core on models,
api knows HTTP but no provider, tracker owns neutral remote types, providers
translate their APIs into those types, sync adapts them to board tasks, and
tui/pages only consume backend capabilities.
Every clickable thing has to say what it stands for, and the only channel a
widget id or an option id gives you is a string. So there is a wire format,
"kind:value" — parsed into an Action at the boundary and never picked apart
again:
Action.parse("sort:due") # Action(kind=ActionKind.SORT, value="due")
Action.of(ActionKind.ACT, Act.CLEAR).chip_id # "chip-act-clear"
Action.from_chip_id("chip-act-clear") # back againcore/actions.py holds the vocabulary —
ActionKind, Menu, Act, ViewToggle, ColumnCommand, HelpTopic — as
enums, so a misspelled action is a parse that returns None at one known place
rather than a branch that silently never fires. The app dispatches with a single
match over ActionKind, which the type checker can see through.
The board re-renders only when the view actually changed. Re-syncing a dropdown to the value it already holds posts a Changed event, and rebuilding for that drops the focused card — which at startup means the first key press after opening goes nowhere.
Keyboard and mouse both converge on KanbanBoard.request_move, and every column
move goes through it:
- check dependencies (
Task.can_move_to) — refuse with a toast if blocked, - ask for confirmation, and stop here if cancelled,
- hand off to
commit_move, the only function that writes a move, - write to the backend,
- bail out with a toast if the write failed — the board is untouched, so there is nothing to roll back,
- only then move the widget and restack the columns.
request_move is a Textual worker rather than a plain coroutine, because
awaiting a modal needs one. That matters in tests: a bare pilot.pause() can
return before the move has landed, so tests/integration/tui/test_board_tui.py has a settle
helper that also drains workers.
The application shell ships twenty interface languages through Python's
standard gettext catalogs: Arabic, Dutch, English, French, German, Hindi,
Indonesian, Italian, Japanese, Korean, Polish, Portuguese (Brazilian),
Russian, Simplified and Traditional Chinese, Spanish, Thai, Turkish,
Ukrainian, and Vietnamese. Provider names, card content, remote comments, and
local Markdown are user data and are never translated.
kbn --locale es # use and save Spanish
$env:PYKANTUI_LOCALE = "ja" # choose Japanese for this environment
kbn --locale auto # follow the terminal or operating systemThe precedence is an explicit --locale, PYKANTUI_LOCALE, the saved locale,
the standard POSIX locale variables, the operating-system locale, then English.
Regional forms resolve to their language — es-MX and es_ES.UTF-8 select
Spanish, zh_CN Simplified and zh_TW/zh_HK Traditional Chinese — while a
regional form whose vocabulary the catalog does not carry (pt_PT against the
Brazilian catalog) falls back to English rather than reading wrong.
When adding or changing application-owned text, update and compile the catalog:
python -m babel.messages.frontend extract -F babel.cfg -o src/pykantui/i18n/locales/pykantui.pot .
python -m babel.messages.frontend update -i src/pykantui/i18n/locales/pykantui.pot -d src/pykantui/i18n/locales -D pykantui
python -m babel.messages.frontend compile -d src/pykantui/i18n/locales -D pykantuiBabel is a development dependency; installed users only need the compiled
.mo catalog bundled in the wheel.
.\.venv\Scripts\python.exe -m ruff check .
.\.venv\Scripts\python.exe -m mypy
.\.venv\Scripts\python.exe -m unittest discover -s tests -t .
# Authoritative Linux gates, including an installed-wheel smoke test
docker compose run --rm checks
docker compose run --rm tests
docker compose run --rm smoke
docker compose run --rm coverageBaseline: ruff clean, mypy strict clean (376 files), 1,845 tests OK. The randomized coverage gate measures branches as well as statements and enforces the current 80% baseline. Raise the floor as mocked coverage expands across provider-specific remote paths.
| Exhaustive shard | Tests |
|---|---|
test_[a-f]*.py |
424 |
test_[g-m]*.py |
541 |
test_[n-s]*.py |
422 |
test_[t-z]*.py |
458 |
Pass -t . so tests is imported as a package. Its __init__.py does two
things that only work if it is: it points PYKANTUI_HOME at a throwaway
directory for the whole run, so a test that forgets to sandbox itself cannot
rewrite your real board, and it quietens the asyncio logger that
IsolatedAsyncioTestCase turns to debug — booting a Textual app trips its
slow-callback warning constantly and buries the results.
The TUI suites are slow because each interaction test boots a real Textual app under the pilot. Run them by module while iterating, or run the four filename shards concurrently for a bounded exhaustive gate. Check the test count, not just the verdict: a module that fails to import is reported as one error and the suite still ends with a summary line.
The suite is organized by responsibility: tests/unit/ holds isolated API,
provider, tracker, workspace, Git, and TUI contracts; tests/integration/
contains CLI, sync, and real Textual-pilot journeys; tests/edge_cases/ owns
Markdown, path, conflict, output-safety, and property boundaries. Live account
audits are isolated under tests/live/ and run only through the live service.
The gif at the top is generated, not captured by hand:
.\.venv\Scripts\python.exe -m pip install pillow
.\.venv\Scripts\python.exe tools\gif.py # assets/demo.gif
# Capture local provider screenshots (no writes)
.\.venv\Scripts\python.exe tools\live_workspace_screenshots.py \
--workspace-root .docker-workspace/live-e2e/PKT-E2E-20260814T122600Z-3bd16524 \
--output artifacts/live-provider-assets-PKT-ASSETS-20260814T202943 \
--run-tag PKT-ASSETS-20260814T202943 \
--stage provider-snapshots
# Drive a local add/edit/comment flow + screenshots on the same seeded run
.\.venv\Scripts\python.exe tools\live_tui_actions.py \
--workspace-root .docker-workspace/live-e2e/PKT-E2E-20260814T122600Z-3bd16524 \
--artifacts artifacts/live-provider-assets-PKT-E2E-20260814T122600Z-3bd16524 \
--run-tag PKT-E2E-20260814T122600Z-3bd16524 \
--provider asana --provider clickup --provider github --provider jira --provider linear --provider monday --provider shortcut --provider trelloPlane is currently excluded because this environment returns 403 when Plane
state refresh is attempted for the configured workspace.
tools/gif.py runs the app under Textual's pilot against a
throwaway in-memory board, reads each screen straight off the compositor as
styled cells, draws it with Pillow, and stitches the frames with ffmpeg. No
terminal recorder, no browser and no pty, which is why it runs the same on a
laptop and in CI. Edit the SCRIPT list at the top of the file to change what
the demo does.
tools/screenshots.py does the same for stills, writing
SVGs — text, so they render crisply at any size and a diff shows what actually
changed instead of a wall of binary.
tools/demo.tape is a vhs
script for the same demo, kept for anyone who has vhs working: it needs ttyd and
a headless Chromium, which is exactly the machinery tools/gif.py avoids.
We keep a reproducible proof bundle for real-provider creation/sync validation:
- Run tag:
PKT-LIVE-REAL-20260814T133200Z - Workspace root (inside the repo, gitignored):
.docker-workspace\live-e2e\PKT-E2E-20260814T122600Z-3bd16524 - Validation report:
...\\live-create-artifacts\\validation-PKT-LIVE-REAL-20260814T133200Z.json - Screenshots (PNG+SVG): one image per provider, per layout (kanban/rows/split):
...\\live-create-artifacts\\PKT-LIVE-REAL-20260814T133200Z\\live-local\\<provider>\\live-real-9x1-*.{png,svg} - GIFs:
assets/live-real-9x1-asana.gifassets/live-real-9x1-clickup.gifassets/live-real-9x1-github.gifassets/live-real-9x1-jira.gifassets/live-real-9x1-linear.gifassets/live-real-9x1-monday.gifassets/live-real-9x1-plane.gifassets/live-real-9x1-shortcut.gifassets/live-real-9x1-trello.gif
# Rebuild exactly this bundle in place (no provider writes in this script)
.\.venv\Scripts\python.exe tools\live_workspace_screenshots.py \
--workspace-root .docker-workspace/live-e2e/PKT-E2E-20260814T122600Z-3bd16524 \
--output .docker-workspace/live-e2e/PKT-E2E-20260814T122600Z-3bd16524/live-create-artifacts/PKT-LIVE-REAL-20260814T133200Z/live-local \
--run-tag PKT-LIVE-REAL-20260814T133200Z \
--stage live-real-9x1
# Recreate the per-provider GIFs from the provider PNG captures.
# (This script uses Pillow and does not require the shell-level filtergraph.
# It includes kanban/rows/split frames per provider.)
.\.venv\Scripts\python.exe -c "from pathlib import Path; from PIL import Image; base=Path('.docker-workspace/live-e2e/PKT-E2E-20260814T122600Z-3bd16524/live-create-artifacts/PKT-LIVE-REAL-20260814T133200Z/live-local'); out=Path('assets'); providers=['asana','clickup','github','jira','linear','monday','plane','shortcut','trello'];\nfor p in providers:\n d=base/p\n frames=[Image.open(d/'live-real-9x1-kanban.png').convert('RGB'), Image.open(d/'live-real-9x1-rows.png').convert('RGB'), Image.open(d/'live-real-9x1-split.png').convert('RGB')]\n frames[0].save(out/f'live-real-9x1-{p}.gif', save_all=True, append_images=frames[1:], duration=1400, loop=0)"
# Combined GIF across all provider captures (one frame per provider):
.\.venv\Scripts\python.exe -c "from pathlib import Path; from PIL import Image; import re\nbase=Path('.docker-workspace/live-e2e/PKT-E2E-20260814T122600Z-3bd16524/live-create-artifacts/PKT-LIVE-REAL-20260814T133200Z/live-local'); out=Path('assets')/ 'live-real-9x1.gif'; files=sorted(list(base.glob('*\\live-real-9x1-*.png')), key=lambda p: p.as_posix());\nframes=[]\nfor p in files:\n if re.search(r'/[a-z]+/live-real-9x1-(kanban|rows|split)\\.png$', p.as_posix()):\n frames.append(Image.open(p).convert('RGB'))\nframes[0].save(out, save_all=True, append_images=frames[1:], duration=800, loop=0)"This run is used as the current all-provider real-card evidence bundle (create/edit path + readback + TUI capture).
MIT.