Spaces:
Running on CPU Upgrade
CLI: global Ctrl+C handling, progressive streaming, headless cleanup
Browse filesCtrl+C:
- Install a SIGINT handler via loop.add_signal_handler, re-armed each loop
iteration since prompt_toolkit wipes it on prompt_async exit.
- First press cancels the current turn (session.cancel() sets the asyncio
Event the agent loop already checks between tokens and tool boundaries).
- Second press within 1s exits the CLI. Hint is shown on first press.
- Approval prompt Ctrl+C/EOF rejects remaining items instead of crashing
the event listener (which used to deadlock the main loop).
Streaming:
- print_markdown is async and uses asyncio.sleep for the typewriter, so
the event loop can service signal handlers between characters. The old
time.sleep loop was blocking SIGINT delivery during a full response.
- On Ctrl+C mid-render, stop cleanly (no dumping the remainder) and emit
an ANSI reset so half-open color state doesn't bleed onto the next line.
- _StreamBuffer renders block-by-block (paragraphs split on \\n\\n) as
the LLM produces them, rather than buffering the whole response. Open
code fences hold back flushing so a code block always renders as one
unit. Major TTFT improvement on long answers.
- Left-align markdown headings (Rich's default centers H1/H2).
Headless mode:
- No thinking shimmer.
- print_markdown(instant=True) dumps the rendered markdown in one write
with no typewriter.
- Research sub-agent activity buffers silently while the sub-agent runs
and dumps as one static block on completion, instead of the live
redrawing overlay which garbles piped output.
- agent/main.py +186 -38
- agent/utils/terminal_display.py +42 -10
|
@@ -10,6 +10,7 @@ import argparse
|
|
| 10 |
import asyncio
|
| 11 |
import json
|
| 12 |
import os
|
|
|
|
| 13 |
import sys
|
| 14 |
import time
|
| 15 |
from dataclasses import dataclass
|
|
@@ -192,6 +193,8 @@ class _ThinkingShimmer:
|
|
| 192 |
self._task = asyncio.ensure_future(self._animate())
|
| 193 |
|
| 194 |
def stop(self):
|
|
|
|
|
|
|
| 195 |
self._running = False
|
| 196 |
if self._task:
|
| 197 |
self._task.cancel()
|
|
@@ -236,7 +239,10 @@ class _ThinkingShimmer:
|
|
| 236 |
|
| 237 |
|
| 238 |
class _StreamBuffer:
|
| 239 |
-
"""Accumulates streamed tokens, renders
|
|
|
|
|
|
|
|
|
|
| 240 |
|
| 241 |
def __init__(self, console):
|
| 242 |
self._console = console
|
|
@@ -245,10 +251,41 @@ class _StreamBuffer:
|
|
| 245 |
def add_chunk(self, text: str):
|
| 246 |
self._buffer += text
|
| 247 |
|
| 248 |
-
def
|
| 249 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
if self._buffer.strip():
|
| 251 |
-
print_markdown(self._buffer)
|
| 252 |
self._buffer = ""
|
| 253 |
|
| 254 |
def discard(self):
|
|
@@ -262,6 +299,7 @@ async def event_listener(
|
|
| 262 |
ready_event: asyncio.Event,
|
| 263 |
prompt_session: PromptSession,
|
| 264 |
config=None,
|
|
|
|
| 265 |
) -> None:
|
| 266 |
"""Background task that listens for events and displays them"""
|
| 267 |
submission_id = [1000]
|
|
@@ -270,6 +308,12 @@ async def event_listener(
|
|
| 270 |
shimmer = _ThinkingShimmer(console)
|
| 271 |
stream_buf = _StreamBuffer(console)
|
| 272 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
while True:
|
| 274 |
try:
|
| 275 |
event = await event_queue.get()
|
|
@@ -282,14 +326,19 @@ async def event_listener(
|
|
| 282 |
shimmer.stop()
|
| 283 |
content = event.data.get("content", "") if event.data else ""
|
| 284 |
if content:
|
| 285 |
-
print_markdown(content)
|
| 286 |
elif event.event_type == "assistant_chunk":
|
| 287 |
content = event.data.get("content", "") if event.data else ""
|
| 288 |
if content:
|
| 289 |
stream_buf.add_chunk(content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
elif event.event_type == "assistant_stream_end":
|
| 291 |
shimmer.stop()
|
| 292 |
-
stream_buf.finish()
|
| 293 |
elif event.event_type == "tool_call":
|
| 294 |
shimmer.stop()
|
| 295 |
stream_buf.discard()
|
|
@@ -584,10 +633,33 @@ async def event_listener(
|
|
| 584 |
if gated is not None:
|
| 585 |
print(f"Gated: {gated}")
|
| 586 |
|
| 587 |
-
# Get user decision for this item
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 591 |
|
| 592 |
response = response.strip().lower()
|
| 593 |
|
|
@@ -805,44 +877,94 @@ async def main():
|
|
| 805 |
ready_event,
|
| 806 |
prompt_session,
|
| 807 |
config,
|
|
|
|
| 808 |
)
|
| 809 |
)
|
| 810 |
|
| 811 |
await ready_event.wait()
|
| 812 |
|
| 813 |
submission_id = [0]
|
| 814 |
-
|
| 815 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 816 |
|
| 817 |
try:
|
| 818 |
while True:
|
| 819 |
-
|
|
|
|
|
|
|
| 820 |
try:
|
| 821 |
await turn_complete_event.wait()
|
| 822 |
except asyncio.CancelledError:
|
| 823 |
break
|
| 824 |
turn_complete_event.clear()
|
| 825 |
-
agent_busy = False
|
| 826 |
|
| 827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 828 |
try:
|
| 829 |
user_input = await get_user_input(prompt_session)
|
| 830 |
except EOFError:
|
| 831 |
break
|
| 832 |
except KeyboardInterrupt:
|
| 833 |
now = time.monotonic()
|
| 834 |
-
if now -
|
| 835 |
break
|
| 836 |
-
|
| 837 |
-
|
| 838 |
-
|
| 839 |
-
if agent_busy and session:
|
| 840 |
-
session.cancel()
|
| 841 |
-
else:
|
| 842 |
-
get_console().print("[dim]Ctrl+C again to exit[/dim]")
|
| 843 |
-
turn_complete_event.set()
|
| 844 |
continue
|
| 845 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 846 |
# Check for exit commands
|
| 847 |
if user_input.strip().lower() in ["exit", "quit", "/quit", "/exit"]:
|
| 848 |
break
|
|
@@ -862,7 +984,6 @@ async def main():
|
|
| 862 |
turn_complete_event.set()
|
| 863 |
continue
|
| 864 |
else:
|
| 865 |
-
agent_busy = True
|
| 866 |
await submission_queue.put(sub)
|
| 867 |
continue
|
| 868 |
|
|
@@ -874,11 +995,16 @@ async def main():
|
|
| 874 |
op_type=OpType.USER_INPUT, data={"text": user_input}
|
| 875 |
),
|
| 876 |
)
|
| 877 |
-
agent_busy = True
|
| 878 |
await submission_queue.put(submission)
|
| 879 |
|
| 880 |
except KeyboardInterrupt:
|
| 881 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 882 |
|
| 883 |
# Shutdown
|
| 884 |
shutdown_submission = Submission(
|
|
@@ -966,13 +1092,17 @@ async def headless_main(
|
|
| 966 |
)
|
| 967 |
await submission_queue.put(submission)
|
| 968 |
|
| 969 |
-
# Process events until turn completes
|
|
|
|
|
|
|
| 970 |
console = _create_rich_console()
|
| 971 |
-
shimmer = _ThinkingShimmer(console)
|
| 972 |
stream_buf = _StreamBuffer(console)
|
| 973 |
_hl_last_tool = [None]
|
| 974 |
_hl_sub_id = [1]
|
| 975 |
-
|
|
|
|
|
|
|
|
|
|
| 976 |
|
| 977 |
while True:
|
| 978 |
event = await event_queue.get()
|
|
@@ -981,16 +1111,14 @@ async def headless_main(
|
|
| 981 |
content = event.data.get("content", "") if event.data else ""
|
| 982 |
if content:
|
| 983 |
stream_buf.add_chunk(content)
|
|
|
|
| 984 |
elif event.event_type == "assistant_stream_end":
|
| 985 |
-
|
| 986 |
-
stream_buf.finish()
|
| 987 |
elif event.event_type == "assistant_message":
|
| 988 |
-
shimmer.stop()
|
| 989 |
content = event.data.get("content", "") if event.data else ""
|
| 990 |
if content:
|
| 991 |
-
print_markdown(content)
|
| 992 |
elif event.event_type == "tool_call":
|
| 993 |
-
shimmer.stop()
|
| 994 |
stream_buf.discard()
|
| 995 |
tool_name = event.data.get("tool", "") if event.data else ""
|
| 996 |
arguments = event.data.get("arguments", {}) if event.data else {}
|
|
@@ -1004,11 +1132,33 @@ async def headless_main(
|
|
| 1004 |
success = event.data.get("success", False) if event.data else False
|
| 1005 |
if _hl_last_tool[0] == "plan_tool" and output:
|
| 1006 |
print_tool_output(output, success, truncate=False)
|
| 1007 |
-
shimmer.start()
|
| 1008 |
elif event.event_type == "tool_log":
|
| 1009 |
tool = event.data.get("tool", "") if event.data else ""
|
| 1010 |
log = event.data.get("log", "") if event.data else ""
|
| 1011 |
-
if log:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1012 |
print_tool_log(tool, log)
|
| 1013 |
elif event.event_type == "approval_required":
|
| 1014 |
# Auto-approve everything in headless mode (safety net if yolo_mode
|
|
@@ -1035,13 +1185,11 @@ async def headless_main(
|
|
| 1035 |
new_tokens = event.data.get("new_tokens", 0) if event.data else 0
|
| 1036 |
print_compacted(old_tokens, new_tokens)
|
| 1037 |
elif event.event_type == "error":
|
| 1038 |
-
shimmer.stop()
|
| 1039 |
stream_buf.discard()
|
| 1040 |
error = event.data.get("error", "Unknown error") if event.data else "Unknown error"
|
| 1041 |
print_error(error)
|
| 1042 |
break
|
| 1043 |
elif event.event_type in ("turn_complete", "interrupted"):
|
| 1044 |
-
shimmer.stop()
|
| 1045 |
stream_buf.discard()
|
| 1046 |
history_size = event.data.get("history_size", "?") if event.data else "?"
|
| 1047 |
print(f"\n--- Agent {event.event_type} (history_size={history_size}) ---", file=sys.stderr)
|
|
|
|
| 10 |
import asyncio
|
| 11 |
import json
|
| 12 |
import os
|
| 13 |
+
import signal
|
| 14 |
import sys
|
| 15 |
import time
|
| 16 |
from dataclasses import dataclass
|
|
|
|
| 193 |
self._task = asyncio.ensure_future(self._animate())
|
| 194 |
|
| 195 |
def stop(self):
|
| 196 |
+
if not self._running:
|
| 197 |
+
return # no-op when never started (e.g. headless mode)
|
| 198 |
self._running = False
|
| 199 |
if self._task:
|
| 200 |
self._task.cancel()
|
|
|
|
| 239 |
|
| 240 |
|
| 241 |
class _StreamBuffer:
|
| 242 |
+
"""Accumulates streamed tokens, renders markdown block-by-block as complete
|
| 243 |
+
blocks appear. A "block" is everything up to a paragraph break (\\n\\n).
|
| 244 |
+
Unclosed code fences (odd count of ```) hold back flushing until closed so
|
| 245 |
+
a code block is always rendered as one unit."""
|
| 246 |
|
| 247 |
def __init__(self, console):
|
| 248 |
self._console = console
|
|
|
|
| 251 |
def add_chunk(self, text: str):
|
| 252 |
self._buffer += text
|
| 253 |
|
| 254 |
+
def _pop_block(self) -> str | None:
|
| 255 |
+
"""Extract the next complete block, or return None if nothing complete."""
|
| 256 |
+
if self._buffer.count("```") % 2 == 1:
|
| 257 |
+
return None # inside an open code fence β wait for close
|
| 258 |
+
idx = self._buffer.find("\n\n")
|
| 259 |
+
if idx == -1:
|
| 260 |
+
return None
|
| 261 |
+
block = self._buffer[:idx]
|
| 262 |
+
self._buffer = self._buffer[idx + 2:]
|
| 263 |
+
return block
|
| 264 |
+
|
| 265 |
+
async def flush_ready(
|
| 266 |
+
self,
|
| 267 |
+
cancel_event: "asyncio.Event | None" = None,
|
| 268 |
+
instant: bool = False,
|
| 269 |
+
):
|
| 270 |
+
"""Render any complete blocks that have accumulated; leave the tail."""
|
| 271 |
+
while True:
|
| 272 |
+
if cancel_event is not None and cancel_event.is_set():
|
| 273 |
+
return
|
| 274 |
+
block = self._pop_block()
|
| 275 |
+
if block is None:
|
| 276 |
+
return
|
| 277 |
+
if block.strip():
|
| 278 |
+
await print_markdown(block, cancel_event=cancel_event, instant=instant)
|
| 279 |
+
|
| 280 |
+
async def finish(
|
| 281 |
+
self,
|
| 282 |
+
cancel_event: "asyncio.Event | None" = None,
|
| 283 |
+
instant: bool = False,
|
| 284 |
+
):
|
| 285 |
+
"""Flush complete blocks, then render whatever incomplete tail remains."""
|
| 286 |
+
await self.flush_ready(cancel_event=cancel_event, instant=instant)
|
| 287 |
if self._buffer.strip():
|
| 288 |
+
await print_markdown(self._buffer, cancel_event=cancel_event, instant=instant)
|
| 289 |
self._buffer = ""
|
| 290 |
|
| 291 |
def discard(self):
|
|
|
|
| 299 |
ready_event: asyncio.Event,
|
| 300 |
prompt_session: PromptSession,
|
| 301 |
config=None,
|
| 302 |
+
session_holder=None,
|
| 303 |
) -> None:
|
| 304 |
"""Background task that listens for events and displays them"""
|
| 305 |
submission_id = [1000]
|
|
|
|
| 308 |
shimmer = _ThinkingShimmer(console)
|
| 309 |
stream_buf = _StreamBuffer(console)
|
| 310 |
|
| 311 |
+
def _cancel_event():
|
| 312 |
+
"""Return the session's cancellation Event so print_markdown can abort
|
| 313 |
+
its typewriter loop mid-stream when Ctrl+C fires."""
|
| 314 |
+
s = session_holder[0] if session_holder else None
|
| 315 |
+
return s._cancelled if s is not None else None
|
| 316 |
+
|
| 317 |
while True:
|
| 318 |
try:
|
| 319 |
event = await event_queue.get()
|
|
|
|
| 326 |
shimmer.stop()
|
| 327 |
content = event.data.get("content", "") if event.data else ""
|
| 328 |
if content:
|
| 329 |
+
await print_markdown(content, cancel_event=_cancel_event())
|
| 330 |
elif event.event_type == "assistant_chunk":
|
| 331 |
content = event.data.get("content", "") if event.data else ""
|
| 332 |
if content:
|
| 333 |
stream_buf.add_chunk(content)
|
| 334 |
+
# Flush any complete markdown blocks progressively so the
|
| 335 |
+
# user sees paragraphs appear as they're produced, not just
|
| 336 |
+
# at the end of the whole response.
|
| 337 |
+
shimmer.stop()
|
| 338 |
+
await stream_buf.flush_ready(cancel_event=_cancel_event())
|
| 339 |
elif event.event_type == "assistant_stream_end":
|
| 340 |
shimmer.stop()
|
| 341 |
+
await stream_buf.finish(cancel_event=_cancel_event())
|
| 342 |
elif event.event_type == "tool_call":
|
| 343 |
shimmer.stop()
|
| 344 |
stream_buf.discard()
|
|
|
|
| 633 |
if gated is not None:
|
| 634 |
print(f"Gated: {gated}")
|
| 635 |
|
| 636 |
+
# Get user decision for this item. Ctrl+C / EOF here is
|
| 637 |
+
# treated as "reject remaining" (matches Codex's modal
|
| 638 |
+
# priority and Forgecode's approval-cancel path). Without
|
| 639 |
+
# this, KeyboardInterrupt kills the event listener and
|
| 640 |
+
# the main loop deadlocks waiting for turn_complete.
|
| 641 |
+
try:
|
| 642 |
+
response = await prompt_session.prompt_async(
|
| 643 |
+
f"Approve item {i}? (y=yes, yolo=approve all, n=no, or provide feedback): "
|
| 644 |
+
)
|
| 645 |
+
except (KeyboardInterrupt, EOFError):
|
| 646 |
+
get_console().print("[dim]Approval cancelled β rejecting remaining items[/dim]")
|
| 647 |
+
approvals.append(
|
| 648 |
+
{
|
| 649 |
+
"tool_call_id": tool_call_id,
|
| 650 |
+
"approved": False,
|
| 651 |
+
"feedback": "User cancelled approval",
|
| 652 |
+
}
|
| 653 |
+
)
|
| 654 |
+
for remaining in tools_data[i:]:
|
| 655 |
+
approvals.append(
|
| 656 |
+
{
|
| 657 |
+
"tool_call_id": remaining.get("tool_call_id", ""),
|
| 658 |
+
"approved": False,
|
| 659 |
+
"feedback": None,
|
| 660 |
+
}
|
| 661 |
+
)
|
| 662 |
+
break
|
| 663 |
|
| 664 |
response = response.strip().lower()
|
| 665 |
|
|
|
|
| 877 |
ready_event,
|
| 878 |
prompt_session,
|
| 879 |
config,
|
| 880 |
+
session_holder=session_holder,
|
| 881 |
)
|
| 882 |
)
|
| 883 |
|
| 884 |
await ready_event.wait()
|
| 885 |
|
| 886 |
submission_id = [0]
|
| 887 |
+
# Mirrors codex-rs/tui/src/bottom_pane/mod.rs:137
|
| 888 |
+
# (`QUIT_SHORTCUT_TIMEOUT = Duration::from_secs(1)`). Two Ctrl+C presses
|
| 889 |
+
# within this window quit; a single press cancels the in-flight turn.
|
| 890 |
+
CTRL_C_QUIT_WINDOW = 1.0
|
| 891 |
+
# Hint string matches codex-rs/tui/src/bottom_pane/footer.rs:746
|
| 892 |
+
# (`" again to quit"` prefixed with the key binding, rendered dim).
|
| 893 |
+
CTRL_C_HINT = "[dim]ctrl + c again to quit[/dim]"
|
| 894 |
+
interrupt_state = {"last": 0.0, "exit": False}
|
| 895 |
+
|
| 896 |
+
loop = asyncio.get_running_loop()
|
| 897 |
+
|
| 898 |
+
def _on_sigint() -> None:
|
| 899 |
+
"""SIGINT handler β fires while the agent is generating (terminal is
|
| 900 |
+
in cooked mode between prompts). Mirrors Codex's `on_ctrl_c` in
|
| 901 |
+
codex-rs/tui/src/chatwidget.rs: first press cancels active work and
|
| 902 |
+
arms the quit hint; second press within the window quits."""
|
| 903 |
+
now = time.monotonic()
|
| 904 |
+
session = session_holder[0]
|
| 905 |
+
|
| 906 |
+
if now - interrupt_state["last"] < CTRL_C_QUIT_WINDOW:
|
| 907 |
+
interrupt_state["exit"] = True
|
| 908 |
+
if session:
|
| 909 |
+
session.cancel()
|
| 910 |
+
# Wake the main loop out of turn_complete_event.wait()
|
| 911 |
+
turn_complete_event.set()
|
| 912 |
+
return
|
| 913 |
+
|
| 914 |
+
interrupt_state["last"] = now
|
| 915 |
+
if session and not session.is_cancelled:
|
| 916 |
+
session.cancel()
|
| 917 |
+
get_console().print(f"\n{CTRL_C_HINT}")
|
| 918 |
+
|
| 919 |
+
def _install_sigint() -> bool:
|
| 920 |
+
try:
|
| 921 |
+
loop.add_signal_handler(signal.SIGINT, _on_sigint)
|
| 922 |
+
return True
|
| 923 |
+
except (NotImplementedError, RuntimeError):
|
| 924 |
+
return False # Windows or non-main thread
|
| 925 |
+
|
| 926 |
+
# prompt_toolkit's prompt_async installs its own SIGINT handler and, on
|
| 927 |
+
# exit, calls loop.remove_signal_handler(SIGINT) β which wipes ours too.
|
| 928 |
+
# So we re-arm at the top of every loop iteration, right before the busy
|
| 929 |
+
# wait. Without this, Ctrl+C during agent streaming after the first turn
|
| 930 |
+
# falls through to the default handler and the terminal just echoes ^C.
|
| 931 |
+
sigint_available = _install_sigint()
|
| 932 |
|
| 933 |
try:
|
| 934 |
while True:
|
| 935 |
+
if sigint_available:
|
| 936 |
+
_install_sigint()
|
| 937 |
+
|
| 938 |
try:
|
| 939 |
await turn_complete_event.wait()
|
| 940 |
except asyncio.CancelledError:
|
| 941 |
break
|
| 942 |
turn_complete_event.clear()
|
|
|
|
| 943 |
|
| 944 |
+
if interrupt_state["exit"]:
|
| 945 |
+
break
|
| 946 |
+
|
| 947 |
+
# Get user input. prompt_toolkit puts the terminal in raw mode and
|
| 948 |
+
# installs its own SIGINT handling; ^C arrives as \x03 and surfaces
|
| 949 |
+
# as KeyboardInterrupt here. On return, prompt_toolkit removes the
|
| 950 |
+
# loop's SIGINT handler β we re-arm at the top of the next iter.
|
| 951 |
try:
|
| 952 |
user_input = await get_user_input(prompt_session)
|
| 953 |
except EOFError:
|
| 954 |
break
|
| 955 |
except KeyboardInterrupt:
|
| 956 |
now = time.monotonic()
|
| 957 |
+
if now - interrupt_state["last"] < CTRL_C_QUIT_WINDOW:
|
| 958 |
break
|
| 959 |
+
interrupt_state["last"] = now
|
| 960 |
+
get_console().print(CTRL_C_HINT)
|
| 961 |
+
turn_complete_event.set()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 962 |
continue
|
| 963 |
|
| 964 |
+
# A successful read ends the double-press window β an unrelated
|
| 965 |
+
# Ctrl+C during the next turn should start a fresh arming.
|
| 966 |
+
interrupt_state["last"] = 0.0
|
| 967 |
+
|
| 968 |
# Check for exit commands
|
| 969 |
if user_input.strip().lower() in ["exit", "quit", "/quit", "/exit"]:
|
| 970 |
break
|
|
|
|
| 984 |
turn_complete_event.set()
|
| 985 |
continue
|
| 986 |
else:
|
|
|
|
| 987 |
await submission_queue.put(sub)
|
| 988 |
continue
|
| 989 |
|
|
|
|
| 995 |
op_type=OpType.USER_INPUT, data={"text": user_input}
|
| 996 |
),
|
| 997 |
)
|
|
|
|
| 998 |
await submission_queue.put(submission)
|
| 999 |
|
| 1000 |
except KeyboardInterrupt:
|
| 1001 |
pass
|
| 1002 |
+
finally:
|
| 1003 |
+
if sigint_available:
|
| 1004 |
+
try:
|
| 1005 |
+
loop.remove_signal_handler(signal.SIGINT)
|
| 1006 |
+
except (NotImplementedError, RuntimeError):
|
| 1007 |
+
pass
|
| 1008 |
|
| 1009 |
# Shutdown
|
| 1010 |
shutdown_submission = Submission(
|
|
|
|
| 1092 |
)
|
| 1093 |
await submission_queue.put(submission)
|
| 1094 |
|
| 1095 |
+
# Process events until turn completes. Headless mode is for scripts /
|
| 1096 |
+
# log capture: no shimmer animation, no typewriter, no live-redrawing
|
| 1097 |
+
# research overlay. Output is plain, append-only text.
|
| 1098 |
console = _create_rich_console()
|
|
|
|
| 1099 |
stream_buf = _StreamBuffer(console)
|
| 1100 |
_hl_last_tool = [None]
|
| 1101 |
_hl_sub_id = [1]
|
| 1102 |
+
# Research sub-agent tool calls are buffered and dumped once the sub-agent
|
| 1103 |
+
# finishes, instead of streaming via the live redrawing SubAgentDisplay.
|
| 1104 |
+
_hl_research_calls: list[str] = []
|
| 1105 |
+
_hl_in_research = [False]
|
| 1106 |
|
| 1107 |
while True:
|
| 1108 |
event = await event_queue.get()
|
|
|
|
| 1111 |
content = event.data.get("content", "") if event.data else ""
|
| 1112 |
if content:
|
| 1113 |
stream_buf.add_chunk(content)
|
| 1114 |
+
await stream_buf.flush_ready(instant=True)
|
| 1115 |
elif event.event_type == "assistant_stream_end":
|
| 1116 |
+
await stream_buf.finish(instant=True)
|
|
|
|
| 1117 |
elif event.event_type == "assistant_message":
|
|
|
|
| 1118 |
content = event.data.get("content", "") if event.data else ""
|
| 1119 |
if content:
|
| 1120 |
+
await print_markdown(content, instant=True)
|
| 1121 |
elif event.event_type == "tool_call":
|
|
|
|
| 1122 |
stream_buf.discard()
|
| 1123 |
tool_name = event.data.get("tool", "") if event.data else ""
|
| 1124 |
arguments = event.data.get("arguments", {}) if event.data else {}
|
|
|
|
| 1132 |
success = event.data.get("success", False) if event.data else False
|
| 1133 |
if _hl_last_tool[0] == "plan_tool" and output:
|
| 1134 |
print_tool_output(output, success, truncate=False)
|
|
|
|
| 1135 |
elif event.event_type == "tool_log":
|
| 1136 |
tool = event.data.get("tool", "") if event.data else ""
|
| 1137 |
log = event.data.get("log", "") if event.data else ""
|
| 1138 |
+
if not log:
|
| 1139 |
+
pass
|
| 1140 |
+
elif tool == "research":
|
| 1141 |
+
# Buffer research sub-agent activity; on completion, dump a
|
| 1142 |
+
# single static block that mirrors the live overlay's styling
|
| 1143 |
+
# without its line-erasing redraws (unfit for non-TTY output).
|
| 1144 |
+
if log == "Starting research sub-agent...":
|
| 1145 |
+
_hl_in_research[0] = True
|
| 1146 |
+
_hl_research_calls.clear()
|
| 1147 |
+
elif log == "Research complete.":
|
| 1148 |
+
_hl_in_research[0] = False
|
| 1149 |
+
f = get_console().file
|
| 1150 |
+
f.write(" \033[38;2;255;200;80mβΈ research\033[0m\n")
|
| 1151 |
+
for call in _hl_research_calls:
|
| 1152 |
+
f.write(f" \033[2m{call}\033[0m\n")
|
| 1153 |
+
f.flush()
|
| 1154 |
+
_hl_research_calls.clear()
|
| 1155 |
+
elif log.startswith("tokens:") or log.startswith("tools:"):
|
| 1156 |
+
pass # stats updates β only useful for the live display
|
| 1157 |
+
elif _hl_in_research[0]:
|
| 1158 |
+
_hl_research_calls.append(log)
|
| 1159 |
+
else:
|
| 1160 |
+
print_tool_log(tool, log)
|
| 1161 |
+
else:
|
| 1162 |
print_tool_log(tool, log)
|
| 1163 |
elif event.event_type == "approval_required":
|
| 1164 |
# Auto-approve everything in headless mode (safety net if yolo_mode
|
|
|
|
| 1185 |
new_tokens = event.data.get("new_tokens", 0) if event.data else 0
|
| 1186 |
print_compacted(old_tokens, new_tokens)
|
| 1187 |
elif event.event_type == "error":
|
|
|
|
| 1188 |
stream_buf.discard()
|
| 1189 |
error = event.data.get("error", "Unknown error") if event.data else "Unknown error"
|
| 1190 |
print_error(error)
|
| 1191 |
break
|
| 1192 |
elif event.event_type in ("turn_complete", "interrupted"):
|
|
|
|
| 1193 |
stream_buf.discard()
|
| 1194 |
history_size = event.data.get("history_size", "?") if event.data else "?"
|
| 1195 |
print(f"\n--- Agent {event.event_type} (history_size={history_size}) ---", file=sys.stderr)
|
|
@@ -3,10 +3,22 @@ Terminal display utilities β rich-powered CLI formatting.
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from rich.console import Console
|
| 6 |
-
from rich.markdown import Markdown
|
| 7 |
from rich.panel import Panel
|
| 8 |
from rich.theme import Theme
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
_THEME = Theme({
|
| 11 |
"tool.name": "bold rgb(255,200,80)",
|
| 12 |
"tool.args": "dim",
|
|
@@ -235,8 +247,13 @@ def print_tool_log(tool: str, log: str) -> None:
|
|
| 235 |
|
| 236 |
# ββ Messages βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 237 |
|
| 238 |
-
def print_markdown(
|
| 239 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
from rich.padding import Padding
|
| 241 |
|
| 242 |
_console.print()
|
|
@@ -260,21 +277,36 @@ def print_markdown(text: str) -> None:
|
|
| 260 |
lines = rendered.split("\n")
|
| 261 |
rendered = "\n".join(line.rstrip() for line in lines)
|
| 262 |
|
| 263 |
-
# CRT typewriter effect β fast, with occasional glitch
|
| 264 |
-
rng = random.Random(42)
|
| 265 |
f = _console.file
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
for ch in rendered:
|
|
|
|
|
|
|
|
|
|
| 267 |
f.write(ch)
|
| 268 |
f.flush()
|
| 269 |
if ch == "\n":
|
| 270 |
-
|
| 271 |
elif ch == " ":
|
| 272 |
-
|
| 273 |
elif rng.random() < 0.03:
|
| 274 |
-
|
| 275 |
else:
|
| 276 |
-
|
| 277 |
-
f.write("\n")
|
| 278 |
f.flush()
|
| 279 |
|
| 280 |
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
from rich.console import Console
|
| 6 |
+
from rich.markdown import Heading, Markdown
|
| 7 |
from rich.panel import Panel
|
| 8 |
from rich.theme import Theme
|
| 9 |
|
| 10 |
+
|
| 11 |
+
class _LeftHeading(Heading):
|
| 12 |
+
"""Rich's default Markdown renders h1/h2 centered via Align.center.
|
| 13 |
+
Yield the styled text directly so headings stay left-aligned."""
|
| 14 |
+
|
| 15 |
+
def __rich_console__(self, console, options):
|
| 16 |
+
self.text.justify = "left"
|
| 17 |
+
yield self.text
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
Markdown.elements["heading_open"] = _LeftHeading
|
| 21 |
+
|
| 22 |
_THEME = Theme({
|
| 23 |
"tool.name": "bold rgb(255,200,80)",
|
| 24 |
"tool.args": "dim",
|
|
|
|
| 247 |
|
| 248 |
# ββ Messages βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 249 |
|
| 250 |
+
async def print_markdown(
|
| 251 |
+
text: str,
|
| 252 |
+
cancel_event: "asyncio.Event | None" = None,
|
| 253 |
+
instant: bool = False,
|
| 254 |
+
) -> None:
|
| 255 |
+
import asyncio
|
| 256 |
+
import io, random
|
| 257 |
from rich.padding import Padding
|
| 258 |
|
| 259 |
_console.print()
|
|
|
|
| 277 |
lines = rendered.split("\n")
|
| 278 |
rendered = "\n".join(line.rstrip() for line in lines)
|
| 279 |
|
|
|
|
|
|
|
| 280 |
f = _console.file
|
| 281 |
+
|
| 282 |
+
# Headless / non-interactive: dump the rendered markdown in one write.
|
| 283 |
+
if instant:
|
| 284 |
+
f.write(rendered)
|
| 285 |
+
f.write("\n")
|
| 286 |
+
f.flush()
|
| 287 |
+
return
|
| 288 |
+
|
| 289 |
+
# CRT typewriter effect β async so the event loop can service signal
|
| 290 |
+
# handlers (Ctrl+C during streaming) between characters. If cancelled
|
| 291 |
+
# mid-type, stop cleanly: write an ANSI reset so half-open color state
|
| 292 |
+
# doesn't bleed onto the "interrupted" line, and return.
|
| 293 |
+
rng = random.Random(42)
|
| 294 |
+
cancelled = False
|
| 295 |
for ch in rendered:
|
| 296 |
+
if cancel_event is not None and cancel_event.is_set():
|
| 297 |
+
cancelled = True
|
| 298 |
+
break
|
| 299 |
f.write(ch)
|
| 300 |
f.flush()
|
| 301 |
if ch == "\n":
|
| 302 |
+
await asyncio.sleep(0.002)
|
| 303 |
elif ch == " ":
|
| 304 |
+
await asyncio.sleep(0.002)
|
| 305 |
elif rng.random() < 0.03:
|
| 306 |
+
await asyncio.sleep(0.015)
|
| 307 |
else:
|
| 308 |
+
await asyncio.sleep(0.004)
|
| 309 |
+
f.write("\033[0m\n" if cancelled else "\n")
|
| 310 |
f.flush()
|
| 311 |
|
| 312 |
|