From 72f337f012cba492885e235d66f75666c00b1c22 Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 12 Oct 2026 13:06:35 +0200 Subject: [PATCH] Chat TUI: one session per bot, so a long task in one bot never blocks talking to another; background approvals wait for you, counted stdio guard for concurrent turns Co-Authored-By: Claude Sonnet 5.5 --- tai.py | 394 ++++++++++++++++++++++++++++++++++++++-------------- test_tai.py | 142 ++++++++++++++++++- tui.md | 18 ++- 3 files changed, 444 insertions(+), 110 deletions(-) diff --git a/tai.py b/tai.py index b1181a5..bb248d5 100755 --- a/tai.py +++ b/tai.py @@ -97,7 +97,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.31.0" +VERSION = "1.32.0" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -12006,7 +12006,7 @@ def tui_avatar_role(name): class TuiChatList(TuiOptions): """Two-line chat rows: avatar chip + name / snippet. Options carry (label, value) as usual; row_meta maps value -> {"snippet": str, - "draft": str, "busy": bool}. Selection moves per chat while each chat + "draft": str, "busy": bool or a spinner glyph, "unread": int}. Selection moves per chat while each chat paints two terminal rows; the selected chat gets an accent bar.""" def __init__(self, options=(), **kwargs): @@ -12043,7 +12043,11 @@ class TuiChatList(TuiOptions): if sub == 0: chip = TuiSpan(" %s " % (label[:1].upper() or "?"), TuiStyle(fg="role:on_accent", bg=tint, bold=True)) name = TuiSpan(" " + label[:max(0, width - 6)], base.merged(TuiStyle(bold=active or focused))) - badge = [TuiSpan(" ●", base.merged(TuiStyle(fg="role:success")))] if meta.get("busy") else [] + busy = meta.get("busy") + badge = [TuiSpan(" %s" % (busy if isinstance(busy, str) else "●"), base.merged(TuiStyle(fg="role:warning", bold=True)))] if busy else [] + unread = int(meta.get("unread") or 0) + if unread: + badge.append(TuiSpan(" %d " % min(unread, 99), TuiStyle(fg="role:on_accent", bg="role:accent", bold=True))) rows.append(tui_pad_spans([bar, chip, name] + badge, width, base)) else: snippet = str(meta.get("snippet") or "") @@ -14099,15 +14103,116 @@ class TuiChatLog(TuiLog): return out -def tui_build_chat(app, agent): +class TuiChatSession: + """One bot's conversation inside the chat scene: its own agent, its own + journal of rendered blocks, its own turn state. Bots run independently, + so a long task in one never blocks talking to another.""" + + def __init__(self, name, agent): + self.name = name + self.agent = agent + self.blocks = [] + self.pending = None + self.busy = False + self.phase = "idle" + self.detail = "" + self.since = 0.0 + self.reveal = None + self.pending_tick = None + self.note = "" + self.unread = 0 + self.queued = None + + +class TuiChatState(dict): + """The chat scene's refs. Turn and journal keys read and write the + active session, everything else is a plain dict entry, so scene code + says refs["busy"] and means the bot on screen.""" + + PROXIED = ("blocks", "pending", "busy", "phase", "detail", "since", "reveal", "pending_tick", "note") + + def __init__(self, session): + super().__init__() + self.session = session + + def __getitem__(self, key): + if key in TuiChatState.PROXIED: + return getattr(self.session, key) + return super().__getitem__(key) + + def __setitem__(self, key, value): + if key in TuiChatState.PROXIED: + setattr(self.session, key, value) + else: + super().__setitem__(key, value) + + def get(self, key, default=None): + if key in TuiChatState.PROXIED: + return getattr(self.session, key) + return super().get(key, default) + + +class TuiStdioGuard: + """Counted stdout/stdin swap for turn threads. Several bots can run at + once; a plain redirect_stdout per thread would restore in the wrong order + and leave sys.stdout pointing at a dead buffer. The first thread in swaps, + the last one out restores.""" + + def __init__(self): + self.lock = threading.Lock() + self.depth = 0 + self.saved = None + + def __enter__(self): + with self.lock: + if self.depth == 0: + self.saved = (sys.stdout, sys.stdin) + sys.stdout = io.StringIO() + sys.stdin = io.StringIO("") + self.depth += 1 + return self + + def __exit__(self, *_exc): + with self.lock: + self.depth -= 1 + if self.depth == 0 and self.saved is not None: + sys.stdout, sys.stdin = self.saved + self.saved = None + return False + + +TUI_STDIO_GUARD = TuiStdioGuard() +TUI_CHAT_BLOCK_CAP = 300 + + +def tui_spawn_agent(base, name): + """A fresh Agent for another bot, sharing the vault with base. Starts + with empty messages so switch_bot never saves a stale copy of base's + conversation over the real one.""" + child = Agent(base.config, base.store, yolo=base.yolo, auto=base.auto) + child.messages = [] + child.switch_profile(base.profile, silent=True) + child.messages = [] + child.switch_bot(name, silent=True) + child.quiet = True + child.env = base.env + child.chat.prefer = base.chat.prefer + child.chat.provider_pin = base.chat.provider_pin + child.chat.model_pin = base.chat.model_pin + return child + + +def tui_build_chat(app, agent, spawn=None): """Chat scene: bots sidebar, header, markdown transcript, rounded composer, status bar. Turns run inline via a deferred timer (see run_pending): the worker thread the research sketch assumed would share one sqlite connection across threads, which sqlite forbids, so the echo 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, "busy": False, "spin": 0, "since": 0.0, "phase": "idle", "detail": "", "reveal": None} + spawn = spawn or tui_spawn_agent + sessions = {agent.bot: TuiChatSession(agent.bot, agent)} + refs = TuiChatState(sessions[agent.bot]) + refs.update({"drafts": {}, "sidebar_auto": False, "sidebar_hide": False, "last_key": None, + "bots": [], "render_w": 0, "spin": 0, "sessions": sessions}) 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") @@ -14209,22 +14314,30 @@ def tui_build_chat(app, agent): if transcript.aw and transcript._content_w() != refs["render_w"]: replay() - def journal(block): - """Journal one block and render it when laid out. Returns the block - (stable across replays) so callers can mutate it and replay.""" - refs["blocks"].append(block) + def cur(): + return refs.session + + def journal(block, session=None): + """Journal one block into a session. The active session renders it + when laid out (returns the block, stable across replays, so callers + can mutate it and replay); a background session only keeps it.""" + session = session or cur() + session.blocks.append(block) + if session is not cur(): + del session.blocks[:-TUI_CHAT_BLOCK_CAP] + return block if transcript.aw: render_block(block) - if len(transcript.lines) > TUI_CHAT_LINE_CAP and len(refs["blocks"]) > 1: - refs["blocks"].pop(0) + if len(transcript.lines) > TUI_CHAT_LINE_CAP and len(session.blocks) > 1: + session.blocks.pop(0) replay() return block - def bubble(side, text, stamp="", ticks="", ticks_read=False, sender=""): - return journal(["msg", side, text, stamp, ticks, ticks_read, sender]) + def bubble(side, text, stamp="", ticks="", ticks_read=False, sender="", session=None): + return journal(["msg", side, text, stamp, ticks, ticks_read, sender], session) - def note(text, side="note"): - return journal(["note", side, text]) + def note(text, side="note", session=None): + return journal(["note", side, text], session) def pill(text): return journal(["pill", text]) @@ -14238,9 +14351,16 @@ def tui_build_chat(app, agent): return note(text) return bubble("in", text, sender=agent.bot) - def render_history(): - refs["blocks"] = [] - refs["pending_tick"] = None + def render_history(force=True): + """Rebuild the active bot's journal from its messages. force=False + keeps an existing journal (switching back to a bot you already + opened) and just replays it.""" + session = cur() + if session.blocks and not force: + replay() + return + session.blocks = [] + session.pending_tick = None transcript.lines = [] refs["last_key"] = None past = [msg for msg in agent.messages if msg.get("role") != "system"][-TUI_CHAT_HISTORY:] @@ -14295,6 +14415,9 @@ def tui_build_chat(app, agent): spans.append(TuiSpan(".", TuiStyle(bg=panel, fg=tint if pos < lit else "role:border", bold=pos < lit))) return word + "...", glyph, TuiStyle(bg=panel, fg=tint, bold=True), spans + def others_busy(): + return sum(1 for item in sessions.values() if item is not cur() and item.busy) + def paint_status(): width = max(20, app.cols) key_style = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True) @@ -14311,7 +14434,7 @@ def tui_build_chat(app, agent): pins = " ".join(part for part in (agent.chat.provider_pin, agent.chat.model_pin) if part) right = [[TuiSpan(" %s/%s " % (agent.profile, agent.bot), TuiStyle(bg="role:accent", fg="role:on_accent", bold=True))], [TuiSpan(" %s " % (pins or "auto"), TuiStyle(bg="role:service_bg", fg="role:service_fg"))]] - busy = running_agents() + busy = running_agents() + others_busy() if busy: right.append([TuiSpan(" +%d " % busy, TuiStyle(bg="role:warning", fg="role:bg", bold=True))]) @@ -14368,40 +14491,76 @@ def tui_build_chat(app, agent): return content.split("\n")[0][:40] return "" + def bot_meta(name): + """Sidebar flags from live sessions only (no vault reads), so the + busy spinner and unread count can repaint at animation speed.""" + item = sessions.get(name) + busy = bool(item and (item.busy or item.pending is not None)) + glyph = TUI_CHAT_SPINNER[refs["spin"] % len(TUI_CHAT_SPINNER)] if busy else "" + if item is not None and item.queued is not None: + glyph = "?" + return {"busy": glyph, "unread": item.unread if item else 0} + + def refresh_meta(): + for name, meta in sidebar.row_meta.items(): + meta.update(bot_meta(name)) + app.dirty_paint() + def refresh_bots(): - if refs["busy"]: - app.dirty_paint() - return names = [item["name"] for item in agent.store.list_bots(agent.profile)] + for name in sessions: + if name not in names: + names.append(name) if agent.bot not in names: names = [agent.bot] + names refs["bots"] = names sidebar.options = [TuiOptions.norm_option(name) for name in names] 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 or refs["busy"]) and name == agent.bot} - for name in names} + sidebar.row_meta = {} + for name in names: + meta = {"snippet": bot_snippet(name), "draft": refs["drafts"].get(name, "").split("\n")[0][:40]} + meta.update(bot_meta(name)) + sidebar.row_meta[name] = meta app.dirty_paint() + def activate(session): + nonlocal agent + refs.session = session + agent = session.agent + if box.history.backend is not None: + box.history.backend.bot = lambda: agent.bot + + def session_for(name): + if name not in sessions: + sessions[name] = TuiChatSession(name, spawn(agent, name)) + return sessions[name] + def select_bot(name): - if name == agent.bot or refs["busy"]: + """Bring a bot on screen. Every bot has its own agent, so switching + never waits for, interrupts or shares state with a running turn.""" + if name == cur().name: refresh_bots() return - refs["drafts"][agent.bot] = box.value - agent.switch_bot(name, silent=True) - draft = refs["drafts"].get(agent.bot, "") + refs["drafts"][cur().name] = box.value + target = session_for(name) + activate(target) + target.unread = 0 + draft = refs["drafts"].get(name, "") box.lines = draft.split("\n") or [""] box.cx = len(box.lines[-1]) box.cy = len(box.lines) - 1 box.top = 0 box.history.reset() refresh_bots() - render_history() + render_history(force=False) paint_status() + if target.queued is not None: + opener, target.queued = target.queued, None + opener() def run_command(text): buf = io.StringIO() + before = (agent.profile, agent.bot) try: with contextlib.redirect_stdout(buf): stay = handle_command(agent, text) @@ -14414,21 +14573,32 @@ def tui_build_chat(app, agent): if not stay: app.quit() return + if agent.profile != before[0]: + for name in [name for name, item in sessions.items() if item is not cur() and not item.busy]: + del sessions[name] + cur().name = agent.bot + elif agent.bot != before[1]: + moved = agent.bot + agent.switch_bot(before[1], silent=True) + select_bot(moved) refresh_bots() paint_status() - def settle_pending_tick(): - block = refs["pending_tick"] - refs["pending_tick"] = None - if block is None or not any(entry is block for entry in refs["blocks"]): + def settle_pending_tick(session=None): + session = session or cur() + block = session.pending_tick + session.pending_tick = None + if block is None or not any(entry is block for entry in session.blocks): return block[4] = TUI_CHAT_TICK_READ block[5] = True - replay() + if session is cur(): + replay() def do_send(value): text = (value or "").strip() - if not text or refs["pending"] is not None or refs["busy"]: + session = cur() + if not text or session.pending is not None or session.busy: return box.history.add(text) clear_box() @@ -14437,86 +14607,92 @@ def tui_build_chat(app, agent): run_command(text) return stamp = datetime.now().strftime("%H:%M") - refs["pending_tick"] = bubble("out", text, stamp, TUI_CHAT_TICK_SEND) - refs["pending"] = text - refs["note"] = "thinking..." - refs["phase"], refs["detail"] = "thinking", "" + session.pending_tick = bubble("out", text, stamp, TUI_CHAT_TICK_SEND) + session.pending = text + session.note = "thinking..." + session.phase, session.detail = "thinking", "" refresh_bots() paint_status() - app.after(0.05, run_pending) + app.after(0.05, lambda: run_pending(session)) - def run_pending(): - text = refs["pending"] - refs["pending"] = None + def run_pending(session=None): + session = session or cur() + text = session.pending + session.pending = None if not text: return - refs["busy"] = True - refs["since"] = time.time() - agent.on_phase = on_phase - agent.prompter = prompter + session.busy = True + session.since = time.time() + worker = session.agent + worker.on_phase = lambda name, detail="": set_phase(session, name, detail) + worker.prompter = lambda kind, ask, options: prompter(kind, ask, options, session) 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 + with TUI_STDIO_GUARD: + try: + return worker.run_turn(text, capture=True) + except KeyboardInterrupt: + worker.checkpoint() + return "[interrupted]" + except BackendError as exc: + return "error: %s" % short_error(exc) def landed(answer, error): if error is not None: answer = "error: %s" % short_error(error) - settle_pending_tick() + settle_pending_tick(session) full = tui_strip_ansi(answer or "[no reply]") stamp = datetime.now().strftime("%H:%M") - animate = app._loop is not None and len(full) > 12 - block = bubble("in", "" if animate else full, stamp, sender=agent.bot) + foreground = session is cur() + animate = app._loop is not None and len(full) > 12 and foreground + block = bubble("in", "" if animate else full, stamp, sender=session.name, session=session) + if not foreground: + session.unread += 1 if not animate: - finish_turn() + finish_turn(session) return - refs["phase"] = "typing" - refs["reveal"] = {"block": block, "full": full, "shown": 0, + session.phase = "typing" + session.reveal = {"block": block, "full": full, "shown": 0, "step": max(6, -(-len(full) // TUI_CHAT_REVEAL_STEPS))} - app.after(TUI_CHAT_REVEAL_TICK, reveal_step) + app.after(TUI_CHAT_REVEAL_TICK, lambda: reveal_step(session)) app.offload(turn, landed) - def finish_turn(): - refs["busy"] = False - refs["phase"], refs["detail"], refs["reveal"] = "idle", "", None - refs["note"] = "" + def finish_turn(session=None): + session = session or cur() + session.busy = False + session.phase, session.detail, session.reveal = "idle", "", None + session.note = "" refresh_bots() paint_status() - def reveal_step(): + def reveal_step(session=None): """Lay the reply out word by word: real typing, not a fake wait. - Bounded to TUI_CHAT_REVEAL_STEPS ticks however long the reply is.""" - state = refs["reveal"] + Bounded to TUI_CHAT_REVEAL_STEPS ticks however long the reply is. + If you switch away mid-reveal the rest lands at once.""" + session = session or cur() + state = session.reveal if state is None: return full = state["full"] end = min(len(full), state["shown"] + state["step"]) while end < len(full) and not full[end - 1].isspace(): end += 1 + if session is not cur(): + end = len(full) state["shown"] = end block = state["block"] - if any(entry is block for entry in refs["blocks"]): + if any(entry is block for entry in session.blocks): block[2] = full[:end] - replay() + if session is cur(): + replay() if end >= len(full): - finish_turn() + finish_turn(session) else: - app.after(TUI_CHAT_REVEAL_TICK, reveal_step) + app.after(TUI_CHAT_REVEAL_TICK, lambda: reveal_step(session)) - def on_phase(name, detail=""): - refs["phase"], refs["detail"] = name, detail + def set_phase(session, name, detail=""): + session.phase, session.detail = name, detail def style_dialog(dialog): """Dress a stock dialog in the chat theme: surface fill, accent @@ -14540,11 +14716,13 @@ def tui_build_chat(app, agent): node.style = TuiStyle(bg="role:surface", fg="role:fg") return dialog - def prompter(kind, text, options): - """Called from the turn thread: show a real dialog on the loop and - wait for the answer. Returns the chosen option / typed text, or None - when the dialog was dismissed or TUI_CHAT_PROMPT_TIMEOUT passed (so a - walked-away user means deny, never an open-ended hang).""" + def prompter(kind, text, options, session=None): + """Called from a turn thread: show a real dialog on the loop and wait + for the answer. Returns the chosen option / typed text, or None when + the dialog was dismissed or TUI_CHAT_PROMPT_TIMEOUT passed (so a + walked-away user means deny, never an open-ended hang). The title + names the bot asking, since it may not be the one on screen.""" + session = session or cur() loop = app._loop if loop is None: return None @@ -14552,6 +14730,7 @@ def tui_build_chat(app, agent): done = threading.Event() shown = [] text = str(text)[:TUI_CHAT_PROMPT_CHARS] + width = min(72, max(30, app.cols - 6)) def finish(event=None): done.set() @@ -14561,7 +14740,7 @@ def tui_build_chat(app, agent): def ask_text(): dialog = style_dialog(TuiPromptDialog(text if kind == "ask" else "Reply:", box_h=3, on_execute=pick, - title="Question", w=min(72, max(30, app.cols - 6)))) + title="Question - %s" % session.name, w=width)) dialog.on_close = finish shown.append(dialog) app.root.add(dialog) @@ -14575,11 +14754,11 @@ def tui_build_chat(app, agent): def open_dialog(): if kind == "approve": - dialog = TuiQuestion("Run this?\n\n%s" % text, options, on_done=pick, title="Approval needed", - w=min(72, max(30, app.cols - 6))) + dialog = TuiQuestion("Run this?\n\n%s" % text, options, on_done=pick, + title="Approval needed - %s" % session.name, w=width) elif options: dialog = TuiQuestion(text, list(options) + [("Type an answer...", TUI_CHAT_TYPE_ANSWER)], on_done=chosen, - title="Question", w=min(72, max(30, app.cols - 6))) + title="Question - %s" % session.name, w=width) else: ask_text() return @@ -14596,20 +14775,30 @@ def tui_build_chat(app, agent): app.root.add(dialog) app.dirty_layout() - before = refs["phase"], refs["detail"] - refs["phase"], refs["detail"] = "ask", "" - loop.call_soon_threadsafe(open_dialog) + before = session.phase, session.detail + session.phase, session.detail = "ask", "" + if session is cur(): + loop.call_soon_threadsafe(open_dialog) + else: + session.queued = lambda: loop.call_soon_threadsafe(open_dialog) + loop.call_soon_threadsafe(refresh_meta) if not done.wait(TUI_CHAT_PROMPT_TIMEOUT): + session.queued = None for dialog in shown: loop.call_soon_threadsafe(dialog.close) - refs["phase"], refs["detail"] = before + session.queued = None + session.phase, session.detail = before return answer["value"] def spin(): - if not refs["busy"]: + live = [item for item in sessions.values() if item.busy] + if not live: return refs["spin"] += 1 - refs["note"] = "%s... %ds" % (phase_word(), int(time.time() - refs["since"])) + for item in live: + word = "typing" if item.phase == "typing" else ("running %s" % (item.detail or "tool") if item.phase == "tool" else ("waiting for you" if item.phase == "ask" else "thinking")) + item.note = "%s... %ds" % (word, int(time.time() - item.since)) + refresh_meta() paint_status() def toggle_sidebar(): @@ -14672,7 +14861,7 @@ def tui_build_chat(app, agent): refresh_bots() render_history() paint_status() - refs.update({"do_send": do_send, "run_pending": run_pending, "toggle_sidebar": toggle_sidebar, + refs.update({"do_send": do_send, "run_pending": run_pending, "select_bot": select_bot, "toggle_sidebar": toggle_sidebar, "paint_status": paint_status, "prompter": prompter, "show": show, "replay": replay, "add_note": note, "render_history": render_history}) return root, refs @@ -14685,6 +14874,7 @@ def tui_chat(agent, theme_name): app = TuiApp() app.rows, app.cols = app._winsize() scheduler_stop = start_scheduler(agent.config, agent.store.seal) + refs = None try: root, refs = tui_build_chat(app, agent) app.mount(root) @@ -14696,7 +14886,9 @@ def tui_chat(agent, theme_name): return 2 finally: scheduler_stop.set() - agent.store.save_session(agent.profile, agent.messages, agent.bot) + owners = [item.agent for item in refs["sessions"].values()] if refs is not None else [agent] + for owner in owners: + owner.store.save_session(owner.profile, owner.messages, owner.bot) def build_parser(): diff --git a/test_tai.py b/test_tai.py index 6693762..0240662 100644 --- a/test_tai.py +++ b/test_tai.py @@ -5859,11 +5859,18 @@ class TuiChatTests(unittest.TestCase): app.rows = rows app.cols = cols agent = FakeChatAgent() - root, refs = tai.tui_build_chat(app, agent) + root, refs = tai.tui_build_chat(app, agent, spawn=self.spawn) app.mount(root) refs["replay"]() return app, agent, root, refs + @staticmethod + def spawn(base, name): + child = FakeChatAgent() + child.bot = name + child.messages = [{"role": "system", "content": "sys"}] + return child + def key(self, app, key, char=""): app.dispatch(tai.TuiKey(key=key, char=char)) @@ -6319,6 +6326,128 @@ class TuiChatTests(unittest.TestCase): self.key(app, "enter") self.assertEqual(box.history.entries[-1].strip(), "/help") + def test_other_bot_stays_usable_while_one_is_busy(self): + """A long task in one bot must not block talking to another.""" + import asyncio + import threading + app, agent, _root, refs = self.make_chat() + gate = threading.Event() + agent.run_turn = lambda text, capture=False: (gate.wait(5), "slow answer to " + text)[1] + refs["box"].focus() + seen = {} + + async def tick(seconds): + end = time.time() + seconds + while time.time() < end: + app._fire_timers(time.time() + 1) + await asyncio.sleep(0.01) + + async def go(): + app._loop = asyncio.get_running_loop() + refs["do_send"]("long ssh task") + await tick(0.15) + self.assertTrue(refs.session.busy) + main = refs.session + refs["select_bot"]("coder") + self.assertEqual(refs.session.name, "coder") + self.assertFalse(refs.session.busy) + self.assertEqual(refs["head_status"].text, "online") + coder_agent = refs.session.agent + self.assertIsNot(coder_agent, agent) + refs["do_send"]("hi coder") + for _ in range(100): + await tick(0.02) + if coder_agent.turns: + break + seen["coder_turns"] = list(coder_agent.turns) + for _ in range(200): + await tick(0.02) + if not refs.session.busy: + break + seen["coder_done"] = not refs.session.busy + seen["main_still_busy"] = main.busy + seen["sidebar_busy"] = bool(refs["sidebar"].row_meta["main"]["busy"]) + gate.set() + for _ in range(300): + await tick(0.02) + if not main.busy: + break + seen["main_done"] = not main.busy + seen["main_unread"] = main.unread + seen["main_text"] = [block[2] for block in main.blocks if block[0] == "msg" and block[1] == "in"] + app._loop = None + + asyncio.run(go()) + self.assertEqual(seen["coder_turns"], ["hi coder"]) + self.assertTrue(seen["coder_done"]) + self.assertTrue(seen["main_still_busy"]) + self.assertTrue(seen["sidebar_busy"]) + self.assertTrue(seen["main_done"]) + self.assertEqual(seen["main_unread"], 1) + self.assertEqual(seen["main_text"][-1], "slow answer to long ssh task") + refs["select_bot"]("main") + self.assertEqual(refs.session.unread, 0) + self.assertIn("slow answer to long ssh task", " ".join(line for line, _spans in refs["transcript"].lines)) + + def test_background_prompt_waits_until_you_open_that_bot(self): + import asyncio + app, _agent, _root, refs = self.make_chat() + result = [] + seen = {} + + async def go(): + app._loop = asyncio.get_running_loop() + refs["select_bot"]("coder") + coder = refs.session + refs["select_bot"]("main") + app.offload(lambda: refs["prompter"]("approve", "ls", ["Allow once", "Deny"], coder), + lambda value, error: result.append(value)) + await asyncio.sleep(0.2) + seen["modal_while_away"] = app.top_modal() is not None + seen["badge"] = refs["sidebar"].row_meta["coder"].get("busy") + refs["do_send"]("main is still usable") + seen["main_usable"] = refs.session.pending == "main is still usable" + refs["select_bot"]("coder") + for _ in range(300): + await asyncio.sleep(0.01) + if app.top_modal() is not None: + break + app.layout() + self.shown = app.render_text() + self.key(app, "enter") + for _ in range(300): + await asyncio.sleep(0.01) + if result: + break + app._loop = None + + asyncio.run(go()) + self.assertFalse(seen["modal_while_away"]) + self.assertEqual(seen["badge"], "?") + self.assertTrue(seen["main_usable"]) + self.assertIn("Approval needed - coder", self.shown) + self.assertEqual(result, ["Allow once"]) + + def test_stdio_guard_is_counted_across_threads(self): + guard = tai.TuiStdioGuard() + real = (sys.stdout, sys.stdin) + with guard: + inner = sys.stdout + self.assertIsNot(inner, real[0]) + with guard: + self.assertIs(sys.stdout, inner) + self.assertIs(sys.stdout, inner) + self.assertEqual((sys.stdout, sys.stdin), real) + + def test_bot_command_switches_session_not_the_shared_agent(self): + app, agent, _root, refs = self.make_chat() + original = agent.bot + agent.handle = None + with mock.patch.object(tai, "handle_command", side_effect=lambda a, text: (setattr(a, "bot", "coder"), True)[1]): + refs["do_send"]("/bot coder") + self.assertEqual(agent.bot, original) + self.assertEqual(refs.session.name, "coder") + def test_hidden_sidebar_frees_its_columns(self): app, _agent, _root, refs = self.make_chat(cols=100) right = refs["transcript"].parent @@ -6427,11 +6556,14 @@ class TuiChatTests(unittest.TestCase): refs["box"].focus() self.type(app, "draft-1") refs["sidebar"].choose(1) - self.assertEqual(agent.bot, "coder") + self.assertEqual(refs.session.name, "coder") + self.assertEqual(refs.session.agent.bot, "coder") + self.assertEqual(agent.bot, "main") self.assertEqual(refs["box"].value, "") self.assertEqual(refs["head_name"].text, "coder") refs["sidebar"].choose(0) - self.assertEqual(agent.bot, "main") + self.assertEqual(refs.session.name, "main") + self.assertIs(refs.session.agent, agent) self.assertEqual(refs["box"].value, "draft-1") def test_strip_ansi(self): @@ -6573,10 +6705,10 @@ class TuiChatTests(unittest.TestCase): sidebar = refs["sidebar"] event = tai.TuiClick(panel=sidebar, x=sidebar.ax + 2, y=sidebar.ay + 2, pressed=True) sidebar.on_click(event) - self.assertEqual(agent.bot, "coder") + self.assertEqual(refs.session.name, "coder") event = tai.TuiClick(panel=sidebar, x=sidebar.ax + 2, y=sidebar.ay + 0, pressed=True) sidebar.on_click(event) - self.assertEqual(agent.bot, "main") + self.assertEqual(refs.session.name, "main") def test_send_button_press_sends(self): _app, _agent, root, refs = self.make_chat() diff --git a/tui.md b/tui.md index 2b996c6..7f0078e 100644 --- a/tui.md +++ b/tui.md @@ -288,10 +288,20 @@ the automatic rule until toggled again. Turns run through `app.offload`: your message paints first, the turn runs on its own thread, and the reply lands back on the loop, so the UI keeps rendering, themes switch, resizes reflow and the sidebar scrolls -while the model works. 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. -The loop keeps the real stdout fd from setup, because the turn thread +while the model works. Every bot is its own session +(`TuiChatSession`: its own `Agent`, journal, draft, turn state), so a long +task in one bot never blocks talking to another: switch bots in the +sidebar and chat on while the first keeps working. A busy bot shows a +spinner in the sidebar, a finished reply you were not looking at shows an +unread count, and its journal is intact when you switch back. Only the +bot on screen refuses a second send while its own turn runs. An approval +or question from a bot you are not looking at does not pop up over the bot +you are talking to: it waits (`?` badge in the sidebar) and opens when you +open that bot; its dialog title names the bot. Sessions share one vault +(the store connection is `check_same_thread=False`, sqlite is serialized) +and a counted `TuiStdioGuard` swaps stdout/stdin once for all turn threads. +Slash commands run on the bot on screen; `/bot x` switches the screen to +that bot's own session instead of retargeting the shared agent. The loop keeps the real stdout fd from setup, because the turn thread swaps `sys.stdout`. The header status only says what is true. The agent reports its real