Update
This commit is contained in:
+4
-3
@@ -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. |
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user