This commit is contained in:
2026-10-11 00:10:46 +02:00
parent 871f3e5ae6
commit 16211480a7
5 changed files with 637 additions and 8 deletions
+4 -3
View File
@@ -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
@@ -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. |
+311 -4
View File
@@ -9327,6 +9327,10 @@ TUI_ALT_ENTER = "\x1b[?1049h"
TUI_ALT_LEAVE = "\x1b[?1049l"
TUI_KITTY_ENABLE = "\x1b[>10u"
TUI_KITTY_DISABLE = "\x1b[<u"
class TuiCompositor:
"""Paints panels to runs, resolves occlusion per row through cuts and
@@ -9497,6 +9501,21 @@ TUI_TILDE_KEYS = {"1": "home", "2": "insert", "3": "delete", "4": "end", "5": "p
TUI_SS3_KEYS = {"P": "f1", "Q": "f2", "R": "f3", "S": "f4", "A": "up", "B": "down", "C": "right", "D": "left", "H": "home", "F": "end"}
TUI_KITTY_NAMED = {27: "escape", 13: "enter", 9: "tab", 127: "backspace", 57414: "enter",
57417: "left", 57418: "right", 57419: "up", 57420: "down", 57421: "pageup",
57422: "pagedown", 57423: "home", 57424: "end", 57425: "insert", 57426: "delete"}
TUI_KITTY_MODS = {57441: "shift", 57447: "shift", 57442: "ctrl", 57448: "ctrl",
57443: "alt", 57449: "alt", 57444: "super", 57450: "super"}
TUI_KITTY_PAD = {57400: "0", 57401: "1", 57402: "2", 57403: "3", 57404: "4", 57405: "5",
57406: "6", 57407: "7", 57408: "8", 57409: ".", 57410: "/", 57411: "*",
57412: "-", 57413: "+", 57415: "="}
TUI_KITTY_US_SHIFT = {"1": "!", "2": "@", "3": "#", "4": "$", "5": "%", "6": "^", "7": "&",
"8": "*", "9": "(", "0": ")", "-": "_", "=": "+", "[": "{", "]": "}",
"\\": "|", ";": ":", "'": '"', ",": "<", ".": ">", "/": "?", "`": "~"}
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"]
+239
View File
@@ -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)
+28 -1
View File
@@ -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