diff --git a/tai.py b/tai.py index e5864de..e3cc0e5 100755 --- a/tai.py +++ b/tai.py @@ -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) diff --git a/test_tai.py b/test_tai.py index e8aa5bd..1f704a9 100644 --- a/test_tai.py +++ b/test_tai.py @@ -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.""" diff --git a/tui.md b/tui.md index bb5c263..58a0c78 100644 --- a/tui.md +++ b/tui.md @@ -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