Files
tai/tui.md
T

18 KiB

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 asyncio 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.

Design background and rejected alternatives: research/tui-framework-design/conclusions/retained-dom-compositor-tui.md.

Quick start

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. One asyncio loop. stdin and sub-terminal ptys are loop readers (and writers while input is queued) that dispatch as bytes arrive; a 30 fps frame clock (asyncio.sleep) advances timers, animations and painting. offload(work, done) runs blocking work on its own daemon thread and calls done(result, error) back on the loop via call_soon_threadsafe; without a running loop (headless tests) it runs inline. An exception in a loop callback is kept, the terminal restores, then it is re-raised. 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

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.
  • TuiHistory(path, cap) - readline-style input history: older(current) / newer(), duplicate-of-last skipped, capped at TUI_HISTORY_CAP (1000), optionally backed by a one-entry-per-line file (the chat scene shares the REPL's history file; multi-line entries persist with newlines folded to spaces). Pass it as TuiTextBox(history=...).
  • 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 27 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) plus 5 chat-scene roles (panel for sidebar, header and status bar, accent2 cyan, accent3 pink, code_bg, tool_bg); 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: a borderless, colored layout. A panel-tinted bots sidebar (colored avatar chip per bot, accent bar on the selected chat, green dot while a turn runs, red Draft: hint), a header with avatar, name and an online / typing... dot, a transcript of aligned bg-fill bubbles (yours right in the accent bubble color, theirs left with the bot name on top, stamp plus ticks on the bottom row inside the bubble), a slim scrollbar in the last transcript column, a rounded composer (accent border while focused) with a Send button, and a status bar of key caps plus profile/bot and model pills. 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.

Assistant text renders through a small line-level markdown renderer (tui_md_blocks, tui_md_inline, tui_wrap_segs): headings, bold, italic (shown in the info color), inline code, fenced code blocks, bullets, numbered lists, quotes, rules. Tool calls show as one-line chips (gear, bold name, k=v arguments, truncated). Command output and errors show as plain bars on the tool background so aligned columns survive. An empty chat shows a welcome card with the key hints.

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), Up / Down recall input history like readline (Up on the first line goes older, Down on the last line goes newer and ends on your unfinished draft; an open completion popup keeps the arrows), 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 through app.offload: your message paints first, the turn runs on its own thread, and the reply lands back on the loop, so the UI keeps rendering, themes switch, resizes reflow and the sidebar scrolls while the model works. While a turn is busy a second send and a bot switch are ignored (the thread owns the agent and the vault); the store connection is opened with check_same_thread=False for that hand-off. The loop keeps the real stdout fd from setup, because the turn thread swaps sys.stdout.

The header status only says what is true. The agent reports its real phase through agent.on_phase(name, detail): thinking while the model or the loop works, running <tool> inside a tool call, waiting for you while a dialog is open, typing... once the final reply exists and is being laid out word by word (bounded to TUI_CHAT_REVEAL_STEPS ticks), then online. Thinking and typing animate: braille spinner in cycling accent colors, a shimmer sweeping the word, trailing dots lighting one by one, and Ns elapsed in the status bar.

Approvals and ask_user are real dialogs. The agent calls agent.prompter(kind, text, options) from the turn thread; the chat scene opens a TuiQuestion (approve: Allow once / Always allow / Deny; ask: the options plus "Type an answer...") on the loop and the thread waits. Esc, Cancel, or no answer within TUI_CHAT_PROMPT_TIMEOUT (600 s) means deny / no answer, never a hang. Without a prompter (CLI, Telegram) behavior is unchanged.

Measured (this machine, 40-exchange transcript, repaint of a dirty frame): 1.9 ms at 80x24, 5.0 ms at 200x50, 7.8 ms at 300x70 against a 33 ms budget (guarded by a unit test at 200x50). Live in a pty: a resize reflows to a correct frame in 18-60 ms, a burst of 30 resizes ends on the right frame, key to screen averages 23 ms (input paints immediately, no longer waiting for the next tick).

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 markdown renderer is line-level only: no tables, no links, no nested emphasis, no syntax highlighting inside code blocks; there are 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-call chips are not collapsible and show no results, no per-token streaming.
  • 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.