Lazy TUI, remaining gaps closed: no heartbeat, DECLRMM margins, skip-clean layout, watched user state, terminal row reuse, geometry animation

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>
This commit is contained in:
2026-10-12 19:42:47 +02:00
co-authored by Claude Sonnet 5.5
parent b37281390f
commit 07f3b36e32
3 changed files with 853 additions and 157 deletions
+368 -116
View File
@@ -14,6 +14,7 @@ import importlib.util
import hmac
import html
import io
import itertools
import json
import operator
import os
@@ -99,7 +100,7 @@ def _fromisoformat(text):
return parsed.replace(tzinfo=tzinfo)
raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00")
VERSION = "1.35.0"
VERSION = "1.36.0"
WORKER_STEPS = 12
CREATE_SKILL_STEPS = 40
FORK_MAX_DEPTH = 2
@@ -141,6 +142,7 @@ OPENCODE_PARSE_MAX_SCANS = 200
DEVPLACE_LABEL = "devplace"
DEVPLACE_BASE = "https://devplace.net/openai/v1"
DEVPLACE_FREE_ROUTES = ("free", "devii")
PREFERRED_MODEL = "molodetz"
ZEN_LABEL = "zen"
ZEN_BASE = "https://opencode.ai/zen/v1"
ZEN_FREE_MODELS = ("mimo-v2.6-flash-free", "nemotron-3.5-lightning-free", "nemotron-3-ultra-free", "muse-spark-1.3-contributor-free", "muse-spark-1.2-contributor-free", "jev-1.13-free", "exo-free", "space-bunny-free", "longcat-2.5-preview-free", "ling-3.1-flash-free", "ling-3.0-flash-fin-free", "fledge-alpha-free")
@@ -5106,8 +5108,9 @@ class ChatClient:
self.scope = ("default", "main")
self.fixed_key = None
self.provider_pin = None
self.pin_fallback_said = False
self.model_pin = None
self.prefer = None
self.prefer = (DEVPLACE_LABEL, PREFERRED_MODEL)
self.health = ModelHealth(getattr(config, "home", "") or "")
def muse_key(self):
@@ -5250,10 +5253,15 @@ class ChatClient:
return self._apply_pins((usable or [cand for _, cand in ranked]) + cli)
def _apply_pins(self, cands):
pinned = cands
if self.provider_pin:
cands = [cand for cand in cands if (cand["provider"] or "").lower() == self.provider_pin]
pinned = [cand for cand in pinned if (cand["provider"] or "").lower() == self.provider_pin]
if self.model_pin:
cands = [cand for cand in cands if cand["model"] == self.model_pin]
pinned = [cand for cand in pinned if cand["model"] == self.model_pin]
if not pinned and cands and not self.pin_fallback_said:
self.pin_fallback_said = True
print(paint("%s: trying every other backend" % self.pin_note(), Ansi.DIM), file=sys.stderr)
cands = pinned + [cand for cand in cands if not any(cand is item for item in pinned)]
if self.prefer and not self.provider_pin and not self.model_pin:
label, model = self.prefer
first = [cand for cand in cands if cand["provider"] == label and cand["model"] == model]
@@ -16736,7 +16744,12 @@ TUI_FPS = 30
TUI_INPUT_PAINT_GAP = 0.008
TUI_IDLE_MAX = 1.0
TUI_GEOMETRY = ("x", "y", "w", "h")
TUI_DECLRMM = 69
TUI_CELL_KEY = operator.itemgetter(1)
TUI_SWITCH_COST = 40
TUI_MOVE_COST = 8
TUI_SCROLL_SLACK = 80
TUI_MIN_ROWS = 8
@@ -16854,7 +16867,49 @@ def tui_cycle_theme_name(current):
return TUI_THEME_DEFAULT
class TuiStyle:
class TuiWatched:
"""Base of state objects a panel paints from. Assign one to a public
panel attribute (or insert it into a list, dict or set kept there) and a
later write to any of its public fields touches that panel, so a widget
never shows stale cells because someone forgot touch(). Equal writes are
ignored. Subclass it for your own models; fields starting with an
underscore are private and never touch. Copies and pickles do not carry
the owner links."""
def __setattr__(self, name, value):
fields = self.__dict__
if name in fields and fields[name] == value:
return
fields[name] = value
if name[0] != "_":
self._changed()
def _changed(self):
owners = self.__dict__.get("_owners")
if owners:
for panel in list(owners):
panel.touch()
def adopt(self, panel):
"""Register a panel that paints from this object, so a field written
later repaints it."""
owners = self.__dict__.get("_owners")
if owners is None:
owners = self.__dict__["_owners"] = weakref.WeakSet()
owners.add(panel)
def __getstate__(self):
state = dict(self.__dict__)
state.pop("_owners", None)
return state
def __copy__(self):
clone = self.__class__.__new__(self.__class__)
clone.__dict__.update(self.__getstate__())
return clone
class TuiStyle(TuiWatched):
"""Foreground/background/attribute bundle. Colors are names ("red",
"bright_cyan"), 256-color ints, "#rrggbb" strings, or (r, g, b) tuples."""
@@ -16868,24 +16923,9 @@ class TuiStyle:
fields["reverse"] = reverse
fields["strike"] = strike
def __setattr__(self, name, value):
fields = self.__dict__
if name in fields and fields[name] == value:
return
fields[name] = value
fields.pop("_key", None)
owners = fields.get("_owners")
if owners:
for panel in list(owners):
panel.touch()
def adopt(self, panel):
"""Register a panel that paints with this style, so a field written
later repaints it."""
owners = self.__dict__.get("_owners")
if owners is None:
owners = self.__dict__["_owners"] = weakref.WeakSet()
owners.add(panel)
def _changed(self):
self.__dict__.pop("_key", None)
TuiWatched._changed(self)
def key(self):
"""Hashable identity of the look; cached, and any later write to a
@@ -16990,12 +17030,13 @@ class TuiStyle:
return ""
class TuiSpan:
class TuiSpan(TuiWatched):
"""One styled text run: the compositor's paint primitive."""
def __init__(self, text, style=None):
self.text = text
self.style = style or TuiStyle()
fields = self.__dict__
fields["text"] = text
fields["style"] = style or TuiStyle()
def tui_ease(name, progress):
@@ -17067,6 +17108,14 @@ class TuiTick(TuiEvent):
bubble = False
class TuiModeReply(TuiEvent):
"""The terminal's answer to a DECRQM query (mode, state): 1 set, 2 reset,
0 unknown, 3 and 4 fixed. Consumed by the app, never delivered to panels."""
name = "mode_reply"
bubble = False
class TuiMount(TuiEvent):
name = "mount"
bubble = False
@@ -17207,6 +17256,8 @@ class TuiObserved:
to TUI_OBSERVE_DEPTH levels."""
if self._depth < TUI_OBSERVE_DEPTH and (type(value) in TUI_PLAIN or isinstance(value, TUI_OBSERVING)):
return tui_observe(self._owner, value, self._depth + 1)
if isinstance(value, TuiWatched) and self._owner is not None:
value.adopt(self._owner)
return value
def _changed(self):
@@ -17329,14 +17380,25 @@ def tui_observe(owner, value, depth=0):
copy = kind()
if kind is TuiDict:
for key, item in dict.items(value):
dict.__setitem__(copy, key, tui_observe(owner, item, depth + 1) if nested and tui_watchable(item) else item)
dict.__setitem__(copy, key, tui_item(owner, item, depth, nested))
else:
list.extend(copy, [tui_observe(owner, item, depth + 1) if nested and tui_watchable(item) else item for item in value])
list.extend(copy, [tui_item(owner, item, depth, nested) for item in value])
copy._depth = depth
copy._owner = owner
return copy
def tui_item(owner, item, depth, nested):
"""What an observing copy keeps for one item: a nested observing copy of
a container, the item itself after its owner link is set when it is a
TuiWatched, else the item."""
if nested and tui_watchable(item):
return tui_observe(owner, item, depth + 1)
if isinstance(item, TuiWatched):
item.adopt(owner)
return item
def tui_watchable(value):
return type(value) in TUI_PLAIN or isinstance(value, TUI_OBSERVING)
@@ -17427,10 +17489,12 @@ class TuiPanel:
kind = type(value)
if kind in TUI_PLAIN:
value = tui_observe(self, value)
elif kind is TuiStyle:
value.adopt(self)
elif kind in TuiPanel._SCALARS:
pass
elif kind in TUI_OBSERVING and value._owner is not self:
value = tui_observe(self, value)
elif isinstance(value, TuiWatched):
value.adopt(self)
object.__setattr__(self, name, value)
if name[0] == "_" or name in TuiPanel._QUIET:
return
@@ -17452,15 +17516,31 @@ class TuiPanel:
app.relayout(self)
self.touch()
elif name == "visible":
self.forget_measure()
app.relayout(self, restack=True)
elif name == "scroll_x" or name == "scroll_y":
if not self._scroll_in_range(name, value):
app.relayout(self, only=True)
self.touch()
elif name in TuiPanel._RESTACK:
app.restack()
else:
self.touch()
def _scroll_in_range(self, name, value):
"""True when a scroll offset write stays inside the range the last
arrange measured, so nothing needs clamping."""
seen = self._measure_seen
if seen is None or type(value) is not int:
return False
if name == "scroll_x":
return 0 <= value <= max(0, seen[0] - self._content_w())
return 0 <= value <= max(0, seen[1] - self._content_h())
def touch(self):
"""Mark this panel's painted cells stale; only its rect repaints."""
self._cells = None
self.forget_measure()
if self.app is not None:
self.app.damage(self)
@@ -17480,6 +17560,7 @@ class TuiPanel:
self.children.append(child)
if self.app is not None:
child._bind_app(self.app)
self.forget_measure()
if self.app is not None:
self.app.relayout(self, inner=True, restack=True)
return children[0] if len(children) == 1 else children
@@ -17489,6 +17570,7 @@ class TuiPanel:
self.children.remove(child)
child.parent = None
child._unbind_app()
self.forget_measure()
if self.app is not None:
self.app.relayout(self, inner=True, restack=True)
return child
@@ -17611,19 +17693,46 @@ class TuiPanel:
_measure_memo = None
memoize_measure = True
_mcache = None
_stale = None
_arranged = None
_owes_arrange = False
def mark_pending(self):
"""Flag this panel and its ancestors as owing an arrange. The flag
stays until arrange() reaches the panel, so a change made under a
hidden ancestor is still applied when it shows again."""
node = self
while node is not None:
node._owes_arrange = True
node = node.parent
def measured(self):
"""measure() once per layout pass: intrinsic sizes depend on content
only, so the repeated asks of one pass (parent, flow, cross axis,
scroll clamp) share one answer. Outside a pass, a plain measure()."""
scroll clamp) share one answer. Between passes the answer is kept
until a touch of this panel or any descendant drops it, so a partial
pass does not re-measure subtrees nobody changed. Outside a pass, a
plain measure()."""
memo = TuiPanel._measure_memo
if memo is None or not self.memoize_measure:
return self.measure()
size = memo.get(self)
if size is None:
size = memo[self] = self.measure()
size = self._mcache if TuiPanel._stale is not None else None
if size is None:
size = self.measure()
self._mcache = size
memo[self] = size
return size
def forget_measure(self):
"""Drop the kept measure of this panel and of every ancestor."""
node = self
while node is not None:
node._mcache = None
node = node.parent
def measure(self):
"""Intrinsic content size (w, h), chrome excluded."""
widths = []
@@ -17658,6 +17767,11 @@ class TuiPanel:
return self.parent is not None and (self.w == "auto" or self.h == "auto")
def arrange(self, x, y, w, h, clip):
if TuiPanel._stale is not None:
if not self._owes_arrange and self._arrange == (x, y, w, h, clip):
return
TuiPanel._arranged.append(self)
self._owes_arrange = False
self._arrange = (x, y, w, h, clip)
ox, oy = self.offset
self.ax = x + tui_int(ox)
@@ -17928,9 +18042,32 @@ class TuiPanel:
# -- animation --------------------------------------------------- #
def anim_start(self, prop):
"""The number an animation of `prop` starts from. x, y, w and h may
be specs (a percent, "center", "auto", a negative offset), so they
start from where the panel was last placed, which is what the eye sees."""
value = getattr(self, prop, 0)
if prop not in TUI_GEOMETRY:
return float(value or 0)
if not hasattr(self, "ax"):
return float(value) if isinstance(value, (int, float)) else 0.0
if prop == "w":
return float(self.aw - self.chrome_w())
if prop == "h":
return float(self.ah - self.chrome_h())
parent = self.parent
if parent is None:
return float(value) if isinstance(value, (int, float)) else 0.0
edge = 1 if parent.border else 0
pad = parent.padding
ox, oy = self.offset
if prop == "x":
return float(self.ax - tui_int(ox) - (parent.ax + edge + pad[3]))
return float(self.ay - tui_int(oy) - (parent.ay + edge + pad[0]))
def animate(self, prop, to, duration=None, speed=None, easing="in_out_cubic", delay=0.0, on_done=None):
if duration is None:
distance = abs(float(to) - float(getattr(self, prop, 0) or 0))
distance = abs(float(to) - self.anim_start(prop))
duration = distance / float(speed) if speed else 0.4
anim = {"panel": self, "prop": prop, "to": float(to), "duration": max(0.001, float(duration)),
"easing": easing, "delay": max(0.0, float(delay)), "on_done": on_done,
@@ -18052,6 +18189,7 @@ class TuiCompositor:
self.sgr_memo = {}
self.sgr_theme = None
self.stats = {"painted": 0, "composed": 0, "emitted": 0, "frames": 0, "scrolled": 0}
self.margins = False
def invalidate(self):
self.last = None
@@ -18326,14 +18464,21 @@ class TuiCompositor:
@staticmethod
def cost(pairs):
"""Bytes-ish cost of turning each old row segment into its new one:
changed cells plus a cursor move per changed row."""
cells = moves = 0
"""Bytes-ish cost of turning each old row segment into its new one,
counted the way diff_row writes it: the changed cells, a cursor move
per run of changed cells, and a style switch per change of style along
the changed cells (all in C-level passes over the row)."""
total = 0
for new, old in pairs:
if new != old:
moves += 1
cells += sum(map(operator.ne, new, old))
return cells + 6 * moves
if new == old:
continue
mask = list(map(operator.ne, new, old))
changed = sum(mask)
runs = sum(map(operator.gt, mask, [False] + mask))
keys = list(map(TUI_CELL_KEY, itertools.compress(new, mask)))
switches = sum(map(operator.ne, keys, keys[1:])) + 1
total += changed + TUI_MOVE_COST * runs + TUI_SWITCH_COST * switches
return total
def scroll(self, stack, shifts, fresh, cols, rows, out):
"""Turn scroll hints into terminal scrolls where that writes less than
@@ -18369,8 +18514,9 @@ class TuiCompositor:
moved = sum(1 for pos in range(size, height) if new_rows[pos] == last[y0 + pos - size][x0:x1])
if moved < 2:
continue
strips = [(0, y0, x0, y1)] if x0 > 0 else []
if x1 < cols:
bounded = self.margins and (x0 > 0 or x1 < cols)
strips = [] if bounded else [(0, y0, x0, y1)] if x0 > 0 else []
if x1 < cols and not bounded:
strips.append((x1, y0, cols, y1))
strip_blocks = [(rect, self.fill(stack, rect[0], rect[1], rect[2], rect[3], True)) for rect in strips]
blank = [TUI_BLANK] * cols
@@ -18378,19 +18524,32 @@ class TuiCompositor:
def moved_row(y):
"""What the terminal would show in row y after the scroll."""
src = y + size if dy > 0 else y - size
return last[src] if y0 <= src < y1 else blank
if y0 <= src < y1:
return last[src] if not bounded else last[y][:x0] + last[src][x0:x1] + last[y][x1:]
return blank if not bounded else last[y][:x0] + blank[x0:x1] + last[y][x1:]
now = TuiCompositor.cost((new, last[y0 + pos][x0:x1]) for pos, new in enumerate(new_rows))
then = TuiCompositor.cost((new, moved_row(y0 + pos)[x0:x1]) for pos, new in enumerate(new_rows))
for (sx0, _sy0, sx1, _sy1), sblock in strip_blocks:
then += TuiCompositor.cost((new, moved_row(y0 + pos)[sx0:sx1]) for pos, new in enumerate(sblock))
if then * 5 // 4 + 24 + 2 * size >= now:
if then + TUI_SCROLL_SLACK + 2 * size >= now:
continue
opening = "\x1b[0m\x1b[%d;%dr" % (y0 + 1, y1)
closing = "\x1b[r"
column = 1
if bounded:
opening += "\x1b[?%dh\x1b[%d;%ds" % (TUI_DECLRMM, x0 + 1, x1)
closing = "\x1b[?%dl" % TUI_DECLRMM + closing
column = x0 + 1
if dy > 0:
out.append("\x1b[0m\x1b[%d;%dr\x1b[%d;1H%s\x1b[r" % (y0 + 1, y1, y1, "\x1bD" * size))
out.append("%s\x1b[%d;%dH%s%s" % (opening, y1, column, "\x1bD" * size, closing))
else:
out.append("%s\x1b[%d;%dH%s%s" % (opening, y0 + 1, column, "\x1bM" * size, closing))
if bounded:
last[y0:y1] = [moved_row(y) for y in range(y0, y1)]
elif dy > 0:
last[y0:y1] = last[y0 + size:y1] + [[TUI_BLANK] * cols for _row in range(size)]
else:
out.append("\x1b[0m\x1b[%d;%dr\x1b[%d;1H%s\x1b[r" % (y0 + 1, y1, y0 + 1, "\x1bM" * size))
last[y0:y1] = [[TUI_BLANK] * cols for _row in range(size)] + last[y0:y1 - size]
self.stats["scrolled"] += height
fresh.extend(strip_blocks)
@@ -18734,6 +18893,9 @@ def tui_parse_one(buf, final):
return tui_parse_paste(buf, rest)
if final_char == "u":
return tui_parse_kitty(body, rest)
if final_char == "y" and body[:1] == "?" and body[-1:] == "$":
mode, _sep, state = body[1:-1].partition(";")
return (TuiModeReply(mode=tui_int(mode, -1), state=tui_int(state, 0)), rest)
if final_char == "~" and ";" not in body:
return ((TuiKey(key=TUI_TILDE_KEYS.get(body, "unknown"), char=""), rest) if body in TUI_TILDE_KEYS else (None, rest))
if ";" in body:
@@ -18835,6 +18997,9 @@ class TuiVtScreen:
self.cursor_visible = True
self.region_top = 0
self.region_bottom = self.rows - 1
self.lr_mode = False
self.margin_left = 0
self.margin_right = self.cols - 1
self._wrap = False
self._saved = None
self._alt = None
@@ -18951,7 +19116,8 @@ class TuiVtScreen:
elif nxt == "M":
self._wrap = False
if self.cy == self.region_top:
self.scroll_down(self.region_top, self.region_bottom)
if self._inside_margins():
self.scroll_down(self.region_top, self.region_bottom)
else:
self.cy = max(0, self.cy - 1)
elif nxt == "c":
@@ -19029,6 +19195,12 @@ class TuiVtScreen:
if final == "c" and body in ("", "0", ">", ">0"):
self.replies += b"\x1b[>0;0;0c" if body[:1] == ">" else b"\x1b[?1;2c"
return
if final == "p" and body.endswith("$"):
mode = self._params(body[:-1], 1, 0)[0]
state = {25: self.cursor_visible, 1: self.app_cursor, 2004: self.bracketed_paste,
69: self.lr_mode, 1049: self._alt is not None}.get(mode) if private else None
self.replies += ("\x1b[%s%d;%d$y" % ("?" if private else "", mode, 0 if state is None else 1 if state else 2)).encode("ascii")
return
if final == "n" and not private:
code = self._params(body, 1, 0)[0]
if code == 5:
@@ -19116,6 +19288,15 @@ class TuiVtScreen:
if self._last_char:
for _i in range(min(self._params(body, 1)[0], self.cols * self.rows)):
self._ground(self._last_char)
elif final == "s" and self.lr_mode:
left, right = self._params(body, 2)
left = min(self.cols, max(1, left))
right = min(self.cols, max(1, right if body.count(";") else self.cols))
if left < right:
self.margin_left = left - 1
self.margin_right = right - 1
self.cx = 0
self.cy = 0
elif final == "s":
self._saved = (self.cx, self.cy, self.fg, self.bg, self.attr)
elif final == "u":
@@ -19135,6 +19316,10 @@ class TuiVtScreen:
self.app_cursor = state
elif value == 2004 and private:
self.bracketed_paste = state
elif value == 69 and private:
self.lr_mode = state
self.margin_left = 0
self.margin_right = self.cols - 1
elif value in (47, 1047, 1049) and private:
self._alt_screen(state)
@@ -19215,12 +19400,30 @@ class TuiVtScreen:
def line_feed(self):
if self.cy == self.region_bottom:
self.scroll_up(self.region_top, self.region_bottom)
if self._inside_margins():
self.scroll_up(self.region_top, self.region_bottom)
else:
self.cy = min(self.rows - 1, self.cy + 1)
def scroll_up(self, top, bottom):
if self.history is not None and top == 0 and bottom == self.rows - 1 and self._alt is None:
def _margins(self):
"""Inclusive left and right column of the scrolling area: the whole
width unless left/right margin mode (DECLRMM) is on."""
if self.lr_mode:
return (self.margin_left, self.margin_right)
return (0, self.cols - 1)
def _inside_margins(self):
left, right = self._margins()
return left <= self.cx <= right
def scroll_up(self, top, bottom, record=True):
left, right = self._margins()
if left > 0 or right < self.cols - 1:
for row in range(top, bottom):
self.grid[row] = self.grid[row][:left] + self.grid[row + 1][left:right + 1] + self.grid[row][right + 1:]
self.grid[bottom] = self.grid[bottom][:left] + [self.blank()] * (right - left + 1) + self.grid[bottom][right + 1:]
return
if record and self.history is not None and top == 0 and bottom == self.rows - 1 and self._alt is None:
self.history.append(self.grid[0])
self.history_pushed += 1
for row in range(top, bottom):
@@ -19228,6 +19431,12 @@ class TuiVtScreen:
self.grid[bottom] = [self.blank()] * self.cols
def scroll_down(self, top, bottom):
left, right = self._margins()
if left > 0 or right < self.cols - 1:
for row in range(bottom, top, -1):
self.grid[row] = self.grid[row][:left] + self.grid[row - 1][left:right + 1] + self.grid[row][right + 1:]
self.grid[top] = self.grid[top][:left] + [self.blank()] * (right - left + 1) + self.grid[top][right + 1:]
return
for row in range(bottom, top, -1):
self.grid[row] = self.grid[row - 1]
self.grid[top] = [self.blank()] * self.cols
@@ -19261,19 +19470,17 @@ class TuiVtScreen:
def _insert_lines(self, count):
count = min(max(1, count), self.region_bottom - self.cy + 1)
if self.cy < self.region_top or self.cy > self.region_bottom:
if self.cy < self.region_top or self.cy > self.region_bottom or not self._inside_margins():
return
for _i in range(count):
self.grid.insert(self.cy, [self.blank()] * self.cols)
del self.grid[self.region_bottom + 1]
self.scroll_down(self.cy, self.region_bottom)
def _delete_lines(self, count):
count = min(max(1, count), self.region_bottom - self.cy + 1)
if self.cy < self.region_top or self.cy > self.region_bottom:
if self.cy < self.region_top or self.cy > self.region_bottom or not self._inside_margins():
return
for _i in range(count):
del self.grid[self.cy]
self.grid.insert(self.region_bottom, [self.blank()] * self.cols)
self.scroll_up(self.cy, self.region_bottom, False)
def _delete_chars(self, count):
row = self.grid[self.cy]
@@ -19305,6 +19512,9 @@ class TuiVtScreen:
self.cursor_visible = True
self.region_top = 0
self.region_bottom = self.rows - 1
self.lr_mode = False
self.margin_left = 0
self.margin_right = self.cols - 1
self._wrap = False
self._saved = None
self._alt = None
@@ -19327,6 +19537,9 @@ class TuiVtScreen:
self.cols = cols
self.region_top = 0
self.region_bottom = rows - 1
self.lr_mode = False
self.margin_left = 0
self.margin_right = cols - 1
self._clamp_cursor()
self._wrap = False
@@ -20529,6 +20742,7 @@ class TuiTerminal(TuiPanel):
self.fd = None
self.exit_status = None
self.error = ""
self._row_memo = {}
self._pending = bytearray()
def measure(self):
@@ -20649,18 +20863,28 @@ class TuiTerminal(TuiPanel):
return rows
show_cursor = self.app is not None and self.app.focused is self and self.screen.cursor_visible and self.exit_status is None
rows = []
memo = self._row_memo
if len(memo) > TUI_ROW_MEMO:
memo.clear()
base_key = self.style.key()
for row in range(min(height, self.screen.rows)):
cells = []
for col in range(min(width, self.screen.cols)):
cell = self.screen.grid[row][col]
char = cell[0] if cell else " "
style = TuiVtScreen.style_for(cell, self.style)
if show_cursor and row == self.screen.cy and col == self.screen.cx:
style = style.merged(TuiStyle(reverse=True))
cells.append((char, style))
while len(cells) < width:
cells.append((" ", self.style))
rows.append(tui_runs(cells))
grid_row = self.screen.grid[row]
cursor_x = self.screen.cx if show_cursor and row == self.screen.cy else -1
key = (tuple(grid_row[:width]), width, base_key, cursor_x)
spans = memo.get(key)
if spans is None:
cells = []
for col in range(min(width, self.screen.cols)):
cell = grid_row[col]
char = cell[0] if cell else " "
style = TuiVtScreen.style_for(cell, self.style)
if col == cursor_x:
style = style.merged(TuiStyle(reverse=True))
cells.append((char, style))
while len(cells) < width:
cells.append((" ", self.style))
spans = memo[key] = tui_runs(cells)
rows.append(spans)
while len(rows) < height:
rows.append([TuiSpan(" " * width, self.style)])
return rows
@@ -20780,6 +21004,8 @@ class TuiApp:
self._resize_pending = False
self._saved_termios = None
self._old_winch = None
self._old_chld = None
self._chld_set = False
self._loop = None
self._failure = None
self._input_seen = False
@@ -20828,21 +21054,24 @@ class TuiApp:
LAYOUT_STARTS = 64
def relayout(self, panel, inner=False, restack=False):
def relayout(self, panel, inner=False, restack=False, only=False):
"""Thread-safe and precise: only this panel's subtree is arranged
again. inner=False means the panel's own size, position or chrome
changed, so its parent lays it out again; inner=True means its
children (or content) changed, so it lays them out again. Either way
the work climbs only while the panel's size follows its content
(content_sized), and stops at the first fixed-size panel. restack
marks a panel added, removed, shown or hidden."""
marks a panel added, removed, shown or hidden. only=True arranges just
this panel again, in the box it already has (its scroll offsets
changed, so they need clamping)."""
with self._lock:
self._need_layout = True
self._need_paint = True
if len(self._layout_starts) >= TuiApp.LAYOUT_STARTS:
self._layout_full = True
else:
self._layout_starts.append((panel, "inner" if inner else "own"))
self._layout_starts.append((panel, "self" if only else "inner" if inner else "own"))
panel.mark_pending()
if restack:
self._restack = True
self._tree_version += 1
@@ -21068,6 +21297,7 @@ class TuiApp:
restack, self._restack = self._restack, False
starts, self._layout_starts = self._layout_starts, []
watch, self._watch = self._watch, set()
touched = list(self._damage)
for panel in watch:
seen = panel._measure_seen
size = panel.measure()
@@ -21079,6 +21309,12 @@ class TuiApp:
if roots is None:
everything, roots = True, [self.root]
TuiPanel._measure_memo = {}
arranged = []
if not everything:
for panel in [item[0] for item in starts] + touched:
panel.mark_pending()
TuiPanel._stale = True
TuiPanel._arranged = arranged
try:
for node in roots:
if everything:
@@ -21087,13 +21323,15 @@ class TuiApp:
node.arrange(*node._arrange)
finally:
TuiPanel._measure_memo = None
TuiPanel._stale = None
TuiPanel._arranged = None
self.layouts["full" if everything else "partial"] += 1
if self.verify and not everything:
self._verify_layout()
if everything or restack or self._geometry_changed_membership(roots):
if everything or restack or self._geometry_changed_membership(arranged):
self._restack_diff()
else:
self._geometry_diff(roots)
self._geometry_diff(arranged)
self.hitmap.rebuild(self._stack)
def _layout_roots(self, starts):
@@ -21124,43 +21362,30 @@ class TuiApp:
roots.append(node)
return roots
@staticmethod
def _shown_subtree(node):
"""node and every descendant that arrange() reaches."""
out = []
pending = [node]
while pending:
top = pending.pop()
out.append(top)
pending.extend(child for child in top.children if child.visible)
return out
def _geometry_changed_membership(self, roots):
def _geometry_changed_membership(self, arranged):
"""True when a re-arranged panel entered or left the stack (it became
zero-sized, or was zero-sized and grew)."""
for root in roots:
for node in TuiApp._shown_subtree(root):
listed = node._geom is not None
if listed != (node.aw > 0 and node.ah > 0):
return True
for node in arranged:
listed = node._geom is not None
if listed != (node.aw > 0 and node.ah > 0):
return True
return False
def _geometry_diff(self, roots):
def _geometry_diff(self, arranged):
"""Same stack: damage the old and new rects of the moved or resized
panels among the re-arranged subtrees."""
panels among the panels this pass arranged."""
rects = self._geom_rects
for root in roots:
for node in TuiApp._shown_subtree(root):
geom = (node.ax, node.ay, node.aw, node.ah, node.clip)
was = node._geom
self.layouts["arranged"] += 1
if was is None or was == geom:
continue
rects.append(was[4])
rects.append(geom[4])
if was[2:4] != geom[2:4]:
node._cells = None
node._geom = geom
for node in arranged:
geom = (node.ax, node.ay, node.aw, node.ah, node.clip)
was = node._geom
self.layouts["arranged"] += 1
if was is None or was == geom:
continue
rects.append(was[4])
rects.append(geom[4])
if was[2:4] != geom[2:4]:
node._cells = None
node._geom = geom
def _restack_diff(self):
"""The stack may have changed: rebuild it and damage what differs."""
@@ -21313,6 +21538,10 @@ class TuiApp:
else:
self.root.emit(event)
return
if isinstance(event, TuiModeReply):
if event.mode == TUI_DECLRMM and event.state in (1, 2):
self.compositor.margins = True
return
if isinstance(event, TuiClick):
self._dispatch_click(event)
return
@@ -21365,12 +21594,14 @@ class TuiApp:
panel = anim["panel"]
if anim["started"] is None:
anim["started"] = now
anim["from"] = float(getattr(panel, anim["prop"], 0) or 0)
anim["from"] = panel.anim_start(anim["prop"])
elapsed = now - anim["started"] - anim["delay"]
if elapsed < 0:
continue
progress = min(1.0, elapsed / anim["duration"])
value = anim["from"] + (anim["to"] - anim["from"]) * tui_ease(anim["easing"], progress)
if anim["prop"] in TUI_GEOMETRY:
value = int(round(value))
try:
setattr(panel, anim["prop"], value)
except (AttributeError, TypeError, ValueError):
@@ -21420,6 +21651,17 @@ class TuiApp:
def _on_winch(self, _signum, _frame):
self._resize_pending = True
self._signal_wake()
def _on_chld(self, signum, frame):
"""A child exited: wake the loop so a terminal notices its exit status
even when a grandchild still holds the pty open."""
self._signal_wake()
previous = self._old_chld
if callable(previous):
previous(signum, frame)
def _signal_wake(self):
loop, wake = self._loop, self._wake
if loop is not None and wake is not None:
try:
@@ -21442,8 +21684,14 @@ class TuiApp:
self._old_winch = signal.signal(signal.SIGWINCH, self._on_winch)
except (ValueError, OSError):
self._old_winch = None
if hasattr(signal, "SIGCHLD"):
try:
self._old_chld = signal.signal(signal.SIGCHLD, self._on_chld)
self._chld_set = True
except (ValueError, OSError):
self._old_chld = None
out = sys.stdout.fileno()
os.write(out, (TUI_ALT_ENTER + TUI_MOUSE_ENABLE + TUI_KITTY_ENABLE + TUI_PASTE_ENABLE + "\x1b[?25l").encode("utf-8"))
os.write(out, (TUI_ALT_ENTER + TUI_MOUSE_ENABLE + TUI_KITTY_ENABLE + TUI_PASTE_ENABLE + "\x1b[?25l" + "\x1b[?%d$p" % TUI_DECLRMM).encode("utf-8"))
def _teardown(self):
try:
@@ -21456,6 +21704,12 @@ class TuiApp:
signal.signal(signal.SIGWINCH, self._old_winch)
except (ValueError, OSError):
pass
if self._chld_set:
self._chld_set = False
try:
signal.signal(signal.SIGCHLD, self._old_chld if self._old_chld is not None else signal.SIG_DFL)
except (ValueError, OSError):
pass
if self._saved_termios is not None and termios is not None:
try:
termios.tcsetattr(sys.stdin.fileno(), termios.TCSADRAIN, self._saved_termios)
@@ -21565,6 +21819,7 @@ class TuiApp:
def _on_term_writable(self, term):
term.drain_pending()
self.wake()
def _sync_terminals(self):
"""Make loop readers/writers match the live terminals: add fds of new
@@ -21606,8 +21861,8 @@ class TuiApp:
"""The event loop. It sleeps until something is due: the next timer,
the next animation or ticker frame, a pending partial key sequence,
or a wake() (a touch from any thread, a pty, a signal). A quiet screen
costs no wakeups beyond the TUI_IDLE_MAX heartbeat, and paints are
spaced at least one frame apart."""
costs no wakeups at all, and paints are spaced at least one frame
apart."""
loop = asyncio.get_running_loop()
self._loop = loop
self._wake = asyncio.Event()
@@ -21665,19 +21920,18 @@ class TuiApp:
def _idle_delay(self, frame, started):
"""Seconds the loop may sleep: zero when a frame is already due, one
frame while something animates or ticks or a key sequence is half
read, else until the next timer, never past TUI_IDLE_MAX."""
read, else until the next timer, or None (until a wake) with no timer."""
now = time.time()
if self._need_paint or self._need_layout or self._resize_pending:
return max(0.0, self._last_frame + frame - now)
if self.animations or self.tickers() or self.input.buf:
return max(0.0, frame - (now - started))
delay = TUI_IDLE_MAX
for deadline, _interval, _callback, _seq in self.timers:
delay = min(delay, deadline - now)
return max(0.0, delay)
if not self.timers:
return None
return max(0.0, min(deadline for deadline, _i, _c, _s in self.timers) - now)
async def _sleep(self, delay):
if delay <= 0:
if delay is not None and delay <= 0:
await asyncio.sleep(0)
return
try:
@@ -21900,7 +22154,6 @@ def tui_chat_sidebar_w(cols):
TUI_CHAT_BUBBLE_MAX = 0.7
TUI_PREFERRED_MODEL = "molodetz"
TUI_CHAT_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
TUI_CHAT_ONLINE_GLYPH = "●"
TUI_CHAT_TYPE_ANSWER = object()
@@ -23117,7 +23370,6 @@ def tui_build_chat(app, agent, spawn=None):
def tui_chat(agent, theme_name):
tui_set_theme(theme_name)
agent.quiet = True
agent.chat.prefer = (DEVPLACE_LABEL, TUI_PREFERRED_MODEL)
app = TuiApp()
app.rows, app.cols = app._winsize()
scheduler_stop = start_scheduler(agent.config, agent.store.seal)
+406 -4
View File
@@ -2405,15 +2405,21 @@ class MentionTests(unittest.TestCase):
with mock.patch.object(self.agent.chat, "candidates", return_value=cands):
self.agent.chat.provider_pin = "opencode"
got = self.agent.chat.ordered_candidates()
self.assertEqual([cand["provider"] for cand in got], ["opencode"])
self.assertEqual([cand["provider"] for cand in got], ["opencode", "ollama"])
self.agent.chat.provider_pin = None
self.agent.chat.model_pin = "b"
got = self.agent.chat.all_candidates()
self.assertEqual([cand["model"] for cand in got], ["b"])
self.assertEqual([cand["model"] for cand in got], ["b", "a"])
def test_empty_candidates_raise(self):
cands = [{"provider": "opencode", "chat_url": "opencode://run", "models_url": "x", "key": "", "model": "a", "profile": ""}]
def test_unmatched_pin_falls_back_to_all(self):
cands = [{"provider": "ollama", "chat_url": "http://x", "models_url": "y", "key": "", "model": "b", "profile": ""}]
with mock.patch.object(self.agent.chat, "candidates", return_value=cands):
self.agent.chat.provider_pin = "no-such-provider"
got = self.agent.chat.ordered_candidates()
self.assertEqual([cand["model"] for cand in got], ["b"])
def test_no_candidates_raise(self):
with mock.patch.object(self.agent.chat, "candidates", return_value=[]):
self.agent.chat.provider_pin = "no-such-provider"
with self.assertRaises(tai.BackendError) as raised:
self.agent.chat.complete([{"role": "user", "content": "hi"}])
@@ -5640,6 +5646,7 @@ class TuiScrollTests(unittest.TestCase):
rng = random.Random(seed)
app, log, vt = self.build(rows=rng.randrange(12, 40), cols=rng.randrange(40, 110), lines=rng.randrange(40, 200),
border=rng.random() < .4, padding=rng.choice([0, 0, 1]), cls=rng.choice([tai.TuiLog, tai.TuiChatLog]))
app.compositor.margins = seed % 2 == 1
dialogs = []
for step in range(120):
op = rng.choice(("wheel", "wheel", "wheel", "append", "page", "dialog", "close", "resize", "scrollx"))
@@ -5719,6 +5726,401 @@ class TuiScrollTests(unittest.TestCase):
self.assertLessEqual(len(log._row_memo), tai.TUI_ROW_MEMO)
class TuiMarginTests(unittest.TestCase):
"""Left/right margins (DECLRMM): a terminal that answers the probe scrolls
only the log's columns, so nothing beside it is touched or repaired."""
chars = staticmethod(TuiScrollTests.chars)
build = TuiScrollTests.build
same = TuiScrollTests.same
def margined(self, **kwargs):
app, log, vt = self.build(**kwargs)
app.compositor.margins = True
return app, log, vt
def test_the_virtual_terminal_scrolls_inside_margins_only(self):
vt = tai.TuiVtScreen(6, 10)
for row in range(6):
vt.feed(("\x1b[%d;1H%s" % (row + 1, str(row) * 10)).encode("ascii"))
vt.feed(b"\x1b[?69h\x1b[3;7s\x1b[2;4r\x1b[4;3H\x1bD\x1b[?69l\x1b[r")
self.assertEqual(vt.grid[1][0][0], "1")
self.assertEqual(vt.grid[1][1][0], "1")
self.assertEqual(vt.grid[1][2][0], "2")
self.assertEqual(vt.grid[2][2][0], "3")
self.assertEqual(vt.grid[3][2][0], " ")
self.assertEqual(vt.grid[3][7][0], "3")
self.assertEqual(vt.grid[1][8][0], "1")
def test_the_virtual_terminal_answers_the_mode_query(self):
vt = tai.TuiVtScreen(4, 10)
vt.feed(b"\x1b[?69$p")
self.assertEqual(vt.take_replies(), b"\x1b[?69;2$y")
vt.feed(b"\x1b[?69h\x1b[?69$p\x1b[?7777$p")
self.assertEqual(vt.take_replies(), b"\x1b[?69;1$y\x1b[?7777;0$y")
def test_the_reply_to_the_probe_turns_margins_on_and_nothing_else_does(self):
for text, want in (("\x1b[?69;2$y", True), ("\x1b[?69;1$y", True), ("\x1b[?69;0$y", False),
("\x1b[?69;4$y", False), ("\x1b[?25;1$y", False)):
app = tai.TuiApp()
app.rows, app.cols = 10, 20
app.mount(tai.TuiPanel(flow=None))
for event in app.input.feed(text.encode("ascii")):
app.dispatch(event)
self.assertIs(app.compositor.margins, want, text)
def test_margins_keep_the_strips_out_of_the_scroll_and_stay_exact(self):
import random
rng = random.Random(5)
for cls in (tai.TuiLog, tai.TuiChatLog):
app, log, vt = self.margined(cls=cls)
for _step in range(100):
app.dispatch(tai.TuiScroll(x=log.ax + 2, y=log.ay + 2, direction=rng.choice(("up", "down", "up"))))
out = app.frame_bytes()
vt.feed(out.encode("utf-8"))
self.same(app, vt)
self.assertGreater(app.compositor.stats["scrolled"], 500, cls.__name__)
def test_margins_write_less_than_repairing_the_strips(self):
written = {}
for margins in (False, True):
app, log, vt = self.build(rows=24, cols=80, lines=300)
app.compositor.margins = margins
log.scroll_by(0, -3)
app.frame_bytes()
app.verify = False
log.scroll_by(0, -3)
out = app.frame_bytes()
vt.feed(out.encode("utf-8"))
written[margins] = len(out)
self.assertEqual("\x1b[?69h" in out, margins)
self.assertLess(written[True], written[False])
def test_a_narrow_screen_that_was_a_wash_now_scrolls(self):
app, log, vt = self.margined(rows=24, cols=80, lines=300)
log.scroll_by(0, -3)
app.frame_bytes()
app.verify = False
log.scroll_by(0, -3)
out = app.frame_bytes()
self.assertIn("\x1b[?69h", out)
self.assertIn("\x1b[?69l", out)
def test_a_scroll_that_misreports_the_margin_model_is_caught(self):
app, log, vt = self.margined()
compositor_scroll = tai.TuiCompositor.scroll
def broken(self, stack, shifts, fresh, cols, rows, out):
believed = [list(row) for row in self.last]
before = self.stats["scrolled"]
compositor_scroll(self, stack, shifts, fresh, cols, rows, out)
if self.stats["scrolled"] != before:
self.last[:] = believed
tai.TuiCompositor.scroll = broken
try:
app.verify = False
with self.assertRaises(AssertionError):
for _n in range(10):
app.dispatch(tai.TuiScroll(x=log.ax + 2, y=log.ay + 2, direction="up"))
vt.feed(app.frame_bytes().encode("utf-8"))
self.same(app, vt)
finally:
tai.TuiCompositor.scroll = compositor_scroll
class TuiClosedGapsTests(unittest.TestCase):
"""The gaps v1.35 listed as open: geometry animation, the idle heartbeat,
watched user state, partial layout through all-auto trees, terminal row
reuse and the scroll cost model."""
def app(self, rows=12, cols=50):
app = tai.TuiApp()
app.rows, app.cols = rows, cols
app.verify = True
return app
def run_animation(self, app, panel, prop, to):
anim = panel.animate(prop, to, duration=1.0, easing="linear")
now = 100.0
app.tick_animations(now)
values = []
for step in range(1, 11):
app.tick_animations(now + step / 10.0)
app.frame_bytes()
values.append(getattr(panel, prop))
return anim, values
def test_animating_x_y_w_h_moves_and_sizes_the_panel(self):
app = self.app()
root = tai.TuiPanel(flow=None)
box = tai.TuiPanel(x="50%", y="center", w="auto", h=2, border=True)
box.add(tai.TuiText("hello world"))
root.add(box)
app.mount(root)
app.frame_bytes()
start_x, start_y = box.ax, box.ay
self.assertGreater(start_x, 0)
_anim, values = self.run_animation(app, box, "x", 4)
self.assertEqual(values[-1], 4)
self.assertTrue(all(isinstance(value, int) for value in values))
self.assertEqual(values, sorted(values, reverse=True))
self.assertEqual(box.ax, 4)
_anim, values = self.run_animation(app, box, "y", 1)
self.assertEqual((box.ay, values[-1]), (1, 1))
_anim, values = self.run_animation(app, box, "h", 6)
self.assertEqual((box.ah - 2, values[-1]), (6, 6))
_anim, values = self.run_animation(app, box, "w", 20)
self.assertEqual((box.aw - 2, values[-1]), (20, 20))
def test_a_negative_offset_and_a_percent_start_from_where_they_are_drawn(self):
app = self.app(10, 40)
root = tai.TuiPanel(flow=None)
box = tai.TuiPanel(x=-12, y=0, w=10, h=3)
root.add(box)
app.mount(root)
app.frame_bytes()
self.assertEqual(box.anim_start("x"), 18.0)
self.assertEqual(box.anim_start("w"), 10.0)
self.assertEqual(box.anim_start("scroll_y"), 0.0)
pct = tai.TuiPanel(x=0, y=0, w="25%", h="50%")
root.add(pct)
app.frame_bytes()
self.assertEqual((pct.anim_start("w"), pct.anim_start("h")), (10.0, 5.0))
def test_no_timer_means_the_loop_sleeps_until_woken(self):
app = self.app()
app.mount(tai.TuiPanel(flow=None))
app.frame_bytes()
self.assertIsNone(app._idle_delay(1 / 30, time.time()))
handle = app.after(5.0, lambda: None)
delay = app._idle_delay(1 / 30, time.time())
self.assertTrue(0 < delay <= 5.0, delay)
self.assertFalse(hasattr(tai, "TUI_IDLE_MAX"))
def test_a_child_exit_signal_wakes_the_loop(self):
import signal
import threading
import asyncio
app = self.app()
app.mount(tai.TuiPanel(flow=None))
stdin_r, stdin_w = os.pipe()
out_r, out_w = os.pipe()
app._stdin_fd, app._stdout_fd = stdin_r, out_w
seen = []
app._old_chld = lambda signum, frame: seen.append(signum)
def driver():
time.sleep(0.2)
before = app.iterations
app._on_chld(signal.SIGCHLD, None)
time.sleep(0.2)
seen.append(app.iterations - before)
app.quit()
thread = threading.Thread(target=driver, daemon=True)
thread.start()
try:
asyncio.run(app._main())
finally:
thread.join(timeout=5)
for fd in (stdin_r, stdin_w, out_r, out_w):
os.close(fd)
self.assertEqual(seen[0], signal.SIGCHLD)
self.assertGreaterEqual(seen[1], 1)
class Model(tai.TuiWatched):
def __init__(self):
self.title = "a"
self.count = 1
self._cache = 0
class Card(tai.TuiPanel):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.model = TuiClosedGapsTests.Model()
self.models = [TuiClosedGapsTests.Model()]
self.by_name = {"k": TuiClosedGapsTests.Model()}
def content_rows(self):
return [[tai.TuiSpan("%s %d" % (self.model.title, self.model.count), self.style)]]
def card(self):
app = self.app(6, 30)
root = tai.TuiPanel(flow=None)
card = self.Card(x=0, y=0, w=20, h=2)
root.add(card)
app.mount(root)
app.frame_bytes()
return app, card
def test_a_field_of_a_watched_object_touches_every_panel_that_holds_it(self):
for label, target in (("attribute", lambda c: c.model), ("list item", lambda c: c.models[0]), ("dict value", lambda c: c.by_name["k"])):
app, card = self.card()
app.frame_bytes()
self.assertNotIn(card, app._damage)
target(card).title = "b"
self.assertIn(card, app._damage, label)
app.frame_bytes()
target(card).title = "b"
self.assertNotIn(card, app._damage, label + " equal write")
target(card)._cache = 5
self.assertNotIn(card, app._damage, label + " private write")
def test_a_watched_object_inserted_later_is_adopted(self):
app, card = self.card()
late = self.Model()
card.models.append(late)
card.by_name["late"] = self.Model()
card.models.extend([self.Model()])
for item in (late, card.by_name["late"], card.models[-1]):
app.frame_bytes()
self.assertNotIn(card, app._damage)
item.count = 50
self.assertIn(card, app._damage)
def test_a_watched_object_shown_by_two_panels_touches_both_and_a_copy_is_free(self):
import copy
import pickle
app = self.app(6, 30)
root = tai.TuiPanel(flow=None)
one = self.Card(x=0, y=0, w=10, h=1)
two = self.Card(x=0, y=2, w=10, h=1)
root.add(one, two)
app.mount(root)
app.frame_bytes()
shared = self.Model()
one.model = shared
two.model = shared
app.frame_bytes()
shared.count = 9
self.assertIn(one, app._damage)
self.assertIn(two, app._damage)
for clone in (copy.copy(shared), pickle.loads(pickle.dumps(shared)), copy.deepcopy(shared)):
app.frame_bytes()
clone.count = 77
self.assertEqual(app._damage, set())
self.assertEqual(shared.count, 9)
def test_spans_and_styles_are_watched_objects(self):
app, card = self.card()
self.assertTrue(issubclass(tai.TuiSpan, tai.TuiWatched))
self.assertTrue(issubclass(tai.TuiStyle, tai.TuiWatched))
card.spans = [tai.TuiSpan("x")]
app.frame_bytes()
card.spans[0].text = "y"
self.assertIn(card, app._damage)
def test_a_partial_layout_through_an_all_auto_tree_arranges_only_the_path(self):
app = self.app(40, 100)
root = tai.TuiRows(w="auto", h="auto")
texts = []
for branch in range(12):
column = tai.TuiRows(w="auto", h="auto")
inner = tai.TuiRows(w="auto", h="auto")
for leaf in range(6):
text = tai.TuiText("leaf %d %d" % (branch, leaf), w="auto", h="auto")
inner.add(text)
texts.append(text)
column.add(inner)
root.add(column)
app.mount(root)
app.frame_bytes()
total = len(list(root.walk()))
before = dict(app.layouts)
texts[40].text = "a much longer leaf than before, to grow its row"
app.frame_bytes()
self.assertEqual(app.layouts["full"], before["full"])
arranged = app.layouts["arranged"] - before["arranged"]
self.assertGreater(arranged, 0)
self.assertLess(arranged, total // 4, "arranged %d of %d panels" % (arranged, total))
def test_a_change_made_under_a_hidden_branch_is_applied_when_it_shows(self):
app = self.app(20, 60)
root = tai.TuiRows()
branch = tai.TuiRows(id="branch")
inner = tai.TuiRows()
leaf = tai.TuiText("deep", offset=(0, 0))
inner.add(leaf)
branch.add(inner)
root.add(tai.TuiText("above"), branch, tai.TuiText("below"))
app.mount(root)
app.frame_bytes()
branch.visible = False
app.frame_bytes()
leaf.offset = (3, 1)
app.frame_bytes()
branch.visible = True
app.frame_bytes()
self.assertEqual((leaf.ax - inner.ax, leaf.ay - inner.ay), (3, 1))
def test_a_scroll_offset_written_past_the_end_is_clamped_by_the_next_frame(self):
app = self.app(10, 40)
root = tai.TuiPanel(flow=None)
log = tai.TuiLog(x=0, y=0, w=30, h=5)
for n in range(8):
log.append("row %d" % n)
root.add(log)
app.mount(root)
app.frame_bytes()
log.scroll_y = 500
app.frame_bytes()
self.assertEqual(log.scroll_y, 3)
log.scroll_x = 7
app.frame_bytes()
self.assertEqual(log.scroll_x, 0)
before = app.layouts["partial"] + app.layouts["full"]
log.scroll_y = 1
app.frame_bytes()
self.assertEqual(app.layouts["partial"] + app.layouts["full"], before, "an in-range scroll asked for a layout")
def test_a_terminal_reuses_the_rows_that_did_not_change(self):
app = self.app(12, 40)
root = tai.TuiPanel(flow=None)
term = tai.TuiTerminal(x=0, y=0, w=30, h=8, border=False)
root.add(term)
app.mount(root)
term.screen.resize(8, 30)
for n in range(8):
term.screen.feed(("\x1b[3%dmline %d\r\n" % (n % 7, n)).encode())
first = term.content_rows()
term.screen.feed(b"\x1b[8;1H\x1b[2Kchanged")
second = term.content_rows()
same = sum(1 for a, b in zip(first, second) if a is b)
self.assertGreaterEqual(same, 6)
self.assertIsNot(first[7], second[7])
self.assertEqual("".join(span.text for span in second[7]).rstrip(), "changed")
for _n in range(tai.TUI_ROW_MEMO + 10):
term.screen.feed(("\x1b[1;1H%d" % _n).encode())
term.content_rows()
self.assertLessEqual(len(term._row_memo), tai.TUI_ROW_MEMO + 1)
def test_a_terminal_cursor_row_is_not_confused_with_the_same_row_elsewhere(self):
app = self.app(12, 40)
root = tai.TuiPanel(flow=None)
term = tai.TuiTerminal(x=0, y=0, w=30, h=4, border=False)
root.add(term)
app.mount(root)
term.screen.resize(4, 30)
term.focus()
term.screen.feed(b"\x1b[1;1Habc\x1b[2;1Habc\x1b[1;1H")
rows = term.content_rows()
self.assertEqual(sum(1 for span in rows[0] if span.style.reverse), 1)
self.assertEqual(sum(1 for span in rows[1] if span.style.reverse), 0)
def test_the_scroll_cost_counts_runs_and_style_switches_like_the_diff_does(self):
red, blue = ("r", "red"), ("b", "blue")
a = [("x", 1)] * 10
self.assertEqual(tai.TuiCompositor.cost([(a, a)]), 0)
one_run = [("y", 1)] * 3 + [("x", 1)] * 7
two_runs = [("y", 1)] * 2 + [("x", 1)] + [("y", 1)] * 2 + [("x", 1)] * 5
switched = [("y", 1)] * 3 + [("y", 2)] * 2 + [("x", 1)] * 5
base = tai.TuiCompositor.cost([(one_run, a)])
self.assertEqual(base, 3 + tai.TUI_MOVE_COST + tai.TUI_SWITCH_COST)
self.assertEqual(tai.TuiCompositor.cost([(two_runs, a)]), 4 + 2 * tai.TUI_MOVE_COST + tai.TUI_SWITCH_COST)
self.assertEqual(tai.TuiCompositor.cost([(switched, a)]), 5 + tai.TUI_MOVE_COST + 2 * tai.TUI_SWITCH_COST)
class TuiLoopTests(unittest.TestCase):
"""The main loop sleeps until something is due. These run the real _main
on pipes (a stand-in for the terminal) and measure wakeups and latency."""
+79 -37
View File
@@ -59,7 +59,7 @@ buttons, Esc closes dialogs, `q` quits demo apps, Ctrl+C always quits.
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()`, never longer than `TUI_IDLE_MAX` (1 s heartbeat). A touch from any thread, a pty read, SIGWINCH, 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.
- **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
@@ -95,9 +95,15 @@ that did not change. Eight layers, each with a hard cap:
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, `children` (use `add`/`remove`), and
state in other objects (a `TuiSpan`, your own class): assign a new value
or call `touch()`. `TAI_TUI_VERIFY=1` finds the misses.
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
@@ -122,7 +128,17 @@ that did not change. Eight layers, each with a hard cap:
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 touched `auto` panel is
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
@@ -135,10 +151,17 @@ that did not change. Eight layers, each with a hard cap:
(`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. It is only taken when the estimate
says it writes less than rewriting the cells (at least 2 rows must match;
cells and cursor moves of the rect and of the repaired strips are counted
exactly, with a 25% margin for style switches),
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.
@@ -150,7 +173,11 @@ that did not change. Eight layers, each with a hard cap:
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: the largest of them is a few rows and costs under 0.1 ms.
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
@@ -159,6 +186,11 @@ that did not change. Eight layers, each with a hard cap:
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
@@ -177,7 +209,11 @@ 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. Run the whole suite with
`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
@@ -284,7 +320,9 @@ 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
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
@@ -474,6 +512,9 @@ length does not matter). "Before" is v1.33.1, whose every dirty frame cost
| 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
@@ -482,23 +523,30 @@ scroll less to win and the estimate then declines it):
| frame | size | v1.34.0 | now |
|---|---|---|---|
| 3-line scroll | 80x24 | 1388 B | 1354 B |
| 3-line scroll | 120x40 | 3585 B | 1576 B |
| 3-line scroll | 200x50 | 7905 B | 1822 B |
| 1-line scroll | 80x24 | 1327 B | 822 B |
| 1-line scroll | 120x40 | 3223 B | 883 B |
| 1-line scroll | 200x50 | 6496 B | 966 B |
| 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) |
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; it wins as the
transcript gets wider.
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 | 7 / 4.0 ms |
| 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
@@ -547,21 +595,15 @@ the widget shows a plain error line instead of crashing.
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: a terminal scroll moves whole rows, so the strips
beside a scrolled panel are repaired by the diff (no left/right margins,
`DECLRMM` is not probed); only `TuiLog` and its subclasses reuse converted
rows or hint scrolls, other widgets repaint whole on a touch (all are a
few rows); the cost model that accepts a scroll counts cells and cursor
moves, not style switches, hence a 25% margin; layout climbs to the first
panel with a fixed size on both axes, so a tree of all-`auto` panels
arranges from the root; state held in objects other than list, dict, set
and `TuiStyle` (a `TuiSpan`, a class of yours) is not watched and needs a
new assignment or `touch()` (`TAI_TUI_VERIFY=1` finds the misses); the loop
still wakes once a second as a heartbeat.
- `animate()` tweens through floats, and positions and sizes only read ints
(`_place_pos`, `_fixed_len`): animating `x`, `y`, `w` or `h` places the
panel at 0 or sizes it 0 instead of moving it. Found while testing the
loop; animating other numeric attributes (`value`, `scroll_y`) works.
- 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