Aksel Joonas Reedi commited on
Commit
1481358
Β·
unverified Β·
2 Parent(s): dc35197f08efb1

Merge pull request #50 from huggingface/fix/ctrl-c-and-streaming-rework

Browse files
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
@@ -268,6 +269,8 @@ class _ThinkingShimmer:
268
  self._task = asyncio.ensure_future(self._animate())
269
 
270
  def stop(self):
 
 
271
  self._running = False
272
  if self._task:
273
  self._task.cancel()
@@ -312,7 +315,10 @@ class _ThinkingShimmer:
312
 
313
 
314
  class _StreamBuffer:
315
- """Accumulates streamed tokens, renders full markdown on finish."""
 
 
 
316
 
317
  def __init__(self, console):
318
  self._console = console
@@ -321,10 +327,41 @@ class _StreamBuffer:
321
  def add_chunk(self, text: str):
322
  self._buffer += text
323
 
324
- def finish(self):
325
- """Render the accumulated text as markdown, then reset."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
  if self._buffer.strip():
327
- print_markdown(self._buffer)
328
  self._buffer = ""
329
 
330
  def discard(self):
@@ -338,6 +375,7 @@ async def event_listener(
338
  ready_event: asyncio.Event,
339
  prompt_session: PromptSession,
340
  config=None,
 
341
  ) -> None:
342
  """Background task that listens for events and displays them"""
343
  submission_id = [1000]
@@ -346,6 +384,12 @@ async def event_listener(
346
  shimmer = _ThinkingShimmer(console)
347
  stream_buf = _StreamBuffer(console)
348
 
 
 
 
 
 
 
349
  while True:
350
  try:
351
  event = await event_queue.get()
@@ -358,14 +402,19 @@ async def event_listener(
358
  shimmer.stop()
359
  content = event.data.get("content", "") if event.data else ""
360
  if content:
361
- print_markdown(content)
362
  elif event.event_type == "assistant_chunk":
363
  content = event.data.get("content", "") if event.data else ""
364
  if content:
365
  stream_buf.add_chunk(content)
 
 
 
 
 
366
  elif event.event_type == "assistant_stream_end":
367
  shimmer.stop()
368
- stream_buf.finish()
369
  elif event.event_type == "tool_call":
370
  shimmer.stop()
371
  stream_buf.discard()
@@ -660,10 +709,33 @@ async def event_listener(
660
  if gated is not None:
661
  print(f"Gated: {gated}")
662
 
663
- # Get user decision for this item
664
- response = await prompt_session.prompt_async(
665
- f"Approve item {i}? (y=yes, yolo=approve all, n=no, or provide feedback): "
666
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
667
 
668
  response = response.strip().lower()
669
 
@@ -910,44 +982,94 @@ async def main():
910
  ready_event,
911
  prompt_session,
912
  config,
 
913
  )
914
  )
915
 
916
  await ready_event.wait()
917
 
918
  submission_id = [0]
919
- last_interrupt_time = 0.0
920
- agent_busy = False # True only while the agent is processing a submission
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
921
 
922
  try:
923
  while True:
924
- # Wait for previous turn to complete, with interrupt support
 
 
925
  try:
926
  await turn_complete_event.wait()
927
  except asyncio.CancelledError:
928
  break
929
  turn_complete_event.clear()
930
- agent_busy = False
931
 
932
- # Get user input
 
 
 
 
 
 
933
  try:
934
  user_input = await get_user_input(prompt_session)
935
  except EOFError:
936
  break
937
  except KeyboardInterrupt:
938
  now = time.monotonic()
939
- if now - last_interrupt_time < 3.0:
940
  break
941
- last_interrupt_time = now
942
- # If agent is actually working, cancel it
943
- session = session_holder[0]
944
- if agent_busy and session:
945
- session.cancel()
946
- else:
947
- get_console().print("[dim]Ctrl+C again to exit[/dim]")
948
- turn_complete_event.set()
949
  continue
950
 
 
 
 
 
951
  # Check for exit commands
952
  if user_input.strip().lower() in ["exit", "quit", "/quit", "/exit"]:
953
  break
@@ -967,7 +1089,6 @@ async def main():
967
  turn_complete_event.set()
968
  continue
969
  else:
970
- agent_busy = True
971
  await submission_queue.put(sub)
972
  continue
973
 
@@ -979,11 +1100,16 @@ async def main():
979
  op_type=OpType.USER_INPUT, data={"text": user_input}
980
  ),
981
  )
982
- agent_busy = True
983
  await submission_queue.put(submission)
984
 
985
  except KeyboardInterrupt:
986
  pass
 
 
 
 
 
 
987
 
988
  # Shutdown
989
  shutdown_submission = Submission(
@@ -1071,13 +1197,17 @@ async def headless_main(
1071
  )
1072
  await submission_queue.put(submission)
1073
 
1074
- # Process events until turn completes
 
 
1075
  console = _create_rich_console()
1076
- shimmer = _ThinkingShimmer(console)
1077
  stream_buf = _StreamBuffer(console)
1078
  _hl_last_tool = [None]
1079
  _hl_sub_id = [1]
1080
- shimmer.start()
 
 
 
1081
 
1082
  while True:
1083
  event = await event_queue.get()
@@ -1086,16 +1216,14 @@ async def headless_main(
1086
  content = event.data.get("content", "") if event.data else ""
1087
  if content:
1088
  stream_buf.add_chunk(content)
 
1089
  elif event.event_type == "assistant_stream_end":
1090
- shimmer.stop()
1091
- stream_buf.finish()
1092
  elif event.event_type == "assistant_message":
1093
- shimmer.stop()
1094
  content = event.data.get("content", "") if event.data else ""
1095
  if content:
1096
- print_markdown(content)
1097
  elif event.event_type == "tool_call":
1098
- shimmer.stop()
1099
  stream_buf.discard()
1100
  tool_name = event.data.get("tool", "") if event.data else ""
1101
  arguments = event.data.get("arguments", {}) if event.data else {}
@@ -1109,11 +1237,33 @@ async def headless_main(
1109
  success = event.data.get("success", False) if event.data else False
1110
  if _hl_last_tool[0] == "plan_tool" and output:
1111
  print_tool_output(output, success, truncate=False)
1112
- shimmer.start()
1113
  elif event.event_type == "tool_log":
1114
  tool = event.data.get("tool", "") if event.data else ""
1115
  log = event.data.get("log", "") if event.data else ""
1116
- if log:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1117
  print_tool_log(tool, log)
1118
  elif event.event_type == "approval_required":
1119
  # Auto-approve everything in headless mode (safety net if yolo_mode
@@ -1140,13 +1290,11 @@ async def headless_main(
1140
  new_tokens = event.data.get("new_tokens", 0) if event.data else 0
1141
  print_compacted(old_tokens, new_tokens)
1142
  elif event.event_type == "error":
1143
- shimmer.stop()
1144
  stream_buf.discard()
1145
  error = event.data.get("error", "Unknown error") if event.data else "Unknown error"
1146
  print_error(error)
1147
  break
1148
  elif event.event_type in ("turn_complete", "interrupted"):
1149
- shimmer.stop()
1150
  stream_buf.discard()
1151
  history_size = event.data.get("history_size", "?") if event.data else "?"
1152
  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
 
269
  self._task = asyncio.ensure_future(self._animate())
270
 
271
  def stop(self):
272
+ if not self._running:
273
+ return # no-op when never started (e.g. headless mode)
274
  self._running = False
275
  if self._task:
276
  self._task.cancel()
 
315
 
316
 
317
  class _StreamBuffer:
318
+ """Accumulates streamed tokens, renders markdown block-by-block as complete
319
+ blocks appear. A "block" is everything up to a paragraph break (\\n\\n).
320
+ Unclosed code fences (odd count of ```) hold back flushing until closed so
321
+ a code block is always rendered as one unit."""
322
 
323
  def __init__(self, console):
324
  self._console = console
 
327
  def add_chunk(self, text: str):
328
  self._buffer += text
329
 
330
+ def _pop_block(self) -> str | None:
331
+ """Extract the next complete block, or return None if nothing complete."""
332
+ if self._buffer.count("```") % 2 == 1:
333
+ return None # inside an open code fence β€” wait for close
334
+ idx = self._buffer.find("\n\n")
335
+ if idx == -1:
336
+ return None
337
+ block = self._buffer[:idx]
338
+ self._buffer = self._buffer[idx + 2:]
339
+ return block
340
+
341
+ async def flush_ready(
342
+ self,
343
+ cancel_event: "asyncio.Event | None" = None,
344
+ instant: bool = False,
345
+ ):
346
+ """Render any complete blocks that have accumulated; leave the tail."""
347
+ while True:
348
+ if cancel_event is not None and cancel_event.is_set():
349
+ return
350
+ block = self._pop_block()
351
+ if block is None:
352
+ return
353
+ if block.strip():
354
+ await print_markdown(block, cancel_event=cancel_event, instant=instant)
355
+
356
+ async def finish(
357
+ self,
358
+ cancel_event: "asyncio.Event | None" = None,
359
+ instant: bool = False,
360
+ ):
361
+ """Flush complete blocks, then render whatever incomplete tail remains."""
362
+ await self.flush_ready(cancel_event=cancel_event, instant=instant)
363
  if self._buffer.strip():
364
+ await print_markdown(self._buffer, cancel_event=cancel_event, instant=instant)
365
  self._buffer = ""
366
 
367
  def discard(self):
 
375
  ready_event: asyncio.Event,
376
  prompt_session: PromptSession,
377
  config=None,
378
+ session_holder=None,
379
  ) -> None:
380
  """Background task that listens for events and displays them"""
381
  submission_id = [1000]
 
384
  shimmer = _ThinkingShimmer(console)
385
  stream_buf = _StreamBuffer(console)
386
 
387
+ def _cancel_event():
388
+ """Return the session's cancellation Event so print_markdown can abort
389
+ its typewriter loop mid-stream when Ctrl+C fires."""
390
+ s = session_holder[0] if session_holder else None
391
+ return s._cancelled if s is not None else None
392
+
393
  while True:
394
  try:
395
  event = await event_queue.get()
 
402
  shimmer.stop()
403
  content = event.data.get("content", "") if event.data else ""
404
  if content:
405
+ await print_markdown(content, cancel_event=_cancel_event())
406
  elif event.event_type == "assistant_chunk":
407
  content = event.data.get("content", "") if event.data else ""
408
  if content:
409
  stream_buf.add_chunk(content)
410
+ # Flush any complete markdown blocks progressively so the
411
+ # user sees paragraphs appear as they're produced, not just
412
+ # at the end of the whole response.
413
+ shimmer.stop()
414
+ await stream_buf.flush_ready(cancel_event=_cancel_event())
415
  elif event.event_type == "assistant_stream_end":
416
  shimmer.stop()
417
+ await stream_buf.finish(cancel_event=_cancel_event())
418
  elif event.event_type == "tool_call":
419
  shimmer.stop()
420
  stream_buf.discard()
 
709
  if gated is not None:
710
  print(f"Gated: {gated}")
711
 
712
+ # Get user decision for this item. Ctrl+C / EOF here is
713
+ # treated as "reject remaining" (matches Codex's modal
714
+ # priority and Forgecode's approval-cancel path). Without
715
+ # this, KeyboardInterrupt kills the event listener and
716
+ # the main loop deadlocks waiting for turn_complete.
717
+ try:
718
+ response = await prompt_session.prompt_async(
719
+ f"Approve item {i}? (y=yes, yolo=approve all, n=no, or provide feedback): "
720
+ )
721
+ except (KeyboardInterrupt, EOFError):
722
+ get_console().print("[dim]Approval cancelled β€” rejecting remaining items[/dim]")
723
+ approvals.append(
724
+ {
725
+ "tool_call_id": tool_call_id,
726
+ "approved": False,
727
+ "feedback": "User cancelled approval",
728
+ }
729
+ )
730
+ for remaining in tools_data[i:]:
731
+ approvals.append(
732
+ {
733
+ "tool_call_id": remaining.get("tool_call_id", ""),
734
+ "approved": False,
735
+ "feedback": None,
736
+ }
737
+ )
738
+ break
739
 
740
  response = response.strip().lower()
741
 
 
982
  ready_event,
983
  prompt_session,
984
  config,
985
+ session_holder=session_holder,
986
  )
987
  )
988
 
989
  await ready_event.wait()
990
 
991
  submission_id = [0]
992
+ # Mirrors codex-rs/tui/src/bottom_pane/mod.rs:137
993
+ # (`QUIT_SHORTCUT_TIMEOUT = Duration::from_secs(1)`). Two Ctrl+C presses
994
+ # within this window quit; a single press cancels the in-flight turn.
995
+ CTRL_C_QUIT_WINDOW = 1.0
996
+ # Hint string matches codex-rs/tui/src/bottom_pane/footer.rs:746
997
+ # (`" again to quit"` prefixed with the key binding, rendered dim).
998
+ CTRL_C_HINT = "[dim]ctrl + c again to quit[/dim]"
999
+ interrupt_state = {"last": 0.0, "exit": False}
1000
+
1001
+ loop = asyncio.get_running_loop()
1002
+
1003
+ def _on_sigint() -> None:
1004
+ """SIGINT handler β€” fires while the agent is generating (terminal is
1005
+ in cooked mode between prompts). Mirrors Codex's `on_ctrl_c` in
1006
+ codex-rs/tui/src/chatwidget.rs: first press cancels active work and
1007
+ arms the quit hint; second press within the window quits."""
1008
+ now = time.monotonic()
1009
+ session = session_holder[0]
1010
+
1011
+ if now - interrupt_state["last"] < CTRL_C_QUIT_WINDOW:
1012
+ interrupt_state["exit"] = True
1013
+ if session:
1014
+ session.cancel()
1015
+ # Wake the main loop out of turn_complete_event.wait()
1016
+ turn_complete_event.set()
1017
+ return
1018
+
1019
+ interrupt_state["last"] = now
1020
+ if session and not session.is_cancelled:
1021
+ session.cancel()
1022
+ get_console().print(f"\n{CTRL_C_HINT}")
1023
+
1024
+ def _install_sigint() -> bool:
1025
+ try:
1026
+ loop.add_signal_handler(signal.SIGINT, _on_sigint)
1027
+ return True
1028
+ except (NotImplementedError, RuntimeError):
1029
+ return False # Windows or non-main thread
1030
+
1031
+ # prompt_toolkit's prompt_async installs its own SIGINT handler and, on
1032
+ # exit, calls loop.remove_signal_handler(SIGINT) β€” which wipes ours too.
1033
+ # So we re-arm at the top of every loop iteration, right before the busy
1034
+ # wait. Without this, Ctrl+C during agent streaming after the first turn
1035
+ # falls through to the default handler and the terminal just echoes ^C.
1036
+ sigint_available = _install_sigint()
1037
 
1038
  try:
1039
  while True:
1040
+ if sigint_available:
1041
+ _install_sigint()
1042
+
1043
  try:
1044
  await turn_complete_event.wait()
1045
  except asyncio.CancelledError:
1046
  break
1047
  turn_complete_event.clear()
 
1048
 
1049
+ if interrupt_state["exit"]:
1050
+ break
1051
+
1052
+ # Get user input. prompt_toolkit puts the terminal in raw mode and
1053
+ # installs its own SIGINT handling; ^C arrives as \x03 and surfaces
1054
+ # as KeyboardInterrupt here. On return, prompt_toolkit removes the
1055
+ # loop's SIGINT handler β€” we re-arm at the top of the next iter.
1056
  try:
1057
  user_input = await get_user_input(prompt_session)
1058
  except EOFError:
1059
  break
1060
  except KeyboardInterrupt:
1061
  now = time.monotonic()
1062
+ if now - interrupt_state["last"] < CTRL_C_QUIT_WINDOW:
1063
  break
1064
+ interrupt_state["last"] = now
1065
+ get_console().print(CTRL_C_HINT)
1066
+ turn_complete_event.set()
 
 
 
 
 
1067
  continue
1068
 
1069
+ # A successful read ends the double-press window β€” an unrelated
1070
+ # Ctrl+C during the next turn should start a fresh arming.
1071
+ interrupt_state["last"] = 0.0
1072
+
1073
  # Check for exit commands
1074
  if user_input.strip().lower() in ["exit", "quit", "/quit", "/exit"]:
1075
  break
 
1089
  turn_complete_event.set()
1090
  continue
1091
  else:
 
1092
  await submission_queue.put(sub)
1093
  continue
1094
 
 
1100
  op_type=OpType.USER_INPUT, data={"text": user_input}
1101
  ),
1102
  )
 
1103
  await submission_queue.put(submission)
1104
 
1105
  except KeyboardInterrupt:
1106
  pass
1107
+ finally:
1108
+ if sigint_available:
1109
+ try:
1110
+ loop.remove_signal_handler(signal.SIGINT)
1111
+ except (NotImplementedError, RuntimeError):
1112
+ pass
1113
 
1114
  # Shutdown
1115
  shutdown_submission = Submission(
 
1197
  )
1198
  await submission_queue.put(submission)
1199
 
1200
+ # Process events until turn completes. Headless mode is for scripts /
1201
+ # log capture: no shimmer animation, no typewriter, no live-redrawing
1202
+ # research overlay. Output is plain, append-only text.
1203
  console = _create_rich_console()
 
1204
  stream_buf = _StreamBuffer(console)
1205
  _hl_last_tool = [None]
1206
  _hl_sub_id = [1]
1207
+ # Research sub-agent tool calls are buffered and dumped once the sub-agent
1208
+ # finishes, instead of streaming via the live redrawing SubAgentDisplay.
1209
+ _hl_research_calls: list[str] = []
1210
+ _hl_in_research = [False]
1211
 
1212
  while True:
1213
  event = await event_queue.get()
 
1216
  content = event.data.get("content", "") if event.data else ""
1217
  if content:
1218
  stream_buf.add_chunk(content)
1219
+ await stream_buf.flush_ready(instant=True)
1220
  elif event.event_type == "assistant_stream_end":
1221
+ await stream_buf.finish(instant=True)
 
1222
  elif event.event_type == "assistant_message":
 
1223
  content = event.data.get("content", "") if event.data else ""
1224
  if content:
1225
+ await print_markdown(content, instant=True)
1226
  elif event.event_type == "tool_call":
 
1227
  stream_buf.discard()
1228
  tool_name = event.data.get("tool", "") if event.data else ""
1229
  arguments = event.data.get("arguments", {}) if event.data else {}
 
1237
  success = event.data.get("success", False) if event.data else False
1238
  if _hl_last_tool[0] == "plan_tool" and output:
1239
  print_tool_output(output, success, truncate=False)
 
1240
  elif event.event_type == "tool_log":
1241
  tool = event.data.get("tool", "") if event.data else ""
1242
  log = event.data.get("log", "") if event.data else ""
1243
+ if not log:
1244
+ pass
1245
+ elif tool == "research":
1246
+ # Buffer research sub-agent activity; on completion, dump a
1247
+ # single static block that mirrors the live overlay's styling
1248
+ # without its line-erasing redraws (unfit for non-TTY output).
1249
+ if log == "Starting research sub-agent...":
1250
+ _hl_in_research[0] = True
1251
+ _hl_research_calls.clear()
1252
+ elif log == "Research complete.":
1253
+ _hl_in_research[0] = False
1254
+ f = get_console().file
1255
+ f.write(" \033[38;2;255;200;80mβ–Έ research\033[0m\n")
1256
+ for call in _hl_research_calls:
1257
+ f.write(f" \033[2m{call}\033[0m\n")
1258
+ f.flush()
1259
+ _hl_research_calls.clear()
1260
+ elif log.startswith("tokens:") or log.startswith("tools:"):
1261
+ pass # stats updates β€” only useful for the live display
1262
+ elif _hl_in_research[0]:
1263
+ _hl_research_calls.append(log)
1264
+ else:
1265
+ print_tool_log(tool, log)
1266
+ else:
1267
  print_tool_log(tool, log)
1268
  elif event.event_type == "approval_required":
1269
  # Auto-approve everything in headless mode (safety net if yolo_mode
 
1290
  new_tokens = event.data.get("new_tokens", 0) if event.data else 0
1291
  print_compacted(old_tokens, new_tokens)
1292
  elif event.event_type == "error":
 
1293
  stream_buf.discard()
1294
  error = event.data.get("error", "Unknown error") if event.data else "Unknown error"
1295
  print_error(error)
1296
  break
1297
  elif event.event_type in ("turn_complete", "interrupted"):
 
1298
  stream_buf.discard()
1299
  history_size = event.data.get("history_size", "?") if event.data else "?"
1300
  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