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:
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user