# 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_` 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. - `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. ## 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. - No stylesheet language (constructor kwargs instead), 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. - 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.