No description
  • Rust 99.8%
  • Makefile 0.2%
Find a file
2026-08-06 22:04:57 +02:00
src renamed executable file to kimai instead of kimai-tui 2026-08-06 22:04:57 +02:00
.gitignore initial commit 2026-08-06 16:03:45 +02:00
Cargo.lock initial commit 2026-08-06 16:03:45 +02:00
Cargo.toml renamed executable file to kimai instead of kimai-tui 2026-08-06 22:04:57 +02:00
CLAUDE.md adding CLAUDE.md 2026-08-06 21:58:30 +02:00
Makefile renamed executable file to kimai instead of kimai-tui 2026-08-06 22:04:57 +02:00
README.md renamed executable file to kimai instead of kimai-tui 2026-08-06 22:04:57 +02:00

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:1518: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
19 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) → closedall — 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}) but GET /api/tags returns 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: visiblehiddenall, 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 f is 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/activities takes a project; PATCH accepts only null there 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:3614: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:

  1. a file in src/tui/views/ implementing the View trait (src/tui/views/mod.rs),
  2. a variant in ApiMessage (src/tui/event.rs) for whatever it fetches,
  3. one line in build_views() in src/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_tobefore 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 fieldPATCH /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:

  • begin accepts 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.
  • tags must 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; see TimesheetPayload.
  • metaFields is not accepted ("This form should not contain extra fields"), so custom fields go out afterwards as PATCH /api/timesheets/{id}/meta with {"name": …, "value": …}. An unknown name is a 404 rather than a new field.
  • The collection reports project, activity and user as bare IDs unless full=true is passed; /active and /recent expand them either way.
  • A running entry reports duration: 0, so elapsed time is counted from begin rather than read off the payload.
  • GET /api/config/timesheet reports activeEntriesHardLimit. 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 duration field; sending one is rejected as an extra field. A duration can only be expressed by moving end, and what comes back is rounded — so end - begin and duration routinely disagree.
  • "end": null reopens a finished entry, putting it back on the clock.
  • "tags": "" clears every tag, and "description": null clears 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:

  • title must be at least 2 characters. A blank one makes the plugin return HTTP 500 instead of a validation error, so TaskForm refuses to send it.
  • end needs a full %Y-%m-%dT%H:%M:%S; a bare date is rejected.
  • estimation is whole seconds; tags accepts an array or a comma-separated string, and [] clears every tag.
  • user takes a user ID, and null leaves the task unassigned.
  • PATCH is a true partial update — omitted fields keep their value, and an explicit null clears 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.

  • visible selects which records come back: 1 visible (the default), 2 hidden, 3 all. 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 scalar customer={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 has globalActivities: true. That flag is honoured server-side, which the client-side narrowing in the two forms does not do.
  • /tasks narrows on projects[], customers[] and activities[]. The brackets are required — project=8 is 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's parentTitle is its project's name, null when it is global. That is why the lists need no second request to show a parent, even though /projects still reports customer as a bare ID.

Writing them:

  • Only name is required for a customer and for an activity; a project also needs customer. 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: customer comes back as "The selected choice is invalid". The picker offers visible customers only.
  • PATCH is a true partial update on all three — verified by patching a single field and diffing the record before and after.
  • address and budgetRollover are rejected as extra fields. The postal address is addressLine1addressLine3, city and postCode; the flat address in the response is a read-only legacy field.
  • invoiceText is 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.
  • timeBudget is written in Kimai's duration format and read back in seconds: 1 is one hour, 1:30 and 90m are both 5400. budget accepts 12.50 and 12,50 alike. budgetType is month or null; year is a per-field validation error.
  • A project's start, end and orderDate need 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 /activities takes project, but PATCH rejects 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, /notes and the singular forms all return 404, i.e. no route matched, under any method.
  • The task payload contains no comments field, and full=true, comments=true and expand=comments change 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.