code-puppy 0.0.341__py3-none-any.whl → 0.0.348__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. code_puppy/agents/base_agent.py +17 -248
  2. code_puppy/agents/event_stream_handler.py +257 -0
  3. code_puppy/cli_runner.py +4 -3
  4. code_puppy/command_line/add_model_menu.py +8 -9
  5. code_puppy/command_line/mcp/catalog_server_installer.py +5 -6
  6. code_puppy/command_line/mcp/custom_server_form.py +54 -19
  7. code_puppy/command_line/mcp/custom_server_installer.py +8 -9
  8. code_puppy/command_line/mcp/handler.py +0 -2
  9. code_puppy/command_line/mcp/help_command.py +1 -5
  10. code_puppy/command_line/mcp/start_command.py +36 -18
  11. code_puppy/command_line/onboarding_slides.py +0 -1
  12. code_puppy/command_line/prompt_toolkit_completion.py +16 -10
  13. code_puppy/command_line/utils.py +54 -0
  14. code_puppy/mcp_/async_lifecycle.py +35 -4
  15. code_puppy/mcp_/managed_server.py +49 -20
  16. code_puppy/mcp_/manager.py +81 -52
  17. code_puppy/messaging/message_queue.py +11 -23
  18. code_puppy/tools/agent_tools.py +66 -13
  19. {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/METADATA +1 -1
  20. {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/RECORD +25 -25
  21. code_puppy/command_line/mcp/add_command.py +0 -170
  22. {code_puppy-0.0.341.data → code_puppy-0.0.348.data}/data/code_puppy/models.json +0 -0
  23. {code_puppy-0.0.341.data → code_puppy-0.0.348.data}/data/code_puppy/models_dev_api.json +0 -0
  24. {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/WHEEL +0 -0
  25. {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/entry_points.txt +0 -0
  26. {code_puppy-0.0.341.dist-info → code_puppy-0.0.348.dist-info}/licenses/LICENSE +0 -0
@@ -7,7 +7,6 @@ import signal
7
7
  import threading
8
8
  import uuid
9
9
  from abc import ABC, abstractmethod
10
- from collections.abc import AsyncIterable
11
10
  from typing import (
12
11
  Any,
13
12
  Callable,
@@ -30,7 +29,6 @@ from pydantic_ai import (
30
29
  BinaryContent,
31
30
  DocumentUrl,
32
31
  ImageUrl,
33
- PartEndEvent,
34
32
  RunContext,
35
33
  UsageLimitExceeded,
36
34
  UsageLimits,
@@ -48,6 +46,8 @@ from pydantic_ai.messages import (
48
46
  )
49
47
  from rich.text import Text
50
48
 
49
+ from code_puppy.agents.event_stream_handler import event_stream_handler
50
+
51
51
  # Consolidated relative imports
52
52
  from code_puppy.config import (
53
53
  get_agent_pinned_model,
@@ -100,9 +100,6 @@ class BaseAgent(ABC):
100
100
  # Cache for MCP tool definitions (for token estimation)
101
101
  # This is populated after the first successful run when MCP tools are retrieved
102
102
  self._mcp_tool_definitions_cache: List[Dict[str, Any]] = []
103
- # Shared console for streaming output - should be set by cli_runner
104
- # to avoid conflicts between spinner's Live display and response streaming
105
- self._console: Optional[Any] = None
106
103
 
107
104
  @property
108
105
  @abstractmethod
@@ -1233,9 +1230,12 @@ class BaseAgent(ABC):
1233
1230
  agent_tools = self.get_available_tools()
1234
1231
  register_tools_for_agent(agent_without_mcp, agent_tools)
1235
1232
 
1236
- # Wrap with DBOS
1233
+ # Wrap with DBOS - pass event_stream_handler at construction time
1234
+ # so DBOSModel gets the handler for streaming output
1237
1235
  dbos_agent = DBOSAgent(
1238
- agent_without_mcp, name=f"{self.name}-{_reload_count}"
1236
+ agent_without_mcp,
1237
+ name=f"{self.name}-{_reload_count}",
1238
+ event_stream_handler=event_stream_handler,
1239
1239
  )
1240
1240
  self.pydantic_agent = dbos_agent
1241
1241
  self._code_generation_agent = dbos_agent
@@ -1314,8 +1314,11 @@ class BaseAgent(ABC):
1314
1314
  )
1315
1315
  agent_tools = self.get_available_tools()
1316
1316
  register_tools_for_agent(temp_agent, agent_tools)
1317
+ # Pass event_stream_handler at construction time for streaming output
1317
1318
  dbos_agent = DBOSAgent(
1318
- temp_agent, name=f"{self.name}-structured-{_reload_count}"
1319
+ temp_agent,
1320
+ name=f"{self.name}-structured-{_reload_count}",
1321
+ event_stream_handler=event_stream_handler,
1319
1322
  )
1320
1323
  return dbos_agent
1321
1324
  else:
@@ -1357,241 +1360,6 @@ class BaseAgent(ABC):
1357
1360
  self.set_message_history(result_messages_filtered_empty_thinking)
1358
1361
  return self.get_message_history()
1359
1362
 
1360
- async def _event_stream_handler(
1361
- self, ctx: RunContext, events: AsyncIterable[Any]
1362
- ) -> None:
1363
- """Handle streaming events from the agent run.
1364
-
1365
- This method processes streaming events and emits TextPart, ThinkingPart,
1366
- and ToolCallPart content with styled banners/tokens as they stream in.
1367
-
1368
- Args:
1369
- ctx: The run context.
1370
- events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
1371
- """
1372
- from pydantic_ai import PartDeltaEvent, PartStartEvent
1373
- from pydantic_ai.messages import (
1374
- TextPartDelta,
1375
- ThinkingPartDelta,
1376
- ToolCallPartDelta,
1377
- )
1378
- from rich.console import Console
1379
- from rich.markup import escape
1380
-
1381
- from code_puppy.messaging.spinner import pause_all_spinners
1382
-
1383
- # IMPORTANT: Use the shared console (set by cli_runner) to avoid conflicts
1384
- # with the spinner's Live display. Multiple Console instances with separate
1385
- # Live displays cause cursor positioning chaos and line duplication.
1386
- if self._console is not None:
1387
- console = self._console
1388
- else:
1389
- # Fallback if console not set (shouldn't happen in normal use)
1390
- console = Console()
1391
-
1392
- # Track which part indices we're currently streaming (for Text/Thinking/Tool parts)
1393
- streaming_parts: set[int] = set()
1394
- thinking_parts: set[int] = (
1395
- set()
1396
- ) # Track which parts are thinking (for dim style)
1397
- text_parts: set[int] = set() # Track which parts are text
1398
- tool_parts: set[int] = set() # Track which parts are tool calls
1399
- banner_printed: set[int] = set() # Track if banner was already printed
1400
- token_count: dict[int, int] = {} # Track token count per text/tool part
1401
- did_stream_anything = False # Track if we streamed any content
1402
-
1403
- # Termflow streaming state for text parts
1404
- from termflow import Parser as TermflowParser
1405
- from termflow import Renderer as TermflowRenderer
1406
-
1407
- termflow_parsers: dict[int, TermflowParser] = {}
1408
- termflow_renderers: dict[int, TermflowRenderer] = {}
1409
- termflow_line_buffers: dict[int, str] = {} # Buffer incomplete lines
1410
-
1411
- def _print_thinking_banner() -> None:
1412
- """Print the THINKING banner with spinner pause and line clear."""
1413
- nonlocal did_stream_anything
1414
- import time
1415
-
1416
- from code_puppy.config import get_banner_color
1417
-
1418
- pause_all_spinners()
1419
- time.sleep(0.1) # Delay to let spinner fully clear
1420
- # Clear line and print newline before banner
1421
- console.print(" " * 50, end="\r")
1422
- console.print() # Newline before banner
1423
- # Bold banner with configurable color and lightning bolt
1424
- thinking_color = get_banner_color("thinking")
1425
- console.print(
1426
- Text.from_markup(
1427
- f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]⚡ "
1428
- ),
1429
- end="",
1430
- )
1431
- did_stream_anything = True
1432
-
1433
- def _print_response_banner() -> None:
1434
- """Print the AGENT RESPONSE banner with spinner pause and line clear."""
1435
- nonlocal did_stream_anything
1436
- import time
1437
-
1438
- from code_puppy.config import get_banner_color
1439
-
1440
- pause_all_spinners()
1441
- time.sleep(0.1) # Delay to let spinner fully clear
1442
- # Clear line and print newline before banner
1443
- console.print(" " * 50, end="\r")
1444
- console.print() # Newline before banner
1445
- response_color = get_banner_color("agent_response")
1446
- console.print(
1447
- Text.from_markup(
1448
- f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
1449
- )
1450
- )
1451
- did_stream_anything = True
1452
-
1453
- async for event in events:
1454
- # PartStartEvent - register the part but defer banner until content arrives
1455
- if isinstance(event, PartStartEvent):
1456
- part = event.part
1457
- if isinstance(part, ThinkingPart):
1458
- streaming_parts.add(event.index)
1459
- thinking_parts.add(event.index)
1460
- # If there's initial content, print banner + content now
1461
- if part.content and part.content.strip():
1462
- _print_thinking_banner()
1463
- escaped = escape(part.content)
1464
- console.print(f"[dim]{escaped}[/dim]", end="")
1465
- banner_printed.add(event.index)
1466
- elif isinstance(part, TextPart):
1467
- streaming_parts.add(event.index)
1468
- text_parts.add(event.index)
1469
- # Initialize termflow streaming for this text part
1470
- termflow_parsers[event.index] = TermflowParser()
1471
- termflow_renderers[event.index] = TermflowRenderer(
1472
- output=console.file, width=console.width
1473
- )
1474
- termflow_line_buffers[event.index] = ""
1475
- # Handle initial content if present
1476
- if part.content and part.content.strip():
1477
- _print_response_banner()
1478
- banner_printed.add(event.index)
1479
- termflow_line_buffers[event.index] = part.content
1480
- elif isinstance(part, ToolCallPart):
1481
- streaming_parts.add(event.index)
1482
- tool_parts.add(event.index)
1483
- token_count[event.index] = 0 # Initialize token counter
1484
- # Track tool name for display
1485
- banner_printed.add(
1486
- event.index
1487
- ) # Use banner_printed to track if we've shown tool info
1488
-
1489
- # PartDeltaEvent - stream the content as it arrives
1490
- elif isinstance(event, PartDeltaEvent):
1491
- if event.index in streaming_parts:
1492
- delta = event.delta
1493
- if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
1494
- if delta.content_delta:
1495
- # For text parts, stream markdown with termflow
1496
- if event.index in text_parts:
1497
- # Print banner on first content
1498
- if event.index not in banner_printed:
1499
- _print_response_banner()
1500
- banner_printed.add(event.index)
1501
-
1502
- # Add content to line buffer
1503
- termflow_line_buffers[event.index] += (
1504
- delta.content_delta
1505
- )
1506
-
1507
- # Process complete lines
1508
- parser = termflow_parsers[event.index]
1509
- renderer = termflow_renderers[event.index]
1510
- buffer = termflow_line_buffers[event.index]
1511
-
1512
- while "\n" in buffer:
1513
- line, buffer = buffer.split("\n", 1)
1514
- events_to_render = parser.parse_line(line)
1515
- renderer.render_all(events_to_render)
1516
-
1517
- termflow_line_buffers[event.index] = buffer
1518
- else:
1519
- # For thinking parts, stream immediately (dim)
1520
- if event.index not in banner_printed:
1521
- _print_thinking_banner()
1522
- banner_printed.add(event.index)
1523
- escaped = escape(delta.content_delta)
1524
- console.print(f"[dim]{escaped}[/dim]", end="")
1525
- elif isinstance(delta, ToolCallPartDelta):
1526
- # For tool calls, count chunks received
1527
- token_count[event.index] += 1
1528
- # Get tool name if available
1529
- tool_name = getattr(delta, "tool_name_delta", "")
1530
- count = token_count[event.index]
1531
- # Display with tool wrench icon and tool name
1532
- if tool_name:
1533
- console.print(
1534
- f" 🔧 Calling {tool_name}... {count} chunks ",
1535
- end="\r",
1536
- )
1537
- else:
1538
- console.print(
1539
- f" 🔧 Calling tool... {count} chunks ",
1540
- end="\r",
1541
- )
1542
-
1543
- # PartEndEvent - finish the streaming with a newline
1544
- elif isinstance(event, PartEndEvent):
1545
- if event.index in streaming_parts:
1546
- # For text parts, finalize termflow rendering
1547
- if event.index in text_parts:
1548
- # Render any remaining buffered content
1549
- if event.index in termflow_parsers:
1550
- parser = termflow_parsers[event.index]
1551
- renderer = termflow_renderers[event.index]
1552
- remaining = termflow_line_buffers.get(event.index, "")
1553
-
1554
- # Parse and render any remaining partial line
1555
- if remaining.strip():
1556
- events_to_render = parser.parse_line(remaining)
1557
- renderer.render_all(events_to_render)
1558
-
1559
- # Finalize the parser to close any open blocks
1560
- final_events = parser.finalize()
1561
- renderer.render_all(final_events)
1562
-
1563
- # Clean up termflow state
1564
- del termflow_parsers[event.index]
1565
- del termflow_renderers[event.index]
1566
- del termflow_line_buffers[event.index]
1567
- # For tool parts, clear the chunk counter line
1568
- elif event.index in tool_parts:
1569
- # Clear the chunk counter line by printing spaces and returning
1570
- console.print(" " * 50, end="\r")
1571
- # For thinking parts, just print newline
1572
- elif event.index in banner_printed:
1573
- console.print() # Final newline after streaming
1574
-
1575
- # Clean up token count
1576
- token_count.pop(event.index, None)
1577
- # Clean up all tracking sets
1578
- streaming_parts.discard(event.index)
1579
- thinking_parts.discard(event.index)
1580
- text_parts.discard(event.index)
1581
- tool_parts.discard(event.index)
1582
- banner_printed.discard(event.index)
1583
-
1584
- # Resume spinner if next part is NOT text/thinking/tool (avoid race condition)
1585
- # If next part is None or handled differently, it's safe to resume
1586
- # Note: spinner itself handles blank line before appearing
1587
- from code_puppy.messaging.spinner import resume_all_spinners
1588
-
1589
- next_kind = getattr(event, "next_part_kind", None)
1590
- if next_kind not in ("text", "thinking", "tool-call"):
1591
- resume_all_spinners()
1592
-
1593
- # Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind)
1594
-
1595
1363
  def _spawn_ctrl_x_key_listener(
1596
1364
  self,
1597
1365
  stop_event: threading.Event,
@@ -1859,32 +1627,33 @@ class BaseAgent(ABC):
1859
1627
  prompt_payload,
1860
1628
  message_history=self.get_message_history(),
1861
1629
  usage_limits=usage_limits,
1862
- event_stream_handler=self._event_stream_handler,
1630
+ event_stream_handler=event_stream_handler,
1863
1631
  **kwargs,
1864
1632
  )
1633
+ return result_
1865
1634
  finally:
1866
1635
  # Always restore original toolsets
1867
1636
  pydantic_agent._toolsets = original_toolsets
1868
1637
  elif get_use_dbos():
1869
- # DBOS without MCP servers
1870
1638
  with SetWorkflowID(group_id):
1871
1639
  result_ = await pydantic_agent.run(
1872
1640
  prompt_payload,
1873
1641
  message_history=self.get_message_history(),
1874
1642
  usage_limits=usage_limits,
1875
- event_stream_handler=self._event_stream_handler,
1643
+ event_stream_handler=event_stream_handler,
1876
1644
  **kwargs,
1877
1645
  )
1646
+ return result_
1878
1647
  else:
1879
1648
  # Non-DBOS path (MCP servers are already included)
1880
1649
  result_ = await pydantic_agent.run(
1881
1650
  prompt_payload,
1882
1651
  message_history=self.get_message_history(),
1883
1652
  usage_limits=usage_limits,
1884
- event_stream_handler=self._event_stream_handler,
1653
+ event_stream_handler=event_stream_handler,
1885
1654
  **kwargs,
1886
1655
  )
1887
- return result_
1656
+ return result_
1888
1657
  except* UsageLimitExceeded as ule:
1889
1658
  emit_info(f"Usage limit exceeded: {str(ule)}", group_id=group_id)
1890
1659
  emit_info(
@@ -0,0 +1,257 @@
1
+ """Event stream handler for processing streaming events from agent runs."""
2
+
3
+ from collections.abc import AsyncIterable
4
+ from typing import Any, Optional
5
+
6
+ from pydantic_ai import PartDeltaEvent, PartEndEvent, PartStartEvent, RunContext
7
+ from pydantic_ai.messages import (
8
+ TextPart,
9
+ TextPartDelta,
10
+ ThinkingPart,
11
+ ThinkingPartDelta,
12
+ ToolCallPart,
13
+ ToolCallPartDelta,
14
+ )
15
+ from rich.console import Console
16
+ from rich.markup import escape
17
+ from rich.text import Text
18
+
19
+ from code_puppy.config import get_banner_color
20
+ from code_puppy.messaging.spinner import pause_all_spinners, resume_all_spinners
21
+
22
+ # Module-level console for streaming output
23
+ # Set via set_streaming_console() to share console with spinner
24
+ _streaming_console: Optional[Console] = None
25
+
26
+
27
+ def set_streaming_console(console: Optional[Console]) -> None:
28
+ """Set the console used for streaming output.
29
+
30
+ This should be called with the same console used by the spinner
31
+ to avoid Live display conflicts that cause line duplication.
32
+
33
+ Args:
34
+ console: The Rich console to use, or None to use a fallback.
35
+ """
36
+ global _streaming_console
37
+ _streaming_console = console
38
+
39
+
40
+ def get_streaming_console() -> Console:
41
+ """Get the console for streaming output.
42
+
43
+ Returns the configured console or creates a fallback Console.
44
+ """
45
+ if _streaming_console is not None:
46
+ return _streaming_console
47
+ return Console()
48
+
49
+
50
+ async def event_stream_handler(
51
+ ctx: RunContext,
52
+ events: AsyncIterable[Any],
53
+ ) -> None:
54
+ """Handle streaming events from the agent run.
55
+
56
+ This function processes streaming events and emits TextPart, ThinkingPart,
57
+ and ToolCallPart content with styled banners/tokens as they stream in.
58
+
59
+ Args:
60
+ ctx: The run context.
61
+ events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
62
+ """
63
+ import time
64
+
65
+ from termflow import Parser as TermflowParser
66
+ from termflow import Renderer as TermflowRenderer
67
+
68
+ # Use the module-level console (set via set_streaming_console)
69
+ console = get_streaming_console()
70
+
71
+ # Track which part indices we're currently streaming (for Text/Thinking/Tool parts)
72
+ streaming_parts: set[int] = set()
73
+ thinking_parts: set[int] = set() # Track which parts are thinking (for dim style)
74
+ text_parts: set[int] = set() # Track which parts are text
75
+ tool_parts: set[int] = set() # Track which parts are tool calls
76
+ banner_printed: set[int] = set() # Track if banner was already printed
77
+ token_count: dict[int, int] = {} # Track token count per text/tool part
78
+ did_stream_anything = False # Track if we streamed any content
79
+
80
+ # Termflow streaming state for text parts
81
+ termflow_parsers: dict[int, TermflowParser] = {}
82
+ termflow_renderers: dict[int, TermflowRenderer] = {}
83
+ termflow_line_buffers: dict[int, str] = {} # Buffer incomplete lines
84
+
85
+ def _print_thinking_banner() -> None:
86
+ """Print the THINKING banner with spinner pause and line clear."""
87
+ nonlocal did_stream_anything
88
+
89
+ pause_all_spinners()
90
+ time.sleep(0.1) # Delay to let spinner fully clear
91
+ # Clear line and print newline before banner
92
+ console.print(" " * 50, end="\r")
93
+ console.print() # Newline before banner
94
+ # Bold banner with configurable color and lightning bolt
95
+ thinking_color = get_banner_color("thinking")
96
+ console.print(
97
+ Text.from_markup(
98
+ f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]\u26a1 "
99
+ ),
100
+ end="",
101
+ )
102
+ did_stream_anything = True
103
+
104
+ def _print_response_banner() -> None:
105
+ """Print the AGENT RESPONSE banner with spinner pause and line clear."""
106
+ nonlocal did_stream_anything
107
+
108
+ pause_all_spinners()
109
+ time.sleep(0.1) # Delay to let spinner fully clear
110
+ # Clear line and print newline before banner
111
+ console.print(" " * 50, end="\r")
112
+ console.print() # Newline before banner
113
+ response_color = get_banner_color("agent_response")
114
+ console.print(
115
+ Text.from_markup(
116
+ f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
117
+ )
118
+ )
119
+ did_stream_anything = True
120
+
121
+ async for event in events:
122
+ # PartStartEvent - register the part but defer banner until content arrives
123
+ if isinstance(event, PartStartEvent):
124
+ part = event.part
125
+ if isinstance(part, ThinkingPart):
126
+ streaming_parts.add(event.index)
127
+ thinking_parts.add(event.index)
128
+ # If there's initial content, print banner + content now
129
+ if part.content and part.content.strip():
130
+ _print_thinking_banner()
131
+ escaped = escape(part.content)
132
+ console.print(f"[dim]{escaped}[/dim]", end="")
133
+ banner_printed.add(event.index)
134
+ elif isinstance(part, TextPart):
135
+ streaming_parts.add(event.index)
136
+ text_parts.add(event.index)
137
+ # Initialize termflow streaming for this text part
138
+ termflow_parsers[event.index] = TermflowParser()
139
+ termflow_renderers[event.index] = TermflowRenderer(
140
+ output=console.file, width=console.width
141
+ )
142
+ termflow_line_buffers[event.index] = ""
143
+ # Handle initial content if present
144
+ if part.content and part.content.strip():
145
+ _print_response_banner()
146
+ banner_printed.add(event.index)
147
+ termflow_line_buffers[event.index] = part.content
148
+ elif isinstance(part, ToolCallPart):
149
+ streaming_parts.add(event.index)
150
+ tool_parts.add(event.index)
151
+ token_count[event.index] = 0 # Initialize token counter
152
+ # Track tool name for display
153
+ banner_printed.add(
154
+ event.index
155
+ ) # Use banner_printed to track if we've shown tool info
156
+
157
+ # PartDeltaEvent - stream the content as it arrives
158
+ elif isinstance(event, PartDeltaEvent):
159
+ if event.index in streaming_parts:
160
+ delta = event.delta
161
+ if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
162
+ if delta.content_delta:
163
+ # For text parts, stream markdown with termflow
164
+ if event.index in text_parts:
165
+ # Print banner on first content
166
+ if event.index not in banner_printed:
167
+ _print_response_banner()
168
+ banner_printed.add(event.index)
169
+
170
+ # Add content to line buffer
171
+ termflow_line_buffers[event.index] += delta.content_delta
172
+
173
+ # Process complete lines
174
+ parser = termflow_parsers[event.index]
175
+ renderer = termflow_renderers[event.index]
176
+ buffer = termflow_line_buffers[event.index]
177
+
178
+ while "\n" in buffer:
179
+ line, buffer = buffer.split("\n", 1)
180
+ events_to_render = parser.parse_line(line)
181
+ renderer.render_all(events_to_render)
182
+
183
+ termflow_line_buffers[event.index] = buffer
184
+ else:
185
+ # For thinking parts, stream immediately (dim)
186
+ if event.index not in banner_printed:
187
+ _print_thinking_banner()
188
+ banner_printed.add(event.index)
189
+ escaped = escape(delta.content_delta)
190
+ console.print(f"[dim]{escaped}[/dim]", end="")
191
+ elif isinstance(delta, ToolCallPartDelta):
192
+ # For tool calls, count chunks received
193
+ token_count[event.index] += 1
194
+ # Get tool name if available
195
+ tool_name = getattr(delta, "tool_name_delta", "")
196
+ count = token_count[event.index]
197
+ # Display with tool wrench icon and tool name
198
+ if tool_name:
199
+ console.print(
200
+ f" \U0001f527 Calling {tool_name}... {count} chunks ",
201
+ end="\r",
202
+ )
203
+ else:
204
+ console.print(
205
+ f" \U0001f527 Calling tool... {count} chunks ",
206
+ end="\r",
207
+ )
208
+
209
+ # PartEndEvent - finish the streaming with a newline
210
+ elif isinstance(event, PartEndEvent):
211
+ if event.index in streaming_parts:
212
+ # For text parts, finalize termflow rendering
213
+ if event.index in text_parts:
214
+ # Render any remaining buffered content
215
+ if event.index in termflow_parsers:
216
+ parser = termflow_parsers[event.index]
217
+ renderer = termflow_renderers[event.index]
218
+ remaining = termflow_line_buffers.get(event.index, "")
219
+
220
+ # Parse and render any remaining partial line
221
+ if remaining.strip():
222
+ events_to_render = parser.parse_line(remaining)
223
+ renderer.render_all(events_to_render)
224
+
225
+ # Finalize the parser to close any open blocks
226
+ final_events = parser.finalize()
227
+ renderer.render_all(final_events)
228
+
229
+ # Clean up termflow state
230
+ del termflow_parsers[event.index]
231
+ del termflow_renderers[event.index]
232
+ del termflow_line_buffers[event.index]
233
+ # For tool parts, clear the chunk counter line
234
+ elif event.index in tool_parts:
235
+ # Clear the chunk counter line by printing spaces and returning
236
+ console.print(" " * 50, end="\r")
237
+ # For thinking parts, just print newline
238
+ elif event.index in banner_printed:
239
+ console.print() # Final newline after streaming
240
+
241
+ # Clean up token count
242
+ token_count.pop(event.index, None)
243
+ # Clean up all tracking sets
244
+ streaming_parts.discard(event.index)
245
+ thinking_parts.discard(event.index)
246
+ text_parts.discard(event.index)
247
+ tool_parts.discard(event.index)
248
+ banner_printed.discard(event.index)
249
+
250
+ # Resume spinner if next part is NOT text/thinking/tool (avoid race condition)
251
+ # If next part is None or handled differently, it's safe to resume
252
+ # Note: spinner itself handles blank line before appearing
253
+ next_kind = getattr(event, "next_part_kind", None)
254
+ if next_kind not in ("text", "thinking", "tool-call"):
255
+ resume_all_spinners()
256
+
257
+ # Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind)
code_puppy/cli_runner.py CHANGED
@@ -827,11 +827,12 @@ async def run_prompt_with_attachments(
827
827
 
828
828
  link_attachments = [link.url_part for link in processed_prompt.link_attachments]
829
829
 
830
- # IMPORTANT: Set the shared console on the agent so that streaming output
830
+ # IMPORTANT: Set the shared console for streaming output so it
831
831
  # uses the same console as the spinner. This prevents Live display conflicts
832
832
  # that cause line duplication during markdown streaming.
833
- if spinner_console is not None:
834
- agent._console = spinner_console
833
+ from code_puppy.agents.event_stream_handler import set_streaming_console
834
+
835
+ set_streaming_console(spinner_console)
835
836
 
836
837
  # Create the agent task first so we can track and cancel it
837
838
  agent_task = asyncio.create_task(
@@ -17,6 +17,7 @@ from prompt_toolkit.layout import Dimension, Layout, VSplit, Window
17
17
  from prompt_toolkit.layout.controls import FormattedTextControl
18
18
  from prompt_toolkit.widgets import Frame
19
19
 
20
+ from code_puppy.command_line.utils import safe_input
20
21
  from code_puppy.config import EXTRA_MODELS_FILE, set_config_value
21
22
  from code_puppy.messaging import emit_error, emit_info, emit_warning
22
23
  from code_puppy.models_dev_parser import ModelInfo, ModelsDevRegistry, ProviderInfo
@@ -724,8 +725,8 @@ class AddModelMenu:
724
725
  emit_info(f" {hint}")
725
726
 
726
727
  try:
727
- # Use regular input - simpler and works in threaded context
728
- value = input(f" Enter {env_var} (or press Enter to skip): ").strip()
728
+ # Use safe_input for cross-platform compatibility (Windows fix)
729
+ value = safe_input(f" Enter {env_var} (or press Enter to skip): ")
729
730
 
730
731
  if not value:
731
732
  emit_warning(
@@ -785,7 +786,7 @@ class AddModelMenu:
785
786
  )
786
787
 
787
788
  try:
788
- model_name = input(" Model ID: ").strip()
789
+ model_name = safe_input(" Model ID: ")
789
790
 
790
791
  if not model_name:
791
792
  emit_warning("No model name provided, cancelled.")
@@ -795,7 +796,7 @@ class AddModelMenu:
795
796
  emit_info("\n Enter the context window size (in tokens).")
796
797
  emit_info(" Common sizes: 8192, 32768, 128000, 200000, 1000000\n")
797
798
 
798
- context_input = input(" Context size [128000]: ").strip()
799
+ context_input = safe_input(" Context size [128000]: ")
799
800
 
800
801
  if not context_input:
801
802
  context_length = 128000 # Default
@@ -1045,11 +1046,9 @@ class AddModelMenu:
1045
1046
  f" It will be very limited for coding tasks."
1046
1047
  )
1047
1048
  try:
1048
- confirm = (
1049
- input("\n Are you sure you want to add this model? (y/N): ")
1050
- .strip()
1051
- .lower()
1052
- )
1049
+ confirm = safe_input(
1050
+ "\n Are you sure you want to add this model? (y/N): "
1051
+ ).lower()
1053
1052
  if confirm not in ("y", "yes"):
1054
1053
  emit_info("Model addition cancelled.")
1055
1054
  return False