# bokftui guidelines Rules for the ncurses client so every view behaves the same. When in doubt, copy the behaviour of the voucher list / voucher form; they are the reference implementations. Inspired by Midnight Commander, htop, mutt and calcurse. The **Interaction model** section is the agreed target (settled 2026-09-22, `DECISIONS.md` #28). The **Kunder** pilot is implemented: drafts in `$XDG_CACHE_HOME/bokf/drafts.json`, `` marking, the explicit `Spara` row and draft deletion work there. The context menu (`→`/`^O`, below) and the web-safe keys are implemented in every screen (2026-09-23, `DECISIONS.md` #29). Every other screen still follows the sections below; the interaction model wins where they conflict as each screen moves over. ## Interaction model (target, 2026-09-22) Every entity has **one lifecycle**, every form **one commit gesture**, and every action **one declaration**. Nothing is written to the backend implicitly. ### Two focus modes The cursor is always in one of two modes: - **Navigation** — `Tab`/`Shift-Tab`, arrows, `Home`/`End`, `PgUp`/`PgDn` only move focus, selection or the viewport. They never mutate data, never save and never run an action. - **Editing** — the focused field is reverse video with a caret. Inside a field, `←`/`→`/`Home`/`End` move the caret, `Backspace`/`Del`/`Ctrl+U` edit the text and `Up`/`Down` leave the field. `Enter` commits the field and advances; `Esc` restores it (the scratch-copy semantics stay). `Enter` activates the focused item **when that item owns an action** (a menu item, an action row, a list row that opens a detail). A report, a pager or a blank area has no such item, so `Enter` does nothing there. `Enter` never saves a whole form, never deletes and never posts. ### Entity lifecycle | Class | Draft | Commit event | `Esc`/`q` | Examples | |---|---|---|---|---| | **Register** | in memory + local draft file | explicit `Spara` (the save action row or the context menu) after validation | back; the draft stays | customers, employees, templates, momsregler, org data | | **Document** | the form is the draft | explicit `Posta` (the save action row or the context menu); immutable once written | back; confirm only when dirty | verifikat, fakturor, löneruns, bokslut | | **Settings** | an edit buffer until `Spara` | explicit `Spara` | back | faktura/SMTP/serie-inställningar | - Every savable form ends with a visible commit action row (`Spara`, or `Posta` for documents), or — in a row table, where rows follow — offers it first in the context menu (`^O`, `Enter`). `Enter` on the focused row commits (it is an item with an action). - There is no commit hotkey: `Ctrl+Enter` cannot be sent by gnome-terminal/ VTE and `F9` is dropped with the other function keys (see "Keys and the web"). The action row and the menu work in every terminal and browser. - Commit is blocked while the data is invalid: the first invalid field is focused and its error shown. The draft is untouched. A server error at commit keeps the draft as well; only a successful command removes it. - `Ctrl+R` (reload) and `Ctrl+C` never flush drafts to the backend; the client-side draft file makes them survive both, so no work is lost. ### Drafts - Every non-committed edit is a **draft**, held in memory and mirrored to `$XDG_CACHE_HOME/bokf/drafts.json` (mode 0600, atomic replace) on every change. This is the "nothing is ever lost" guarantee — `Ctrl+R`, a crash and `Ctrl+C` included. - A draft is keyed by `(org, entity, id)`; a new entity gets a temporary id and shows up in its list immediately. - Drafts are marked `` directly after the row number in lists (`3. Namn`) and in the editor's frame title (`Kund `). - **Delete draft** is an action both on the list row (`d`, also in the context menu) and inside the editor (context menu), with a confirmation. It removes the memory and file draft and never touches the backend. A successful commit removes the draft too. - Drafts are client-local and never synced; another client sees the last committed value. - When the backend entity is gone at commit time (`NOT_FOUND`), the editor offers "spara som ny" or "radera utkast". - Review point: a draft of an encrypted field (an employee's personnummer) puts plaintext in the cache file. 0600 is the same protection as the Bitwarden session file; decide whether such fields are excluded from drafts. ### Actions and the context menu Screens declare actions, never keys: ```c struct tui_action { const char *id; /* stable, e.g. "customer.archive" */ const char *label; /* Swedish UI text */ int key; /* accelerator, or a virtual key (menu only) */ int enabled; /* 1 runnable, 0 dimmed with a reason, -1 heading */ const char *reason; /* why a disabled action is dim */ }; ``` - **`→` opens the context menu** ("Åtgärder") wherever no caret uses the arrow: lists, menus, pagers (report/detail views), forms and a row table's header fields. **`^O` opens it everywhere**, also while typing in a table cell, where `→` moves the caret. The menu is a box at the right edge next to the selected row; each line shows the label and, right- aligned, the key that does the same thing — the menu doubles as the hotkey list. Up/down/Home/End move, `Enter` (or the action's own key) runs, `Esc`/`←`/`q`/`^O` close. A disabled action is dim and `Enter` shows its reason; a separator line divides the groups. - The screen registers its actions with `tui_set_actions(acts, n)` right before the widget call; the widget takes them at entry, so they never leak into a widget opened from a hook. The menu lists the screen's actions first, then the widget's own (Öppna, Ny, Gå till/sök, Uppdatera, Tillbaka; in forms Ändra/Utför; in row tables Ändra fält/Rensa raden, Validera, Spara, Avbryt), skipping own actions whose key the screen took (`tui_ctx_merge`). - **Choosing an action is the same as pressing its key**: the widget gets the key as if it was typed, so the accelerator and the menu share one code path in the screen's key hook. An action without a physical key uses a virtual key: `TUI_KEY_SUBMIT` (spara/bokför/utfärda — was F9), `TUI_KEY_REFRESH` (uppdatera/validera/förhandsvisa — was F5) or `TUI_KEY_ACTION(n)` for the screen's own menu-only actions (mall, bifoga in the voucher form, nästa fakturanummer). - Right before the menu opens the key hook gets `TUI_KEY_MENU`, so a screen can set `enabled`/`label`/`reason` for the current row in its own array (the Kunder list shows "Radera utkast" for a draft row and "Arkivera" or "Återaktivera" otherwise, all on `d`). - Footers show `→ = åtgärder` (`^O` in row tables) and at most the few letter accelerators that matter on the screen. Universal navigation keys are not repeated there. ### Keys and the web The TUI is meant to run in a browser terminal too (xterm.js/ttyd). A browser keeps some keys for itself, so the TUI only uses keys every terminal and browser delivers (checked by `make check`): - **No function keys.** F1/F3/F5/F6/F7/F10/F11/F12 are browser keys and F2, F4, F9 need `Fn` on many laptops. Their old jobs are context-menu actions (F2 → `→`, F5 → "Uppdatera"/"Validera"/"Förhandsvisa" with `r` in lists and pagers, F9 → "Spara"/"Bokför"/"Utfärda", F4 → "Hämta mall…"). - **No `Ctrl+N`, `Ctrl+T`, `Ctrl+W`, `Ctrl+Tab`**: Chrome never lets a page have them. "Ny" is `n` in lists, menus and the voucher detail. - **No `Ctrl+Enter`** and no extended keyboard protocol: the TUI no longer enables xterm `modifyOtherKeys` or the Kitty protocol, so every key comes in its legacy encoding. - **Allowed**: arrows, `Enter`, `Esc`, `Tab`/`Shift-Tab`, PgUp/PgDn, Home/End, letters and digits, and the control keys a page can take (`^O` menu, `^X` clear row, `^A`/`^E`/`^U` in fields, `^C` quit, `^R` reload — `^R` is a developer convenience and never the only way). - Every action is reachable with arrows + `Enter` through the menu; a letter accelerator is a shortcut, never the only path. - **Web mode** (`BOKF_WEB=1`, set by the browser-terminal wrapper): the TUI runs on the web frontend, not on the user's machine, so a screen never reads or writes a local file or starts a program there. Every such path goes through `ui_web_block(title)` (`clients/ui.c`), which shows why and returns 1: the file browser, `save_cache_and_open` (PDF viewer), the attachment download and the SRU/eSKD/årsredovisning save prompts. New file features must call it too (pty scenario `web-mode`). ### Implementation status 1. Done: `struct tui_action`, the context menu (`tui_action_menu_at`, `tui_set_actions`, `tui_ctx_merge`, `tui_key_name`) and `→`/`^O` in all widgets, unit-tested in `tests/test_tui.c`; pty scenario `context-menu`. 2. Done: every screen declares its actions; no F-keys, `^N` or `^Enter` remain (`scripts/check-consistency.sh` fails on `KEY_F(`, `^N` and `CTRL_ENTER` in `clients/`). 3. Done for Kunder: `clients/drafts.[ch]` (JSON store, atomic 0600 write, temporary ids, `` marking, delete action), unit-tested. 4. Done for Kunder: draft/`Spara` model with the `Spara` action row and draft deletion from both the list and the editor. The other register screens and the settings forms (explicit `Spara`, no per-field autosave) are next. 5. Done for Kunder: pty scenarios `customer-draft` and `customer-draft-save` cover create, edit, ``, delete and save. ## Session start After login the org picker ("Välj organisation att representera") is always shown (`--org ID` bypasses it for scripts). "Byt bolag" in the main menu opens the same picker, with the cursor on the current org, and switches the session to the chosen one: `session.use_org`, then the org's name, role, current fiscal year and series are reloaded and the remembered list selections are cleared; `Esc` keeps the current org. `Ctrl+R` keeps the switched org. "Byt lösenord" asks for the current password, the new one and the new one again in masked prompts (`Esc` in any of them cancels), checks length (≥ 10), match and difference before calling `user.set_password`, and reports that the user's other logins were logged out and that a Bitwarden item has to be updated; `Ctrl+R` keeps working with the new password. The fiscal year is chosen from the dashboard and is changeable during the session; the `Räkenskapsår` screen also closes and reopens years there. ## Universal keys | Key | Meaning | |---|---| | `→` | Open the context menu ("Åtgärder") for the selected row/view: every action with its key. Not in a table cell, where `→` moves the caret | | `^O` | Open the context menu anywhere, also while typing in a table cell | | `n` | Ny: new verifikat (dashboard, voucher list/detail), new mall, kund, faktura, anställd, lönekörning, regel, ledamot, new voucher from a bank transaction, upload (Underlag) | | `r` | Uppdatera: reload the list or view | | `Esc` / `q` | Back one level. At the dashboard it does nothing — Esc never exits the app. In a row table (`tui_rt_run`) and while a field is being edited `q` is text; only `Esc` goes back there | | `Ctrl+C` | Quit the application (closes the session). The only key that exits | | `Ctrl+R` | Reload the client in place: re-execs the installed binary and restores session, org, fiscal year and the current view (for after a rebuild) | | `Ctrl+A` | Close or reopen the highlighted räkenskapsår (Räkenskapsår screen; asks for confirmation) | | `1`–`9` | In lists: jump to that row. In menus: activate that item | | `g` | Live goto or search in every list and menu. The first key decides: a digit makes it "Gå till rad/nummer:" (the selection follows the number as you type, backspace steps back, `g` closes); any other printable key makes it "Sök:" — the selection jumps to the first row (from the top) whose text contains the typed text, case-insensitive also for åäö; up/down step to the next/previous match and wrap; "(ingen träff)" when nothing matches. Digits, `g`, `q` and screen keys (`s` = sortera, `d`…) are text while searching. Backspace to empty returns to the undecided prompt. Enter/Esc close the prompt without opening anything; the selection stays | | arrows, PgUp/PgDn, Home/End | Move/scroll; selection always stays visible. Pages stop at the first/last row, they never wrap | | `e` | Edit the shown object (IB, where applicable) | | `a` | Add/upload: upload (Underlag), import a bank file (Bankavstämning), attach a file (voucher detail) | | `c` | Correct (voucher detail) | | `d` | Delete/arkivera the selected row (only where the action exists; asks for confirmation) | | `s` | Verifikat list: cycle the sort order — nummer stigande/fallande, datum stigande/fallande (ties by series and number). The title shows the current order; it is saved as `voucher_sort` in `tui.conf` | | up/down (voucher detail) | Previous/next voucher in the list's current order; the title shows `(n av N)`. The detail scrolls with PgUp/PgDn/Home/End (`TUI_PAGER_NO_ARROWS`). A voucher posted from the detail (`n`) is shown without stepping until the list is reopened | | `f` | Voucher detail: list the voucher's underlag — Enter opens Granska (text in a pager, PDFs/images in the desktop viewer) or Ladda ned…, `d` removes the link (asks first). Underlag: Enter does the same | | `p` / `s` / `u` / `b` | Faktura detail: `p` shows the PDF, `s` sends it, `u` duplicates the invoice into a new draft (same rows, dates reset to today), `b` (unpaid invoices) prefills and posts the payment voucher, then marks the invoice paid | | `u` | Bankavstämning: remove the highlighted transaction's match | | `k` | Underlag: link the highlighted attachment to a voucher picked from a list | | `Ctrl+X` | Clear the current row — only inside row editors (never "new") | Saving, posting, validating, previewing, templates and attaching in forms have no hotkey: they are context-menu actions (see "Keys and the web"). Every key a screen handles is listed in its context menu; the footer shows `→ = åtgärder` and the most important letters. Control keys are written compactly as `^O`, `^X`, `^C`, `^R` to save width. ## Lists (`select_list`, `menu`) - Rows are numbered `NN. text`, right-aligned so 2- and 3-digit numbers line up. - Verifikation ids are shown concatenated as `series+number` (`V-8`, `A8`), using the org's `series_voucher` (Bolaget → Verifikationsserier) for new vouchers. - The last row may be an action (e.g. `+ Nytt verifikat (n)`); selecting it runs the action instead of opening a detail view. - Selection memory: lists remember the selected row by identity (voucher id), not index, across detail round-trips, refreshes and screen re-entry. - Digits move the highlight; only Enter activates. Menus are the exception: digits activate directly (they are shortcuts). - Empty lists are never a dead end: show a message or keep the add-row. - Menus may be split into sections: an item whose text starts with `\x01` (`TUI_MARK_HEADING`) is a non-selectable header, drawn dim and without a number. Numbering, `1`–`9` and `g` count only the selectable items and continue across sections; arrows/PgUp/PgDn/Home/End skip the headers. A cursor that points at a header snaps to the next selectable item. ## Forms - Layout: bold header fields at the top, then a bold column header, then rows; footer hints carry the keys. - Field navigation: `Tab` / `Shift-Tab` forward/back, arrows Up/Down between rows. `Enter` advances in simple forms; it never saves unless the screen is a one-line prompt. - Focus memory: the screen keeps the focused row in an `int` and passes its address as the last argument to `tui_form_run`, `tui_form_run_hook` and `tui_form_run_actions` (like a list cursor). On entry `*focus` is clamped to a selectable row — out of range snaps to the nearest end, a heading row snaps forward and wraps — and on every return (`Esc`, Uppdatera, Spara, a chosen action, an edited field) the row is written back, so a re-run after a save or a round-trip through another screen lands on the same row. `NULL` starts at the first focusable row and stores nothing. - Focus marks the **value, not the row**: the focused field's label is accent/bold and only its value is reverse video, in a box at least 24 columns wide so an empty field is visible. Action rows (`Spara`, …) are buttons and highlight their whole label. - Fields are **edited in place**, in the value column (never in a prompt at the bottom): `Enter` opens the editor on the focused field, which widens to the right border and shows the caret. One editor (`field_loop` in `tui.c`) serves forms, row-table header fields and the bottom-line prompts. The caret is a real position: ←/→/Home/End/Del work inside the field, `Ctrl+U` clears it. Text longer than the field scrolls horizontally (`tui_field_scroll`) so the caret is always visible; it never wraps onto the lines below. - In the editor `Enter` commits and stays on the field, `Tab`/`Shift-Tab` commit and move to the next/previous row, `Esc` restores the old value (the editor works on a scratch copy). A choice field shows `< value >` in place (←/→ or space cycle). An amount that does not parse is reported and stays in the field for correction. - First keystroke in a freshly focused field replaces its content (`field_fresh`), so prefilled values like dates can be typed over. Backspace/Del/`^U`/caret moves edit what is there and end that state: the next printable key is inserted, it never wipes the field. - Byte input is UTF-8 safe: a character that does not fit in the buffer is dropped whole, never half-inserted. - Forms taller than the screen scroll with the focus; dim `↑ fler` / `↓ fler` markers show that rows are hidden above/below (`tui_form_scroll`). - Date fields (`date_field_edit`, `date_prompt`) accept digits only and insert the dashes themselves: type `20260315` and the field shows `2026-03-15`. Backspace deletes a digit (with its separator), ←/→/Home/End move by digit, and the caret renders like in any other field. Validation (`util_parse_iso_date`) happens on Validera/Spara. - Derived values (account names, balances) are dim and non-editable. - Row tables: always exactly one empty trailing row; entering data appends a new empty row; an empty row followed by another empty row collapses. `Ctrl+X` clears the selected row. `Tab` walks the editable cells and wraps to the next row's first cell (`Shift-Tab` back). A dim footer callback shows one derived line (balance, attachment list) on its own row above the hints (`LINES-2`), never over a table row; `\n` in the text becomes a space. A cell holding one of a fixed set of values (unit, VAT code) is changed with `Enter`, which opens `tui_choice_prompt` from the row table's key hook; the chosen value stays in the same cell buffer as any other row. - Fields with `mask` set are shown as `*` (`tui_form_field.mask`); used for passwords. ACTION fields show their `value` when set, else `(lista)`. - Header fields plus rows: one `tui_rt_run` with `tui_rt_set_fields` draws the fields above the table on the same screen. Focus starts on the first field; `Tab`/`Shift-Tab` walk fields, then the cells row by row, and wrap back to the first field. `Up`/`Down` move between fields (in the table they change row). `Enter` opens the field's in-place editor (text/date/ amount/choice) and advances to the next field on commit; `Esc` cancels the editor without changing anything. Typing a printable character on a focused text/date/amount field starts editing that field with the key (replacing the value, like any fresh field); on a choice/action field it does nothing. The hardware cursor only shows in a table cell. `q` is text here — only `Esc` leaves the table. Moving to another cell (arrows, `Enter`, `Tab`) resets the caret to the end of the new cell. A focused cell scrolls horizontally like a form field. `^O`/`Esc` work the same from both. - A form may carry an action list under its fields (`tui_form_run_actions`): fields first, then action rows, one focus ring (`Tab`/`Shift-Tab`/ up/down, `Home`/`End`; the ring wraps and heading rows are skipped). `enabled: 1` runs on `Enter`; `0` is dimmed but selectable and shown as `label (reason)`, `Enter` shows the reason in a message; `-1` is a dim non-selectable heading/status row like a menu section. The call returns the field index after an edit, `TUI_FORM_ACTION + i` for a chosen action, or `TUI_FORM_BACK`/`REFRESH`/`SUBMIT`, and stores the row it stopped on in the `int *focus` argument (see focus memory above); `tui_form_hint()` overrides the footer of the next run as usual. - `Bokslut` is the year-end hub in one view: the year fields, the status heading `Bokslut bokfört: ja/nej` (derived from the `Skatt på årets resultat` voucher that `bokslut.post` creates; a closed year counts as posted) and the actions Årsredovisning (K2), Inkomstdeklaration (INK2/SRU), Bokslutsplan (torrkörning) and Bokför bokslut — the last dimmed with a reason when the year is closed or already posted. The context menu's "Visa bokslutsplan" shows the plan, "Bokför planen…" posts it. - Validation: the "Validera" action (`TUI_KEY_REFRESH`) validates without writing and reports exactly what is wrong (field, row number). Server `dry_run` is used where available. - Saving: the "Spara"/"Bokför" action (`TUI_KEY_SUBMIT`) writes, shows a confirmation message, and returns to the previous view. `Esc` cancels without saving. - Screen-specific keys (template picker, attach, link, download) are handled in a key hook, never with a local loop. The hook runs for keys the widget itself does not handle and returns `TUI_HOOK_*`: `STAY` (consumed, redraw), `BACK`, `REFRESH` or `SUBMIT` (the widget returns as on `Esc` or the Uppdatera/Spara actions). Hooks exist on `tui_form_run_hook`, `tui_rt_set_key`, `tui_pager_hook` and `tui_select_list_hook`. - File browser (`file_browser`): starts in the directory where the last attachment was picked (remembered in `tui.conf`), falling back to `$HOME` when it is gone (a leading `~` is expanded to `$HOME`), `.. (uppåt)` is the first row, directories sort first with a trailing `/`, hidden files are skipped. Enter enters a directory or picks a file; `Esc` cancels. Picking a file remembers its directory. Selected files are uploaded immediately as unlinked underlag and linked when the voucher is posted. ## Messages - Info/confirmation: `message(title, ...)` box, dismissed with Enter. Dialogs read keys through the application's input hook like every other widget, so arrow keys are decoded (never leak `[A` into the next view) and `^C`/`^R` work inside them. - Quitting: only `Ctrl+C` (from anywhere) or "Logga ut / avsluta" ends the app; `Esc`/`q` only navigate. After `Ctrl+C` every screen unwinds, the session is closed and the terminal restored. - Errors: `show_error(title, resp)` prints `CODE: message` from the server error object; if the response is missing/empty it says "Inget svar från servern (kör daemonen?)". Never render an empty error. - Irreversible actions (close year, lock, archive) ask first. ## Layout and text - Screen frame: title top-left, dim status line under it (`org | start - end | role | user`), hints in the last line. - Columns are padded with `pad_field()` (UTF-8 display width) and truncated to their column; amounts right-aligned via `kr_format()`; never pad with `%s` widths directly on user text. - Selection = reverse video, headers = bold, derived data = dim. Drawing uses the semantic styles below, never raw `A_BOLD`/`A_DIM`. ## Theme and styles `clients/tui.c` owns the theme. The widget layer (and only the widget layer) calls `tui_style(enum tui_style)` to set a semantic style and `tui_style_reset()` to return to the terminal default: | Style | Meaning | |---|---| | `TUI_TITLE` | screen title in the frame | | `TUI_HEADING` | headings, column headers, form labels | | `TUI_SUBHEADING` | secondary headings | | `TUI_DIM` | derived/secondary data, section headers | | `TUI_ACCENT` | interactive prompts (goto) | | `TUI_POS` / `TUI_NEG` | positive/negative amounts | | `TUI_RULE` | full-width rules | | `TUI_STATUS` | status line, hints, footers | Colours are initialised in `tui_keys_setup()`: a small set of pairs (cyan headings, yellow accent, green/red amounts, blue rules) on the default background. If the terminal has no colour support or `NO_COLOR` is set in the environment, every style falls back to attributes (bold/dim) via the pure `tui_style_attrs(style, colours_ok, no_color)`; reverse video for the selection is kept in both modes. ## Pager markup Pager text may carry a one-byte leading marker per line (set by the report formatters, interpreted by `tui_markup`): | Marker | Effect | |---|---| | `\x01` | line is a heading (`TUI_HEADING`) | | `\x02` | line is dimmed (`TUI_DIM`) | | `\x03` | full-width `ACS_HLINE` rule (`TUI_RULE`); the rest of the line is ignored | | `\x04` | line is pinned as a column header: the pager keeps it visible above the scrolling body (combine with another marker, e.g. `\x04\x01`) | Report formatters mark headings, sums/derived lines and rules before the text reaches `tui_pager`; the markers never change column widths. Anything that saves pager text to a file strips the markers first (`strip_markup()` in `bokftui.c`). ## Widget layer (`clients/tui.[ch]`) Every element a screen needs is a widget here, and the widget layer is the only place that touches ncurses. Rules: - Screens never call ncurses directly (`mvadd*`, `attron`, `getch`…) and never keep a local one-off input or drawing loop. - If no widget fits, **extend the widget layer first** (spec in this file + unit tests in `tests/test_tui.c`) and then use it. There is no "do it locally once" option. - Pure logic (line editing, date input, list navigation, prompts parsing) is separated from drawing so it can be tested without a terminal. - Widgets return key codes (`-1` back, `-2` refresh, `-4` new, `-6` remove, `-7` toggle) and write text into the caller's buffer; nothing returns static storage. `TUI_NAV_NONE`, `TUI_FORM_BACK/REFRESH/SUBMIT` and the `TUI_HOOK_*` codes are all distinct. - `tui_pager_hook` takes `TUI_PAGER_SAVE` to enable the `s` save action; the `extra_hint` is shown in the footer. `TUI_PAGER_NO_ARROWS` leaves Up/Down to the key hook (record stepping) and the footer says `PgUp/PgDn/Home/End rullar` instead. Plain `tui_pager` keeps `s` on when a save hint is passed. Pager lines go through `tui_markup` (see below). - `struct tui_list_nav.items` holds the row texts the goto search matches (`tui_text_match`); `tui_select_list*`, `tui_menu` and `tui_action_menu` set it. Keys typed into the goto prompt never reach a screen's key hook. - Styles come from `tui_style`/`tui_style_attrs`; report markup and menu sections from `tui_markup`/`TUI_MARK_HEADING`. Their fallbacks and the navigation over section headers are unit-tested in `tests/test_tui.c`. - `tui_redraw` flushes a `tui_frame`/`tui_set_status` pair drawn by the application outside a widget loop (startup/reconnect messages). - The application supplies input, frame/hints and quit through `tui_set_input`/`tui_set_screen`/`tui_set_quit`. ## Adding a view — checklist 1. Data comes from public protocol commands only. 2. Wrap the screen in `tui_frame()` and let the widgets carry the footer hints; return `Esc`/`q` to the parent. 3. Use `tui_menu`/`tui_select_list` instead of writing a new loop; pass `allow_new`/`allow_refresh` so the universal keys apply. 4. Forms use `tui_form_run*`/`tui_rt_run`, which edit fields in place; one-off questions use the prompts (`tui_prompt_into`, `tui_date_prompt_into`, `tui_amount_prompt_into`, `tui_choice_prompt`). All of them share one field editor. 5. Support refresh (`r` / "Uppdatera") if the data can change elsewhere. 6. Update `PROTOCOL.md` §8 and this file if you add a new key or interaction. 7. Declare the screen's actions in one `tui_action` list (once the interaction model is implemented); dispatch, the context menu and the footer hint all read that list, so a key cannot exist without a visible action.