Files
tai/tui.md
T
2026-10-12 00:53:52 +02:00

304 lines
15 KiB
Markdown

# tai TUI framework
A retained, DOM-like terminal UI toolkit living entirely in `tai.py`
(standard library only, no dependencies). Components are plain Python
objects in a tree; a compositor merges them bottom-to-top by layer and
z-order every frame; one synchronous tick loop drives input, timers,
animations, and rendering.
Run the live tour with `/demo` in the REPL or `./tai.py --demo`.
`./tai.py --demo-shot` prints one composed frame as plain text (the
headless smoke path). Covered by `test_tai.py`'s `Tui*Tests` classes
plus live pty checks; user-facing summary in [README.md](README.md#tui-framework).
Design background and rejected alternatives:
[research/tui-framework-design/conclusions/retained-dom-compositor-tui.md](research/tui-framework-design/conclusions/retained-dom-compositor-tui.md).
## Quick start
```python
import tai
app = tai.TuiApp()
root = tai.TuiPanel(flow=None, style=tai.TuiStyle(bg="black"))
def answered(value):
print("picked:", value)
root.add(tai.TuiQuestion(
"Deploy to production?",
["Ship it", "Staging first", "Abort"],
on_done=answered, title="Confirm", w=44,
))
app.mount(root)
app.run()
```
Keys: Tab / Shift+Tab move focus, arrows + Enter drive lists and
buttons, Esc closes dialogs, `q` quits demo apps, Ctrl+C always quits.
## Architecture
- **Tree.** `TuiPanel` is the base of everything: geometry, children,
paint, event dispatch. `TuiRows` / `TuiCols` preset flow direction.
`add()` / `remove()` / `find(id)` mutate the tree; Mount/Unmount
events fire on bind.
- **Layout.** Two phases: `measure()` (intrinsic content size, chrome
excluded) then `arrange()` (absolute rects plus clip rects). Docked
children carve edges first, flow children fill the remainder, and
positioned children (x/y given) overlay out of flow. See sizes below.
- **Compositor.** Each panel paints styled text runs for its own rect.
Per screen row the compositor collects cuts (every run edge),
slices runs into aligned chops, drops occluded chops, and merges
the topmost survivor per interval. Frames diff against the previous
frame; one single-write update wrapped in synchronized-output
markers (`CSI ? 2026 h/l`, ignored where unsupported) keeps output
atomic and flicker-free.
- **Hit-testing.** Panels index into an 8x4-cell spatial grid rebuilt
on every layout; clicks resolve topmost-first inside clip rects.
- **Loop.** `select()` over stdin plus sub-terminal ptys at 30 fps.
Dirty flags skip idle frames entirely. Timers (`after`/`every`),
animations, widget `on_tick`, and terminal polling all run on the
same clock. Terminal state (raw mode, alt screen, mouse, cursor,
SIGWINCH handler, child ptys) restores in a `finally`.
## Geometry
Sizes accept `int` cells, `"50%"` (of the parent content box), `"1fr"`
(share of leftover flow space, largest-remainder exact), and `"auto"`
(measured content). Positions accept ints (negative counts back from
the far edge), percents, and `"center"`. Sizes are content-box: border
and padding add outside the given w/h (CSS `content-box`).
- `flow="rows"` stacks children top to bottom; `"cols"` left to right;
`None` places unpositioned children at the origin (use explicit
geometry or docking instead).
- `align` / `valign` (`left`/`center`/`right`, `top`/`middle`/`bottom`)
place children in leftover main-axis space and center them on the
cross axis. Rows-cross `auto` fills the width; cols-cross `auto`
hugs content (Textual parity).
- `dock="top|bottom|left|right"` pins a child to an edge outside the
flow; docked panels never scroll.
- `layer` names a z band (`base` 0, `panel` 10, `dialog` 20, `toast` 30);
`z` offsets inside the band; ties break by creation order. There is
no limit on layers, dialogs, nesting, or components.
- `offset=(dx, dy)` shifts a panel visually after layout.
- `padding` (int, `(v, h)`, or `(t, r, b, l)`), `gap` (flow spacing),
`border` with `title`, `scroll_x` / `scroll_y` with `scrollable`.
## Events
`TuiEvent` subclasses: Key, Click, Scroll, Focus, Blur, Resize, Tick,
Mount, Unmount, Select, Press, Close, Paste. Dispatch calls `on_<name>` on
the target, then walks base classes implicitly (plain attribute
lookup), then bubbles to the parent while `bubble` is true.
`event.stop()` halts everything. Custom events: subclass `TuiEvent`,
set `name`, `panel.emit()` it. Widget callbacks (`on_press`,
`on_select`, `on_close`) are the same namespace: assigning one sets
the handler, and it receives the event object (with `panel`, and
`index`/`option`/`value` for Select). Only `on_done` (Question) is a
plain callback taking the answered value.
Focus: `focusable` panels join a chain sorted by on-screen (y, x).
Tab / Shift+Tab ALWAYS cycle with wraparound, even out of a live
terminal: no widget may swallow the only focus-movement keys
(keyboard trap). Tradeoff: shells lose Tab-completion; click or Tab
into a terminal to type, Tab out to leave. Clicks focus the topmost
focusable under the cursor. An open modal traps the chain inside its
subtree and swallows outside clicks. `set_focus`, `focus_first`,
`focus_next`, `focus_prev`, `ensure_focus` manage it; Focus/Blur
events fire on change and never bubble. Automatic focus (mount,
reset) prefers non-terminal widgets via `auto_focus` so keys keep
bubbling to app handlers by default; opening a dialog remembers the
previous focus and closing it restores that panel (validated as
still attached and shown) before falling back to `auto_focus`.
Stale focus on a removed panel is detected by tree attachment, not
just visibility.
## Mouse and keyboard
Mouse enables `?1000h` + `?1006h` (clicks plus wheel, no motion
flood). The parser accepts SGR (`ESC [ < b ; x ; y M/m`) and legacy
(`ESC [ M bxy`) reports, 1-indexed to 0-indexed, with shift/alt/ctrl
modifiers. Split reports across reads are buffered, never parsed
partial; parsed reports are consumed and never leak into text input.
Keys: printable text (UTF-8), enter, tab, backtab (Shift+Tab),
backspace, escape (lone ESC after input idles; ESC+key is alt+key),
arrows, home/end, insert/delete, pageup/pagedown, F1-F12, ctrl+letter,
and modified arrows (`shift+up` style). Tab/Shift+Tab always move
focus (see Focus above); terminals receive every other key.
Ctrl+C always quits the app loop.
Kitty keyboard: setup pushes progressive-enhancement flags 10
(all-keys-as-escape-codes plus event types) best-effort and pops on
teardown; unsupported terminals ignore both bytes. The parser decodes
`CSI unicode-key-code:alternate ; mods:event-type ; text u` next to
the legacy grammar, so kitty and legacy terminals share one stream:
bare modifier presses arrive as `shift`/`ctrl`/`alt`/`super` key
events (press only; repeats and releases are dropped, text-key
repeats still type), text stays legacy-identical (associated-text
tail wins, shift+letter uppercases, US-shift map covers the rest),
super/hyper/meta/locks are dropped. Only kitty-protocol terminals
(kitty, WezTerm, foot, ghostty) report bare Shift at all; everywhere
else no bytes exist to detect, so double-Shift chords only work
there. `TuiDoubleTap(timeout=0.4)` matches two same-key presses on
an injectable clock; set `app.on_double_tap` to a callback returning
True when it acted (falsy falls through to normal handling, so fast
double-Tab still cycles).
Paste: setup enables bracketed paste (`CSI ? 2004 h`, best-effort
like the kitty flags) and teardown disables it. A marked paste arrives
as one `TuiPaste(text)` event routed to the focused panel; the body
stays literal (no escape parsing inside) and a paste over 1MB
(`TUI_PASTE_CAP`) is dropped whole, never truncated. Unmarked CRLF
still coalesces to a single Enter, so plain pastes keep correct line
breaks on terminals without bracketed mode.
## Animation and timers
```python
panel.animate("x", 40, duration=0.6, easing="in_out_cubic", on_done=next_step)
app.after(2.0, popup_tip)
app.every(0.5, poll_status)
```
`animate()` tweens any numeric attribute (x, y, w, h relayout;
anything else repaints) with `duration` or `speed`, `delay`, and an
easing (`linear`, `in|out|in_out` quad/cubic). Timers take plain
callbacks; exceptions in callbacks never break the loop.
## Components
- `TuiText(text, text_align, wrap)` - wrapped static text.
- `TuiButton(label, on_press)` - Enter/Space/click; focused style;
`on_press(event)` fires per press.
- `TuiOptions(options, multi, on_select)` - virtualized list; options
as strings, `(label, value)` pairs, or dicts. Arrows/home/end/pgup/
pgdn/wheel move, Enter/Space/click selects; `on_select(event)`
fires per pick and the event also bubbles.
- `TuiDialog(*children, on_close)` - centered modal; Esc closes;
`buttons(("OK", cb), ...)` adds a button row (callbacks take the
Press event); `on_close(event)` fires on close.
- `TuiQuestion(prompt, options, on_done, multi)` - prompt plus
options; single-select answers on first pick, multi-select via OK.
- `TuiTextBox(text, complete, mentions)` - multiline editor; arrows/
home/end/pgup/pgdn move (no line wrap), Enter splits,
Backspace/Delete join, click positions the cursor, Escape bubbles
for dialogs; `.value` joins lines. Bracketed pastes insert
literally with CR/CRLF normalized to LF. `complete(prefix)` wires
a `TuiAutocomplete` popup for `/words` and `mentions(prefix)` the
same for `@words` (bots, providers, models, files) before the
cursor. `send_on_enter=True` with `on_send(value)` turns Enter
into send (an open popup still accepts first); Shift+Enter (kitty
terminals) and Alt+Enter insert a newline instead. `max_chars`
caps the buffer (`TUI_CHAT_INPUT_CAP`, 2000, in the chat scene).
- `TuiAutocomplete` - non-focusable `TuiOptions` popup owned by a
textbox; typing filters, arrows/home/end/pgup/pgdn move, Enter
accepts, Esc dismisses (a second Esc still bubbles); flips above
the cursor when short on room, max 8 rows. The box keeps focus
throughout, and Tab is never used for completion (it always moves
focus). Clicks pick outside modals; inside a modal the modal click
guard swallows them and the keyboard path still works.
- `TuiPromptDialog(label, box_h, on_execute, complete)` - label plus a
`box_h`-row editor with OK/Cancel; OK runs
`on_execute(value)` then closes. The demo opens one on
double-Shift with slash-command completion and feeds accepted
text to the shell.
- `TuiTerminal(cmd, interactive)` - real pty child streamed through
the VT screen; click to focus and type; read-only mode for logs.
- `TuiProgress(value)` - fraction bar; `set()` clamps 0..1.
- `TuiLog` - virtualized scrollback; `append(text, style)` follows
the tail while pinned; wheel/pgup/pgdn/home/end navigate.
## Themes
Colors go through named roles, not literals. `TuiTheme(name, dark,
roles)` holds 22 roles: the 12 base roles (`bg`, `surface`, `fg`,
`muted`, `border`, `accent`, `on_accent`, `selection`, `success`,
`warning`, `error`, `info`) plus 10 Telegram roles (`bubble_in`,
`bubble_out`, `bubble_fg`, `meta_in`, `meta_out`, `tick_read`,
`service_bg`, `service_fg`, `unread`, `unread_muted`, with day/night
values from the tdesktop theme files); `TUI_THEMES` is the registry
with five builtins
(`dark`, `light`, `nord`, `gruvbox`, `ansi`); `tui_register_theme()`
adds more (all 12 roles required). Widgets use `fg="role:accent"`
style values, resolved against the current theme at draw time; raw
colors (`#rrggbb`, ANSI names, ints) pass through untouched, and
per-widget styles merge over role styles as before. Unknown roles
render uncolored rather than crashing.
Selection precedence: `--theme` flag, `TAI_THEME` env var, `dark`
default; an unknown name prints the available list and exits 2.
`App.set_theme(name)` swaps the global theme and redraws everything;
F4 cycles registry order in the chat scene. To change a color, edit
one value in `_tui_builtin_themes()` or register a custom theme.
## Chat scene (`--tui`)
`./tai.py --tui` runs the fullscreen chat UI with REPL parity,
styled after the Telegram client: a bots sidebar with two-line rows
(name / snippet or red draft), a conversation header (avatar
initial, name, `online` / `typing...` plus hairline), a message
transcript of aligned bg-fill bubbles (yours right, theirs left,
70% max width, time plus ticks tucked bottom-right inside), a
1-line status bar, and a composer (input plus Send button). Ticks
follow Telegram: clock while sending, single check on the cloud,
blue double check once read; service pills mark the date. Sidebar
selection switches bots with per-bot input drafts; Tab cycles
sidebar, transcript, input, Send.
Slash commands run inline with output captured into a dim bubble
(`/quit` quits); anything else runs a full agent turn whose reply
lands as an incoming bubble. The transcript keeps a block journal
and replays it on resize, so bubbles reflow instead of freezing at
the old width; below usable width they degrade to full-width rows.
Keys: Enter sends (Shift+Enter / Alt+Enter newline), F2 toggles the
sidebar, F4 cycles the theme, `i` or Esc jumps to the input, `q`
quits from outside the input. Below 80 columns the sidebar
auto-hides (`TUI_CHAT_NARROW_COLS`); a manual F2 hide/show wins over
the automatic rule until toggled again.
Turns run inline on the loop via a deferred timer: your message and
a `thinking...` status paint first, then the turn blocks (like the
telegram bot) until the reply lands. A worker thread was considered
and rejected: it would share one sqlite connection across threads,
which sqlite forbids. `ask_user` cannot prompt mid-turn here (stdin
is swapped for a non-tty during the turn, so the tool declines and
the agent decides instead). Approvals cannot prompt mid-turn either
for the same reason, so gated tools are denied unless the session
pre-approved them (`--yes` / `--yolo`); allowlisted read-only
commands still run.
## Terminal widget notes
`spawn()` forks the command onto a pty sized to the widget; resizes
propagate via SIGWINCH-winning `TIOCSWINSZ`. Input queues and drains
cooperatively with output reads (never a blocking write from a
handler). Exited children keep their last screen with the exit status;
teardown kills survivors so demos never leak processes. Without `pty`
the widget shows a plain error line instead of crashing.
## Gaps and non-goals (v1)
- Wide (CJK/emoji) characters count as one column; columns around
them misalign. Structured to fix with a width table later.
- The theme layer is role names plus a registry, not a full
stylesheet language (no selectors, no file-based overrides); only
the key-event subset of the kitty protocol (no release tracking,
no super/hyper/meta, no alternate-key shortcut matching), no true
opacity blending.
- The chat scene has no markdown-to-styled-run renderer yet
(assistant markdown shows unstyled), no bubble tails, no unread
pills or per-chat times (no honest data source), no attach/voice
or reply-preview (no function behind them), tool calls show as dim
bubbles (no collapsible blocks), no per-token streaming, no
send-history recall.
- No per-widget async tasks; blocking work belongs in threads that
post back via timers.
- The VT parser drops provably-malformed tails past 64KB and caps
parsed CSI params at 64; these are corruption guards, not UI
limits. UI multiplicity itself (layers, dialogs, depth,
components, animations) is intentionally unbounded.