diff --git a/tai.py b/tai.py index 0544f31..b4a5668 100755 --- a/tai.py +++ b/tai.py @@ -9330,6 +9330,18 @@ TUI_KITTY_ENABLE = "\x1b[>10u" TUI_KITTY_DISABLE = "\x1b[ TUI_PASTE_CAP: + return (None, b"") + return ("need_more", buf) + if close > TUI_PASTE_CAP: + return (None, rest[close + len(TUI_PASTE_CLOSE):]) + text = rest[:close].decode("utf-8", "replace") + return (TuiPaste(text=text), rest[close + len(TUI_PASTE_CLOSE):]) + + def tui_parse_one(buf, final): """Parse one event from head of buf. Returns (event|"need_more"|None, rest). None means empty buffer; "need_more" means wait for bytes (or flush).""" @@ -9645,6 +9681,10 @@ def tui_parse_one(buf, final): return (None, buf) if buf[0] != 0x1B: if buf[0] in (0x0D, 0x0A): + if buf[0] == 0x0D and len(buf) > 1 and buf[1] == 0x0A: + return (TuiKey(key="enter", char="\n"), buf[2:]) + if buf[0] == 0x0D and len(buf) == 1 and not final: + return ("need_more", buf) return (TuiKey(key="enter", char="\n"), buf[1:]) if buf[0] == 0x09: return (TuiKey(key="tab", char="\t"), buf[1:]) @@ -9699,6 +9739,8 @@ def tui_parse_one(buf, final): except UnicodeDecodeError: return (None, buf[end + 1:]) rest = buf[end + 1:] + if final_char == "~" and body == "200": + return tui_parse_paste(buf, rest) if final_char == "u": return tui_parse_kitty(body, rest) if final_char == "~" and ";" not in body: @@ -9740,7 +9782,7 @@ class TuiInput: def flush(self): events = self._drain(True) - while self.buf and self.buf[:1] == b"\x1b": + while self.buf and self.buf[:1] == b"\x1b" and not tui_paste_head(self.buf): before = len(self.buf) events.extend(self._drain(True)) if len(self.buf) == before: @@ -10509,12 +10551,68 @@ class TuiOptions(TuiPanel): self.move(1) +COMPLETE_WORD_RE = re.compile(r"(?:^|\s)/([A-Za-z0-9_-]*)$") + + +class TuiAutocomplete(TuiOptions): + """Non-focusable completion popup owned by a TuiTextBox. The box keeps + focus and drives the list: typing filters, arrows move, Enter accepts + via the Select event, Esc dismisses. Tab is never used: it always + moves focus by framework rule.""" + + def __init__(self, **kwargs): + kwargs.setdefault("focusable", False) + kwargs.setdefault("border", True) + kwargs.setdefault("layer", "dialog") + kwargs.setdefault("z", 5) + super().__init__((), **kwargs) + + def set_items(self, items): + self.options = [TuiOptions.norm_option(item) for item in items] + self.selected = 0 + self.scroll_y = 0 + if self.app is not None: + self.app.dirty_paint() + + +TUI_COMMAND_HELP = {} + + +def tui_command_help(): + """Slash-command descriptions parsed once from HELP_TEXT.""" + if not TUI_COMMAND_HELP: + for line in HELP_TEXT.splitlines(): + match = re.match(r"\s*/(\S+)\s+(.*)", line) + if not match: + continue + rest = match.group(2) + desc = re.split(r"\s{2,}", rest.strip())[-1].strip() + TUI_COMMAND_HELP.setdefault(match.group(1), desc) + return TUI_COMMAND_HELP + + +def tui_complete_command(prefix): + """Completion source for slash commands: [(label, name)] filtered by + prefix. Labels carry the HELP_TEXT description when one exists.""" + help_map = tui_command_help() + found = [] + for name in COMMANDS: + if not name.startswith(prefix or ""): + continue + desc = help_map.get(name, "") + label = "/%s - %s" % (name, desc) if desc else "/" + name + found.append((label, name)) + return found + + class TuiTextBox(TuiPanel): """Multiline text editor. Arrows/home/end/pgup/pgdn move, Enter splits lines, Backspace/Delete join them, clicks position the cursor. Escape - is left alone so dialogs can close on it.""" + is left alone so dialogs can close on it, unless a completion popup + is open: then Esc dismisses the popup first. complete= wires a + prefix-to-items callable driving a TuiAutocomplete popup.""" - def __init__(self, text="", **kwargs): + def __init__(self, text="", complete=None, **kwargs): kwargs.setdefault("focusable", True) kwargs.setdefault("border", True) super().__init__(**kwargs) @@ -10522,6 +10620,8 @@ class TuiTextBox(TuiPanel): self.cx = len(self.lines[-1]) self.cy = len(self.lines) - 1 self.top = 0 + self.complete_fn = complete + self.complete_popup = None @property def value(self): @@ -10588,12 +10688,141 @@ class TuiTextBox(TuiPanel): self._clamp() self._touch() + def _complete_refresh(self): + popup = self.complete_popup + if not callable(self.complete_fn) or self.app is None: + if popup is not None: + self._complete_hide() + return + match = COMPLETE_WORD_RE.search(self.lines[self.cy][:self.cx]) + try: + items = self.complete_fn(match.group(1)) if match else [] + except Exception: + items = [] + if not items: + if popup is not None: + self._complete_hide() + return + if popup is None: + popup = TuiAutocomplete() + popup.on_select = self._complete_picked + self.complete_popup = popup + self.root().add(popup) + popup.set_items(items) + self._complete_place(popup) + self.app.dirty_layout() + + def _complete_place(self, popup): + root = self.root() + edge = 1 if root.border else 0 + ox = root.ax + edge + root.padding[3] + oy = root.ay + edge + root.padding[0] + room_w = max(0, root._content_w()) + room_h = max(0, root._content_h()) + chrome_w = popup.chrome_w() + chrome_h = popup.chrome_h() + width = 4 + for label, _value in popup.options: + width = max(width, len(label) + 4) + width = max(4, min(width, max(4, room_w - chrome_w))) + height = max(1, min(len(popup.options), TUI_COMPLETE_MAXH)) + view_h = self._content_h() + if self.cy < self.top: + self.top = self.cy + elif self.cy >= self.top + view_h: + self.top = self.cy - view_h + 1 + box_ox, box_oy = self._origin() + left = max(0, self.cx - self._content_w() + 1) + cur_x = box_ox + (self.cx - left) + cur_y = box_oy + (self.cy - self.top) + popup.x = min(max(0, cur_x - ox), max(0, room_w - width - chrome_w)) + below = cur_y - oy + 1 + if below + height + chrome_h <= room_h: + popup.y = below + else: + popup.y = max(0, cur_y - oy - height - chrome_h) + popup.w = width + popup.h = height + + def _complete_move(self, key): + popup = self.complete_popup + if popup is None: + return + if key == "up": + popup.move(-1) + elif key == "down": + popup.move(1) + elif key == "home": + popup.move(-len(popup.options)) + elif key == "end": + popup.move(len(popup.options)) + elif key == "pageup": + popup.move(-max(1, popup._content_h())) + elif key == "pagedown": + popup.move(max(1, popup._content_h())) + + def _complete_hide(self): + popup = self.complete_popup + self.complete_popup = None + if popup is not None and popup.parent is not None: + popup.parent.remove(popup) + + def _complete_picked(self, event): + if event.panel is not self.complete_popup: + return + event.stop() + self._complete_accept(event.value) + + def _complete_accept(self, value): + line = self.lines[self.cy] + start = self.cx + while start > 0 and (line[start - 1].isalnum() or line[start - 1] in "_-"): + start -= 1 + if start <= 0 or line[start - 1] != "/": + self._complete_hide() + return + word_start = start - 1 + end = self.cx + while end < len(line) and (line[end].isalnum() or line[end] in "_-"): + end += 1 + replacement = "/" + str(value) + self.lines[self.cy] = line[:word_start] + replacement + line[end:] + self.cx = word_start + len(replacement) + if self.cx >= len(self.lines[self.cy]): + self.lines[self.cy] += " " + self.cx += 1 + self._clamp() + self._touch() + self._complete_hide() + + def on_paste(self, event): + if not self.enabled: + return + event.stop() + text = (event.text or "").replace("\r\n", "\n").replace("\r", "\n") + if text: + self.insert(text) + self._complete_refresh() + def on_key(self, event): if not self.enabled: return key = event.key or "" + if key == "escape" and self.complete_popup is not None: + event.stop() + self._complete_hide() + return if key in ("escape", "tab", "backtab"): return + if self.complete_popup is not None: + if key in ("up", "down", "home", "end", "pageup", "pagedown"): + event.stop() + self._complete_move(key) + return + if key == "enter": + event.stop() + self.complete_popup.choose() + return if key == "left": event.stop() self.cx -= 1 @@ -10619,6 +10848,7 @@ class TuiTextBox(TuiPanel): elif key == "enter": event.stop() self.insert("\n") + self._complete_refresh() return elif key == "backspace": event.stop() @@ -10645,10 +10875,12 @@ class TuiTextBox(TuiPanel): if char and "+" not in key and not key.startswith(("ctrl+", "alt+")): event.stop() self.insert(char) + self._complete_refresh() return return self._clamp() self._touch() + self._complete_refresh() def on_click(self, event): if not self.enabled or not event.pressed: @@ -10661,6 +10893,13 @@ class TuiTextBox(TuiPanel): self.cx = left + (event.x - ox) self._clamp() self._touch() + self._complete_refresh() + + def on_blur(self, event): + self._complete_hide() + + def on_unmount(self, event): + self._complete_hide() class TuiDialog(TuiPanel): @@ -10729,13 +10968,13 @@ class TuiPromptDialog(TuiDialog): """Pretty prompt box: label, multiline editor, OK/Cancel. OK runs on_execute(value) then closes; Cancel and Esc just close.""" - def __init__(self, label="Enter prompt:", box_h=3, on_execute=None, **kwargs): + def __init__(self, label="Enter prompt:", box_h=3, on_execute=None, complete=None, **kwargs): kwargs.setdefault("title", "Prompt") kwargs.setdefault("w", 56) super().__init__(**kwargs) self.on_execute = on_execute self.add(TuiText(label, style=TuiStyle(dim=True))) - self.box = TuiTextBox(h=box_h, style=TuiStyle(bg="black", fg="white")) + self.box = TuiTextBox(h=box_h, style=TuiStyle(bg="black", fg="white"), complete=complete) self.add(self.box) self.buttons(("OK", lambda _event: self._run()), ("Cancel", lambda _event: self.close())) @@ -11006,6 +11245,12 @@ class TuiTerminal(TuiPanel): def send_text(self, text): self._pending += text.encode("utf-8", "replace") + def on_paste(self, event): + if not self.interactive or self.exit_status is not None: + return + event.stop() + self._pending += (event.text or "").encode("utf-8", "replace") + @staticmethod def key_bytes(event): key = event.key or "" @@ -11293,6 +11538,12 @@ class TuiApp: if isinstance(event, TuiScroll): self._dispatch_scroll(event) return + if isinstance(event, TuiPaste): + if self.focused is not None: + self.focused.emit(event) + else: + self.root.emit(event) + return self.root.emit(event) def _modal_blocks(self, panel): @@ -11398,12 +11649,12 @@ class TuiApp: except (ValueError, OSError): self._old_winch = None out = sys.stdout.fileno() - os.write(out, (TUI_ALT_ENTER + TUI_MOUSE_ENABLE + TUI_KITTY_ENABLE + "\x1b[?25l").encode("utf-8")) + os.write(out, (TUI_ALT_ENTER + TUI_MOUSE_ENABLE + TUI_KITTY_ENABLE + TUI_PASTE_ENABLE + "\x1b[?25l").encode("utf-8")) def _teardown(self): try: out = sys.stdout.fileno() - os.write(out, (TUI_KITTY_DISABLE + TUI_MOUSE_DISABLE + TUI_ALT_LEAVE + "\x1b[?25h").encode("utf-8")) + os.write(out, (TUI_PASTE_DISABLE + TUI_KITTY_DISABLE + TUI_MOUSE_DISABLE + TUI_ALT_LEAVE + "\x1b[?25h").encode("utf-8")) except OSError: pass if self._old_winch is not None: @@ -11566,7 +11817,8 @@ def tui_build_demo(app, live=True): first = text.split("\n")[0][:40] or "(empty)" toast("executing: %s" % first) log.append("prompt: %s" % text.split("\n")[0][:60]) - root.add(TuiPromptDialog(on_execute=execute, id="prompt")) + root.add(TuiPromptDialog(on_execute=execute, id="prompt", complete=tui_complete_command, + label="Enter prompt (/ for commands):")) return True def on_double_tap(event): diff --git a/test_tai.py b/test_tai.py index 0a04952..60e9887 100644 --- a/test_tai.py +++ b/test_tai.py @@ -3929,6 +3929,54 @@ class TuiInputTests(unittest.TestCase): _parser, events = self.feed(b"\x1b[1;2A") self.assertEqual(events[0].key, "shift+up") + def test_bracketed_paste(self): + _parser, events = self.feed(b"\x1b[200~hello\nworld\x1b[201~") + self.assertEqual(len(events), 1) + self.assertIsInstance(events[0], tai.TuiPaste) + self.assertEqual(events[0].text, "hello\nworld") + + def test_split_paste(self): + parser, events = self.feed(b"\x1b[200~ab") + self.assertEqual(events, []) + events = parser.feed(b"cd\x1b[201~") + self.assertEqual(len(events), 1) + self.assertEqual(events[0].text, "abcd") + + def test_paste_keeps_escapes_literal(self): + _parser, events = self.feed(b"\x1b[200~\x1b[A\tX\x1b[201~") + self.assertEqual(len(events), 1) + self.assertEqual(events[0].text, "\x1b[A\tX") + + def test_paste_cap_drops_whole(self): + big = b"x" * (tai.TUI_PASTE_CAP + 1) + parser, events = self.feed(b"\x1b[200~" + big) + self.assertEqual(events, []) + self.assertEqual(parser.buf, b"") + events = parser.feed(b"\x1b[201~") + self.assertEqual(events, []) + + def test_flush_keeps_partial_paste(self): + parser, events = self.feed(b"\x1b[200~ab") + self.assertEqual(events, []) + self.assertEqual(parser.flush(), []) + events = parser.feed(b"c\x1b[201~") + self.assertEqual(len(events), 1) + self.assertEqual(events[0].text, "abc") + + def test_crlf_single_enter(self): + _parser, events = self.feed(b"a\r\nb") + self.assertEqual([event.key for event in events], ["a", "enter", "b"]) + + def test_lone_cr_waits_for_next_byte(self): + parser, events = self.feed(b"\r") + self.assertEqual(events, []) + self.assertEqual([event.key for event in parser.feed(b"x")], ["enter", "x"]) + + def test_lone_cr_resolves_on_flush(self): + parser, events = self.feed(b"\r") + self.assertEqual(events, []) + self.assertEqual([event.key for event in parser.flush()], ["enter"]) + class TuiKittyTests(unittest.TestCase): def feed(self, *chunks): @@ -4094,6 +4142,15 @@ class TuiWidgetTests(unittest.TestCase): dialog.emit(tai.TuiKey(key="escape", char="\x1b")) self.assertIsNone(dialog.parent) + def test_terminal_paste_queues_bytes(self): + term = tai.TuiTerminal() + term.on_paste(tai.TuiPaste(text="a\nb")) + self.assertEqual(bytes(term._pending), b"a\nb") + term.exit_status = 0 + term._pending = bytearray() + term.on_paste(tai.TuiPaste(text="a\nb")) + self.assertEqual(bytes(term._pending), b"") + @staticmethod def _btn(): return tai.TuiButton("ok", w=8) @@ -4298,6 +4355,149 @@ class TuiTextBoxTests(unittest.TestCase): self.key(app, "space", " ") self.assertEqual(box.value, " ") + def test_paste_multiline(self): + app, box = self.make_box() + app.dispatch(tai.TuiPaste(text="a\r\nb\rc")) + self.assertEqual(box.value, "a\nb\nc") + self.assertEqual((box.cx, box.cy), (1, 2)) + + +class TuiAutocompleteTests(unittest.TestCase): + def make_box(self, complete=tai.tui_complete_command, **kwargs): + app = tai.TuiApp() + app.rows = 12 + app.cols = 50 + root = tai.TuiPanel(flow=None) + kwargs.setdefault("x", 1) + kwargs.setdefault("y", 1) + kwargs.setdefault("w", 30) + kwargs.setdefault("h", 3) + box = tai.TuiTextBox(complete=complete, **kwargs) + root.add(box) + app.mount(root) + return app, box + + def key(self, app, key, char=""): + app.dispatch(tai.TuiKey(key=key, char=char)) + + def type(self, app, text): + for char in text: + self.key(app, "space" if char == " " else char, char) + + def test_opens_and_filters(self): + app, box = self.make_box() + self.type(app, "/bo") + popup = box.complete_popup + self.assertIsNotNone(popup) + self.assertEqual([value for _label, value in popup.options], ["bots", "bot"]) + self.assertIs(popup.parent, box.root()) + self.assertIs(app.focused, box) + + def test_accept_replaces_word(self): + app, box = self.make_box() + self.type(app, "/bo") + self.key(app, "down", "") + self.key(app, "enter", "\n") + self.assertEqual(box.value, "/bot ") + self.assertEqual(box.cx, 5) + self.assertIsNone(box.complete_popup) + + def test_accept_mid_word_replaces_through_end(self): + app, box = self.make_box() + self.type(app, "/bot x") + for _i in range(5): + self.key(app, "left", "") + self.key(app, "enter", "\n") + self.assertEqual(box.value, "/help x") + self.assertEqual(box.cx, 5) + self.assertIsNone(box.complete_popup) + + def test_escape_dismisses_then_bubbles(self): + app, box = self.make_box() + self.type(app, "/bo") + event = tai.TuiKey(key="escape", char="\x1b") + box.emit(event) + self.assertTrue(event.stopped) + self.assertIsNone(box.complete_popup) + event = tai.TuiKey(key="escape", char="\x1b") + box.emit(event) + self.assertFalse(event.stopped) + + def test_no_match_hides(self): + app, box = self.make_box() + self.type(app, "/zzz") + self.assertIsNone(box.complete_popup) + + def test_space_hides(self): + app, box = self.make_box() + self.type(app, "/bot") + self.assertIsNotNone(box.complete_popup) + self.type(app, " ") + self.assertIsNone(box.complete_popup) + + def test_up_down_navigate(self): + app, box = self.make_box() + self.type(app, "/bo") + popup = box.complete_popup + self.assertEqual(popup.selected, 0) + self.key(app, "down", "") + self.assertEqual(popup.selected, 1) + self.key(app, "down", "") + self.assertEqual(popup.selected, 1) + self.key(app, "up", "") + self.assertEqual(popup.selected, 0) + self.assertEqual(box.value, "/bo") + + def test_popup_placed_under_cursor(self): + app, box = self.make_box() + self.type(app, "/bo") + popup = box.complete_popup + self.assertEqual((popup.x, popup.y), (4, 3)) + + def test_popup_flips_above_when_no_room(self): + app, box = self.make_box(y=8) + self.type(app, "/bo") + popup = box.complete_popup + self.assertEqual(popup.y, 5) + + def test_click_accepts(self): + app, box = self.make_box() + self.type(app, "/bo") + app.layout() + popup = box.complete_popup + app._dispatch_click(tai.TuiClick(x=popup.ax + 1, y=popup.ay + 1, button=0, modifiers=[], pressed=True)) + self.assertEqual(box.value, "/bots ") + + def test_blur_hides(self): + app, box = self.make_box() + self.type(app, "/bo") + self.assertIsNotNone(box.complete_popup) + box.emit(tai.TuiBlur(panel=box)) + self.assertIsNone(box.complete_popup) + + def test_unmount_removes_popup(self): + app, box = self.make_box() + self.type(app, "/bo") + popup = box.complete_popup + box.parent.remove(box) + self.assertIsNone(popup.parent) + + def test_completer_failure_hides(self): + def broken(_prefix): + raise ValueError("nope") + + app, box = self.make_box(complete=broken) + self.type(app, "/bo") + self.assertIsNone(box.complete_popup) + self.assertEqual(box.value, "/bo") + + def test_tui_complete_command_labels(self): + items = tai.tui_complete_command("bo") + self.assertEqual([value for _label, value in items], ["bots", "bot"]) + self.assertIn("list bots", items[0][0]) + plain = [label for label, value in tai.tui_complete_command("exi") if value == "exit"] + self.assertEqual(plain, ["/exit"]) + class TuiPromptDialogTests(unittest.TestCase): def make_demo(self): @@ -4348,6 +4548,17 @@ class TuiPromptDialogTests(unittest.TestCase): self.assertIs(app.focused, actions) self.assertIsNone(root.find("prompt")) + def test_complete_passthrough(self): + dialog = tai.TuiPromptDialog(complete=tai.tui_complete_command) + self.assertIs(dialog.box.complete_fn, tai.tui_complete_command) + + def test_demo_prompt_has_command_completion(self): + app, root, _refs = self.make_demo() + app.dispatch(tai.TuiKey(key="shift", char="")) + app.dispatch(tai.TuiKey(key="shift", char="")) + dialog = root.find("prompt") + self.assertIs(dialog.box.complete_fn, tai.tui_complete_command) + class TuiCommandTests(unittest.TestCase): def test_demo_in_commands_and_help(self): diff --git a/tui.md b/tui.md index d0f90f6..1de2397 100644 --- a/tui.md +++ b/tui.md @@ -89,7 +89,7 @@ and padding add outside the given w/h (CSS `content-box`). ## Events `TuiEvent` subclasses: Key, Click, Scroll, Focus, Blur, Resize, Tick, -Mount, Unmount, Select, Press, Close. Dispatch calls `on_` on +Mount, Unmount, Select, Press, Close, Paste. Dispatch calls `on_` on the target, then walks base classes implicitly (plain attribute lookup), then bubbles to the parent while `bubble` is true. `event.stop()` halts everything. Custom events: subclass `TuiEvent`, @@ -147,6 +147,14 @@ an injectable clock; set `app.on_double_tap` to a callback returning True when it acted (falsy falls through to normal handling, so fast double-Tab still cycles). +Paste: setup enables bracketed paste (`CSI ? 2004 h`, best-effort +like the kitty flags) and teardown disables it. A marked paste arrives +as one `TuiPaste(text)` event routed to the focused panel; the body +stays literal (no escape parsing inside) and a paste over 1MB +(`TUI_PASTE_CAP`) is dropped whole, never truncated. Unmarked CRLF +still coalesces to a single Enter, so plain pastes keep correct line +breaks on terminals without bracketed mode. + ## Animation and timers ```python @@ -174,14 +182,24 @@ callbacks; exceptions in callbacks never break the loop. Press event); `on_close(event)` fires on close. - `TuiQuestion(prompt, options, on_done, multi)` - prompt plus options; single-select answers on first pick, multi-select via OK. -- `TuiTextBox(text)` - multiline editor; arrows/home/end/pgup/pgdn - move (no line wrap), Enter splits, Backspace/Delete join, +- `TuiTextBox(text, complete)` - multiline editor; arrows/home/end/ + pgup/pgdn move (no line wrap), Enter splits, Backspace/Delete join, click positions the cursor, Escape bubbles for dialogs; - `.value` joins lines. -- `TuiPromptDialog(label, box_h, on_execute)` - label plus a + `.value` joins lines. Bracketed pastes insert literally with + CR/CRLF normalized to LF. `complete(prefix)` wires a + `TuiAutocomplete` popup for `/words` before the cursor. +- `TuiAutocomplete` - non-focusable `TuiOptions` popup owned by a + textbox; typing filters, arrows/home/end/pgup/pgdn move, Enter + accepts, Esc dismisses (a second Esc still bubbles); flips above + the cursor when short on room, max 8 rows. The box keeps focus + throughout, and Tab is never used for completion (it always moves + focus). Clicks pick outside modals; inside a modal the modal click + guard swallows them and the keyboard path still works. +- `TuiPromptDialog(label, box_h, on_execute, complete)` - label plus a `box_h`-row editor with OK/Cancel; OK runs `on_execute(value)` then closes. The demo opens one on - double-Shift and feeds accepted text to the shell. + double-Shift with slash-command completion and feeds accepted + text to the shell. - `TuiTerminal(cmd, interactive)` - real pty child streamed through the VT screen; click to focus and type; read-only mode for logs. - `TuiProgress(value)` - fraction bar; `set()` clamps 0..1. @@ -201,10 +219,10 @@ the widget shows a plain error line instead of crashing. - Wide (CJK/emoji) characters count as one column; columns around them misalign. Structured to fix with a width table later. -- No stylesheet language (constructor kwargs instead), no bracketed - paste, only the key-event subset of the kitty protocol (no - release tracking, no super/hyper/meta, no alternate-key shortcut - matching), no true opacity blending. +- No stylesheet language (constructor kwargs instead), only the + key-event subset of the kitty protocol (no release tracking, no + super/hyper/meta, no alternate-key shortcut matching), no true + opacity blending. - 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