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 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:06:35 +02:00
co-authored by Claude Sonnet 5.5
parent 3c3c498b49
commit 72f337f012
3 changed files with 444 additions and 110 deletions
+293 -101
View File
@@ -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():
+137 -5
View File
@@ -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()
+14 -4
View File
@@ -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