okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
0f44211
main
tui
the terminal client's screens, how they share one look and one set of keys, and how to see them. the code is cli/internal/tui. the daemon owns the data; this page is only about rendering and keys.
what the screens share
every list screen is built from the same four parts. a new list screen composes them and writes no layout of its own.
| part | file | owns |
|---|---|---|
listFrame | frame.go | the whole layout: indented title rule, filter line, list, detail pane, footer, one clip to the terminal height |
listView | list_view.go | the always-live filter, the cursor, sections, row drawing, the pane's contents (listEntry, paneTitle, factRows, paneNote) |
confirm | confirm.go | the one yes/no question |
matchFields, searchWords | model_search.go | the one fuzzy matcher every filter uses |
pageStatus (page.go) is the small state every screen embeds beside those: width and height, the load or save in flight, the last error and notice, and the generation that makes a stale reply harmless (settle). every command carries the generation it started under.
on the shared look: /model, /sessions, /folders, /extensions, /skills, /instructions, /mcp, /webhooks, /tree, /quota, /context, /login, and the daemon-driven pages (/work, /paperclips, /schedule, /links, run jobs, web-search order).
not on it, on purpose: the chat (a transcript is not a list) and the agents canvas (a graph). the agents delete question still uses confirm.
layout
- the title is
brand("albedo") + " " + Muted("/command"). the frame draws the rule and the right-hand summary. - at width >= 96 and height >= 14 the detail pane sits beside the list. below that it stacks under the list when the body has at least 12 rows (the pane gets two fifths of the body), and is dropped otherwise. a screen supplies
paneand gets all three behaviours; it never decides. - the frame clips to the terminal once. screens do not truncate, pad or count rows, and there are no
Height-Nconstants. - the filter line is hidden only while a form is open over the list.
footerLine(width, hints, status, urgent)builds the footer: keys on the left, a status on the right. an urgent status (error, notice, work in flight) takes its own row above the keys when it does not fit beside them.- list hints most important first and
esclast.fitHintssheds the least important from the end, keepsesc, and drops every label before it drops a key. - an empty list says why in
listView.Empty. errors and notices go in the footer status, never into rows. - a pane shows what used to need enter to see (an mcp server's settings, a webhook's url and state, a skill's source). nothing important lives only below the list.
keys
the filter is always live and focused, so a bare letter is typed into it. no list screen has a bare-letter action, and none toggles on space. esc is always back, even with text typed; inside a confirm it cancels the confirm, inside a form it cancels the form.
these mean the same thing on every screen that has them:
| key | does |
|---|---|
| up/down, ctrl+p/ctrl+n, pgup/pgdn | move (owned by listView) |
| enter | the row's main action |
| shift+enter, alt+enter | the same action for this session only, where a screen has a session scope |
| ctrl+x | drop this session's own choice and follow global |
| ctrl+d | delete, remove or unlink, always after a confirm |
| ctrl+e | edit |
| ctrl+o | add, create, link |
| ctrl+r | refresh, or retry a failed load |
other chords belong to one screen and say so in their hint: ctrl+t (enable an extension on /skills, reply or toggle on pages, agent access on /webhooks), ctrl+g (enable an mcp server, new webhook secret, done or acknowledge on pages), ctrl+l (copy a webhook url, sign in on /folders), ctrl+w (webhooks from /extensions), ctrl+a (actions menu on pages, archive on /sessions), ctrl+s (save a form, pin a session).
known exceptions to the table: on /sessions ctrl+r renames (on /folders it retries), and on the agents canvas ctrl+x deletes. pages use ctrl+x for dismiss or stop. a terminal without the kitty keyboard protocol sends plain enter for shift+enter, so the confirm on /extensions, /skills and /mcp names its scope ("globally?" or "for this session only?") and a mixup is visible before enter.
confirms
confirm is the only way a screen asks. enter does it, esc or ctrl+c cancels, every other key is ignored, so a stray press while you read the question changes nothing. never "any other key cancels" and never y. a confirm is for destructive or far-reaching actions (delete, a new secret, enabling something that reloads the session); toggles that are cheap to undo act directly. a failed attempt keeps the question open with enter as retry.
adding a screen
- embed
pageStatus,listViewandconfirm; rebuild rows withsetRows. - ask the confirm first with
confirm.ask(what, target, verb, prompt), act onconfirm.key. - draw with
listView.frame(title, right, footer).view(w, h). build the footer withfooterLine, orconfirm.footerwhile asking. - call
listView.updatefor movement and typing and compute its command before returning the model (cmd := m.listView.update(msg); return m, cmd). go does not promise the order in whichreturn m, m.listView.update(msg)readsmand runs the call, and the returned model can miss the filter change. - register the screen's states for the gallery (below).
seeing the screens
ALBEDO_SHOT_DIR makes tests write what a view draws. nothing is written without it, so the gate never creates files. shotTerminal(t) gives the styles a real terminal with its own palette would get; without it a test falls back to reverse video and the selection looks wrong.
ALBEDO_SHOT_DIR=/tmp/shots go -C cli test ./internal/tui -run TestShotGallery -count=1 python3 test/manual/tui_shot.py /tmp/shots
each screen calls registerShots("<screen>", states...) from its shot_<screen>_test.go. TestShotGallery draws every registered state at 120x30 (wide) and 70x20 (narrow). tui_shot.py turns each .ans into a png (needs rsvg-convert and ImageMagick) and builds contact sheets, sheet-<screen>-<n>.png, six states per sheet so each stays under the 2000px edge a model can read. run one screen with -run TestShotGallery/<screen>.
cover every state a person would want to look at: the list, a filter typed, no match, each confirm, loading, empty, load failed, a notice, a form open. look at the sheets after a layout change; text dumps hid a patchwork selection and a collapsed footer that the pictures showed at once.
markdown
replies render through glamour, from our fork tangled.org/okami.mom/glamour (replace in cli/go.mod; v2.0.1 plus hooks). glamour.WithExtensions adds goldmark parsers and glamour.WithElement(kind, fn) renders a node kind, new or built in; ansi.RenderContext.Width() is the room left inside the current block. the fork also renders list items as blocks: every row after the marker's hangs under the item's text, so wrapped lines, paragraphs, code and display math stay inside the item (style.List.LevelIndent is 0 because the hang already indents nested lists). upstream glamour has neither hook, and prints a warning to stdout for a node kind it does not know, which corrupts the screen.
to add a construct, write its goldmark parser and node, then its element, in one file beside markdown.go, and pass both in markdownRenderer. math.go is the example: $…$ and \(…\) inline, $$…$$ and \[…\] on their own lines as display math, laid out in unicode by termtex. a lone $ opens only before a non-space and after no word character, and closes only before no word character, so $5 and $HOME/$USER stay text. display math that has not closed yet, or that termtex cannot read, shows as its source. fenceAfter treats open display math like a code fence, so streaming never splits it into blocks. use ansi.BaseElement{Verbatim: true} for laid-out text; without it glamour removes markdown escapes from the token.
markdown's colors are ink accents (markdownAccents in ink.go), palette hues made legible on the background: headings violet, strong text yellow toward red, inline code green, links blue, math cyan. headings and strong text sit at a higher contrast than the others. without reported colors each falls back to its palette slot.
terminal output
transcript rows carry private albedo: APC marks for copy, wrap joining and click targets. they stay in the chat's internal rows, but AppModel.View removes them from the final content before Bubble Tea sees it. ultraviolet preserves APC payloads; terminal.app prints unknown ones, which corrupts the layout. remove only our private marks, not styling, hyperlinks or graphics sequences. the real-daemon TUI driver's view assertion checks the raw content before ansi.Strip can hide a leak.
ctrl- or cmd-click on a transcript row opens the file it names (editor.go, openFileAt); a plain click keeps its row action. the token under the click wins, else the row's only existing file; a numbered diff row opens its file at that line, read after the rail (rowBody). only regular files that exist under the workspace (or absolute) open. inside zed's or a vs code family terminal (TERM_PROGRAM, VSCODE_PID, ZED_TERM) the file opens through that editor's cli in the background; otherwise through $EDITOR, run on the terminal with tea.Exec unless it is a gui editor. cli/test/e2e/tui_open_file_test.go clicks real rendered rows.
tests
unit tests here catch what the daemon e2e cannot drive: key routing, stale replies, in-flight save rendering, footer fitting. the screen-level scenarios in cli/test/e2e drive the real daemon through the same keys. when a screen changes keys, update both and the gallery state that shows the footer.
1# tui 2 3the terminal client's screens, how they share one look and one set of keys, and how to see them. the code is `cli/internal/tui`. the daemon owns the data; this page is only about rendering and keys. 4 5## what the screens share 6 7every list screen is built from the same four parts. a new list screen composes them and writes no layout of its own. 8 9| part | file | owns | 10| --- | --- | --- | 11| `listFrame` | `frame.go` | the whole layout: indented title rule, filter line, list, detail pane, footer, one clip to the terminal height | 12| `listView` | `list_view.go` | the always-live filter, the cursor, sections, row drawing, the pane's contents (`listEntry`, `paneTitle`, `factRows`, `paneNote`) | 13| `confirm` | `confirm.go` | the one yes/no question | 14| `matchFields`, `searchWords` | `model_search.go` | the one fuzzy matcher every filter uses | 15 16`pageStatus` (`page.go`) is the small state every screen embeds beside those: width and height, the load or save in flight, the last error and notice, and the generation that makes a stale reply harmless (`settle`). every command carries the generation it started under. 17 18on the shared look: `/model`, `/sessions`, `/folders`, `/extensions`, `/skills`, `/instructions`, `/mcp`, `/webhooks`, `/tree`, `/quota`, `/context`, `/login`, and the daemon-driven pages (`/work`, `/paperclips`, `/schedule`, `/links`, run jobs, web-search order). 19 20not on it, on purpose: the chat (a transcript is not a list) and the agents canvas (a graph). the agents delete question still uses `confirm`. 21 22## layout 23 24- the title is `brand("albedo") + " " + Muted("/command")`. the frame draws the rule and the right-hand summary. 25- at width >= 96 and height >= 14 the detail pane sits beside the list. below that it stacks under the list when the body has at least 12 rows (the pane gets two fifths of the body), and is dropped otherwise. a screen supplies `pane` and gets all three behaviours; it never decides. 26- the frame clips to the terminal once. screens do not truncate, pad or count rows, and there are no `Height-N` constants. 27- the filter line is hidden only while a form is open over the list. 28- `footerLine(width, hints, status, urgent)` builds the footer: keys on the left, a status on the right. an urgent status (error, notice, work in flight) takes its own row above the keys when it does not fit beside them. 29- list hints most important first and `esc` last. `fitHints` sheds the least important from the end, keeps `esc`, and drops every label before it drops a key. 30- an empty list says why in `listView.Empty`. errors and notices go in the footer status, never into rows. 31- a pane shows what used to need enter to see (an mcp server's settings, a webhook's url and state, a skill's source). nothing important lives only below the list. 32 33## keys 34 35the filter is always live and focused, so a bare letter is typed into it. no list screen has a bare-letter action, and none toggles on space. `esc` is always back, even with text typed; inside a confirm it cancels the confirm, inside a form it cancels the form. 36 37these mean the same thing on every screen that has them: 38 39| key | does | 40| --- | --- | 41| up/down, ctrl+p/ctrl+n, pgup/pgdn | move (owned by `listView`) | 42| enter | the row's main action | 43| shift+enter, alt+enter | the same action for this session only, where a screen has a session scope | 44| ctrl+x | drop this session's own choice and follow global | 45| ctrl+d | delete, remove or unlink, always after a confirm | 46| ctrl+e | edit | 47| ctrl+o | add, create, link | 48| ctrl+r | refresh, or retry a failed load | 49 50other chords belong to one screen and say so in their hint: `ctrl+t` (enable an extension on `/skills`, reply or toggle on pages, agent access on `/webhooks`), `ctrl+g` (enable an mcp server, new webhook secret, done or acknowledge on pages), `ctrl+l` (copy a webhook url, sign in on `/folders`), `ctrl+w` (webhooks from `/extensions`), `ctrl+a` (actions menu on pages, archive on `/sessions`), `ctrl+s` (save a form, pin a session). 51 52known exceptions to the table: on `/sessions` `ctrl+r` renames (on `/folders` it retries), and on the agents canvas `ctrl+x` deletes. pages use `ctrl+x` for dismiss or stop. a terminal without the kitty keyboard protocol sends plain enter for shift+enter, so the confirm on `/extensions`, `/skills` and `/mcp` names its scope ("globally?" or "for this session only?") and a mixup is visible before enter. 53 54## confirms 55 56`confirm` is the only way a screen asks. enter does it, esc or ctrl+c cancels, every other key is ignored, so a stray press while you read the question changes nothing. never "any other key cancels" and never `y`. a confirm is for destructive or far-reaching actions (delete, a new secret, enabling something that reloads the session); toggles that are cheap to undo act directly. a failed attempt keeps the question open with enter as retry. 57 58## adding a screen 59 601. embed `pageStatus`, `listView` and `confirm`; rebuild rows with `setRows`. 612. ask the confirm first with `confirm.ask(what, target, verb, prompt)`, act on `confirm.key`. 623. draw with `listView.frame(title, right, footer).view(w, h)`. build the footer with `footerLine`, or `confirm.footer` while asking. 634. call `listView.update` for movement and typing and compute its command before returning the model (`cmd := m.listView.update(msg); return m, cmd`). go does not promise the order in which `return m, m.listView.update(msg)` reads `m` and runs the call, and the returned model can miss the filter change. 645. register the screen's states for the gallery (below). 65 66## seeing the screens 67 68`ALBEDO_SHOT_DIR` makes tests write what a view draws. nothing is written without it, so the gate never creates files. `shotTerminal(t)` gives the styles a real terminal with its own palette would get; without it a test falls back to reverse video and the selection looks wrong. 69 70``` 71ALBEDO_SHOT_DIR=/tmp/shots go -C cli test ./internal/tui -run TestShotGallery -count=1 72python3 test/manual/tui_shot.py /tmp/shots 73``` 74 75each screen calls `registerShots("<screen>", states...)` from its `shot_<screen>_test.go`. `TestShotGallery` draws every registered state at 120x30 (wide) and 70x20 (narrow). `tui_shot.py` turns each `.ans` into a png (needs `rsvg-convert` and ImageMagick) and builds contact sheets, `sheet-<screen>-<n>.png`, six states per sheet so each stays under the 2000px edge a model can read. run one screen with `-run TestShotGallery/<screen>`. 76 77cover every state a person would want to look at: the list, a filter typed, no match, each confirm, loading, empty, load failed, a notice, a form open. look at the sheets after a layout change; text dumps hid a patchwork selection and a collapsed footer that the pictures showed at once. 78 79## markdown 80 81replies render through glamour, from our fork `tangled.org/okami.mom/glamour` (`replace` in `cli/go.mod`; v2.0.1 plus hooks). `glamour.WithExtensions` adds goldmark parsers and `glamour.WithElement(kind, fn)` renders a node kind, new or built in; `ansi.RenderContext.Width()` is the room left inside the current block. the fork also renders list items as blocks: every row after the marker's hangs under the item's text, so wrapped lines, paragraphs, code and display math stay inside the item (`style.List.LevelIndent` is 0 because the hang already indents nested lists). upstream glamour has neither hook, and prints a warning to stdout for a node kind it does not know, which corrupts the screen. 82 83to add a construct, write its goldmark parser and node, then its element, in one file beside `markdown.go`, and pass both in `markdownRenderer`. `math.go` is the example: `$…$` and `\(…\)` inline, `$$…$$` and `\[…\]` on their own lines as display math, laid out in unicode by `termtex`. a lone `$` opens only before a non-space and after no word character, and closes only before no word character, so `$5` and `$HOME/$USER` stay text. display math that has not closed yet, or that termtex cannot read, shows as its source. `fenceAfter` treats open display math like a code fence, so streaming never splits it into blocks. use `ansi.BaseElement{Verbatim: true}` for laid-out text; without it glamour removes markdown escapes from the token. 84 85markdown's colors are ink accents (`markdownAccents` in `ink.go`), palette hues made legible on the background: headings violet, strong text yellow toward red, inline code green, links blue, math cyan. headings and strong text sit at a higher contrast than the others. without reported colors each falls back to its palette slot. 86 87## terminal output 88 89transcript rows carry private `albedo:` APC marks for copy, wrap joining and click targets. they stay in the chat's internal rows, but `AppModel.View` removes them from the final content before Bubble Tea sees it. ultraviolet preserves APC payloads; terminal.app prints unknown ones, which corrupts the layout. remove only our private marks, not styling, hyperlinks or graphics sequences. the real-daemon TUI driver's view assertion checks the raw content before `ansi.Strip` can hide a leak. 90 91ctrl- or cmd-click on a transcript row opens the file it names (`editor.go`, `openFileAt`); a plain click keeps its row action. the token under the click wins, else the row's only existing file; a numbered diff row opens its file at that line, read after the rail (`rowBody`). only regular files that exist under the workspace (or absolute) open. inside zed's or a vs code family terminal (`TERM_PROGRAM`, `VSCODE_PID`, `ZED_TERM`) the file opens through that editor's cli in the background; otherwise through `$EDITOR`, run on the terminal with `tea.Exec` unless it is a gui editor. `cli/test/e2e/tui_open_file_test.go` clicks real rendered rows. 92 93## tests 94 95unit tests here catch what the daemon e2e cannot drive: key routing, stale replies, in-flight save rendering, footer fitting. the screen-level scenarios in `cli/test/e2e` drive the real daemon through the same keys. when a screen changes keys, update both and the gallery state that shows the footer.