TUI: bracketed paste plus slash-command autocomplete

Paste: enable CSI ? 2004 mode, parse ESC[200~...ESC[201~ into one
TuiPaste event routed to the focused panel, literal body, over-cap
pastes dropped whole. TextBox inserts with CR/CRLF normalized,
Terminal queues bytes to the pty, unmarked CRLF coalesces to one
Enter, idle flush no longer shreds slow pastes.

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