TUI loop is asyncio: readers/writers plus frame clock, agent turns offloaded to a thread so the UI never freezes; spinner while thinking

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 12:23:57 +02:00
co-authored by Claude Sonnet 5.5
parent ac813ab731
commit e8e88cb741
3 changed files with 238 additions and 73 deletions
+192 -66
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# retoor <retoor@molodetz.nl>
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()
+37
View File
@@ -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
+9 -7
View File
@@ -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