diff --git a/research/INDEX.md b/research/INDEX.md index b14cfeb..ad226dc 100644 --- a/research/INDEX.md +++ b/research/INDEX.md @@ -7,8 +7,8 @@ | Metric | Value | |---|---| | Subjects | 5 | -| Total source documents | 346 | -| — Tier 1 (Source Dossier, independently analyzed) | 103 | +| Total source documents | 347 | +| — Tier 1 (Source Dossier, independently analyzed) | 104 | | — Tier 2 (Source Citation Card, listed only) | 243 | | Total conclusion documents | 6 | | Archive opened | 2026-10-09 | @@ -22,7 +22,7 @@ | [`agent-memory-recency-tracking`](agent-memory-recency-tracking/) | 63 (16 / 47) | 1 | 2026-10-09 | 2026-10-09 | Designing an LRU-style last-used/use-count tracker for vault records and chunks | | [`async-pty-process-testing`](async-pty-process-testing/) | 59 (20 / 39) | 1 | 2026-10-09 | 2026-10-09 | Designing tai_test.py (pty vs. pipes, non-blocking stdin, process-group cleanup, test-framework shape) — backfilled after it was initially skipped, see activity log | | [`shell-multiplexer-auto-attach`](shell-multiplexer-auto-attach/) | 138 (48 / 90) | 2 | 2026-10-09 | 2026-10-10 | Designing a tai-managed terminal multiplexer + bashrc auto-attach: tmux-as-substrate recommendation, interactive-vs-automation shell detection; extended 2026-10-10 to cover zsh/fish hook syntax | -| [`tui-framework-design`](tui-framework-design/) | 54 (9 / 45) | 1 | 2026-10-10 | 2026-10-10 | Designing tai's in-house TUI framework (retained DOM, cuts/chops compositor, SGR mouse, bubbling events, VT sub-terminals); extended 2026-10-10 with synchronized-output verification (DEC 2026); implemented in working-tree tai.py and verified the same session; same-day follow-up fixed the 19 suite failures natively, deleted tai_pkg/, suite 320/320 green, committed | +| [`tui-framework-design`](tui-framework-design/) | 55 (10 / 45) | 1 | 2026-10-10 | 2026-10-10 | Designing tai's in-house TUI framework (retained DOM, cuts/chops compositor, SGR mouse, bubbling events, VT sub-terminals); extended 2026-10-10 with synchronized-output verification (DEC 2026); implemented in working-tree tai.py and verified the same session; same-day follow-up fixed the 19 failures natively, deleted tai_pkg/, suite 320/320 green, committed; extended again for kitty keyboard protocol (double-Shift prompt dialog) | ## Recent activity log @@ -39,6 +39,7 @@ | 2026-10-10 | `tui-framework-design` | Extended with 10 more sources (2 Tier 1 / 8 Tier 2) verifying synchronized-output framing (DEC mode 2026, `CSI ? 2026 h/l`, harmless no-op where unsupported). Subject total now 54 sources (9 Tier 1 / 45 Tier 2), 1 conclusion. Implementation approved by retoor the same session; framework being built into tai.py. | | 2026-10-10 | `tui-framework-design` | Implementation finished and verified (no new sources): framework in working-tree `tai.py`, `tui.md` docs, `/demo` + `--demo` + `--demo-shot` wiring, 49 TUI unit tests green, 21/21 headless interaction checks green, 10/10 live pty e2e checks green, `test_seal.py` green. The 19 remaining `test_tai.py` failures are the pre-existing monolith/facade working-tree mismatch, proven unrelated via stash comparison. Conclusion document updated. | | 2026-10-10 | `tui-framework-design` | Follow-up (no new sources): the 19 failures from the previous row were fixed natively — five classes (`Consts`, `Install`, `Sandbox`, `Opencode`, `SysInfo`) defined directly in standalone `tai.py` with module functions delegating, four test patch-targets corrected to the real `tai.*` seams (assertions unchanged) — then `tai_pkg/` deleted outright per retoor's call. Suite now 320/320 green (`test_tai.py`) + 16/16 (`test_seal.py`); `tui.md` focus rules updated (Tab always cycles, auto-focus, focus memory); committed. Conclusion document forward-corrected. | +| 2026-10-10 | `tui-framework-design` | Extended with 1 more source (Tier 1: the kitty keyboard protocol specification, direct fetch) to settle double-Shift detection for a prompt dialog: bare modifier presses need flag 8, press is `57441;2u`, filter `:2`/`:3` via flag 2, push `CSI > 10 u` best-effort with no query round-trip, pop on teardown. Subject total now 55 sources (10 Tier 1 / 45 Tier 2), 1 conclusion. | ## How to use this index diff --git a/research/tui-framework-design/kovidgoyal-kitty-keyboard-protocol.md b/research/tui-framework-design/kovidgoyal-kitty-keyboard-protocol.md new file mode 100644 index 0000000..fa89f3d --- /dev/null +++ b/research/tui-framework-design/kovidgoyal-kitty-keyboard-protocol.md @@ -0,0 +1,55 @@ +# Kitty Keyboard Protocol Specification + +## Metadata + +| Field | Value | +|---|---| +| Institute | Molodetz Institute — Research & Retrieval Division | +| Subject | tui-framework-design | +| Document type | Source Dossier | +| Tier | 1 | +| Source title | Comprehensive keyboard handling in terminals - kitty | +| Source URL | https://sw.kovidgoyal.net/kitty/keyboard-protocol/ | +| Source publisher / author | Kovid Goyal / kitty project | +| Source type | standard | +| Source date (published) | Unknown — not stated | +| Date researched | 2026-10-10 | +| Researched by | tai (agent), model: Muse Spark, on behalf of retoor | +| Research method | direct page fetch | +| Confidence | High | +| Status | Active | + +## Summary + +The authoritative specification of the kitty keyboard protocol: `CSI unicode-key-code:alternate-key ; mods:event-type ; text-as-codepoints u` key encoding, the five progressive-enhancement flags, the push/pop/query mechanism, the modifier bitmask, the functional key code table, and the support-detection procedure. Read to settle exactly which bytes enable bare-modifier key reporting and what a Shift press looks like on the wire. + +## Key findings + +- Key event form: `CSI unicode-key-code:alternate-key ; mods:event-type u`, plus optional `;text-as-codepoints` tail (only with flag 16). Text keys use the codepoint as the number (shift+a is key `97`, mods shift). +- Progressive-enhancement flags: 1 disambiguate escape codes, 2 report event types, 4 alternate keys, 8 all keys as escape codes, 16 associated text. Enable with `CSI flags ; mode u` (mode 1 set-all default, 2 set-bits, 3 clear-bits); stack discipline via `CSI > flags u` push (default 0) and `CSI < number u` pop (default 1); query via `CSI ? u`, reply `CSI ? flags u`. +- Bare modifier press events are reported only with flag 8 (all keys as escape codes); without it there is no way to observe a lone Shift press at all. Flag 8 also converts every text key to escape-code form (no plain UTF-8 bytes) and implies disambiguation. +- Event types (needs flag 2) are a `:type` subfield on the modifier parameter: 1 press (default when absent), 2 repeat, 3 release. Without flag 2, repeats arrive indistinguishable from presses. +- Modifier mask is `1 + bits` with shift=1, alt=2, ctrl=4, super=8, hyper=16, meta=32, caps_lock=64, num_lock=128; absent field defaults to 1 (no modifiers). For modifier-key events the mask reflects state including the current event, so a bare left-Shift press is `CSI 57441 ; 2 u` and its release is `CSI 57441 ; 1 : 3 u`. +- Functional codes: ESCAPE `27u`, ENTER `13u`, TAB `9u`, BACKSPACE `127u`, arrows `1A/1B/1C/1D`, LEFT_SHIFT `57441u`, RIGHT_SHIFT `57447u`, LEFT_CTRL `57442u`, F1-F12 keep legacy `~`/letter forms. +- Main and alternate screens maintain independent keyboard-mode stacks, so pushing flags after entering the alternate screen never leaks into the shell's mode. +- Recommended detection: send the `CSI ? u` query followed by a primary-device-attributes request; a DA1 reply with no kitty reply means unsupported. Partial implementations are detected by set-then-query. + +## Pros / strengths + +- It is the normative document: exact byte sequences, flag semantics, event-type encoding, and the full functional table in one place, with rationale notes (e.g. why Enter/Tab/Backspace keep legacy bytes under flag 1). +- Explicitly covers graceful degradation (unsupported terminals ignore the push) and the alternate-screen stack split, which is exactly what a fullscreen TUI needs. + +## Cons / limitations / caveats + +- Dense single page (navigation-heavy HTML); the functional table mixes `u` and legacy `~`/letter forms, so a reader must track which form applies under which flags. +- The spec permits partial implementations while discouraging them, so set-then-query is the only honest capability check; a push-and-hope client must still parse legacy input as fallback. + +## Relevance to tai + +Directly load-bearing for double-Shift detection in the TUI demo: it proves bare Shift is unobservable without flag 8, fixes the push sequence (`CSI > 10 u` for flags 8+2, pop with `CSI < u`), the press encoding (`57441;2u`), and the repeat/release filtering rule (ignore `:2`/`:3` for tap detection). Drove the decision to push best-effort with no query round-trip and to parse legacy and CSI-u input side by side, plus a double-Escape fallback for terminals without kitty support. + +## Changelog + +| Date | Change | +|---|---| +| 2026-10-10 | Initial dossier created. | diff --git a/tai.py b/tai.py index cae0cc2..77a680b 100755 --- a/tai.py +++ b/tai.py @@ -9327,6 +9327,10 @@ TUI_ALT_ENTER = "\x1b[?1049h" TUI_ALT_LEAVE = "\x1b[?1049l" +TUI_KITTY_ENABLE = "\x1b[>10u" + +TUI_KITTY_DISABLE = "\x1b[", "/": "?", "`": "~"} + def tui_modifiers(code): mods = [] @@ -9553,6 +9572,73 @@ def tui_parse_mouse_legacy(data): return (None, True) +def tui_parse_kitty(body, rest): + """Decode one kitty CSI-u key body. Returns (event|None, rest). Stack + forms, unknown numbers, and release events are consumed silently.""" + if not body or body[0] in "?>!<=" or not all(char.isdigit() or char in ":;" for char in body): + return (None, rest) + parts = body.split(";") + try: + number = int(parts[0].split(":")[0] or 0) + except ValueError: + return (None, rest) + mods, etype = 1, 1 + if len(parts) > 1 and parts[1]: + fields = parts[1].split(":") + mods = tui_int(fields[0], 1) if fields[0] else 1 + if len(fields) > 1 and fields[1]: + etype = tui_int(fields[1], 1) + if etype == 3: + return (None, rest) + text = "" + for extra in parts[2:]: + for piece in extra.split(":"): + if piece.isdigit(): + point = int(piece) + if point <= 0x10FFFF: + text += chr(point) + if number in TUI_KITTY_MODS: + if etype == 2: + return (None, rest) + name = TUI_KITTY_MODS[number] + return (TuiKey(key=name, char="", modifiers=[name]), rest) + if number in TUI_KITTY_NAMED: + if etype == 2: + return (None, rest) + name = TUI_KITTY_NAMED[number] + names = tui_modifiers(((mods - 1) & 0b111) + 1) + key = "+".join(names + [name]) if names else name + return (TuiKey(key=key, char="", modifiers=names), rest) + if number in TUI_KITTY_PAD: + char = TUI_KITTY_PAD[number] + return (TuiKey(key=char, char=char), rest) + if 1 <= number <= 26: + letter = chr(number + 96) + return (TuiKey(key="ctrl+" + letter, char=chr(number), modifiers=["ctrl"]), rest) + if number == 0: + if not text: + return (None, rest) + elif 57344 <= number <= 63743 or not 32 <= number <= 0x10FFFF or 0xD800 <= number <= 0xDFFF: + return (None, rest) + if text: + key = "space" if text == " " else text + return (TuiKey(key=key, char=text), rest) + char = chr(number) + names = tui_modifiers(((mods - 1) & 0b111) + 1) + if names == ["shift"] and "a" <= char <= "z": + upper = chr(ord(char) - 32) + return (TuiKey(key=upper, char=upper), rest) + if names == ["shift"] and char in TUI_KITTY_US_SHIFT: + shifted = TUI_KITTY_US_SHIFT[char] + return (TuiKey(key=shifted, char=shifted), rest) + if names == ["ctrl"] and "a" <= char <= "z": + return (TuiKey(key="ctrl+" + char, char=chr(ord(char) - 96), modifiers=names), rest) + if names: + return (TuiKey(key="+".join(names + [char]), char=char, modifiers=names), rest) + key = "space" if char == " " else char + return (TuiKey(key=key, char=char), rest) + + 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).""" @@ -9602,7 +9688,7 @@ def tui_parse_one(buf, final): event, _ok = tui_parse_mouse_sgr(text) return ((event, buf[end + 1:]) if event is not None else (None, buf[end + 1:])) end = -1 - for pos in range(2, min(len(buf), 32)): + for pos in range(2, min(len(buf), 64)): if 0x40 <= buf[pos] <= 0x7E: end = pos break @@ -9614,6 +9700,8 @@ def tui_parse_one(buf, final): except UnicodeDecodeError: return (None, buf[end + 1:]) rest = buf[end + 1:] + if final_char == "u": + return tui_parse_kitty(body, 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: @@ -10422,6 +10510,160 @@ class TuiOptions(TuiPanel): self.move(1) +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.""" + + def __init__(self, text="", **kwargs): + kwargs.setdefault("focusable", True) + kwargs.setdefault("border", True) + super().__init__(**kwargs) + self.lines = str(text).split("\n") or [""] + self.cx = len(self.lines[-1]) + self.cy = len(self.lines) - 1 + self.top = 0 + + @property + def value(self): + return "\n".join(self.lines) + + def measure(self): + width = 0 + for line in self.lines: + width = max(width, len(line)) + return (max(1, width), max(1, len(self.lines))) + + def _clamp(self): + self.cy = max(0, min(len(self.lines) - 1, self.cy)) + self.cx = max(0, min(len(self.lines[self.cy]), self.cx)) + + def _touch(self): + if self.app is not None: + self.app.dirty_paint() + + def _origin(self): + edge = 1 if self.border else 0 + return (self.ax + edge + self.padding[3], self.ay + edge + self.padding[0]) + + def content_rows(self): + width = self._content_w() + height = self._content_h() + self._clamp() + if self.cy < self.top: + self.top = self.cy + if self.cy >= self.top + height: + self.top = self.cy - height + 1 + left = max(0, self.cx - width + 1) + focused = self.app is not None and self.app.focused is self and self.enabled + rows = [] + for pos in range(height): + index = self.top + pos + line = self.lines[index][left:left + width] if 0 <= index < len(self.lines) else "" + line = line.ljust(width) + if focused and index == self.cy: + col = self.cx - left + before, at, after = line[:col], line[col:col + 1], line[col + 1:] + rows.append([TuiSpan(before, self.style), + TuiSpan(at or " ", self.style.merged(TuiStyle(reverse=True))), + TuiSpan(after, self.style)]) + else: + rows.append([TuiSpan(line[:width], self.style)]) + return rows + + def insert(self, text): + parts = str(text).split("\n") + line = self.lines[self.cy] + head, tail = line[:self.cx], line[self.cx:] + self.lines[self.cy] = head + parts[0] + self.cx = len(head) + len(parts[0]) + for part in parts[1:-1]: + self.cy += 1 + self.lines.insert(self.cy, part) + if len(parts) > 1: + self.cy += 1 + self.lines.insert(self.cy, parts[-1] + tail) + self.cx = len(parts[-1]) + else: + self.lines[self.cy] += tail + self._clamp() + self._touch() + + def on_key(self, event): + if not self.enabled: + return + key = event.key or "" + if key in ("escape", "tab", "backtab"): + return + if key == "left": + event.stop() + self.cx -= 1 + elif key == "right": + event.stop() + self.cx += 1 + elif key == "up": + event.stop() + self.cy -= 1 + elif key == "down": + event.stop() + self.cy += 1 + elif key == "home": + event.stop() + self.cx = 0 + elif key == "end": + event.stop() + self.cx = len(self.lines[self.cy]) + elif key in ("pageup", "pagedown"): + event.stop() + step = max(1, self._content_h()) + self.cy += -step if key == "pageup" else step + elif key == "enter": + event.stop() + self.insert("\n") + return + elif key == "backspace": + event.stop() + if self.cx > 0: + line = self.lines[self.cy] + self.lines[self.cy] = line[:self.cx - 1] + line[self.cx:] + self.cx -= 1 + elif self.cy > 0: + prev = self.lines[self.cy - 1] + self.cx = len(prev) + self.lines[self.cy - 1] = prev + self.lines[self.cy] + del self.lines[self.cy] + self.cy -= 1 + elif key == "delete": + event.stop() + line = self.lines[self.cy] + if self.cx < len(line): + self.lines[self.cy] = line[:self.cx] + line[self.cx + 1:] + elif self.cy < len(self.lines) - 1: + self.lines[self.cy] = line + self.lines[self.cy + 1] + del self.lines[self.cy + 1] + else: + char = event.char or "" + if char and "+" not in key and not key.startswith(("ctrl+", "alt+")): + event.stop() + self.insert(char) + return + return + self._clamp() + self._touch() + + def on_click(self, event): + if not self.enabled or not event.pressed: + return + event.stop() + self.focus() + ox, oy = self._origin() + left = max(0, self.cx - self._content_w() + 1) + self.cy = self.top + (event.y - oy) + self.cx = left + (event.x - ox) + self._clamp() + self._touch() + + class TuiDialog(TuiPanel): """Centered modal panel with a button row. Esc closes.""" @@ -10484,6 +10726,26 @@ class TuiQuestion(TuiDialog): self.close() +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): + 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.add(self.box) + self.buttons(("OK", lambda _event: self._run()), ("Cancel", lambda _event: self.close())) + + def _run(self): + if callable(self.on_execute): + self.on_execute(self.box.value) + self.close() + + class TuiProgress(TuiPanel): """Fraction bar with percent label.""" @@ -10742,6 +11004,9 @@ class TuiTerminal(TuiPanel): event.stop() self._pending += data + def send_text(self, text): + self._pending += text.encode("utf-8", "replace") + @staticmethod def key_bytes(event): key = event.key or "" @@ -10767,6 +11032,25 @@ class TuiError(Exception): pass +class TuiDoubleTap: + """Two same-key presses inside a window. Clock is injected (now) so + tests stay deterministic; the app passes time.time().""" + + def __init__(self, timeout=0.4): + self.timeout = timeout + self.last_key = None + self.last_at = 0.0 + + def hit(self, key, now): + if key == self.last_key and (now - self.last_at) <= self.timeout: + self.last_key = None + self.last_at = 0.0 + return True + self.last_key = key + self.last_at = now + return False + + class TuiApp: """Owns the screen: terminal setup, tick loop, focus, timers, routing. @@ -10782,6 +11066,8 @@ class TuiApp: self.cols = 80 self.focused = None self._focus_memory = [] + self.double_tap = TuiDoubleTap() + self.on_double_tap = None self.compositor = TuiCompositor() self.hitmap = TuiHitMap() self.input = TuiInput() @@ -10988,6 +11274,9 @@ class TuiApp: if event.key == "ctrl+c": self.quit() return + if self.on_double_tap is not None and self.double_tap.hit(event.key, time.time()): + if self.on_double_tap(event): + return if event.key == "tab": self.focus_next() return @@ -11110,12 +11399,12 @@ class TuiApp: except (ValueError, OSError): self._old_winch = None out = sys.stdout.fileno() - os.write(out, (TUI_ALT_ENTER + TUI_MOUSE_ENABLE + "\x1b[?25l").encode("utf-8")) + os.write(out, (TUI_ALT_ENTER + TUI_MOUSE_ENABLE + TUI_KITTY_ENABLE + "\x1b[?25l").encode("utf-8")) def _teardown(self): try: out = sys.stdout.fileno() - os.write(out, (TUI_MOUSE_DISABLE + TUI_ALT_LEAVE + "\x1b[?25h").encode("utf-8")) + os.write(out, (TUI_KITTY_DISABLE + TUI_MOUSE_DISABLE + TUI_ALT_LEAVE + "\x1b[?25h").encode("utf-8")) except OSError: pass if self._old_winch is not None: @@ -11216,7 +11505,7 @@ def tui_build_demo(app, live=True): root = TuiPanel(flow=None, style=TuiStyle(bg="black"), id="root") header = TuiText(" tai TUI demo -- layers, mouse, focus, animation", dock="top", h=1, style=TuiStyle(bg="blue", fg="white", bold=True), id="header") - footer = TuiText(" tab/shift+tab focus - click options - q quit - n terminal - c close - F1 ask - ctrl+c quit", + footer = TuiText(" tab/shift+tab focus - click options - q quit - n terminal - c close - F1 ask - 2xshift prompt - ctrl+c quit", dock="bottom", h=1, style=TuiStyle(dim=True), id="footer") root.add(header, footer) sidebar = TuiPanel(dock="left", w=28, border=True, title="Actions", id="sidebar", gap=1, padding=1) @@ -11270,6 +11559,24 @@ def tui_build_demo(app, live=True): on_done=answered, title="Question", w=44, id="welcome" if refs.get("first_ask", True) else None)) refs["first_ask"] = False + def open_prompt(): + if root.find("prompt") is not None: + return True + def execute(text): + shell.send_text(text + "\n") + 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")) + return True + + def on_double_tap(event): + if event.key == "shift": + return open_prompt() + return False + + app.on_double_tap = on_double_tap + def spawn_terminal(): refs["term_count"] += 1 nth = refs["term_count"] diff --git a/test_tai.py b/test_tai.py index b61170d..77de4b1 100644 --- a/test_tai.py +++ b/test_tai.py @@ -3895,6 +3895,73 @@ class TuiInputTests(unittest.TestCase): self.assertEqual(events[0].key, "shift+up") +class TuiKittyTests(unittest.TestCase): + def feed(self, *chunks): + parser = tai.TuiInput() + events = [] + for chunk in chunks: + events.extend(parser.feed(chunk)) + return parser, events + + def test_shift_press(self): + _parser, events = self.feed(b"\x1b[57441;2u") + self.assertEqual(len(events), 1) + self.assertEqual(events[0].key, "shift") + + def test_shift_right_press(self): + _parser, events = self.feed(b"\x1b[57447;2u") + self.assertEqual(events[0].key, "shift") + + def test_release_ignored(self): + _parser, events = self.feed(b"\x1b[57441;1:3u") + self.assertEqual(events, []) + + def test_repeat_modifier_ignored(self): + _parser, events = self.feed(b"\x1b[57441;2:2u") + self.assertEqual(events, []) + + def test_text_key(self): + _parser, events = self.feed(b"\x1b[97u") + self.assertEqual((events[0].key, events[0].char), ("a", "a")) + + def test_text_repeat_emits(self): + _parser, events = self.feed(b"\x1b[97;1:2u") + self.assertEqual(events[0].key, "a") + + def test_shift_letter_uppercases(self): + _parser, events = self.feed(b"\x1b[97;2u") + self.assertEqual((events[0].key, events[0].char), ("A", "A")) + self.assertEqual(getattr(events[0], "modifiers", []), []) + + def test_ctrl_letter(self): + _parser, events = self.feed(b"\x1b[97;5u") + self.assertEqual(events[0].key, "ctrl+a") + self.assertEqual(events[0].modifiers, ["ctrl"]) + + def test_functional(self): + _parser, events = self.feed(b"\x1b[27u\x1b[13u\x1b[9u\x1b[127u") + self.assertEqual([event.key for event in events], ["escape", "enter", "tab", "backspace"]) + + def test_associated_text_wins(self): + _parser, events = self.feed(b"\x1b[49;2;33u") + self.assertEqual((events[0].key, events[0].char), ("!", "!")) + + def test_split_chunk(self): + parser, events = self.feed(b"\x1b[57441") + self.assertEqual(events, []) + events = parser.feed(b";2u") + self.assertEqual(len(events), 1) + self.assertEqual(events[0].key, "shift") + + def test_unknown_swallowed(self): + _parser, events = self.feed(b"\x1b[59999uq") + self.assertEqual([event.key for event in events], ["q"]) + + def test_stack_forms_ignored(self): + _parser, events = self.feed(b"\x1b[>10u\x1b[?u") + self.assertEqual(events, []) + + class TuiVtTests(unittest.TestCase): def test_text_wrap_and_scroll(self): screen = tai.TuiVtScreen(2, 4) @@ -4075,6 +4142,178 @@ class TuiWidgetTests(unittest.TestCase): self.assertIn(needle, text) +class TuiDoubleTapTests(unittest.TestCase): + def test_fires_on_second_within_window(self): + tap = tai.TuiDoubleTap(timeout=0.4) + self.assertFalse(tap.hit("shift", 100.0)) + self.assertTrue(tap.hit("shift", 100.2)) + + def test_no_fire_across_window(self): + tap = tai.TuiDoubleTap(timeout=0.4) + self.assertFalse(tap.hit("shift", 100.0)) + self.assertFalse(tap.hit("shift", 100.5)) + self.assertTrue(tap.hit("shift", 100.6)) + + def test_triple_fires_once(self): + tap = tai.TuiDoubleTap(timeout=0.4) + self.assertFalse(tap.hit("shift", 100.0)) + self.assertTrue(tap.hit("shift", 100.1)) + self.assertFalse(tap.hit("shift", 100.2)) + + def test_other_key_resets(self): + tap = tai.TuiDoubleTap(timeout=0.4) + self.assertFalse(tap.hit("shift", 100.0)) + self.assertFalse(tap.hit("ctrl", 100.1)) + self.assertFalse(tap.hit("shift", 100.2)) + self.assertTrue(tap.hit("shift", 100.3)) + + def test_default_timeout(self): + self.assertEqual(tai.TuiDoubleTap().timeout, 0.4) + + +class TuiTextBoxTests(unittest.TestCase): + def make_box(self, **kwargs): + app = tai.TuiApp() + app.rows = 10 + app.cols = 40 + root = tai.TuiPanel(flow=None) + kwargs.setdefault("x", 1) + kwargs.setdefault("y", 1) + kwargs.setdefault("w", 20) + kwargs.setdefault("h", 3) + box = tai.TuiTextBox(**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 test_insert_and_value(self): + app, box = self.make_box() + self.assertIs(app.focused, box) + for char in "abc": + self.key(app, char, char) + self.assertEqual(box.value, "abc") + self.assertEqual((box.cx, box.cy), (3, 0)) + + def test_enter_splits_line(self): + app, box = self.make_box() + for char in "ab": + self.key(app, char, char) + self.key(app, "left", "") + self.key(app, "enter", "\n") + self.assertEqual(box.value, "a\nb") + self.assertEqual((box.cx, box.cy), (0, 1)) + + def test_backspace_joins_lines(self): + app, box = self.make_box() + for char in "a": + self.key(app, char, char) + self.key(app, "enter", "\n") + self.key(app, "b", "b") + self.key(app, "left", "") + self.key(app, "left", "") + self.key(app, "backspace", "\x7f") + self.assertEqual(box.value, "ab") + self.assertEqual((box.cx, box.cy), (1, 0)) + + def test_delete_joins_lines(self): + app, box = self.make_box() + for char in "a": + self.key(app, char, char) + self.key(app, "enter", "\n") + self.key(app, "b", "b") + self.key(app, "up", "") + self.key(app, "delete", "") + self.assertEqual(box.value, "ab") + self.assertEqual((box.cx, box.cy), (1, 0)) + + def test_home_end_arrows(self): + app, box = self.make_box() + for char in "abc": + self.key(app, char, char) + self.key(app, "home", "") + self.assertEqual(box.cx, 0) + self.key(app, "end", "") + self.assertEqual(box.cx, 3) + self.key(app, "left", "") + self.assertEqual(box.cx, 2) + + def test_stop_and_bubble(self): + _app, box = self.make_box() + event = tai.TuiKey(key="a", char="a") + box.emit(event) + self.assertTrue(event.stopped) + event = tai.TuiKey(key="escape", char="\x1b") + box.emit(event) + self.assertFalse(event.stopped) + + def test_click_positions_cursor(self): + app, box = self.make_box() + for char in "hello": + self.key(app, char, char) + app.layout() + app._dispatch_click(tai.TuiClick(x=4, y=2, button=0, modifiers=[], pressed=True)) + self.assertIs(app.focused, box) + self.assertEqual((box.cx, box.cy), (2, 0)) + + def test_space_inserts(self): + app, box = self.make_box() + self.key(app, "space", " ") + self.assertEqual(box.value, " ") + + +class TuiPromptDialogTests(unittest.TestCase): + def make_demo(self): + app = tai.TuiApp() + app.rows = 30 + app.cols = 100 + root, refs = tai.tui_build_demo(app, live=False) + app.mount(root) + app.dispatch(tai.TuiKey(key="enter", char="\r")) + app.double_tap.timeout = 3600 + return app, root, refs + + def test_double_shift_opens(self): + app, root, _refs = self.make_demo() + app.dispatch(tai.TuiKey(key="shift", char="")) + self.assertIsNone(root.find("prompt")) + app.dispatch(tai.TuiKey(key="shift", char="")) + dialog = root.find("prompt") + self.assertIsNotNone(dialog) + self.assertIs(app.focused, dialog.box) + + def test_ok_executes(self): + app, root, refs = self.make_demo() + app.dispatch(tai.TuiKey(key="shift", char="")) + app.dispatch(tai.TuiKey(key="shift", char="")) + for char in "hi": + app.dispatch(tai.TuiKey(key=char, char=char)) + app.dispatch(tai.TuiKey(key="tab", char="\t")) + app.dispatch(tai.TuiKey(key="enter", char="\r")) + self.assertIsNone(root.find("prompt")) + self.assertEqual(bytes(refs["shell"]._pending), b"hi\n") + + def test_cancel_closes_without_executing(self): + app, root, refs = self.make_demo() + app.dispatch(tai.TuiKey(key="shift", char="")) + app.dispatch(tai.TuiKey(key="shift", char="")) + app.dispatch(tai.TuiKey(key="x", char="x")) + app.dispatch(tai.TuiKey(key="escape", char="\x1b")) + self.assertIsNone(root.find("prompt")) + self.assertEqual(bytes(refs["shell"]._pending), b"") + + def test_double_tab_still_cycles(self): + app, root, _refs = self.make_demo() + actions = root.find("actions") + self.assertIs(app.focused, actions) + app.dispatch(tai.TuiKey(key="tab", char="\t")) + app.dispatch(tai.TuiKey(key="tab", char="\t")) + self.assertIs(app.focused, actions) + self.assertIsNone(root.find("prompt")) + + class TuiCommandTests(unittest.TestCase): def test_demo_in_commands_and_help(self): self.assertIn("demo", tai.COMMANDS) diff --git a/tui.md b/tui.md index 7ed047d..d0f90f6 100644 --- a/tui.md +++ b/tui.md @@ -130,6 +130,23 @@ and modified arrows (`shift+up` style). Tab/Shift+Tab always move focus (see Focus above); terminals receive every other key. Ctrl+C always quits the app loop. +Kitty keyboard: setup pushes progressive-enhancement flags 10 +(all-keys-as-escape-codes plus event types) best-effort and pops on +teardown; unsupported terminals ignore both bytes. The parser decodes +`CSI unicode-key-code:alternate ; mods:event-type ; text u` next to +the legacy grammar, so kitty and legacy terminals share one stream: +bare modifier presses arrive as `shift`/`ctrl`/`alt`/`super` key +events (press only; repeats and releases are dropped, text-key +repeats still type), text stays legacy-identical (associated-text +tail wins, shift+letter uppercases, US-shift map covers the rest), +super/hyper/meta/locks are dropped. Only kitty-protocol terminals +(kitty, WezTerm, foot, ghostty) report bare Shift at all; everywhere +else no bytes exist to detect, so double-Shift chords only work +there. `TuiDoubleTap(timeout=0.4)` matches two same-key presses on +an injectable clock; set `app.on_double_tap` to a callback returning +True when it acted (falsy falls through to normal handling, so fast +double-Tab still cycles). + ## Animation and timers ```python @@ -157,6 +174,14 @@ 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, + click positions the cursor, Escape bubbles for dialogs; + `.value` joins lines. +- `TuiPromptDialog(label, box_h, on_execute)` - 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. - `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. @@ -177,7 +202,9 @@ 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, no kitty keyboard protocol, no true opacity blending. + 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 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