TUI: honest phases (thinking/running/typing/online) with animation, real approval and ask_user dialogs instead of silent denial, 18x faster frames, paint on input, fd fixes for turn-thread stdout swap

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 12:45:50 +02:00
co-authored by Claude Sonnet 5.5
parent e8e88cb741
commit 80bb245be7
3 changed files with 534 additions and 66 deletions
+317 -52
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.28.0"
VERSION = "1.29.0"
WORKER_STEPS = 12
CREATE_SKILL_STEPS = 40
FORK_MAX_DEPTH = 2
@@ -7076,18 +7076,24 @@ class Tools:
return "ask_user unavailable in autonomous mode: decide yourself and state the assumption"
if not getattr(self.app, "persist", True) or getattr(self.app, "depth", 0) > 0:
return "ask_user unavailable to background workers: decide yourself and state the assumption"
if not sys.stdin.isatty():
return "error: no interactive user on this stdin, decide yourself and state the assumption"
print(paint("?- " + question, Ansi.CYAN, Ansi.BOLD))
for pos, label in enumerate(options, 1):
print(paint(" %d) %s" % (pos, label), Ansi.DIM))
try:
if options:
raw = input(paint("answer [1-%d, option text, or free input] " % len(options), Ansi.YELLOW))
else:
raw = input(paint("answer (free input, empty skips) ", Ansi.YELLOW))
except (EOFError, KeyboardInterrupt):
return "user gave no answer (input closed): proceed with your best assumption"
prompter = getattr(self.app, "prompter", None)
if prompter is not None:
raw = prompter("ask", question, options)
if raw is None:
return "user gave no answer (input closed): proceed with your best assumption"
else:
if not sys.stdin.isatty():
return "error: no interactive user on this stdin, decide yourself and state the assumption"
print(paint("?- " + question, Ansi.CYAN, Ansi.BOLD))
for pos, label in enumerate(options, 1):
print(paint(" %d) %s" % (pos, label), Ansi.DIM))
try:
if options:
raw = input(paint("answer [1-%d, option text, or free input] " % len(options), Ansi.YELLOW))
else:
raw = input(paint("answer (free input, empty skips) ", Ansi.YELLOW))
except (EOFError, KeyboardInterrupt):
return "user gave no answer (input closed): proceed with your best assumption"
self.app.user_stepped_in = True
answer = raw.strip()
if not answer:
@@ -7974,6 +7980,18 @@ class Agent:
return True
if not self.persist:
return self.config.auto_approve
prompter = getattr(self, "prompter", None)
if prompter is not None:
choice = prompter("approve", command, ["Allow once", "Always allow (approvals off)", "Deny"])
if choice == "Always allow (approvals off)":
self.user_stepped_in = True
self.yolo = True
self.config.auto_approve = True
return True
if choice == "Allow once":
self.user_stepped_in = True
return True
return False
if not sys.stdin.isatty():
return False
print(paint("run: %s" % command, Ansi.YELLOW))
@@ -8161,6 +8179,17 @@ class Agent:
return None, self.run_mention(selectors["bot"], prompt, text, capture, max_steps)
return prompt, None
def phase(self, name, detail=""):
"""Report what the turn is really doing (thinking, tool, typing) to
an optional on_phase(name, detail) listener; listener errors never
reach the turn."""
listener = getattr(self, "on_phase", None)
if listener is not None:
try:
listener(name, detail)
except Exception:
pass
def run_turn(self, text, capture=False, max_steps=MAX_STEPS, _routed=False):
self.apply_system()
if not _routed:
@@ -8203,6 +8232,7 @@ class Agent:
if loud:
spinner = Spinner("thinking")
spinner.start()
self.phase("thinking")
try:
reply = self.chat.complete(self.messages, payload, stream_sink=sink, deadline=self.deadline)
except BackendError as exc:
@@ -8231,12 +8261,14 @@ class Agent:
elif reply["reasoning"]:
last_text = reply["reasoning"]
if last_text:
self.phase("typing")
self.store.log_event(self.profile, "assistant", "message", last_text)
break
aborted = False
step_pairs = []
for pos, call in enumerate(reply["tool_calls"]):
self.show_call(call)
self.phase("tool", call["name"])
call_started = time.time()
try:
result = self.tools.dispatch(call["name"], call["arguments"])
@@ -9703,6 +9735,8 @@ def boot(args):
TUI_FPS = 30
TUI_INPUT_PAINT_GAP = 0.008
TUI_MIN_ROWS = 8
TUI_MIN_COLS = 20
@@ -10023,27 +10057,65 @@ def tui_flatten(spans):
def tui_runs(cells):
"""Merge neighbor cells with equal style into runs. Cells mostly share
one style object, so identity short-circuits the key comparison."""
runs = []
last = last_key = None
for char, style in cells:
if runs and runs[-1][1].key() == style.key():
if style is last:
runs[-1][0].append(char)
else:
runs.append([[char], style])
continue
key = style.key()
last = style
if runs and key == last_key:
runs[-1][0].append(char)
continue
last_key = key
runs.append([[char], style])
return [TuiSpan("".join(chars), style) for chars, style in runs]
def tui_pad_spans(spans, width, fill_style):
cells = tui_flatten(spans)[:max(0, width)]
while len(cells) < width:
cells.append((" ", fill_style))
return tui_runs(cells)
return tui_slice_spans(spans, 0, width, fill_style)
def tui_slice_spans(spans, start, length, fill_style):
cells = tui_flatten(spans)[max(0, start):max(0, start) + max(0, length)]
while len(cells) < max(0, length):
cells.append((" ", fill_style))
return tui_runs(cells)
"""Columns [start, start+length) of a span row, padded with fill_style
to exactly length. Works on whole spans, never per character; neighbors
sharing a style object (or an equal key) merge into one run."""
start = max(0, start)
length = max(0, length)
end = start + length
out = []
last = last_key = None
def put(text, style):
nonlocal last, last_key
if style is last:
out[-1].text += text
return
key = style.key()
if out and key == last_key:
out[-1].text += text
else:
out.append(TuiSpan(text, style))
last_key = key
last = style
pos = 0
for span in spans:
if pos >= end:
break
nxt = pos + len(span.text)
if nxt > start:
piece = span.text[max(start, pos) - pos:min(end, nxt) - pos]
if piece:
put(piece, span.style)
pos = nxt
covered = min(pos, end) - start if pos > start else 0
if covered < length:
put(" " * (length - covered), fill_style)
return out
class TuiPanel:
@@ -10647,6 +10719,7 @@ class TuiCompositor:
runs_by_row.setdefault(ay, []).append((max(panel.ax, x0), min(panel.ax + panel.aw, x1), panel.ax, spans))
frame = [[(" ", (None, None, False, False, False, False, False)) for _x in range(cols)] for _y in range(rows)]
styles = {}
keys = {}
for ay, runs in runs_by_row.items():
cuts = set()
for x0, x1, _origin, _spans in runs:
@@ -10664,11 +10737,23 @@ class TuiCompositor:
if winner is None:
continue
_x0, _x1, origin, spans = winner
cells = tui_flatten(spans)[start - origin:end - origin]
for offset, (char, style) in enumerate(cells):
key = style.key()
styles[key] = style
frame[ay][start + offset] = (char, key)
out_row = frame[ay]
col = origin
for span in spans:
text = span.text
nxt = col + len(text)
lo = max(col, start)
hi = min(nxt, end)
if lo < hi:
style = span.style
key = keys.get(id(style))
if key is None:
key = keys[id(style)] = style.key()
styles[key] = style
out_row[lo:hi] = [(char, key) for char in text[lo - col:hi - col]]
col = nxt
if col >= end:
break
self.styles = styles
return frame
@@ -12756,6 +12841,8 @@ class TuiApp:
self._loop = None
self._failure = None
self._input_seen = False
self._last_frame = 0.0
self._stdout_fd = None
self._readers = {}
self._writers = {}
self._eof = set()
@@ -13065,7 +13152,7 @@ class TuiApp:
rows, cols = 24, 80
if fcntl is not None and termios is not None:
try:
packed = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8)
packed = fcntl.ioctl(self._stdout_fd if self._stdout_fd is not None else sys.stdout.fileno(), termios.TIOCGWINSZ, b"\0" * 8)
rows, cols = struct.unpack("HH", packed[:4])
except OSError:
pass
@@ -13083,6 +13170,7 @@ class TuiApp:
if not sys.stdin.isatty() or not sys.stdout.isatty():
raise TuiError("tui needs an interactive terminal")
self.rows, self.cols = self._winsize()
self._stdout_fd = sys.stdout.fileno()
fd = sys.stdin.fileno()
self._saved_termios = termios.tcgetattr(fd)
tty.setraw(fd)
@@ -13096,7 +13184,7 @@ class TuiApp:
def _teardown(self):
try:
out = sys.stdout.fileno()
out = self._stdout_fd if self._stdout_fd is not None else sys.stdout.fileno()
os.write(out, (TUI_PASTE_DISABLE + TUI_KITTY_DISABLE + TUI_MOUSE_DISABLE + TUI_ALT_LEAVE + "\x1b[?25h").encode("utf-8"))
except OSError:
pass
@@ -13184,6 +13272,20 @@ class TuiApp:
self._input_seen = True
for event in self.input.feed(data):
self.dispatch(event)
if time.time() - self._last_frame >= TUI_INPUT_PAINT_GAP:
self._paint_frame()
def _paint_frame(self):
"""Write one frame if anything is dirty. False when stdout is gone."""
if not (self._need_paint or self._need_layout):
return True
try:
TuiApp.write_all(self._stdout_fd, self.frame_bytes().encode("utf-8"))
except OSError:
self._running = False
return False
self._last_frame = time.time()
return True
def _on_term_readable(self, fd, term):
if not term.feed_output():
@@ -13234,7 +13336,6 @@ class TuiApp:
self._loop = loop
loop.set_exception_handler(self._loop_failed)
stdin_fd = sys.stdin.fileno()
stdout_fd = sys.stdout.fileno()
self.dirty_layout()
self._running = True
loop.add_reader(stdin_fd, self._on_stdin, stdin_fd)
@@ -13260,11 +13361,8 @@ class TuiApp:
self.tick_widgets(tick_now - last, tick_now)
last = tick_now
self.ensure_focus()
if self._need_paint or self._need_layout:
try:
TuiApp.write_all(stdout_fd, self.frame_bytes().encode("utf-8"))
except OSError:
break
if not self._paint_frame():
break
await asyncio.sleep(max(0.0, frame - (time.time() - started)))
finally:
for fd in list(self._readers):
@@ -13500,6 +13598,12 @@ def tui_chat_sidebar_w(cols):
TUI_CHAT_BUBBLE_MAX = 0.7
TUI_PREFERRED_MODEL = "molodetz"
TUI_CHAT_SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
TUI_CHAT_ONLINE_GLYPH = "●"
TUI_CHAT_TYPE_ANSWER = object()
TUI_CHAT_PROMPT_TIMEOUT = 600
TUI_CHAT_PROMPT_CHARS = 600
TUI_CHAT_REVEAL_TICK = 0.04
TUI_CHAT_REVEAL_STEPS = 40
TUI_CHAT_TICK_SEND = "◷"
TUI_CHAT_TICK_SENT = "✓"
TUI_CHAT_TICK_READ = "✓✓"
@@ -13836,7 +13940,7 @@ def tui_build_chat(app, agent):
paints first and the turn blocks the loop like the telegram bot does."""
refs = {"drafts": {}, "pending": None, "note": "", "sidebar_auto": False,
"sidebar_hide": False, "last_key": None, "bots": [], "pending_tick": None,
"blocks": [], "render_w": 0, "busy": False, "spin": 0, "since": 0.0}
"blocks": [], "render_w": 0, "busy": False, "spin": 0, "since": 0.0, "phase": "idle", "detail": "", "reveal": None}
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")
@@ -13859,7 +13963,7 @@ def tui_build_chat(app, agent):
head_name = TuiText("", h=1, wrap=False, style=name_style, id="head_name")
status_row = TuiCols(h=1, style=bar_style, id="status_row")
head_dot = TuiText("● ", w=2, h=1, wrap=False, style=TuiStyle(bg="role:panel", fg="role:success"), id="head_dot")
head_status = TuiText("", w="1fr", h=1, wrap=False, style=TuiStyle(bg="role:panel", fg="role:muted"), id="head_status")
head_status = TuiRich(w="1fr", h=1, style=TuiStyle(bg="role:panel", fg="role:muted"), id="head_status")
status_row.add(head_dot, head_status)
info.add(head_name, status_row)
head_meta = TuiRich(w=24, h=3, style=bar_style, id="head_meta")
@@ -13989,6 +14093,40 @@ def tui_build_chat(app, agent):
def chip(text, style):
return TuiSpan(" %s " % text, style)
def phase_word():
"""What the turn is really doing right now: thinking while the model
or the loop works, running <tool> inside a tool call, typing once
the reply exists and is being laid out."""
if refs["phase"] == "tool":
return "running %s" % (refs["detail"] or "tool")
if refs["phase"] == "typing":
return "typing"
if refs["phase"] == "ask":
return "waiting for you"
return "thinking"
def status_spans():
"""Header status line. Idle is a calm green dot; a live phase gets a
spinner in cycling accent colors and a shimmer sweeping the word,
with trailing dots that light up one after another."""
panel = "role:panel"
if not (refs["busy"] or refs["pending"] is not None):
return "online", TUI_CHAT_ONLINE_GLYPH, TuiStyle(bg=panel, fg="role:success"), [TuiSpan("online", TuiStyle(bg=panel, fg="role:muted"))]
word = phase_word()
frame = refs["spin"]
tint = ("role:accent", "role:accent2", "role:accent3")[(frame // 3) % 3]
glyph = TUI_CHAT_SPINNER[frame % len(TUI_CHAT_SPINNER)] if refs["phase"] != "typing" else ("✎" if frame % 4 < 2 else "✐")
head = frame % (len(word) + 6) - 3
spans = []
for pos, char in enumerate(word):
reach = abs(pos - head)
fg = "role:fg" if reach == 0 else (tint if reach == 1 else "role:muted")
spans.append(TuiSpan(char, TuiStyle(bg=panel, fg=fg, bold=reach <= 1)))
lit = (frame // 4) % 4
for pos in range(3):
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 paint_status():
width = max(20, app.cols)
key_style = TuiStyle(bg="role:service_bg", fg="role:service_fg", bold=True)
@@ -13996,8 +14134,8 @@ def tui_build_chat(app, agent):
gap = TuiSpan(" ", bar_style)
left = []
if refs["note"]:
warn = refs["note"].startswith("thinking")
glyph = TUI_CHAT_SPINNER[refs["spin"] % len(TUI_CHAT_SPINNER)] if warn else "●"
warn = refs["busy"] or refs["pending"] is not None
glyph = status_spans()[1] if warn else "●"
left.append([TuiSpan(" %s " % glyph, TuiStyle(bg="role:panel", fg="role:warning" if warn else "role:success", bold=True)),
TuiSpan(refs["note"], TuiStyle(bg="role:panel", fg="role:fg", bold=True))])
for keycap, label in (("F2", "Bots"), ("F4", "Theme"), ("Tab", "Focus"), ("i", "Input"), ("/help", "Commands")):
@@ -14030,9 +14168,11 @@ def tui_build_chat(app, agent):
av = TuiStyle(bg=tint, fg="role:on_accent", bold=True)
avatar.set([[TuiSpan(" " * 7, av)], [TuiSpan((" %s " % (agent.bot[:1].upper() or "?")).center(7), av)], [TuiSpan(" " * 7, av)]])
head_name.text = agent.bot
typing = refs["pending"] is not None or refs["busy"]
head_status.text = "typing..." if typing else "online"
head_dot.style = TuiStyle(bg="role:panel", fg="role:warning" if typing else "role:success")
_text, glyph, dot_style, spans = status_spans()
head_status.set(spans)
head_status.text = _text
head_dot.text = glyph + " "
head_dot.style = dot_style
brand.set([[], [TuiSpan(" ✦ ", TuiStyle(bg="role:panel", fg="role:accent", bold=True)),
TuiSpan("tai", TuiStyle(bg="role:panel", fg="role:fg", bold=True)),
TuiSpan(" %s" % VERSION, TuiStyle(bg="role:panel", fg="role:muted"))],
@@ -14130,6 +14270,7 @@ def tui_build_chat(app, agent):
refs["pending_tick"] = bubble("out", text, stamp, TUI_CHAT_TICK_SEND)
refs["pending"] = text
refs["note"] = "thinking..."
refs["phase"], refs["detail"] = "thinking", ""
refresh_bots()
paint_status()
app.after(0.05, run_pending)
@@ -14141,6 +14282,8 @@ def tui_build_chat(app, agent):
return
refs["busy"] = True
refs["since"] = time.time()
agent.on_phase = on_phase
agent.prompter = prompter
def turn():
old_stdin = sys.stdin
@@ -14158,23 +14301,145 @@ def tui_build_chat(app, agent):
sys.stdin = old_stdin
def landed(answer, error):
refs["busy"] = False
if error is not None:
answer = "error: %s" % short_error(error)
settle_pending_tick()
full = tui_strip_ansi(answer or "[no reply]")
stamp = datetime.now().strftime("%H:%M")
bubble("in", tui_strip_ansi(answer or "[no reply]"), stamp, sender=agent.bot)
refs["note"] = ""
refresh_bots()
paint_status()
animate = app._loop is not None and len(full) > 12
block = bubble("in", "" if animate else full, stamp, sender=agent.bot)
if not animate:
finish_turn()
return
refs["phase"] = "typing"
refs["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.offload(turn, landed)
def finish_turn():
refs["busy"] = False
refs["phase"], refs["detail"], refs["reveal"] = "idle", "", None
refs["note"] = ""
refresh_bots()
paint_status()
def reveal_step():
"""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"]
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
state["shown"] = end
block = state["block"]
if any(entry is block for entry in refs["blocks"]):
block[2] = full[:end]
replay()
if end >= len(full):
finish_turn()
else:
app.after(TUI_CHAT_REVEAL_TICK, reveal_step)
def on_phase(name, detail=""):
refs["phase"], refs["detail"] = name, detail
def style_dialog(dialog):
"""Dress a stock dialog in the chat theme: surface fill, accent
rounded border, accent buttons."""
dialog.style = TuiStyle(bg="role:surface", fg="role:fg")
dialog.border_style = TuiStyle(bg="role:bg", fg="role:accent")
dialog.title_style = TuiStyle(bg="role:bg", fg="role:accent2", bold=True)
dialog.rounded = True
dialog.padding = TuiPanel.norm_padding((0, 1))
for node in dialog.walk():
if node is dialog:
continue
if isinstance(node, TuiButton):
node.style = TuiStyle(bg="role:accent", fg="role:on_accent", bold=True)
node.rounded = True
node.border_style = TuiStyle(bg="role:surface", fg="role:accent")
node.focus_border_style = TuiStyle(bg="role:surface", fg="role:accent2")
elif isinstance(node, TuiTextBox):
node.style = TuiStyle(bg="role:bg", fg="role:fg")
elif not isinstance(node, (TuiRows, TuiCols)):
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)."""
loop = app._loop
if loop is None:
return None
answer = {"value": None}
done = threading.Event()
shown = []
text = str(text)[:TUI_CHAT_PROMPT_CHARS]
def finish(event=None):
done.set()
def pick(value):
answer["value"] = value
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))))
dialog.on_close = finish
shown.append(dialog)
app.root.add(dialog)
app.dirty_layout()
def chosen(value):
if value is TUI_CHAT_TYPE_ANSWER:
answer["typing"] = True
return
pick(value)
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)))
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)))
else:
ask_text()
return
def closed(event):
if answer.get("typing"):
answer["typing"] = False
ask_text()
return
finish()
dialog.on_close = closed
shown.append(style_dialog(dialog))
app.root.add(dialog)
app.dirty_layout()
before = refs["phase"], refs["detail"]
refs["phase"], refs["detail"] = "ask", ""
loop.call_soon_threadsafe(open_dialog)
if not done.wait(TUI_CHAT_PROMPT_TIMEOUT):
for dialog in shown:
loop.call_soon_threadsafe(dialog.close)
refs["phase"], refs["detail"] = before
return answer["value"]
def spin():
if not refs["busy"]:
return
refs["spin"] += 1
refs["note"] = "thinking... %ds" % int(time.time() - refs["since"])
refs["note"] = "%s... %ds" % (phase_word(), int(time.time() - refs["since"]))
paint_status()
def toggle_sidebar():
@@ -14232,13 +14497,13 @@ def tui_build_chat(app, agent):
root.on_resize = on_resize
app.every(0.2, tick_input)
app.every(1.0, paint_status)
app.every(0.12, spin)
app.every(0.08, spin)
on_resize(None)
refresh_bots()
render_history()
paint_status()
refs.update({"do_send": do_send, "run_pending": run_pending, "toggle_sidebar": toggle_sidebar,
"paint_status": paint_status, "show": show, "replay": replay, "add_note": note,
"paint_status": paint_status, "prompter": prompter, "show": show, "replay": replay, "add_note": note,
"render_history": render_history})
return root, refs
+185 -2
View File
@@ -5967,13 +5967,196 @@ class TuiChatTests(unittest.TestCase):
def test_busy_turn_blocks_second_send_and_bot_switch(self):
app, agent, _root, refs = self.make_chat()
refs["do_send"]("one")
self.assertEqual(refs["head_status"].text, "typing...")
self.assertEqual(refs["head_status"].text, "thinking...")
refs["do_send"]("two")
self.assertEqual(agent.turns, [])
refs["run_pending"]()
self.assertEqual(agent.turns, ["one"])
self.assertFalse(refs["busy"])
def test_status_follows_real_phases(self):
app, agent, _root, refs = self.make_chat()
self.assertEqual(refs["head_status"].text, "online")
refs["do_send"]("go")
agent.on_phase = None
phases = []
original = agent.run_turn
def turn(text, capture=False):
agent.on_phase("tool", "shell")
refs["paint_status"]()
phases.append(refs["head_status"].text)
agent.on_phase("typing")
refs["paint_status"]()
phases.append(refs["head_status"].text)
return original(text, capture)
agent.run_turn = turn
refs["run_pending"]()
self.assertEqual(phases, ["running shell...", "typing..."])
self.assertEqual(refs["head_status"].text, "online")
def test_typing_is_shown_only_while_reply_reveals(self):
import asyncio
app, agent, _root, refs = self.make_chat()
long_reply = "word " * 80
agent.run_turn = lambda text, capture=False: long_reply
seen = []
async def go():
app._loop = asyncio.get_running_loop()
refs["do_send"]("hello")
seen.append(refs["head_status"].text)
refs["run_pending"]()
for _ in range(200):
app._fire_timers(time.time() + 1)
await asyncio.sleep(0.01)
seen.append(refs["head_status"].text)
if not refs["busy"]:
break
app._loop = None
asyncio.run(go())
self.assertEqual(seen[0], "thinking...")
self.assertIn("typing...", seen)
self.assertEqual(seen[-1], "online")
self.assertLess(seen.index("typing..."), len(seen) - 1)
self.assertTrue(all(item in ("thinking...", "typing...", "online") for item in seen))
shown = " ".join(line for line, _spans in refs["transcript"].lines)
self.assertIn("word word word", shown)
self.assertEqual(refs["blocks"][-1][2], long_reply)
def test_status_shimmer_changes_each_frame_but_text_is_stable(self):
app, _agent, _root, refs = self.make_chat()
refs["do_send"]("go")
refs["paint_status"]()
first = [(span.text, span.style.key()) for span in refs["head_status"].rows[0]]
refs["spin"] += 2
refs["paint_status"]()
second = [(span.text, span.style.key()) for span in refs["head_status"].rows[0]]
self.assertNotEqual(first, second)
self.assertEqual("".join(x for x, _k in first), "".join(x for x, _k in second))
def test_frame_stays_inside_budget_on_large_screen(self):
app = tai.TuiApp()
app.rows, app.cols = 50, 200
agent = FakeChatAgent()
for n in range(40):
agent.messages += [{"role": "user", "content": "q %d" % n},
{"role": "assistant", "content": "answer " + "word " * 60}]
root, refs = tai.tui_build_chat(app, agent)
app.mount(root)
refs["replay"]()
app.frame_bytes()
started = time.perf_counter()
for _ in range(20):
app.dirty_paint()
app.frame_bytes()
per_frame = (time.perf_counter() - started) / 20
self.assertLess(per_frame, 1.0 / tai.TUI_FPS, "frame %.1fms over the %.1fms budget" % (per_frame * 1000, 1000.0 / tai.TUI_FPS))
def run_prompt(self, drive, kind="approve", text="ls -la", options=("Allow once", "Always allow (approvals off)", "Deny")):
"""Drive prompter() from a worker thread against a live asyncio loop;
drive(app, refs) answers the dialog. Returns the prompter result."""
import asyncio
app, _agent, _root, refs = self.make_chat()
result = []
async def go():
app._loop = asyncio.get_running_loop()
app.offload(lambda: refs["prompter"](kind, text, list(options)), lambda value, error: result.append((value, error)))
for _ in range(300):
await asyncio.sleep(0.01)
if app.top_modal() is not None:
break
app.layout()
self.assertIsNotNone(app.top_modal(), "dialog never opened")
self.shown = app.render_text()
drive(app, refs)
for _ in range(300):
await asyncio.sleep(0.01)
if result:
break
app._loop = None
asyncio.run(go())
self.assertEqual(len(result), 1, "prompter never returned")
self.assertIsNone(result[0][1])
return result[0][0]
def test_approval_dialog_allows_once(self):
value = self.run_prompt(lambda app, refs: self.key(app, "enter"))
self.assertEqual(value, "Allow once")
self.assertIn("Approval needed", self.shown)
self.assertIn("ls -la", self.shown)
def test_approval_dialog_deny_and_escape(self):
def deny(app, refs):
self.key(app, "down")
self.key(app, "down")
self.key(app, "enter")
self.assertEqual(self.run_prompt(deny), "Deny")
self.assertIsNone(self.run_prompt(lambda app, refs: self.key(app, "escape")))
def test_ask_dialog_free_text_and_typed_answer(self):
def type_it(app, refs):
for char in "blue":
self.key(app, char, char)
app.top_modal()._run()
self.assertEqual(self.run_prompt(type_it, "ask", "Favorite color?", ()), "blue")
def pick_other(app, refs):
self.key(app, "down")
self.key(app, "down")
self.key(app, "enter")
app.layout()
for char in "mauve":
self.key(app, char, char)
app.top_modal()._run()
self.assertEqual(self.run_prompt(pick_other, "ask", "Pick?", ("red", "green")), "mauve")
def test_prompter_without_loop_denies(self):
app, _agent, _root, refs = self.make_chat()
self.assertIsNone(refs["prompter"]("approve", "rm x", ["Allow once", "Deny"]))
def test_agent_hooks_use_prompter_for_approval_and_questions(self):
import types
calls = []
stub = types.SimpleNamespace(yolo=False, auto=False, persist=True, user_stepped_in=False,
config=types.SimpleNamespace(auto_approve=False))
stub.prompter = lambda kind, text, options: calls.append(kind) or "Allow once"
self.assertTrue(tai.Agent.ask_approval(stub, "ls"))
stub.prompter = lambda kind, text, options: "Deny"
self.assertFalse(tai.Agent.ask_approval(stub, "ls"))
stub.prompter = lambda kind, text, options: "Always allow (approvals off)"
self.assertTrue(tai.Agent.ask_approval(stub, "ls"))
self.assertTrue(stub.yolo)
self.assertTrue(stub.config.auto_approve)
stub.yolo = False
stub.prompter = lambda kind, text, options: None
self.assertFalse(tai.Agent.ask_approval(stub, "ls"))
def test_paint_and_winsize_survive_redirected_stdout(self):
"""A turn thread swaps sys.stdout for a StringIO; the loop must keep
painting to the real terminal fd and keep reading its size."""
app, _agent, _root, _refs = self.make_chat()
read_fd, write_fd = os.pipe()
app._stdout_fd = write_fd
app._running = True
app.dirty_paint()
old = sys.stdout
sys.stdout = io.StringIO()
try:
self.assertTrue(app._paint_frame())
app._winsize()
finally:
sys.stdout = old
os.close(write_fd)
painted = os.read(read_fd, 1 << 20)
os.close(read_fd)
self.assertTrue(app._running)
self.assertIn(b"\x1b[", painted)
def test_hidden_sidebar_frees_its_columns(self):
app, _agent, _root, refs = self.make_chat(cols=100)
right = refs["transcript"].parent
@@ -6187,7 +6370,7 @@ class TuiChatTests(unittest.TestCase):
shown = " ".join(line for line, _spans in refs["transcript"].lines)
self.assertIn(tai.TUI_CHAT_TICK_SEND, shown)
self.assertNotIn(tai.TUI_CHAT_TICK_READ, shown)
self.assertEqual(refs["head_status"].text, "typing...")
self.assertEqual(refs["head_status"].text, "thinking...")
refs["run_pending"]()
shown = " ".join(line for line, _spans in refs["transcript"].lines)
self.assertIn(tai.TUI_CHAT_TICK_READ, shown)
+32 -12
View File
@@ -272,18 +272,38 @@ quits from outside the input. Below 80 columns the sidebar
auto-hides (`TUI_CHAT_NARROW_COLS`); a manual F2 hide/show wins over
the automatic rule until toggled again.
Turns run through `app.offload`: your message and a spinner
`thinking... Ns` status paint first, the turn runs on its own thread,
and the reply lands back on the loop, so the UI keeps rendering, themes
switch and the sidebar scrolls while the model thinks. While a turn is
busy a second send and a bot switch are ignored (the thread owns the
agent and the vault); the store connection is opened with
`check_same_thread=False` for that hand-off. `ask_user` cannot prompt mid-turn here (stdin
is swapped for a non-tty during the turn, so the tool declines and
the agent decides instead). Approvals cannot prompt mid-turn either
for the same reason, so gated tools are denied unless the session
pre-approved them (`--yes` / `--yolo`); allowlisted read-only
commands still run.
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
swaps `sys.stdout`.
The header status only says what is true. The agent reports its real
phase through `agent.on_phase(name, detail)`: `thinking` while the model
or the loop works, `running <tool>` inside a tool call, `waiting for you`
while a dialog is open, `typing...` once the final reply exists and is
being laid out word by word (bounded to `TUI_CHAT_REVEAL_STEPS` ticks),
then `online`. Thinking and typing animate: braille spinner in cycling
accent colors, a shimmer sweeping the word, trailing dots lighting one
by one, and `Ns` elapsed in the status bar.
Approvals and `ask_user` are real dialogs. The agent calls
`agent.prompter(kind, text, options)` from the turn thread; the chat
scene opens a `TuiQuestion` (approve: Allow once / Always allow / Deny;
ask: the options plus "Type an answer...") on the loop and the thread
waits. Esc, Cancel, or no answer within `TUI_CHAT_PROMPT_TIMEOUT` (600
s) means deny / no answer, never a hang. Without a `prompter` (CLI,
Telegram) behavior is unchanged.
Measured (this machine, 40-exchange transcript, repaint of a dirty
frame): 1.9 ms at 80x24, 5.0 ms at 200x50, 7.8 ms at 300x70 against a
33 ms budget (guarded by a unit test at 200x50). Live in a pty: a
resize reflows to a correct frame in 18-60 ms, a burst of 30 resizes
ends on the right frame, key to screen averages 23 ms (input paints
immediately, no longer waiting for the next tick).
## Terminal widget notes