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