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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user