code-puppy 0.0.302__py3-none-any.whl → 0.0.335__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 (87) hide show
  1. code_puppy/agents/base_agent.py +343 -35
  2. code_puppy/chatgpt_codex_client.py +283 -0
  3. code_puppy/cli_runner.py +898 -0
  4. code_puppy/command_line/add_model_menu.py +23 -1
  5. code_puppy/command_line/autosave_menu.py +271 -35
  6. code_puppy/command_line/colors_menu.py +520 -0
  7. code_puppy/command_line/command_handler.py +8 -2
  8. code_puppy/command_line/config_commands.py +82 -10
  9. code_puppy/command_line/core_commands.py +70 -7
  10. code_puppy/command_line/diff_menu.py +5 -0
  11. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  12. code_puppy/command_line/mcp/edit_command.py +3 -1
  13. code_puppy/command_line/mcp/handler.py +7 -2
  14. code_puppy/command_line/mcp/install_command.py +8 -3
  15. code_puppy/command_line/mcp/install_menu.py +5 -1
  16. code_puppy/command_line/mcp/logs_command.py +173 -64
  17. code_puppy/command_line/mcp/restart_command.py +7 -2
  18. code_puppy/command_line/mcp/search_command.py +10 -4
  19. code_puppy/command_line/mcp/start_all_command.py +16 -6
  20. code_puppy/command_line/mcp/start_command.py +3 -1
  21. code_puppy/command_line/mcp/status_command.py +2 -1
  22. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  23. code_puppy/command_line/mcp/stop_command.py +3 -1
  24. code_puppy/command_line/mcp/wizard_utils.py +10 -4
  25. code_puppy/command_line/model_settings_menu.py +58 -7
  26. code_puppy/command_line/motd.py +13 -7
  27. code_puppy/command_line/onboarding_slides.py +180 -0
  28. code_puppy/command_line/onboarding_wizard.py +340 -0
  29. code_puppy/command_line/prompt_toolkit_completion.py +16 -2
  30. code_puppy/command_line/session_commands.py +11 -4
  31. code_puppy/config.py +106 -17
  32. code_puppy/http_utils.py +155 -196
  33. code_puppy/keymap.py +8 -0
  34. code_puppy/main.py +5 -828
  35. code_puppy/mcp_/__init__.py +17 -0
  36. code_puppy/mcp_/blocking_startup.py +61 -32
  37. code_puppy/mcp_/config_wizard.py +5 -1
  38. code_puppy/mcp_/managed_server.py +23 -3
  39. code_puppy/mcp_/manager.py +65 -0
  40. code_puppy/mcp_/mcp_logs.py +224 -0
  41. code_puppy/messaging/__init__.py +20 -4
  42. code_puppy/messaging/bus.py +64 -0
  43. code_puppy/messaging/markdown_patches.py +57 -0
  44. code_puppy/messaging/messages.py +16 -0
  45. code_puppy/messaging/renderers.py +21 -9
  46. code_puppy/messaging/rich_renderer.py +113 -67
  47. code_puppy/messaging/spinner/console_spinner.py +34 -0
  48. code_puppy/model_factory.py +271 -45
  49. code_puppy/model_utils.py +57 -48
  50. code_puppy/models.json +21 -7
  51. code_puppy/plugins/__init__.py +12 -0
  52. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  53. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  54. code_puppy/plugins/antigravity_oauth/antigravity_model.py +612 -0
  55. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  56. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  57. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  58. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  59. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  60. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  61. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  62. code_puppy/plugins/antigravity_oauth/transport.py +595 -0
  63. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  64. code_puppy/plugins/chatgpt_oauth/config.py +5 -1
  65. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  66. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +5 -3
  67. code_puppy/plugins/chatgpt_oauth/test_plugin.py +26 -11
  68. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  69. code_puppy/plugins/claude_code_oauth/register_callbacks.py +30 -0
  70. code_puppy/plugins/claude_code_oauth/utils.py +1 -0
  71. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
  72. code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
  73. code_puppy/prompts/codex_system_prompt.md +310 -0
  74. code_puppy/pydantic_patches.py +131 -0
  75. code_puppy/reopenable_async_client.py +8 -8
  76. code_puppy/terminal_utils.py +291 -0
  77. code_puppy/tools/agent_tools.py +34 -9
  78. code_puppy/tools/command_runner.py +344 -27
  79. code_puppy/tools/file_operations.py +33 -45
  80. code_puppy/uvx_detection.py +242 -0
  81. {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models.json +21 -7
  82. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/METADATA +30 -1
  83. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/RECORD +87 -64
  84. {code_puppy-0.0.302.data → code_puppy-0.0.335.data}/data/code_puppy/models_dev_api.json +0 -0
  85. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/WHEEL +0 -0
  86. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/entry_points.txt +0 -0
  87. {code_puppy-0.0.302.dist-info → code_puppy-0.0.335.dist-info}/licenses/LICENSE +0 -0
@@ -7,7 +7,19 @@ import signal
7
7
  import threading
8
8
  import uuid
9
9
  from abc import ABC, abstractmethod
10
- from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Union
10
+ from collections.abc import AsyncIterable
11
+ from typing import (
12
+ Any,
13
+ Callable,
14
+ Dict,
15
+ List,
16
+ Optional,
17
+ Sequence,
18
+ Set,
19
+ Tuple,
20
+ Type,
21
+ Union,
22
+ )
11
23
 
12
24
  import mcp
13
25
  import pydantic
@@ -18,6 +30,7 @@ from pydantic_ai import (
18
30
  BinaryContent,
19
31
  DocumentUrl,
20
32
  ImageUrl,
33
+ PartEndEvent,
21
34
  RunContext,
22
35
  UsageLimitExceeded,
23
36
  UsageLimits,
@@ -33,6 +46,7 @@ from pydantic_ai.messages import (
33
46
  ToolReturn,
34
47
  ToolReturnPart,
35
48
  )
49
+ from rich.text import Text
36
50
 
37
51
  # Consolidated relative imports
38
52
  from code_puppy.config import (
@@ -44,11 +58,10 @@ from code_puppy.config import (
44
58
  get_protected_token_count,
45
59
  get_use_dbos,
46
60
  get_value,
47
- load_mcp_server_configs,
48
61
  )
49
62
  from code_puppy.error_logging import log_error
50
63
  from code_puppy.keymap import cancel_agent_uses_signal, get_cancel_agent_char_code
51
- from code_puppy.mcp_ import ServerConfig, get_mcp_manager
64
+ from code_puppy.mcp_ import get_mcp_manager
52
65
  from code_puppy.messaging import (
53
66
  emit_error,
54
67
  emit_info,
@@ -87,6 +100,9 @@ class BaseAgent(ABC):
87
100
  # Cache for MCP tool definitions (for token estimation)
88
101
  # This is populated after the first successful run when MCP tools are retrieved
89
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
90
106
 
91
107
  @property
92
108
  @abstractmethod
@@ -364,7 +380,9 @@ class BaseAgent(ABC):
364
380
  # fixed instructions. For other models, count the full system prompt.
365
381
  try:
366
382
  from code_puppy.model_utils import (
383
+ get_chatgpt_codex_instructions,
367
384
  get_claude_code_instructions,
385
+ is_chatgpt_codex_model,
368
386
  is_claude_code_model,
369
387
  )
370
388
 
@@ -376,6 +394,11 @@ class BaseAgent(ABC):
376
394
  # The full system prompt is already in the message history
377
395
  instructions = get_claude_code_instructions()
378
396
  total_tokens += self.estimate_token_count(instructions)
397
+ elif is_chatgpt_codex_model(model_name):
398
+ # For ChatGPT Codex models, only count the short fixed instructions
399
+ # The full system prompt is already in the message history
400
+ instructions = get_chatgpt_codex_instructions()
401
+ total_tokens += self.estimate_token_count(instructions)
379
402
  else:
380
403
  # For other models, count the full system prompt
381
404
  system_prompt = self.get_system_prompt()
@@ -979,45 +1002,31 @@ class BaseAgent(ABC):
979
1002
  return self._puppy_rules
980
1003
 
981
1004
  def load_mcp_servers(self, extra_headers: Optional[Dict[str, str]] = None):
982
- """Load MCP servers through the manager and return pydantic-ai compatible servers."""
1005
+ """Load MCP servers through the manager and return pydantic-ai compatible servers.
1006
+
1007
+ Note: The manager automatically syncs from mcp_servers.json during initialization,
1008
+ so we don't need to sync here. Use reload_mcp_servers() to force a re-sync.
1009
+ """
983
1010
 
984
1011
  mcp_disabled = get_value("disable_mcp_servers")
985
1012
  if mcp_disabled and str(mcp_disabled).lower() in ("1", "true", "yes", "on"):
986
1013
  return []
987
1014
 
988
1015
  manager = get_mcp_manager()
989
- configs = load_mcp_server_configs()
990
- if not configs:
991
- existing_servers = manager.list_servers()
992
- if not existing_servers:
993
- return []
994
- else:
995
- for name, conf in configs.items():
996
- try:
997
- server_config = ServerConfig(
998
- id=conf.get("id", f"{name}_{hash(name)}"),
999
- name=name,
1000
- type=conf.get("type", "sse"),
1001
- enabled=conf.get("enabled", True),
1002
- config=conf,
1003
- )
1004
- existing = manager.get_server_by_name(name)
1005
- if not existing:
1006
- manager.register_server(server_config)
1007
- else:
1008
- if existing.config != server_config.config:
1009
- manager.update_server(existing.id, server_config)
1010
- except Exception:
1011
- continue
1012
-
1013
1016
  return manager.get_servers_for_agent()
1014
1017
 
1015
1018
  def reload_mcp_servers(self):
1016
- """Reload MCP servers and return updated servers."""
1019
+ """Reload MCP servers and return updated servers.
1020
+
1021
+ Forces a re-sync from mcp_servers.json to pick up any configuration changes.
1022
+ """
1017
1023
  # Clear the MCP tool cache when servers are reloaded
1018
1024
  self._mcp_tool_definitions_cache = []
1019
- self.load_mcp_servers()
1025
+
1026
+ # Force re-sync from mcp_servers.json
1020
1027
  manager = get_mcp_manager()
1028
+ manager.sync_from_config()
1029
+
1021
1030
  return manager.get_servers_for_agent()
1022
1031
 
1023
1032
  def _load_model_with_fallback(
@@ -1173,7 +1182,9 @@ class BaseAgent(ABC):
1173
1182
 
1174
1183
  if len(filtered_mcp_servers) != len(mcp_servers):
1175
1184
  emit_info(
1176
- f"[dim]Filtered {len(mcp_servers) - len(filtered_mcp_servers)} conflicting MCP tools[/dim]"
1185
+ Text.from_markup(
1186
+ f"[dim]Filtered {len(mcp_servers) - len(filtered_mcp_servers)} conflicting MCP tools[/dim]"
1187
+ )
1177
1188
  )
1178
1189
 
1179
1190
  self._last_model_name = resolved_model_name
@@ -1230,6 +1241,74 @@ class BaseAgent(ABC):
1230
1241
  self._mcp_servers = mcp_servers
1231
1242
  return self._code_generation_agent
1232
1243
 
1244
+ def _create_agent_with_output_type(self, output_type: Type[Any]) -> PydanticAgent:
1245
+ """Create a temporary agent configured with a custom output_type.
1246
+
1247
+ This is used when structured output is requested via run_with_mcp.
1248
+ The agent is created fresh with the same configuration as the main agent
1249
+ but with the specified output_type instead of str.
1250
+
1251
+ Args:
1252
+ output_type: The Pydantic model or type for structured output.
1253
+
1254
+ Returns:
1255
+ A configured PydanticAgent (or DBOSAgent wrapper) with the custom output_type.
1256
+ """
1257
+ from code_puppy.model_utils import prepare_prompt_for_model
1258
+ from code_puppy.tools import register_tools_for_agent
1259
+
1260
+ model_name = self.get_model_name()
1261
+ models_config = ModelFactory.load_config()
1262
+ model, resolved_model_name = self._load_model_with_fallback(
1263
+ model_name, models_config, str(uuid.uuid4())
1264
+ )
1265
+
1266
+ instructions = self.get_system_prompt()
1267
+ puppy_rules = self.load_puppy_rules()
1268
+ if puppy_rules:
1269
+ instructions += f"\n{puppy_rules}"
1270
+
1271
+ mcp_servers = getattr(self, "_mcp_servers", []) or []
1272
+ model_settings = make_model_settings(resolved_model_name)
1273
+
1274
+ prepared = prepare_prompt_for_model(
1275
+ model_name, instructions, "", prepend_system_to_user=False
1276
+ )
1277
+ instructions = prepared.instructions
1278
+
1279
+ global _reload_count
1280
+ _reload_count += 1
1281
+
1282
+ if get_use_dbos():
1283
+ temp_agent = PydanticAgent(
1284
+ model=model,
1285
+ instructions=instructions,
1286
+ output_type=output_type,
1287
+ retries=3,
1288
+ toolsets=[],
1289
+ history_processors=[self.message_history_accumulator],
1290
+ model_settings=model_settings,
1291
+ )
1292
+ agent_tools = self.get_available_tools()
1293
+ register_tools_for_agent(temp_agent, agent_tools)
1294
+ dbos_agent = DBOSAgent(
1295
+ temp_agent, name=f"{self.name}-structured-{_reload_count}"
1296
+ )
1297
+ return dbos_agent
1298
+ else:
1299
+ temp_agent = PydanticAgent(
1300
+ model=model,
1301
+ instructions=instructions,
1302
+ output_type=output_type,
1303
+ retries=3,
1304
+ toolsets=mcp_servers,
1305
+ history_processors=[self.message_history_accumulator],
1306
+ model_settings=model_settings,
1307
+ )
1308
+ agent_tools = self.get_available_tools()
1309
+ register_tools_for_agent(temp_agent, agent_tools)
1310
+ return temp_agent
1311
+
1233
1312
  # It's okay to decorate it with DBOS.step even if not using DBOS; the decorator is a no-op in that case.
1234
1313
  @DBOS.step()
1235
1314
  def message_history_accumulator(self, ctx: RunContext, messages: List[Any]):
@@ -1255,6 +1334,216 @@ class BaseAgent(ABC):
1255
1334
  self.set_message_history(result_messages_filtered_empty_thinking)
1256
1335
  return self.get_message_history()
1257
1336
 
1337
+ async def _event_stream_handler(
1338
+ self, ctx: RunContext, events: AsyncIterable[Any]
1339
+ ) -> None:
1340
+ """Handle streaming events from the agent run.
1341
+
1342
+ This method processes streaming events and emits TextPart, ThinkingPart,
1343
+ and ToolCallPart content with styled banners/tokens as they stream in.
1344
+
1345
+ Args:
1346
+ ctx: The run context.
1347
+ events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
1348
+ """
1349
+ from pydantic_ai import PartDeltaEvent, PartStartEvent
1350
+ from pydantic_ai.messages import (
1351
+ TextPartDelta,
1352
+ ThinkingPartDelta,
1353
+ ToolCallPartDelta,
1354
+ )
1355
+ from rich.console import Console
1356
+ from rich.markdown import Markdown
1357
+ from rich.markup import escape
1358
+
1359
+ from code_puppy.messaging.spinner import pause_all_spinners
1360
+
1361
+ # IMPORTANT: Use the shared console (set by cli_runner) to avoid conflicts
1362
+ # with the spinner's Live display. Multiple Console instances with separate
1363
+ # Live displays cause cursor positioning chaos and line duplication.
1364
+ if self._console is not None:
1365
+ console = self._console
1366
+ else:
1367
+ # Fallback if console not set (shouldn't happen in normal use)
1368
+ console = Console()
1369
+
1370
+ # Track which part indices we're currently streaming (for Text/Thinking/Tool parts)
1371
+ streaming_parts: set[int] = set()
1372
+ thinking_parts: set[int] = (
1373
+ set()
1374
+ ) # Track which parts are thinking (for dim style)
1375
+ text_parts: set[int] = set() # Track which parts are text
1376
+ tool_parts: set[int] = set() # Track which parts are tool calls
1377
+ banner_printed: set[int] = set() # Track if banner was already printed
1378
+ text_buffer: dict[int, list[str]] = {} # Buffer text for final markdown render
1379
+ token_count: dict[int, int] = {} # Track token count per text/tool part
1380
+ did_stream_anything = False # Track if we streamed any content
1381
+
1382
+ def _print_thinking_banner() -> None:
1383
+ """Print the THINKING banner with spinner pause and line clear."""
1384
+ nonlocal did_stream_anything
1385
+ import time
1386
+
1387
+ from code_puppy.config import get_banner_color
1388
+
1389
+ pause_all_spinners()
1390
+ time.sleep(0.1) # Delay to let spinner fully clear
1391
+ # Clear line and print newline before banner
1392
+ console.print(" " * 50, end="\r")
1393
+ console.print() # Newline before banner
1394
+ # Bold banner with configurable color and lightning bolt
1395
+ thinking_color = get_banner_color("thinking")
1396
+ console.print(
1397
+ Text.from_markup(
1398
+ f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]⚡ "
1399
+ ),
1400
+ end="",
1401
+ )
1402
+ did_stream_anything = True
1403
+
1404
+ def _print_response_banner() -> None:
1405
+ """Print the AGENT RESPONSE banner with spinner pause and line clear."""
1406
+ nonlocal did_stream_anything
1407
+ import time
1408
+
1409
+ from code_puppy.config import get_banner_color
1410
+
1411
+ pause_all_spinners()
1412
+ time.sleep(0.1) # Delay to let spinner fully clear
1413
+ # Clear line and print newline before banner
1414
+ console.print(" " * 50, end="\r")
1415
+ console.print() # Newline before banner
1416
+ response_color = get_banner_color("agent_response")
1417
+ console.print(
1418
+ Text.from_markup(
1419
+ f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
1420
+ )
1421
+ )
1422
+ did_stream_anything = True
1423
+
1424
+ async for event in events:
1425
+ # PartStartEvent - register the part but defer banner until content arrives
1426
+ if isinstance(event, PartStartEvent):
1427
+ part = event.part
1428
+ if isinstance(part, ThinkingPart):
1429
+ streaming_parts.add(event.index)
1430
+ thinking_parts.add(event.index)
1431
+ # If there's initial content, print banner + content now
1432
+ if part.content and part.content.strip():
1433
+ _print_thinking_banner()
1434
+ escaped = escape(part.content)
1435
+ console.print(f"[dim]{escaped}[/dim]", end="")
1436
+ banner_printed.add(event.index)
1437
+ elif isinstance(part, TextPart):
1438
+ streaming_parts.add(event.index)
1439
+ text_parts.add(event.index)
1440
+ text_buffer[event.index] = [] # Initialize buffer
1441
+ token_count[event.index] = 0 # Initialize token counter
1442
+ # Buffer initial content if present
1443
+ if part.content and part.content.strip():
1444
+ text_buffer[event.index].append(part.content)
1445
+ # Count chunks (each part counts as 1)
1446
+ token_count[event.index] += 1
1447
+ elif isinstance(part, ToolCallPart):
1448
+ streaming_parts.add(event.index)
1449
+ tool_parts.add(event.index)
1450
+ token_count[event.index] = 0 # Initialize token counter
1451
+ # Track tool name for display
1452
+ banner_printed.add(
1453
+ event.index
1454
+ ) # Use banner_printed to track if we've shown tool info
1455
+
1456
+ # PartDeltaEvent - stream the content as it arrives
1457
+ elif isinstance(event, PartDeltaEvent):
1458
+ if event.index in streaming_parts:
1459
+ delta = event.delta
1460
+ if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
1461
+ if delta.content_delta:
1462
+ # For text parts, show token counter then render at end
1463
+ if event.index in text_parts:
1464
+ # Print banner on first content
1465
+ if event.index not in banner_printed:
1466
+ _print_response_banner()
1467
+ banner_printed.add(event.index)
1468
+ # Accumulate text for final markdown render
1469
+ text_buffer[event.index].append(delta.content_delta)
1470
+ # Count chunks received
1471
+ token_count[event.index] += 1
1472
+ # Update chunk counter in place (single line)
1473
+ count = token_count[event.index]
1474
+ console.print(
1475
+ f" ⏳ Receiving... {count} chunks ",
1476
+ end="\r",
1477
+ )
1478
+ else:
1479
+ # For thinking parts, stream immediately (dim)
1480
+ if event.index not in banner_printed:
1481
+ _print_thinking_banner()
1482
+ banner_printed.add(event.index)
1483
+ escaped = escape(delta.content_delta)
1484
+ console.print(f"[dim]{escaped}[/dim]", end="")
1485
+ elif isinstance(delta, ToolCallPartDelta):
1486
+ # For tool calls, count chunks received
1487
+ token_count[event.index] += 1
1488
+ # Get tool name if available
1489
+ tool_name = getattr(delta, "tool_name_delta", "")
1490
+ count = token_count[event.index]
1491
+ # Display with tool wrench icon and tool name
1492
+ if tool_name:
1493
+ console.print(
1494
+ f" 🔧 Calling {tool_name}... {count} chunks ",
1495
+ end="\r",
1496
+ )
1497
+ else:
1498
+ console.print(
1499
+ f" 🔧 Calling tool... {count} chunks ",
1500
+ end="\r",
1501
+ )
1502
+
1503
+ # PartEndEvent - finish the streaming with a newline
1504
+ elif isinstance(event, PartEndEvent):
1505
+ if event.index in streaming_parts:
1506
+ # For text parts, clear counter line and render markdown
1507
+ if event.index in text_parts:
1508
+ # Clear the chunk counter line by printing spaces and returning
1509
+ console.print(" " * 50, end="\r")
1510
+ # Render the final markdown nicely
1511
+ if event.index in text_buffer:
1512
+ try:
1513
+ final_content = "".join(text_buffer[event.index])
1514
+ if final_content.strip():
1515
+ console.print(Markdown(final_content))
1516
+ except Exception:
1517
+ pass
1518
+ del text_buffer[event.index]
1519
+ # For tool parts, clear the chunk counter line
1520
+ elif event.index in tool_parts:
1521
+ # Clear the chunk counter line by printing spaces and returning
1522
+ console.print(" " * 50, end="\r")
1523
+ # For thinking parts, just print newline
1524
+ elif event.index in banner_printed:
1525
+ console.print() # Final newline after streaming
1526
+
1527
+ # Clean up token count
1528
+ token_count.pop(event.index, None)
1529
+ # Clean up all tracking sets
1530
+ streaming_parts.discard(event.index)
1531
+ thinking_parts.discard(event.index)
1532
+ text_parts.discard(event.index)
1533
+ tool_parts.discard(event.index)
1534
+ banner_printed.discard(event.index)
1535
+
1536
+ # Resume spinner if next part is NOT text/thinking/tool (avoid race condition)
1537
+ # If next part is None or handled differently, it's safe to resume
1538
+ # Note: spinner itself handles blank line before appearing
1539
+ from code_puppy.messaging.spinner import resume_all_spinners
1540
+
1541
+ next_kind = getattr(event, "next_part_kind", None)
1542
+ if next_kind not in ("text", "thinking", "tool-call"):
1543
+ resume_all_spinners()
1544
+
1545
+ # Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind)
1546
+
1258
1547
  def _spawn_ctrl_x_key_listener(
1259
1548
  self,
1260
1549
  stop_event: threading.Event,
@@ -1409,6 +1698,7 @@ class BaseAgent(ABC):
1409
1698
  *,
1410
1699
  attachments: Optional[Sequence[BinaryContent]] = None,
1411
1700
  link_attachments: Optional[Sequence[Union[ImageUrl, DocumentUrl]]] = None,
1701
+ output_type: Optional[Type[Any]] = None,
1412
1702
  **kwargs,
1413
1703
  ) -> Any:
1414
1704
  """Run the agent with MCP servers, attachments, and full cancellation support.
@@ -1417,10 +1707,13 @@ class BaseAgent(ABC):
1417
1707
  prompt: Primary user prompt text (may be empty when attachments present).
1418
1708
  attachments: Local binary payloads (e.g., dragged images) to include.
1419
1709
  link_attachments: Remote assets (image/document URLs) to include.
1710
+ output_type: Optional Pydantic model or type for structured output.
1711
+ When provided, creates a temporary agent configured to return
1712
+ this type instead of the default string output.
1420
1713
  **kwargs: Additional arguments forwarded to `pydantic_ai.Agent.run`.
1421
1714
 
1422
1715
  Returns:
1423
- The agent's response.
1716
+ The agent's response (typed according to output_type if specified).
1424
1717
 
1425
1718
  Raises:
1426
1719
  asyncio.CancelledError: When execution is cancelled by user.
@@ -1444,10 +1737,17 @@ class BaseAgent(ABC):
1444
1737
  pydantic_agent = (
1445
1738
  self._code_generation_agent or self.reload_code_generation_agent()
1446
1739
  )
1447
- # Handle claude-code models: prepend system prompt to first user message
1448
- from code_puppy.model_utils import is_claude_code_model
1449
1740
 
1450
- if is_claude_code_model(self.get_model_name()):
1741
+ # If a custom output_type is specified, create a temporary agent with that type
1742
+ if output_type is not None:
1743
+ pydantic_agent = self._create_agent_with_output_type(output_type)
1744
+
1745
+ # Handle claude-code and chatgpt-codex models: prepend system prompt to first user message
1746
+ from code_puppy.model_utils import is_chatgpt_codex_model, is_claude_code_model
1747
+
1748
+ if is_claude_code_model(self.get_model_name()) or is_chatgpt_codex_model(
1749
+ self.get_model_name()
1750
+ ):
1451
1751
  if len(self.get_message_history()) == 0:
1452
1752
  system_prompt = self.get_system_prompt()
1453
1753
  puppy_rules = self.load_puppy_rules()
@@ -1511,6 +1811,7 @@ class BaseAgent(ABC):
1511
1811
  prompt_payload,
1512
1812
  message_history=self.get_message_history(),
1513
1813
  usage_limits=usage_limits,
1814
+ event_stream_handler=self._event_stream_handler,
1514
1815
  **kwargs,
1515
1816
  )
1516
1817
  finally:
@@ -1523,6 +1824,7 @@ class BaseAgent(ABC):
1523
1824
  prompt_payload,
1524
1825
  message_history=self.get_message_history(),
1525
1826
  usage_limits=usage_limits,
1827
+ event_stream_handler=self._event_stream_handler,
1526
1828
  **kwargs,
1527
1829
  )
1528
1830
  else:
@@ -1531,6 +1833,7 @@ class BaseAgent(ABC):
1531
1833
  prompt_payload,
1532
1834
  message_history=self.get_message_history(),
1533
1835
  usage_limits=usage_limits,
1836
+ event_stream_handler=self._event_stream_handler,
1534
1837
  **kwargs,
1535
1838
  )
1536
1839
  return result_
@@ -1635,7 +1938,12 @@ class BaseAgent(ABC):
1635
1938
  def graceful_sigint_handler(_sig, _frame):
1636
1939
  # When using keyboard-based cancel, SIGINT should be a no-op
1637
1940
  # (just show a hint to user about the configured cancel key)
1941
+ # Also reset terminal to prevent bricking on Windows+uvx
1638
1942
  from code_puppy.keymap import get_cancel_agent_display_name
1943
+ from code_puppy.terminal_utils import reset_windows_terminal_full
1944
+
1945
+ # Reset terminal state first to prevent bricking
1946
+ reset_windows_terminal_full()
1639
1947
 
1640
1948
  cancel_key = get_cancel_agent_display_name()
1641
1949
  emit_info(f"Use {cancel_key} to cancel the agent task.")