- Rust 99.8%
- Makefile 0.2%
| src | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| Makefile | ||
| README.md | ||
kimai-tui
A terminal UI for Kimai, written in Rust.
┌────────────────────┬──────────────────────────────────────────────────────┐
│ ▸ Tasks 1 │ Task #1 · Blöcke umsetzen │
│ Projects 2 │ Status progress │
│ Customers 3 │ Estimated 3:00 │
│ Activities 4 │ Logged 4:15 │
│ Timesheets 5 │ ████████████████████████ 142% │
│ Settings 6 │ │
│ │ Customer Hellmund Berater (10015) │
│ │ Project hellmund-berater.de (Relaunch) (0012) │
│ │ │
│ │ Time entries (4) total 4:15 │
│ ────────────────── │ Date Time Dur. Rate Exp. │
│ ▶ 0:12:34 │ 2026-07-09 17:15–18:45 1:30 120.00 ✓ │
│ friday-knight.com │ │
│ Administration │ │
│ x stop │ │
├────────────────────┴──────────────────────────────────────────────────────┤
│ q quit ⇥ focus ↑↓ move Esc back r reload ? help │
└───────────────────────────────────────────────────────────────────────────┘
The bottom of the sidebar is the running timer: elapsed time, what it is booked against, and how to stop it. It is drawn from every section and re-checked every 30 seconds, so starting or stopping a timer in Kimai's web UI shows up here too.
Install
make install # builds --release and copies to ~/.local/bin
PREFIX and BINDIR are overridable: make install BINDIR=/usr/local/bin.
make help lists every target; make uninstall removes the binary again.
First run
Starting kimai without a configuration opens a wizard asking for your
instance URL and an API token. Create the token in Kimai under your avatar →
API access. The wizard checks the credentials against /api/version and
/api/users/me before writing anything, so a rejected token never lands on disk.
The configuration is stored at ~/.config/kimai-tui/config.toml
($XDG_CONFIG_HOME is honoured) with mode 0600:
url = "https://your-instance.kimai.cloud"
api_token = "…"
Re-open the wizard any time with kimai --reconfigure, or with s then e
inside the app.
Keys
| Key | Action |
|---|---|
↑ ↓ / j k |
move or scroll within the focused pane |
Tab |
switch between sidebar and content |
Enter |
open what the cursor is on; leave the sidebar |
Esc |
back one step: close a page, drop a filter, leave the pane |
g / G |
jump to first / last |
f |
cycle the section's filter |
n / e |
create / edit, in every section |
x |
stop the running timer, from any section |
^S |
save the open form |
1 … 9 |
jump straight to a section |
r |
reload the current section |
s |
open Settings |
? |
help overlay |
q / ^C |
quit |
Following the hierarchy — customer, project, activity, task — is four keys named after where they go. They work from a list row and from an open detail page alike:
| Key | Goes to |
|---|---|
c |
the customer of the selected record |
p |
its project, or a customer's projects |
a |
its activity, or a project's activities |
t |
the tasks of the selected record |
Arriving that way narrows the target section to the record you came from, which
its title says (Projects · Hellmund Berater · visible). Esc drops the
narrowing and shows everything again.
Two of them are already taken in the Tasks section, where t tracks time and
c closes a task, so a task links only to its project (p) and its activity
(a) — its customer is one further hop, through the project. Mind the reverse
too: arriving in Tasks with t and pressing t again starts a timer.
| Key | Only in |
|---|---|
t |
Tasks: track time on the selected task |
c / o |
Tasks: close the task / reopen a closed one |
← → |
Timesheets: switch between the two lists |
c |
Timesheets: start new work like an entry |
Tasks
The list shows id, title, status, project, activity, due date and estimate, with
a green ▶ on any task that has a running timer.
Kimai hides finished tasks by default, so f cycles the status filter —
open (pending + progress) → closed → all — and the active one is
shown in the pane title (Tasks · closed).
n creates a task and e edits the selected one (also available from the
detail page). Ctrl-S saves, Esc cancels.
c closes the selected task and o reopens a closed one. Two keys rather than
one toggle, so neither can do the opposite of what the status bar just said: the
hint names whichever transition applies to the task under the cursor, and swaps
to o reopen on a finished one. They are transitions rather than a status
picker because that is the whole of what the plugin offers — see below.
Closing a task also stops a timer running on it, which Kimai does by itself,
and a closed task drops t from its hints because starting a timer on one is
HTTP 403. Closing under the default filter makes the task disappear from the
list it was closed from, so the confirmation says how to get it back.
The form covers title, project, activity, assignee, due date, estimate, tags, to do and description. Project, activity and assignee use a type-to-filter picker; choosing a different project drops an activity that does not belong to it, and the assignee list starts with an entry that leaves the task unassigned.
To do and Description are deliberately separate blocks, because they mean different things: to do stays on the task, while the description is a template Kimai copies onto time entries started from that task — as are the tags.
Dates take YYYY-MM-DD, optionally with HH:MM. Estimates take h:mm, 90m,
1.5h or a bare number of hours. Tags are comma-separated. Everything is
validated before anything is sent, and the offending field is highlighted; a
rejected save shows Kimai's own per-field messages.
To do, Description and the time entry's description are drawn as three
wrapped rows rather than one line, so a paragraph is readable rather than cut at
the panel edge. A value taller than that scrolls, marked with ⋮. The one-line
fields scroll sideways instead: while a field has focus it follows the end of
what you are typing, with … where the start is hidden, and once focus moves on
it goes back to reading from the beginning.
Editing sends only the fields on the form, so a task's team and status are left as they were.
Tags are created on sight. Kimai turns any unfamiliar tag name into a new tag entity — and it does so while binding the form, so the tag survives even when the request fails validation and no task is saved. The field lists the existing tags while it has focus to make near-duplicates less likely. The API can delete a tag (
DELETE /api/tags/{id}) butGET /api/tagsreturns only names, so removing a stray one means using the web UI.
Enter opens the detail page, which carries everything the API returns for that
task: status, due date, estimate against time actually logged (with a progress
bar that turns red past 100 %), customer and company, project and activity in
their own Kimai colours, assignee, team, tags, the to do and description text,
any custom fields — and, from GET /api/tasks/{id}/timesheets, every time entry
with its date, span, duration, rate and export flag, plus the total. A running
entry counts up live. Fields the model does not recognise are listed under
Other fields rather than dropped, so a plugin update stays visible.
Customers, projects and activities
Three sections of the same shape, one per level of Kimai's hierarchy. Each is a
list, a detail page behind ↵, and a create/edit modal on n and e. There is
no delete — see the note on hiding below.
f cycles which records the list asks for: visible → hidden → all,
shown in the pane title. A hidden row is marked ∅.
Hiding is this client's archive. Kimai's collections return visible records only unless asked otherwise, and nothing here deletes, so clearing Visible on a record is how it is retired — and
fis the only way back to it. A hidden customer also stops being offered for new projects: the endpoint rejects one with "The selected choice is invalid".
A customer page carries what the list cannot: GET /customers leaves out the
address, the contact block and the budgets, so both the page and the edit form
read GET /customers/{id} first. The form covers name, number, company,
visibility, billing, colour, the locale trio, the contact block, the postal
address, VAT and invoicing, and the budgets. Country, currency and timezone have
no endpoint listing what Kimai accepts, so ↵ on those offers the values this
instance already uses; a new customer starts on your own account's timezone.
A project belongs to exactly one customer and cannot be saved without one.
Its page lists the activities it can book, which is Kimai's own answer and not
simply "the ones belonging to it": the shared ones are included when Global
activities is set. Dates take YYYY-MM-DD.
An activity is either global — every project can book it — or bound to one project. The list says which, the picker leads with the global entry, and a new activity created from inside a project-scoped list is bound to that project.
A binding can be given up but never moved.
POST /api/activitiestakes a project;PATCHaccepts onlynullthere and rejects an id outright as an extra field, failing the whole request. So the edit form offers releasing an activity to global and nothing else, and says so on the row.
Every edit sends only the fields that actually changed. That is what makes a partial update safe here: the collections omit fields the entity has, so a form that wrote back everything it displayed would blank whatever it never read.
One field is write-only. invoiceText is accepted on customers, projects and
activities but never returned by any route, so it cannot be prefilled — leaving
it blank keeps whatever is stored.
Time tracking
The Timesheets section is two lists. Resume on top is Kimai's own recent
activities — the last five distinct project/activity combinations — and below it
are the entries themselves, newest first. ←/→ switch between them, and the
running entry counts up live in both the list and the sidebar.
↵ opens whatever it is pointing at, which is not the same thing in the two
lists: on Resume it starts a new timer modelled on that combination, on the
entries list it opens that entry for editing. They are deliberately separate,
because treating a real entry as a template moves its date to today — save
without noticing and the time lands on the wrong day. n starts from scratch,
e edits, and c starts new work modelled on an entry too old to be on the
shortcut list. The status bar names whichever ↵ currently does.
All of them open the same modal, which only prefills — nothing is sent until
^S. The fields narrow each other from the top down: a customer limits the
projects, a project limits the activities, and picking a project fills its
customer in above.
Date, Start and Duration decide when the entry sits and how long it lasted. An empty duration means the entry is running — that is the whole difference between starting a timer and logging finished work, and the modal's heading follows along: Start a timer, Log time, or Edit entry #43. Clearing the duration of a finished entry puts it back on the clock.
Duration is the one Kimai booked. The API has no duration field at all — the server derives it from the start and end and rounds it by its own configuration. This instance rounds up to the next quarter hour, so an entry that ran 14:36–14:37 is booked as 0:15, and two thirds of the entries on a real instance have a booked duration that differs from their clock span. Booked is the figure on the list, in the web UI and on the invoice, so booked is what the field prefills; the clock span is disclosed underneath while the field has focus.
The consequence is that start + duration is not always the stored end. So editing an entry keeps its original timestamps and only rebuilds them when date, start or duration is actually changed — otherwise editing a description would move that 14:37 end out to 14:51.
Editing sends only the fields on the form, so an entry's rates, its billable flag and its export flag are left exactly as they were.
t in the Tasks section starts a timer on the selected task instead. That is a
different route on purpose: PATCH /api/tasks/{id}/start links the entry back to
the task and copies the task's own project, activity, description and tags — a
link nothing else can create. It also moves the task to progress.
Starting a timer stops whichever one is running. That is Kimai's own behaviour under
activeEntriesHardLimit(1 on this instance): it stops the old entry rather than refusing the new one. The modal says which timer it is about to replace.
The last row or rows of the modal are the timesheet's custom fields — the remote task among them. Kimai has no endpoint for their definitions, so the names are read off the most recent entry and the form has one row per field it finds. They also cannot be set while creating an entry, so they follow as a second request; see the table below.
Inspecting the API
probe performs a raw GET against any path below /api using the stored
token — handy before writing a view for a new endpoint:
kimai probe /tasks
kimai probe /timesheets -q size=5 -q order=DESC
-X and -d send a body instead, which is how the write side of an endpoint
gets mapped: which fields it requires, which it rejects as extra, and whether a
PATCH really is partial. It writes to the instance, so point it at a
throwaway record:
kimai probe -X POST /customers -d '{"name":"zz-scratch"}'
kimai probe -X PATCH /customers/13 -d '{"city":"Berlin"}'
A path that does not exist answers 404; one that exists but refuses the method
answers 405. So a plain GET maps the route surface without writing anything —
that is how the tables below were built.
Adding a section
The sidebar is the list of registered views, so a new entry is:
- a file in
src/tui/views/implementing theViewtrait (src/tui/views/mod.rs), - a variant in
ApiMessage(src/tui/event.rs) for whatever it fetches, - one line in
build_views()insrc/tui/app.rs.
Nothing in the event loop or the layout changes. src/tui/views/tasks.rs and
customers.rs are the worked examples: a list, a detail page and a modal each,
with the parts that hold no state living in views/form.rs (field rows, toggles,
the scrolling panel) and views/detail.rs (field lines, wrapping, scrolling).
A section that links to another one returns Action::Goto from handle_key
carrying a Jump (src/tui/nav.rs). The app resolves the target by title, hands
it the Scope through View::scope_to — before on_enter, so it fetches for
the new question rather than the old one — and moves the focus. A Jump names
its target by title, so app.rs has a test that every one of them still resolves
to a section; a typo would otherwise be a key that quietly does nothing.
Every list carries the ListRequest it was asked for back with its reply and
drops the reply if the scope or the filter has changed since. A fetch is slower
than a key press, and without that guard an older answer overwrites a newer one.
A view reports to the user with ctx.notice(…) or ctx.error(…), which put the
message in the status bar. It has no way to draw one into its own pane on
purpose: a message that appears and disappears would push the rows below it down
and back up, moving whatever was under the cursor.
A section that needs to pick an entity gets a filterable modal from
src/tui/views/picker.rs rather than writing its own list. Messages a view does
not claim are passed to the next one, which is how a started timer refreshes the
Tasks list, the Timesheets list and the sidebar widget from one reply.
Status
Implemented: setup wizard, configuration handling, sidebar shell, Tasks list with status filtering, task detail, creating and editing tasks, the Customers, Projects and Activities sections with their detail pages and forms, the links between all four, the Timesheets section, starting and stopping timers, and the running-timer widget.
Not implemented: deleting anything (hiding is the archive), teams and rates on any record, and a section's own search.
/api/tasks is provided by Kimai's Task Management plugin, whose response schema
is not publicly documented. The models in src/api/models.rs were derived from a
live instance with probe, and every field stays optional so a plugin update
cannot break the whole response.
The filters were found the same way. /tasks accepts searchTerm, customers,
projects, activities, tags, users, teams, status, size, page,
order and orderBy. status is a multi-value choice, so it must be sent
as repeated status[]=…; a scalar status=closed is rejected with HTTP 400.
The only accepted values are pending, progress and closed — anything else
is a 400 rather than being silently ignored, which is what makes the set
verifiable.
The task API surface
Mapped against Kimai 2.63.0 / TaskManagementBundle 2.18.1. Unknown paths return
404, while a path that exists but rejects the method returns 405 — so a GET
probe distinguishes "no such route" from "route exists, wrong method":
| Route | Methods | Used by |
|---|---|---|
/api/tasks |
GET, POST | list, create |
/api/tasks/{id} |
GET, PATCH, DELETE | edit |
/api/tasks/{id}/timesheets |
GET | task detail |
/api/tasks/{id}/start |
PATCH | t in the Tasks list |
/api/tasks/{id}/stop |
PATCH | not wired up — x stops the timesheet instead |
/api/tasks/{id}/close |
PATCH | c |
/api/tasks/{id}/reopen |
PATCH | c, on a closed task |
/api/tasks/{id}/meta |
PATCH | not wired up |
/tasks/{id}/start takes no parameters: it ignores the request body and
starts immediately, returning the timesheet it created. There is no way to
preview or adjust what it will book, which is worth knowing before binding it to
a key.
Task status is a one-way street through pending
status is not a writable field — PATCH /api/tasks/{id} rejects it as an
extra field. The only transitions are the two routes above, and neither takes
parameters:
| Route | From | To |
|---|---|---|
/close |
pending, progress |
closed |
/reopen |
closed |
progress |
Repeating a transition that does not apply is a bare 400 Bad Request with no
explanation, so ApiError supplies its own hint.
pending cannot be restored. It is the status a task is created with, and
nothing puts it back: /status, /pending, /open and /progress all 404, and
reopen always lands on progress. That is why the UI offers a close/reopen
toggle instead of a picker — a picker would list a status that cannot be reached.
/start also moves a task to progress, but starts a timer as a side effect, so
it is not a status control.
The timesheet API surface
| Route | Methods | Used by |
|---|---|---|
/api/timesheets |
GET, POST | the list, starting a timer |
/api/timesheets/{id} |
GET, PATCH, DELETE | e — editing an entry |
/api/timesheets/active |
GET | the sidebar widget |
/api/timesheets/recent |
GET | the resume list |
/api/timesheets/{id}/stop |
PATCH | x |
/api/timesheets/{id}/meta |
PATCH | the custom fields on the start modal |
/api/timesheets/{id}/restart |
PATCH | not wired up |
/api/timesheets/{id}/duplicate |
PATCH | not wired up |
/api/timesheets/{id}/export |
PATCH | not wired up |
Starting a timer is POST /api/timesheets with no end — that is what makes
the entry a running one; there is no separate start route. Established against
the live instance:
beginaccepts RFC 3339 with an offset, and honours it. The offset is always sent, so a machine in another timezone than the Kimai account still books the right time.tagsmust be a comma-separated string. An array is rejected with "This value is not valid." — the exact opposite of/api/tasks, which wants a list. The two endpoints really do disagree; seeTimesheetPayload.metaFieldsis not accepted ("This form should not contain extra fields"), so custom fields go out afterwards asPATCH /api/timesheets/{id}/metawith{"name": …, "value": …}. An unknown name is a 404 rather than a new field.- The collection reports
project,activityanduseras bare IDs unlessfull=trueis passed;/activeand/recentexpand them either way. - A running entry reports
duration: 0, so elapsed time is counted frombeginrather than read off the payload. GET /api/config/timesheetreportsactiveEntriesHardLimit. At the default of 1, starting a second timer stops the first instead of failing.
PATCH /api/timesheets/{id} takes the same fields and is a true partial
update — verified by patching one field and diffing the entry before and after.
Beyond that:
- There is no
durationfield; sending one is rejected as an extra field. A duration can only be expressed by movingend, and what comes back is rounded — soend - beginanddurationroutinely disagree. "end": nullreopens a finished entry, putting it back on the clock."tags": ""clears every tag, and"description": nullclears the text.- An inverted span comes back as a form-level error ("The end date must not be earlier than the start date"), not one attached to a field.
Custom field definitions are not exposed anywhere in the API. Every timesheet lists all configured fields whether they hold a value or not, so the names come from the most recent entry — an instance with no entries yet simply offers none.
Writing a task (POST /api/tasks, PATCH /api/tasks/{id}) requires title,
project and activity; todo, end, estimation, description, tags,
user and team are optional. Established against the live instance:
titlemust be at least 2 characters. A blank one makes the plugin return HTTP 500 instead of a validation error, soTaskFormrefuses to send it.endneeds a full%Y-%m-%dT%H:%M:%S; a bare date is rejected.estimationis whole seconds;tagsaccepts an array or a comma-separated string, and[]clears every tag.usertakes a user ID, andnullleaves the task unassigned.PATCHis a true partial update — omitted fields keep their value, and an explicitnullclears one. Verified by patching a single field and diffing the task before and after.
The customer, project and activity API surface
Mapped the same way, against the same instance. All three behave alike, so the differences below are the whole of what sets them apart.
| Route | Methods | Used by |
|---|---|---|
/api/customers |
GET, POST | the list, create |
/api/customers/{id} |
GET, PATCH | the detail page, edit |
/api/customers/{id}/meta |
PATCH | not wired up |
/api/customers/{id}/rates |
GET, POST | not wired up |
/api/projects, /api/activities |
the same four routes each | |
/api/{entity}/{id}/duplicate |
— | 404, no such route |
The collection is a subset of the entity. GET /customers omits the budgets and
the entire address and contact block; GET /projects and GET /activities omit the
budgets. So a list row is not enough to edit from — the forms read GET /{id} first,
and send only the fields that actually changed. Seeding a form from a row and PATCHing
it back would blank whatever the row did not carry.
These three collections ignore size and page and return every record, unlike
/tasks and /timesheets, which honour both. Nothing here needs paging.
visibleselects which records come back:1visible (the default),2hidden,3all.0— or anything else — is HTTP 400, which is what makes the set verifiable. With no delete route wired up, hiding is the archive, and this parameter is the only way back to a hidden record./projects?customers[]={id}narrows to one customer. A scalarcustomer={id}works too, but an unknown id is silently ignored and the full list comes back, so a filter that does nothing looks exactly like one that works./activities?projects[]={id}returns that project's own activities, plus the global ones only when the project hasglobalActivities: true. That flag is honoured server-side, which the client-side narrowing in the two forms does not do./tasksnarrows onprojects[],customers[]andactivities[]. The brackets are required —project=8is HTTP 400 — and unlike/projects, an unknown id is rejected rather than ignored.- A project row carries
parentTitle, which is its customer's name; an activity row'sparentTitleis its project's name,nullwhen it is global. That is why the lists need no second request to show a parent, even though/projectsstill reportscustomeras a bare ID.
Writing them:
- Only
nameis required for a customer and for an activity; a project also needscustomer. Country, currency and timezone are filled from the instance defaults, so the customer form does not have to ask for them. - A hidden customer cannot be given a project:
customercomes back as "The selected choice is invalid". The picker offers visible customers only. PATCHis a true partial update on all three — verified by patching a single field and diffing the record before and after.addressandbudgetRolloverare rejected as extra fields. The postal address isaddressLine1…addressLine3,cityandpostCode; the flataddressin the response is a read-only legacy field.invoiceTextis accepted on write but never returned, on customers and projects alike. It cannot be prefilled, so the form leaves it blank and only sends it when something is typed.timeBudgetis written in Kimai's duration format and read back in seconds:1is one hour,1:30and90mare both 5400.budgetaccepts12.50and12,50alike.budgetTypeismonthor null;yearis a per-field validation error.- A project's
start,endandorderDateneed a full%Y-%m-%dT%H:%M:%S— a bare date is "Please enter a valid date" — but they come back as bare dates. - An activity's project is create-only.
POST /activitiestakesproject, butPATCHrejects an id as an extra field. It does accept"project": null, which releases a bound activity to global — so the binding can be given up but never changed or reassigned.
Task comments are not available via the API
Investigated and ruled out — please don't re-investigate. Comments are a Task Management feature in the web UI, but no API route exposes them:
/api/tasks/{id}/comments,/comments,/task-comments,/taskComments,/task_comment,/notesand the singular forms all return404, i.e. no route matched, under any method.- The task payload contains no comments field, and
full=true,comments=trueandexpand=commentschange nothing.
The web UI reaches comments through a session-authenticated route that an API token cannot use. Listing or adding them needs upstream support in the plugin's API first.