The loop now sleeps until woken (no 1 s heartbeat); SIGCHLD and a drained pty writer wake it, the latter also fixes a busy writer callback. Partial layout no longer climbs the whole tree for all-auto panels: arrange() skips panels whose box is unchanged and that nothing under them asked about (a pending flag that survives hidden ancestors), measured() keeps measures until a touch drops them, and only really arranged panels are diffed; one leaf growing in a 301-panel all-auto tree costs 0.29 ms instead of 4.6 ms. A scroll offset written out of range arranges that panel for its clamp. TuiWatched is the base for state objects (TuiStyle and TuiSpan are ones): a write to a public field touches every panel holding it, directly or through a watched list, dict or set. TuiTerminal keeps converted screen rows keyed by content (200x50 scroll-by-one frame 16.0 ms to 1.8 ms). The terminal scroll uses left/right margins (DECLRMM) when the startup DECRQM probe says the terminal has them, so the sidebar beside a transcript is not touched (3-line scroll at 80x24: 1354 B to 734 B in the emulator); TuiVtScreen implements the margins and answers DECRQM. The scroll cost model counts runs and style switches the way diff_row writes them and its constants were swept over 1200 recorded frames. animate() of x, y, w, h now starts from the drawn place and writes whole cells. Not verified on a real terminal: the margin path (tmux has no DECLRMM). Remaining gaps are listed in tui.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
613 lines
36 KiB
Markdown
613 lines
36 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. A pass arranges
|
|
only the subtrees that asked, see "Lazy rendering". See sizes below.
|
|
- **Compositor.** Lazy, see "Lazy rendering" below: painted cells are
|
|
cached per panel, a frame is built front to back so every cell is
|
|
written once, and only damaged rects are recomposed and diffed against
|
|
the screen the compositor believes is shown. One single-write update
|
|
wrapped in synchronized-output markers (`CSI ? 2026 h/l`, ignored where
|
|
unsupported) keeps output atomic and flicker-free; a frame that changes
|
|
nothing writes nothing.
|
|
- **Hit-testing.** Panels index into an 8x4-cell spatial grid. A layout
|
|
only hands over its stack; the grid is built by the first click or
|
|
wheel event after it, so frames without mouse input never pay for it.
|
|
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. The loop is event-driven: it sleeps until the next timer, the next animation or `on_tick` frame (30 fps, only while something animates or ticks), a half-read key sequence, or a `wake()`. With nothing due it sleeps until woken: there is no heartbeat. A touch from any thread, a pty read, SIGWINCH, SIGCHLD (a child exited, so a terminal notices its exit status even while a grandchild holds the pty open), a new timer or animation, `quit()` and end of input all call `wake()`. Paints are at least one frame apart; input paints immediately. `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 run in the same
|
|
loop; the ticker and terminal lists are cached per tree change, not
|
|
walked per frame. Terminal state (raw mode, alt screen, mouse, cursor,
|
|
SIGWINCH handler, child ptys) restores in a `finally`.
|
|
|
|
## Lazy rendering
|
|
|
|
Rule: nothing is computed that is not visible, and nothing is recomputed
|
|
that did not change. Eight layers, each with a hard cap:
|
|
|
|
1. **Per-panel cell cache.** `paint()` output is converted once to interned
|
|
`(char, style key)` cells and kept on the panel (`_cells`, at most the
|
|
panel's own area). A panel repaints only after `touch()`. Writing any
|
|
attribute of a mounted panel is the change signal: `TuiPanel.__setattr__`
|
|
calls `touch()` (repaint this panel), plus `relayout()` for geometry names
|
|
(`x y w h flow dock offset align valign padding gap border`), `relayout()`
|
|
with restack for `visible`, and `restack()` for `layer` and `z`. A scalar or
|
|
an equal `TuiStyle` written back unchanged is not a change. Names starting
|
|
`on_` are handlers, not state. The anonymous `app.dirty_paint()` /
|
|
`app.dirty_layout()` still mean "everything may have changed" and drop
|
|
every cache, so an unaudited caller is slow, never wrong. `set_focus`
|
|
touches the old and new focus panel (border and cursor style depend on
|
|
focus). Invalidation also covers resize (`aw`/`ah` change), re-attach
|
|
(`_bind_app`), and a broken widget (its `paint()` raising covers nothing
|
|
that frame and is retried).
|
|
2. **Watched state.** A list, dict or set assigned to a public attribute of a
|
|
panel is stored as an observing copy (`TuiList`, `TuiDict`, `TuiSet`,
|
|
nested plain containers included, down to `TUI_OBSERVE_DEPTH` 4): every
|
|
in-place mutation (`append`, item assignment, `update`, `|=`, ...) touches
|
|
the owner, so no widget needs `touch()` after mutating its own containers.
|
|
The panel keeps a copy: a list you keep a reference to is not the panel's
|
|
list, and writes to your original do not reach it. Copies and pickles are
|
|
plain containers. A `TuiStyle` assigned to a panel registers that panel;
|
|
writing a field of the style later repaints every panel using it. What is
|
|
not watched: underscore attributes and `children` (use `add`/`remove`).
|
|
State of your own lives in a `TuiWatched` subclass (`TuiStyle` and
|
|
`TuiSpan` are ones): assign it to a public attribute, or put it in a list,
|
|
dict or set kept there, and a write to any of its public fields touches
|
|
every panel that holds it (equal writes and underscore fields do not;
|
|
copies and pickles do not carry the owner links). Objects that are neither
|
|
a container nor a `TuiWatched`, and a `TuiWatched` inside a tuple, are not
|
|
watched: subclass `TuiWatched`, assign a new value or call `touch()`.
|
|
`TAI_TUI_VERIFY=1` finds the misses.
|
|
3. **Front-to-back coverage.** `TuiCompositor.fill` walks the stack from the
|
|
topmost panel down, keeping per-row covered intervals. Each panel copies
|
|
only its uncovered intervals (list slice copies, no per-cell Python), and a
|
|
panel with no uncovered cell is never painted at all: the root background
|
|
under a full-screen layout costs nothing. No overdraw, no sort per frame
|
|
(the stack is rebuilt only when it can have changed).
|
|
4. **Damage rects.** `touch()` records the panel; a layout pass diffs the
|
|
`(ax, ay, aw, ah, clip)` of every panel it arranged against the previous
|
|
pass and records old and new rects of what moved or resized. A stack that
|
|
gained or lost panels damages those panels' rects; a stack whose remaining
|
|
panels changed order is the whole screen. Only the merged rects are
|
|
recomposed and compared with the persistent screen (`TuiCompositor.last`).
|
|
More than `TUI_DAMAGE_RECTS` (64) rects means the whole screen. Damage is
|
|
recorded under a lock because turn threads touch panels while the loop
|
|
composes, and the dirty flags are cleared at the start of a pass, so a
|
|
touch during a frame schedules the next frame.
|
|
5. **Incremental layout.** `relayout(panel)` (own size, position or chrome
|
|
changed) arranges the panel's parent again; `relayout(panel, inner=True)`
|
|
(children or content changed) arranges the panel again. Either climbs only
|
|
while the panel is `content_sized()` (a `w` or `h` of `"auto"`, because the
|
|
parent's `measure()` reads both axes of every child) and stops at the first
|
|
panel with a fixed, percent or fr size on both axes: a layout boundary.
|
|
Requests from many panels collapse to the outermost; more than
|
|
`TuiApp.LAYOUT_STARTS` (64) requests, an anonymous `dirty_layout()`, a
|
|
resize and the first pass arrange everything. A panel is skipped by
|
|
`arrange()` when its box is the one it already has and nothing under it
|
|
asked for a layout (`mark_pending()` flags the requester and its ancestors,
|
|
the flag stays until `arrange()` reaches the panel, so a change made under
|
|
a hidden branch is applied when it shows), and `measured()` keeps each
|
|
panel's measure until a touch of it or a descendant drops it
|
|
(`forget_measure()`). So a climb through a tree of all-`auto` panels
|
|
costs the path to the root plus the fan-out along it, not the tree, and
|
|
only the panels really arranged are diffed for damage. Writing `scroll_x`
|
|
or `scroll_y` outside the range the last arrange measured arranges that
|
|
panel again (the clamp); inside it, nothing. A touched `auto` panel is
|
|
measured again at the next pass and relays out when its size changed (text
|
|
that grows, a log that gains lines); if only a non-auto axis changed, just
|
|
that panel is arranged again (its scroll clamp). `z`/`layer` changes
|
|
arrange nothing. `TAI_TUI_VERIFY=1` arranges the whole tree after every
|
|
partial pass and raises `TuiError` naming the first panel that differs.
|
|
6. **Terminal scroll.** A panel with `shifts = True` (`TuiLog` and
|
|
subclasses) records the net change of `scroll_y` as a hint. The compositor
|
|
checks the hint against the real cells: when the new rows of the content
|
|
rect equal the old rows moved by the hint, it emits a scroll of those rows
|
|
(`CSI top;bottom r`, `ESC D` or `ESC M` per row, `CSI r`, no capability
|
|
needed beyond VT100), moves the persistent screen the same way, and
|
|
recomposes the strips left and right of the rect (the scroll moved them
|
|
too) so the ordinary diff repairs them. On a terminal that answers the
|
|
startup probe `CSI ? 69 $ p` with mode 69 known (`DECLRMM`, left and right
|
|
margins: xterm, kitty, foot, wezterm, iTerm2; tmux does not) the scroll is
|
|
bounded to the rect's columns (`CSI ? 69 h`, `CSI l;r s`, scroll, `CSI ? 69 l`),
|
|
nothing beside it moves and no strip is repaired. It is only taken when the
|
|
estimate says it writes less than rewriting the cells (at least 2 rows must
|
|
match; `TuiCompositor.cost` counts changed cells, a cursor move per run of
|
|
changed cells and a style switch per style change, the way `diff_row`
|
|
writes them, for the rect and for the repaired strips;
|
|
`TUI_MOVE_COST` 8, `TUI_SWITCH_COST` 40, `TUI_SCROLL_SLACK` 80 were picked
|
|
by sweeping them over 1200 recorded scroll frames for the fewest bytes),
|
|
at most `TUI_SCROLLS` (4) per frame, and never on a whole-screen frame. A
|
|
wrong hint costs nothing but the check; correctness never depends on it,
|
|
because the diff that follows compares every cell that may differ.
|
|
7. **Row reuse.** `TuiPanel.paint_cells()` is what the compositor calls;
|
|
the default converts `paint()`. `TuiLog` (and `TuiChatLog`, which adds the
|
|
scrollbar cell) overrides it: each line's converted row is kept, looked up
|
|
by the identity of its span list and checked against scroll_x, width and
|
|
style (cap `TUI_ROW_MEMO` 4096 rows, cleared on overflow). A scroll or a
|
|
repaint converts only lines it has not shown at this width. This is safe
|
|
because log lines are append-only span lists; a subclass that overrides
|
|
`paint()` or `content_rows()` gets the plain path. Other widgets repaint
|
|
whole on a touch: they are a few rows and cost under 0.1 ms. The exception
|
|
was `TuiTerminal`, which converted every cell of its screen (a style object
|
|
per cell) on each touch; it now keeps each screen row's runs keyed by the
|
|
row's content, the width, the base style and the cursor column (same cap
|
|
and overflow rule), so a frame converts only the rows that changed.
|
|
8. **Quiet output.** Bytes are built from the cell keys alone
|
|
(`TuiStyle.sgr_from_key`, memoized, `TUI_SGR_CAP` 4096, dropped on theme
|
|
change). A frame in which no cell differs and the cursor stayed returns
|
|
`""` and writes nothing. `measure()` is memoized per layout pass
|
|
(`TuiPanel.measured()`; `TuiTerminal` opts out because its measure follows
|
|
its screen size), and `TuiStyle.key()` is cached and dropped on any field
|
|
write. The hit map is built on the first click or wheel event after a layout.
|
|
|
|
Probe replies are `TuiModeReply` events consumed by `TuiApp.dispatch`; panels
|
|
never see them. The emulator (`TuiVtScreen`, so also the multiplexer panes)
|
|
implements `DECLRMM` margins for `IND`, `RI`, `IL` and `DL`, and answers
|
|
`DECRQM` for the modes it knows (0 for the rest).
|
|
|
|
Event-driven composer: `TuiTextBox` emits `TuiChange(rows)` (`on_change`,
|
|
on the loop thread) when its number of lines changes; the chat scene sizes the
|
|
composer from it instead of a 0.2 s timer, and its spinner timer exists only
|
|
while a turn is running. An idle chat has one repeating timer (the 1 Hz
|
|
status line).
|
|
|
|
Correctness guard: the original whole-tree algorithm is kept as
|
|
`TuiCompositor.compose_reference`. `TuiApp.verify` (env `TAI_TUI_VERIFY=1`)
|
|
recomposes from scratch after every frame and raises `TuiError` naming the
|
|
first diverging cell and the panel on top of it, and checks every partial
|
|
layout against a full arrange. `test_tai.TuiLazyTests` runs a seeded 400-step
|
|
storm (keys, wheel, clicks, resizes, themes, dialogs, bot switches, timers)
|
|
with it on, replays the emitted bytes through `TuiVtScreen` and compares with
|
|
a fresh render, and mutation-checks that dropping damage or geometry rects is
|
|
caught. `TuiLayoutTests` runs random trees (every size spec, docks, floats,
|
|
borders, padding, visibility, adds, removes, text growth) through partial
|
|
layouts; `TuiScrollTests` replays scroll storms with overlays and resizes
|
|
through the VT emulator; `TuiObservedTests` covers every mutator;
|
|
`TuiLoopTests` runs the real `_main` on pipes; `TuiMarginTests` replays
|
|
scrolls through the emulator with margins on and off; `TuiClosedGapsTests`
|
|
covers geometry animation, the idle sleep, watched user state, partial layout
|
|
through all-`auto` trees and hidden branches, terminal row reuse and the cost
|
|
model. Run the whole suite with
|
|
`TAI_TUI_VERIFY=1` after touching paint, layout, or the compositor.
|
|
|
|
## 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, Change (a text box's line
|
|
count changed, `rows`; does not bubble). 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). `x`, `y`, `w` and `h` start from
|
|
where the panel is drawn (so a percent, `"center"`, `"auto"` or a negative
|
|
offset animates from its real place) and are written as whole cells. 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(backend, cap)` - readline-style input history:
|
|
`older(current)` / `newer()`, an immediate repeat is skipped, capped at
|
|
`INPUT_HISTORY_CAP` (1000). The backend is any object with `load()` and
|
|
`append(text)`; `VaultHistory(store, profile, bot)` stores it in the
|
|
vault's sealed `input_history` table (one list per profile, shared by
|
|
every bot, the bot kept as a column), the same table the REPL reads
|
|
into readline and writes to. A profile with no rows imports the old
|
|
plain-text `history` file once. A backend failure is counted in
|
|
`write_errors` and the history keeps working in memory. 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 invalidates the compositor,
|
|
because cells are diffed by style key (role names), which a theme switch does
|
|
not change; without the invalidation only cells whose text changed got the
|
|
new colors and old-theme speckles stayed on spaces. 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. Every bot is its own session
|
|
(`TuiChatSession`: its own `Agent`, journal, draft, turn state), so a long
|
|
task in one bot never blocks talking to another: switch bots in the
|
|
sidebar and chat on while the first keeps working. A busy bot shows a
|
|
spinner in the sidebar, a finished reply you were not looking at shows an
|
|
unread count, and its journal is intact when you switch back. Only the
|
|
bot on screen refuses a second send while its own turn runs. An approval
|
|
or question from a bot you are not looking at does not pop up over the bot
|
|
you are talking to: it waits (`?` badge in the sidebar) and opens when you
|
|
open that bot; its dialog title names the bot. Sessions share one vault
|
|
(the store connection is `check_same_thread=False`, sqlite is serialized)
|
|
and a counted `TuiStdioGuard` swaps stdout/stdin once for all turn threads.
|
|
Slash commands run on the bot on screen; `/bot x` switches the screen to
|
|
that bot's own session instead of retargeting the shared agent. 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.
|
|
|
|
Shell commands (host and sandbox box) stream live as a message: the turn
|
|
thread calls `agent.stream_factory(command)`, the scene journals a `live`
|
|
block (header chip with the command, the last `TUI_CHAT_LIVE_ROWS` (8) lines
|
|
in a fixed window, a status row at the end, `TUI_CHAT_LIVE_KEEP` (200) lines
|
|
kept) and repaints it at most every `TUI_CHAT_LIVE_TICK` (50 ms). It stays in
|
|
the transcript where it started, so you keep chatting while it scrolls. The
|
|
REPL has the same thing as the 4-line `LiveStream` window. When a command
|
|
stops on a prompt (sudo, ssh, a y/n; a partial last line ending like a prompt
|
|
after 1 s of silence, or echo switched off), the command runs on a pty and
|
|
the question goes through the same `agent.prompter("ask", ...)` dialog (REPL:
|
|
`input`/`getpass`); the answer is typed into the command, a decline sends
|
|
end-of-file, and the wait does not count against the 120 s timeout (max 20
|
|
questions per command). Gap: the TUI dialog does not mask a typed password.
|
|
Without an interactive user (autonomous mode, background workers, piped
|
|
stdin) commands run on pipes with no input, as before.
|
|
|
|
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 on one machine only (the development box, Python 3.14, chat scene
|
|
with a 40-exchange transcript, 19 panels, cost of `dirty -> frame_bytes()`,
|
|
mean of 50, three runs; the 400-exchange transcript costs the same, history
|
|
length does not matter). "Before" is v1.33.1, whose every dirty frame cost
|
|
4.0-4.6 ms at 200x50 (1.5 ms at 80x24) whatever had changed:
|
|
|
|
| frame at 200x50 | before | v1.34.0 | now |
|
|
|---|---|---|---|
|
|
| one typed character | 4.6 ms | 0.08-0.13 ms | 0.09 ms |
|
|
| 1 Hz status refresh | 4.9 ms | 0.05-0.08 ms | 0.05 ms |
|
|
| Tab moves focus | 4.3 ms | 0.45 ms | 0.42 ms |
|
|
| transcript scroll, 2 frames | 9.5 ms | 2.6-2.8 ms | 1.3 ms |
|
|
| one line appended while pinned | n/a | 0.78 ms | 0.6-0.7 ms |
|
|
| anonymous `dirty_paint()` (everything) | 4.0 ms | 1.2 ms | 0.8 ms |
|
|
| anonymous `dirty_layout()` | 5.4 ms | 1.6-1.8 ms | 1.2 ms |
|
|
| sidebar hidden and shown, 2 frames | n/a | 4.0-4.5 ms | 4.3-4.7 ms |
|
|
| one leaf grows in a 301-panel all-`auto` tree | n/a | 4.60 ms (302 panels arranged) | 0.29 ms (5 arranged) |
|
|
| `TuiTerminal` 200x50, one new line (scroll by one) | n/a | 16.0 ms | 1.8 ms |
|
|
| `TuiTerminal` 200x50, one row rewritten | n/a | 16.4 ms | 1.0 ms |
|
|
|
|
The sidebar toggle is a genuine whole-screen change (every panel moves) and
|
|
did not get faster. Bytes written per frame with varied transcript text
|
|
(random words, so rows differ; a transcript of repeated rows gives the
|
|
scroll less to win and the estimate then declines it):
|
|
|
|
| frame | size | v1.34.0 | now |
|
|
|---|---|---|---|
|
|
| 3-line scroll | 80x24 | 1388 B | 1354 B (734 B with margins) |
|
|
| 3-line scroll | 120x40 | 3585 B | 1576 B (897 B) |
|
|
| 3-line scroll | 200x50 | 7905 B | 1822 B (1143 B) |
|
|
| 1-line scroll | 80x24 | 1327 B | 822 B (369 B) |
|
|
| 1-line scroll | 120x40 | 3223 B | 883 B (395 B) |
|
|
| 1-line scroll | 200x50 | 6496 B | 966 B (478 B) |
|
|
|
|
The figures in brackets are for a terminal that answers the `DECLRMM` probe
|
|
(measured through the emulator, not on a real terminal: this machine's tmux
|
|
does not support it). Without margins, at 80x24 the sidebar is 30 of 80
|
|
columns and the scroll moves it too, which the strip repair pays for, so a
|
|
3-line scroll is a wash there; with margins the sidebar is not touched.
|
|
Over 1200 recorded scroll frames (five sizes, margins on and off) the cost
|
|
model took a scroll that wrote more bytes than not scrolling in 35 frames
|
|
(1.7 KB in all), against 63 frames (7.4 KB) with the cell-and-move model it
|
|
replaced. Summed over the margins-off runs of that sweep the frames wrote
|
|
769 KB, against 776 KB with the old model and 1633 KB with scrolling off.
|
|
|
|
The loop, 5 s of the real `_main` on pipes at 200x50 (CPU is process time
|
|
including the first full frame):
|
|
|
|
| 5 s | v1.34.0 | now |
|
|
|---|---|---|
|
|
| idle: loop iterations / CPU | 147 / 20.4 ms | 6 / 3.4 ms (the chat scene's 1 Hz status timer) |
|
|
| a running turn: loop iterations / CPU | 147 / 32.6 ms | 14 / 14.8 ms |
|
|
|
|
Latency on pipes (no real terminal): key to frame 0.2-0.5 ms, a touch from
|
|
another thread to frame 0.2 ms. These are CPU numbers: the app was never slow
|
|
enough to feel, the gain is battery, SSH bandwidth and headroom. Not
|
|
measured: other machines, real terminals' scroll speed, 300x70. Guarded by
|
|
`test_frame_stays_inside_budget_on_large_screen` (full repaint under one
|
|
frame), `test_typing_frame_budget_at_200x50` (5 ms ceiling, 30x slack),
|
|
`test_an_idle_screen_wakes_almost_never` and the latency tests in
|
|
`TuiLoopTests`. 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, and the demo ran
|
|
150 random keys, mouse events and resizes with the oracle on without a
|
|
divergence; key to screen on a real terminal was last measured at 23 ms,
|
|
before the lazy pipeline, and was not re-measured.
|
|
|
|
## Terminal widget notes
|
|
|
|
`TuiVtScreen` also serves the multiplexer, so it keeps scrollback (`history`, a bounded
|
|
deque set by the owner; only full-screen scrolls outside the alternate screen feed it),
|
|
answers cursor-position and device-attribute queries (`take_replies()`), tracks
|
|
application cursor keys, bracketed paste and the window title, draws the DEC line
|
|
charset, repeats characters (`CSI b`) and keeps italic, blink and hidden attributes.
|
|
|
|
`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.
|
|
- `TuiVtScreen` does not reflow on resize: narrowing a screen drops the
|
|
columns that no longer fit, and widening does not bring them back. The
|
|
multiplexer (`mplex.md`) inherits this.
|
|
- 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.
|
|
- Lazy rendering gaps: the scroll on a terminal that does not answer the
|
|
`DECLRMM` probe (tmux, the Linux console) moves whole rows, so the strips
|
|
beside the panel are repaired by the diff; the margin path is verified
|
|
through the emulator only, never on a real xterm, kitty, foot, wezterm or
|
|
iTerm2; state held in an object that is not a list, dict, set or
|
|
`TuiWatched` (and a `TuiWatched` inside a tuple) is not watched and needs a
|
|
new assignment or `touch()` (`TAI_TUI_VERIFY=1` finds the misses); widgets
|
|
other than `TuiLog` and `TuiTerminal` convert their rows on every touch
|
|
(a few rows each, under 0.1 ms).
|
|
- 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.
|