346 lines
18 KiB
Markdown
346 lines
18 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 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](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.** 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
|
|
|
|
```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.
|
|
- `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.
|