akseljoonas HF Staff commited on
Commit
f08efb1
Β·
1 Parent(s): 1c0de34

CLI: global Ctrl+C handling, progressive streaming, headless cleanup

Browse files

Ctrl+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.

Files changed (2) hide show
  1. agent/main.py +186 -38
  2. agent/utils/terminal_display.py +42 -10
agent/main.py CHANGED
@@ -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 full markdown on finish."""
 
 
 
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 finish(self):
249
- """Render the accumulated text as markdown, then reset."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- response = await prompt_session.prompt_async(
589
- f"Approve item {i}? (y=yes, yolo=approve all, n=no, or provide feedback): "
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
- last_interrupt_time = 0.0
815
- agent_busy = False # True only while the agent is processing a submission
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
816
 
817
  try:
818
  while True:
819
- # Wait for previous turn to complete, with interrupt support
 
 
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
- # Get user input
 
 
 
 
 
 
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 - last_interrupt_time < 3.0:
835
  break
836
- last_interrupt_time = now
837
- # If agent is actually working, cancel it
838
- session = session_holder[0]
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
- shimmer.start()
 
 
 
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
- shimmer.stop()
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)
agent/utils/terminal_display.py CHANGED
@@ -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(text: str) -> None:
239
- import io, time, random
 
 
 
 
 
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
- time.sleep(0.002)
271
  elif ch == " ":
272
- time.sleep(0.002)
273
  elif rng.random() < 0.03:
274
- time.sleep(0.015)
275
  else:
276
- time.sleep(0.004)
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