summaryrefslogtreecommitdiff
path: root/docs/TUI-GUIDELINES.md
blob: 28b7f899a6bb198aa7026f6bd9b7fe1e923cf4d4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
# 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`, `<UTKAST>` marking, the explicit `Spara`
row, the `F2` action menu and draft deletion work there. 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, `F9`) after validation | back; the draft stays | customers, employees, templates, momsregler, org data |
| **Document** | the form is the draft | explicit `Posta` (the save action row, `F9`); 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); `Enter` on the focused row commits (it is an item
  with an action), `F9` is the accelerator.
- `Ctrl+Enter` is **dropped** as a commit key: gnome-terminal/VTE cannot
  send it. The save action row and `F9` are the only commit gestures, which
  makes every terminal behave the same.
- 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 `<UTKAST>` directly after the row number in lists
  (`3. <UTKAST> Namn`) and in the editor's frame title (`Kund <UTKAST>`).
- **Delete draft** is an action both on the list row (via `F2`) and inside
  the editor, 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 `F2` 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; 0 = menu only */
    int enabled;         /* 1 runnable, 0 dimmed with a reason, -1 heading */
    const char *reason;  /* why a disabled action is dim */
};
```

- One ordered action list per context drives everything: `F2` opens the
  `Åtgärder` menu, the same list dispatches the accelerator keys and builds
  the footer hint. A key can no longer exist outside the registry.
- The menu is sectioned: **Aktuell rad** (item actions), **Skärmen** (save,
  delete draft, attach, …), **Globalt** (`F5` uppdatera, `Ctrl+R` ladda om,
  …). Destructive actions are last and still ask for confirmation.
- `Enter` in the menu runs the highlighted action; disabled actions are dim
  with their reason (as in `tui_form_action` today); `Esc` closes. The
  actions that complex forms hide behind hotkeys today live here unchanged.
- The footer shows at most the two or three most important contextual
  actions plus `F2 = fler`. Universal navigation keys (`Tab`, arrows,
  `PgUp`/`PgDn`, `Home`/`End`) are not repeated there.
- The session's key decisions: letter accelerators stay, `F9` is the only
  commit key (`Ctrl+Enter` is dropped) and `F2` only — no `§` binding (it is
  not reliably encodable across terminals).

### Implementation status

1. Done: `struct tui_action`, `tui_action_menu()` and `tui_action_hint()` in
   `clients/tui.[ch]`, unit-tested in `tests/test_tui.c`.
2. Partly done: the Kunder list builds its `F2` actions in a key hook and
   appends `F2 = åtgärder` to the footer via `tui_list_hint_extra()`; other
   lists have no actions yet, and `tui_rt` still uses its own key branches.
3. Done for Kunder: `clients/drafts.[ch]` (JSON store, atomic 0600 write,
   temporary ids, `<UTKAST>` marking, delete action), unit-tested.
4. Done for Kunder: draft/`Spara` model with the `Spara` action row,
   `F2` menu 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, `<UTKAST>`, 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 |
|---|---|
| `Ctrl+N` | Add: new verifikat (dashboard, voucher list/detail), new mall (Mallar), new fiscal year (year picker), open editor (IB), upload file (Underlag) |
| `F5` | Refresh the 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 (Underlag) |
| `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 |
| `u` / `b` | Faktura detail: `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 |
| `Ctrl+F` | Attach a file via the file browser (voucher form and voucher detail) |
| `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") |
| `Ctrl+Enter` | Save/post the current form. Needs xterm `modifyOtherKeys` level 2 or the Kitty keyboard protocol (xterm, kitty, foot, WezTerm); gnome-terminal/VTE sends neither, so the hints advertise `F9`, which works everywhere. The interaction model drops `Ctrl+Enter` entirely — don't add it to new views |

Every screen prints its keys in the footer via `hints()`. If a key exists, the
footer shows it; if the footer shows it, the key works. Control keys are
written compactly as `^N`, `^A`, `^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   (Ctrl+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`, `F5`, `^Enter`, 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 F5/`^Enter`.
- 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. `F5`/`Ctrl+Enter`/`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. `F5`
  shows the plan, `Ctrl+Enter` posts it.
- Validation: `F5` validates without writing and reports exactly what is wrong
  (field, row number). Server `dry_run` is used where available.
- Saving: `Ctrl+Enter` 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`/`F5`/
  `Ctrl+Enter`). 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 `F5` 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 `F2` menu and the footer
   hint all read that list, so a key cannot exist without a visible action.