diff --git a/tai.py b/tai.py index 4f7a90b..eb11f83 100755 --- a/tai.py +++ b/tai.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 # retoor import argparse +import asyncio import base64 import codecs import collections @@ -96,7 +97,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.27.0" +VERSION = "1.28.0" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -2667,7 +2668,7 @@ class Store: def __init__(self, config, seal): self.config = config self.seal = seal - self.db = sqlite3.connect(config.db_path, timeout=30) + self.db = sqlite3.connect(config.db_path, timeout=30, check_same_thread=False) self.db.execute("PRAGMA journal_mode=WAL") self.db.execute("PRAGMA synchronous=NORMAL") self.db.execute("PRAGMA busy_timeout=30000") @@ -12723,10 +12724,13 @@ class TuiDoubleTap: class TuiApp: """Owns the screen: terminal setup, tick loop, focus, timers, routing. - One synchronous loop: select stdin plus sub-terminal ptys, dispatch - input, advance timers and animations, relayout on demand, composite - and write one frame. Dirty flags skip idle frames entirely. Ctrl+C - always quits (terminal state restores in a finally).""" + One asyncio loop: stdin and sub-terminal ptys are loop readers/writers + that dispatch as bytes arrive, a frame clock (1/fps) advances timers + and animations, relayouts on demand, composites and writes one frame. + Dirty flags skip idle frames entirely. Blocking work goes through + offload(): it runs on its own thread and calls back on the loop, so + the UI never freezes. Ctrl+C always quits (terminal state restores in + a finally).""" def __init__(self, root=None, fps=TUI_FPS): self.root = root or TuiPanel(flow=None, style=TuiStyle(bg="black")) @@ -12749,6 +12753,13 @@ class TuiApp: self._resize_pending = False self._saved_termios = None self._old_winch = None + self._loop = None + self._failure = None + self._input_seen = False + self._readers = {} + self._writers = {} + self._eof = set() + self._workers = [] # -- structure --------------------------------------------------- # @@ -13117,53 +13128,133 @@ class TuiApp: def run(self): self._setup() - self.dirty_layout() - self._running = True + try: + asyncio.run(self._main()) + finally: + self._teardown() + if self._failure is not None: + failure, self._failure = self._failure, None + raise failure + return 0 + + def offload(self, work, done): + """Run work() on its own thread and call done(result, error) back on + the loop thread. Without a running loop (headless tests) both run + inline, so callers never branch.""" + loop = self._loop + if loop is None: + try: + result, error = work(), None + except Exception as exc: + result, error = None, exc + done(result, error) + return + + def finish(result, error): + try: + done(result, error) + finally: + self.dirty_paint() + + def body(): + try: + result, error = work(), None + except BaseException as exc: + result, error = None, exc + try: + loop.call_soon_threadsafe(finish, result, error) + except RuntimeError: + pass + + worker = threading.Thread(target=body, name="tui-offload", daemon=True) + self._workers = [item for item in self._workers if item.is_alive()] + [worker] + worker.start() + + def _on_stdin(self, fd): + try: + data = os.read(fd, 65536) + except BlockingIOError: + return + except OSError: + data = b"" + if not data: + self._loop.remove_reader(fd) + self._running = False + return + self._input_seen = True + for event in self.input.feed(data): + self.dispatch(event) + + def _on_term_readable(self, fd, term): + if not term.feed_output(): + self._eof.add(fd) + if self._readers.pop(fd, None) is not None: + self._loop.remove_reader(fd) + + def _on_term_writable(self, term): + term.drain_pending() + + def _sync_terminals(self): + """Make loop readers/writers match the live terminals: add fds of new + ptys, drop closed ones, hold a writer only while input is queued.""" + loop = self._loop + want_r = {} + want_w = {} + for term in self._terminals(): + term.poll_exit() + for fd in term.fds(): + if fd not in self._eof: + want_r[fd] = term + if term.fd is not None and term._pending and term.exit_status is None: + want_w[term.fd] = term + self._eof &= {fd for term in self._terminals() for fd in term.fds()} + for fd in [fd for fd in self._readers if fd not in want_r]: + loop.remove_reader(fd) + del self._readers[fd] + for fd, term in want_r.items(): + if fd not in self._readers: + loop.add_reader(fd, self._on_term_readable, fd, term) + self._readers[fd] = term + for fd in [fd for fd in self._writers if fd not in want_w]: + loop.remove_writer(fd) + del self._writers[fd] + for fd, term in want_w.items(): + if fd not in self._writers: + loop.add_writer(fd, self._on_term_writable, term) + self._writers[fd] = term + + def _loop_failed(self, _loop, context): + """An exception in a loop callback must not print over the screen: + keep it, stop the loop, and re-raise it after the terminal restores.""" + self._failure = context.get("exception") or RuntimeError(context.get("message", "tui loop error")) + self._running = False + + async def _main(self): + loop = asyncio.get_running_loop() + self._loop = loop + loop.set_exception_handler(self._loop_failed) stdin_fd = sys.stdin.fileno() stdout_fd = sys.stdout.fileno() + self.dirty_layout() + self._running = True + loop.add_reader(stdin_fd, self._on_stdin, stdin_fd) + frame = 1.0 / self.fps last = time.time() try: while self._running: - now = time.time() - self._fire_timers(now) - rlist = [stdin_fd] - for term in self._terminals(): - rlist.extend(term.fds()) - term.poll_exit() - wlist = [term.fd for term in self._terminals() if term.fd is not None and term._pending] - try: - readable, writable, _rest = select.select(rlist, wlist, [], 1.0 / self.fps) - except InterruptedError: - continue - except (OSError, ValueError): - break + started = time.time() + self._fire_timers(started) + self._sync_terminals() if self._resize_pending: self._resize_pending = False self.rows, self.cols = self._winsize() self.compositor.invalidate() self.dirty_layout() self.root.emit(TuiResize(panel=self.root, rows=self.rows, cols=self.cols)) - for term in self._terminals(): - if term.fd is not None and term.fd in writable: - term.drain_pending() - for fd in readable: - if fd == stdin_fd: - try: - data = os.read(stdin_fd, 65536) - except OSError: - data = b"" - if not data: - self._running = False - break - for event in self.input.feed(data): - self.dispatch(event) - else: - for term in self._terminals(): - if term.fd == fd: - term.feed_output() - if not readable and not writable: + if not self._input_seen: for event in self.input.flush(): self.dispatch(event) + self._input_seen = False tick_now = time.time() self.tick_animations(tick_now) self.tick_widgets(tick_now - last, tick_now) @@ -13174,9 +13265,19 @@ class TuiApp: TuiApp.write_all(stdout_fd, self.frame_bytes().encode("utf-8")) except OSError: break + await asyncio.sleep(max(0.0, frame - (time.time() - started))) finally: - self._teardown() - return 0 + for fd in list(self._readers): + loop.remove_reader(fd) + for fd in list(self._writers): + loop.remove_writer(fd) + self._readers.clear() + self._writers.clear() + try: + loop.remove_reader(stdin_fd) + except Exception: + pass + self._loop = None def tui_build_demo(app, live=True): @@ -13398,6 +13499,7 @@ def tui_chat_sidebar_w(cols): TUI_CHAT_BUBBLE_MAX = 0.7 TUI_PREFERRED_MODEL = "molodetz" +TUI_CHAT_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏" TUI_CHAT_TICK_SEND = "◷" TUI_CHAT_TICK_SENT = "✓" TUI_CHAT_TICK_READ = "✓✓" @@ -13734,7 +13836,7 @@ def tui_build_chat(app, agent): paints first and the turn blocks the loop like the telegram bot does.""" refs = {"drafts": {}, "pending": None, "note": "", "sidebar_auto": False, "sidebar_hide": False, "last_key": None, "bots": [], "pending_tick": None, - "blocks": [], "render_w": 0} + "blocks": [], "render_w": 0, "busy": False, "spin": 0, "since": 0.0} panel_style = TuiStyle(bg="role:bg", fg="role:fg") dim_style = TuiStyle(bg="role:bg", fg="role:muted") err_style = TuiStyle(bg="role:bg", fg="role:error") @@ -13895,7 +13997,8 @@ def tui_build_chat(app, agent): left = [] if refs["note"]: warn = refs["note"].startswith("thinking") - left.append([TuiSpan(" %s " % ("◌" if warn else "●"), TuiStyle(bg="role:panel", fg="role:warning" if warn else "role:success", bold=True)), + glyph = TUI_CHAT_SPINNER[refs["spin"] % len(TUI_CHAT_SPINNER)] if warn else "●" + left.append([TuiSpan(" %s " % glyph, TuiStyle(bg="role:panel", fg="role:warning" if warn else "role:success", bold=True)), TuiSpan(refs["note"], TuiStyle(bg="role:panel", fg="role:fg", bold=True))]) for keycap, label in (("F2", "Bots"), ("F4", "Theme"), ("Tab", "Focus"), ("i", "Input"), ("/help", "Commands")): left.append([chip(keycap, key_style), TuiSpan(" %s " % label, label_style)]) @@ -13927,7 +14030,7 @@ def tui_build_chat(app, agent): av = TuiStyle(bg=tint, fg="role:on_accent", bold=True) avatar.set([[TuiSpan(" " * 7, av)], [TuiSpan((" %s " % (agent.bot[:1].upper() or "?")).center(7), av)], [TuiSpan(" " * 7, av)]]) head_name.text = agent.bot - typing = refs["pending"] is not None + typing = refs["pending"] is not None or refs["busy"] head_status.text = "typing..." if typing else "online" head_dot.style = TuiStyle(bg="role:panel", fg="role:warning" if typing else "role:success") brand.set([[], [TuiSpan(" ✦ ", TuiStyle(bg="role:panel", fg="role:accent", bold=True)), @@ -13958,6 +14061,9 @@ def tui_build_chat(app, agent): return "" def refresh_bots(): + if refs["busy"]: + app.dirty_paint() + return names = [item["name"] for item in agent.store.list_bots(agent.profile)] if agent.bot not in names: names = [agent.bot] + names @@ -13966,12 +14072,13 @@ def tui_build_chat(app, agent): sidebar.selected = names.index(agent.bot) sidebar.row_meta = {name: {"snippet": bot_snippet(name), "draft": refs["drafts"].get(name, "").split("\n")[0][:40], - "busy": refs["pending"] is not None and name == agent.bot} + "busy": (refs["pending"] is not None or refs["busy"]) and name == agent.bot} for name in names} app.dirty_paint() def select_bot(name): - if name == agent.bot: + if name == agent.bot or refs["busy"]: + refresh_bots() return refs["drafts"][agent.bot] = box.value agent.switch_bot(name, silent=True) @@ -14012,7 +14119,7 @@ def tui_build_chat(app, agent): def do_send(value): text = (value or "").strip() - if not text or refs["pending"] is not None: + if not text or refs["pending"] is not None or refs["busy"]: return clear_box() refs["drafts"][agent.bot] = "" @@ -14032,24 +14139,42 @@ def tui_build_chat(app, agent): refs["pending"] = None if not text: return - old_stdin = sys.stdin - sys.stdin = io.StringIO("") - buried = io.StringIO() - try: - with contextlib.redirect_stdout(buried): - answer = agent.run_turn(text, capture=True) - except KeyboardInterrupt: - agent.checkpoint() - answer = "[interrupted]" - except BackendError as exc: - answer = "error: %s" % short_error(exc) - finally: - sys.stdin = old_stdin - settle_pending_tick() - stamp = datetime.now().strftime("%H:%M") - bubble("in", tui_strip_ansi(answer or "[no reply]"), stamp, sender=agent.bot) - refs["note"] = "" - refresh_bots() + refs["busy"] = True + refs["since"] = time.time() + + def turn(): + old_stdin = sys.stdin + sys.stdin = io.StringIO("") + buried = io.StringIO() + try: + with contextlib.redirect_stdout(buried): + return agent.run_turn(text, capture=True) + except KeyboardInterrupt: + agent.checkpoint() + return "[interrupted]" + except BackendError as exc: + return "error: %s" % short_error(exc) + finally: + sys.stdin = old_stdin + + def landed(answer, error): + refs["busy"] = False + if error is not None: + answer = "error: %s" % short_error(error) + settle_pending_tick() + stamp = datetime.now().strftime("%H:%M") + bubble("in", tui_strip_ansi(answer or "[no reply]"), stamp, sender=agent.bot) + refs["note"] = "" + refresh_bots() + paint_status() + + app.offload(turn, landed) + + def spin(): + if not refs["busy"]: + return + refs["spin"] += 1 + refs["note"] = "thinking... %ds" % int(time.time() - refs["since"]) paint_status() def toggle_sidebar(): @@ -14107,6 +14232,7 @@ def tui_build_chat(app, agent): root.on_resize = on_resize app.every(0.2, tick_input) app.every(1.0, paint_status) + app.every(0.12, spin) on_resize(None) refresh_bots() render_history() diff --git a/test_tai.py b/test_tai.py index 33e0c68..2a81955 100644 --- a/test_tai.py +++ b/test_tai.py @@ -5937,6 +5937,43 @@ class TuiChatTests(unittest.TestCase): app.dispatch(tai.TuiResize(panel=root, rows=24, cols=100)) self.assertTrue(refs["sidebar_wrap"].visible) + def test_offload_keeps_loop_ticking(self): + import asyncio + app = tai.TuiApp() + done = [] + + async def go(): + app._loop = asyncio.get_running_loop() + started = time.time() + app.offload(lambda: (time.sleep(0.3), "x")[1], lambda result, error: done.append((result, error))) + ticks = 0 + while not done: + ticks += 1 + await asyncio.sleep(0.01) + app._loop = None + return ticks, time.time() - started + + ticks, elapsed = asyncio.run(go()) + self.assertEqual(done, [("x", None)]) + self.assertGreater(ticks, 10) + self.assertGreaterEqual(elapsed, 0.3) + + def test_offload_reports_errors_and_runs_inline_headless(self): + app = tai.TuiApp() + got = [] + app.offload(lambda: 1 / 0, lambda result, error: got.append((result, type(error).__name__))) + self.assertEqual(got, [(None, "ZeroDivisionError")]) + + def test_busy_turn_blocks_second_send_and_bot_switch(self): + app, agent, _root, refs = self.make_chat() + refs["do_send"]("one") + self.assertEqual(refs["head_status"].text, "typing...") + refs["do_send"]("two") + self.assertEqual(agent.turns, []) + refs["run_pending"]() + self.assertEqual(agent.turns, ["one"]) + self.assertFalse(refs["busy"]) + def test_hidden_sidebar_frees_its_columns(self): app, _agent, _root, refs = self.make_chat(cols=100) right = refs["transcript"].parent diff --git a/tui.md b/tui.md index d9dacab..dfe15cf 100644 --- a/tui.md +++ b/tui.md @@ -3,7 +3,7 @@ A retained, DOM-like terminal UI toolkit living entirely in `tai.py` (standard library only, no dependencies). Components are plain Python objects in a tree; a compositor merges them bottom-to-top by layer and -z-order every frame; one synchronous tick loop drives input, timers, +z-order every frame; one asyncio loop drives input, timers, animations, and rendering. Run the live tour with `/demo` in the REPL or `./tai.py --demo`. @@ -56,7 +56,7 @@ buttons, Esc closes dialogs, `q` quits demo apps, Ctrl+C always quits. atomic and flicker-free. - **Hit-testing.** Panels index into an 8x4-cell spatial grid rebuilt on every layout; clicks resolve topmost-first inside clip rects. -- **Loop.** `select()` over stdin plus sub-terminal ptys at 30 fps. +- **Loop.** One `asyncio` loop. stdin and sub-terminal ptys are loop readers (and writers while input is queued) that dispatch as bytes arrive; a 30 fps frame clock (`asyncio.sleep`) advances timers, animations and painting. `offload(work, done)` runs blocking work on its own daemon thread and calls `done(result, error)` back on the loop via `call_soon_threadsafe`; without a running loop (headless tests) it runs inline. An exception in a loop callback is kept, the terminal restores, then it is re-raised. Dirty flags skip idle frames entirely. Timers (`after`/`every`), animations, widget `on_tick`, and terminal polling all run on the same clock. Terminal state (raw mode, alt screen, mouse, cursor, @@ -272,11 +272,13 @@ quits from outside the input. Below 80 columns the sidebar auto-hides (`TUI_CHAT_NARROW_COLS`); a manual F2 hide/show wins over the automatic rule until toggled again. -Turns run inline on the loop via a deferred timer: your message and -a `thinking...` status paint first, then the turn blocks (like the -telegram bot) until the reply lands. A worker thread was considered -and rejected: it would share one sqlite connection across threads, -which sqlite forbids. `ask_user` cannot prompt mid-turn here (stdin +Turns run through `app.offload`: your message and a spinner +`thinking... Ns` status paint first, the turn runs on its own thread, +and the reply lands back on the loop, so the UI keeps rendering, themes +switch and the sidebar scrolls while the model thinks. While a turn is +busy a second send and a bot switch are ignored (the thread owns the +agent and the vault); the store connection is opened with +`check_same_thread=False` for that hand-off. `ask_user` cannot prompt mid-turn here (stdin is swapped for a non-tty during the turn, so the tool declines and the agent decides instead). Approvals cannot prompt mid-turn either for the same reason, so gated tools are denied unless the session