code-puppy 0.0.287__py3-none-any.whl → 0.0.323__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 (110) hide show
  1. code_puppy/__init__.py +3 -1
  2. code_puppy/agents/agent_code_puppy.py +5 -4
  3. code_puppy/agents/agent_creator_agent.py +22 -18
  4. code_puppy/agents/agent_manager.py +2 -2
  5. code_puppy/agents/base_agent.py +496 -102
  6. code_puppy/callbacks.py +8 -0
  7. code_puppy/chatgpt_codex_client.py +283 -0
  8. code_puppy/cli_runner.py +795 -0
  9. code_puppy/command_line/add_model_menu.py +19 -16
  10. code_puppy/command_line/attachments.py +10 -5
  11. code_puppy/command_line/autosave_menu.py +269 -41
  12. code_puppy/command_line/colors_menu.py +515 -0
  13. code_puppy/command_line/command_handler.py +10 -24
  14. code_puppy/command_line/config_commands.py +106 -25
  15. code_puppy/command_line/core_commands.py +32 -20
  16. code_puppy/command_line/mcp/add_command.py +3 -16
  17. code_puppy/command_line/mcp/base.py +0 -3
  18. code_puppy/command_line/mcp/catalog_server_installer.py +15 -15
  19. code_puppy/command_line/mcp/custom_server_form.py +66 -5
  20. code_puppy/command_line/mcp/custom_server_installer.py +17 -17
  21. code_puppy/command_line/mcp/edit_command.py +15 -22
  22. code_puppy/command_line/mcp/handler.py +7 -2
  23. code_puppy/command_line/mcp/help_command.py +2 -2
  24. code_puppy/command_line/mcp/install_command.py +10 -14
  25. code_puppy/command_line/mcp/install_menu.py +2 -6
  26. code_puppy/command_line/mcp/list_command.py +2 -2
  27. code_puppy/command_line/mcp/logs_command.py +174 -65
  28. code_puppy/command_line/mcp/remove_command.py +2 -2
  29. code_puppy/command_line/mcp/restart_command.py +7 -2
  30. code_puppy/command_line/mcp/search_command.py +16 -10
  31. code_puppy/command_line/mcp/start_all_command.py +16 -6
  32. code_puppy/command_line/mcp/start_command.py +12 -10
  33. code_puppy/command_line/mcp/status_command.py +4 -5
  34. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  35. code_puppy/command_line/mcp/stop_command.py +6 -4
  36. code_puppy/command_line/mcp/test_command.py +2 -2
  37. code_puppy/command_line/mcp/wizard_utils.py +20 -16
  38. code_puppy/command_line/model_settings_menu.py +53 -7
  39. code_puppy/command_line/motd.py +1 -1
  40. code_puppy/command_line/pin_command_completion.py +82 -7
  41. code_puppy/command_line/prompt_toolkit_completion.py +32 -9
  42. code_puppy/command_line/session_commands.py +11 -4
  43. code_puppy/config.py +217 -53
  44. code_puppy/error_logging.py +118 -0
  45. code_puppy/gemini_code_assist.py +385 -0
  46. code_puppy/keymap.py +126 -0
  47. code_puppy/main.py +5 -745
  48. code_puppy/mcp_/__init__.py +17 -0
  49. code_puppy/mcp_/blocking_startup.py +63 -36
  50. code_puppy/mcp_/captured_stdio_server.py +1 -1
  51. code_puppy/mcp_/config_wizard.py +4 -4
  52. code_puppy/mcp_/dashboard.py +15 -6
  53. code_puppy/mcp_/managed_server.py +25 -5
  54. code_puppy/mcp_/manager.py +65 -0
  55. code_puppy/mcp_/mcp_logs.py +224 -0
  56. code_puppy/mcp_/registry.py +6 -6
  57. code_puppy/messaging/__init__.py +184 -2
  58. code_puppy/messaging/bus.py +610 -0
  59. code_puppy/messaging/commands.py +167 -0
  60. code_puppy/messaging/markdown_patches.py +57 -0
  61. code_puppy/messaging/message_queue.py +3 -3
  62. code_puppy/messaging/messages.py +470 -0
  63. code_puppy/messaging/renderers.py +43 -141
  64. code_puppy/messaging/rich_renderer.py +900 -0
  65. code_puppy/messaging/spinner/console_spinner.py +39 -2
  66. code_puppy/model_factory.py +292 -53
  67. code_puppy/model_utils.py +57 -48
  68. code_puppy/models.json +19 -5
  69. code_puppy/plugins/__init__.py +152 -10
  70. code_puppy/plugins/chatgpt_oauth/config.py +20 -12
  71. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  72. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
  73. code_puppy/plugins/chatgpt_oauth/test_plugin.py +30 -13
  74. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  75. code_puppy/plugins/claude_code_oauth/config.py +15 -11
  76. code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
  77. code_puppy/plugins/claude_code_oauth/utils.py +6 -1
  78. code_puppy/plugins/example_custom_command/register_callbacks.py +2 -2
  79. code_puppy/plugins/oauth_puppy_html.py +3 -0
  80. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -134
  81. code_puppy/plugins/shell_safety/command_cache.py +156 -0
  82. code_puppy/plugins/shell_safety/register_callbacks.py +77 -3
  83. code_puppy/prompts/codex_system_prompt.md +310 -0
  84. code_puppy/pydantic_patches.py +131 -0
  85. code_puppy/session_storage.py +2 -1
  86. code_puppy/status_display.py +7 -5
  87. code_puppy/terminal_utils.py +126 -0
  88. code_puppy/tools/agent_tools.py +131 -70
  89. code_puppy/tools/browser/browser_control.py +10 -14
  90. code_puppy/tools/browser/browser_interactions.py +20 -28
  91. code_puppy/tools/browser/browser_locators.py +27 -29
  92. code_puppy/tools/browser/browser_navigation.py +9 -9
  93. code_puppy/tools/browser/browser_screenshot.py +12 -14
  94. code_puppy/tools/browser/browser_scripts.py +17 -29
  95. code_puppy/tools/browser/browser_workflows.py +24 -25
  96. code_puppy/tools/browser/camoufox_manager.py +22 -26
  97. code_puppy/tools/command_runner.py +410 -88
  98. code_puppy/tools/common.py +51 -38
  99. code_puppy/tools/file_modifications.py +98 -24
  100. code_puppy/tools/file_operations.py +113 -202
  101. code_puppy/version_checker.py +28 -13
  102. {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
  103. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/METADATA +3 -8
  104. code_puppy-0.0.323.dist-info/RECORD +168 -0
  105. code_puppy/tui_state.py +0 -55
  106. code_puppy-0.0.287.dist-info/RECORD +0 -153
  107. {code_puppy-0.0.287.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
  108. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
  109. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
  110. {code_puppy-0.0.287.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
@@ -4,10 +4,23 @@ import asyncio
4
4
  import json
5
5
  import math
6
6
  import signal
7
+ import sys
7
8
  import threading
8
9
  import uuid
9
10
  from abc import ABC, abstractmethod
10
- from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Union
11
+ from collections.abc import AsyncIterable
12
+ from typing import (
13
+ Any,
14
+ Callable,
15
+ Dict,
16
+ List,
17
+ Optional,
18
+ Sequence,
19
+ Set,
20
+ Tuple,
21
+ Type,
22
+ Union,
23
+ )
11
24
 
12
25
  import mcp
13
26
  import pydantic
@@ -18,6 +31,7 @@ from pydantic_ai import (
18
31
  BinaryContent,
19
32
  DocumentUrl,
20
33
  ImageUrl,
34
+ PartEndEvent,
21
35
  RunContext,
22
36
  UsageLimitExceeded,
23
37
  UsageLimits,
@@ -33,6 +47,7 @@ from pydantic_ai.messages import (
33
47
  ToolReturn,
34
48
  ToolReturnPart,
35
49
  )
50
+ from rich.text import Text
36
51
 
37
52
  # Consolidated relative imports
38
53
  from code_puppy.config import (
@@ -44,9 +59,10 @@ from code_puppy.config import (
44
59
  get_protected_token_count,
45
60
  get_use_dbos,
46
61
  get_value,
47
- load_mcp_server_configs,
48
62
  )
49
- from code_puppy.mcp_ import ServerConfig, get_mcp_manager
63
+ from code_puppy.error_logging import log_error
64
+ from code_puppy.keymap import cancel_agent_uses_signal, get_cancel_agent_char_code
65
+ from code_puppy.mcp_ import get_mcp_manager
50
66
  from code_puppy.messaging import (
51
67
  emit_error,
52
68
  emit_info,
@@ -85,6 +101,9 @@ class BaseAgent(ABC):
85
101
  # Cache for MCP tool definitions (for token estimation)
86
102
  # This is populated after the first successful run when MCP tools are retrieved
87
103
  self._mcp_tool_definitions_cache: List[Dict[str, Any]] = []
104
+ # Shared console for streaming output - should be set by cli_runner
105
+ # to avoid conflicts between spinner's Live display and response streaming
106
+ self._console: Optional[Any] = None
88
107
 
89
108
  @property
90
109
  @abstractmethod
@@ -362,8 +381,10 @@ class BaseAgent(ABC):
362
381
  # fixed instructions. For other models, count the full system prompt.
363
382
  try:
364
383
  from code_puppy.model_utils import (
365
- is_claude_code_model,
384
+ get_chatgpt_codex_instructions,
366
385
  get_claude_code_instructions,
386
+ is_chatgpt_codex_model,
387
+ is_claude_code_model,
367
388
  )
368
389
 
369
390
  model_name = (
@@ -374,6 +395,11 @@ class BaseAgent(ABC):
374
395
  # The full system prompt is already in the message history
375
396
  instructions = get_claude_code_instructions()
376
397
  total_tokens += self.estimate_token_count(instructions)
398
+ elif is_chatgpt_codex_model(model_name):
399
+ # For ChatGPT Codex models, only count the short fixed instructions
400
+ # The full system prompt is already in the message history
401
+ instructions = get_chatgpt_codex_instructions()
402
+ total_tokens += self.estimate_token_count(instructions)
377
403
  else:
378
404
  # For other models, count the full system prompt
379
405
  system_prompt = self.get_system_prompt()
@@ -827,30 +853,11 @@ class BaseAgent(ABC):
827
853
  total_current_tokens = message_tokens + context_overhead
828
854
  proportion_used = total_current_tokens / model_max
829
855
 
830
- # Check if we're in TUI mode and can update the status bar
831
- from code_puppy.tui_state import get_tui_app_instance, is_tui_mode
832
-
833
856
  context_summary = SpinnerBase.format_context_info(
834
857
  total_current_tokens, model_max, proportion_used
835
858
  )
836
859
  update_spinner_context(context_summary)
837
860
 
838
- if is_tui_mode():
839
- tui_app = get_tui_app_instance()
840
- if tui_app:
841
- try:
842
- # Update the status bar instead of emitting a chat message
843
- status_bar = tui_app.query_one("StatusBar")
844
- status_bar.update_token_info(
845
- total_current_tokens, model_max, proportion_used
846
- )
847
- except Exception as e:
848
- emit_error(e)
849
- else:
850
- emit_info(
851
- f"Final token count after processing: {total_current_tokens}",
852
- message_group="token_context_status",
853
- )
854
861
  # Get the configured compaction threshold
855
862
  compaction_threshold = get_compaction_threshold()
856
863
 
@@ -889,30 +896,12 @@ class BaseAgent(ABC):
889
896
  final_token_count = sum(
890
897
  self.estimate_tokens_for_message(msg) for msg in result_messages
891
898
  )
892
- # Update status bar with final token count if in TUI mode
899
+ # Update spinner with final token count
893
900
  final_summary = SpinnerBase.format_context_info(
894
901
  final_token_count, model_max, final_token_count / model_max
895
902
  )
896
903
  update_spinner_context(final_summary)
897
904
 
898
- if is_tui_mode():
899
- tui_app = get_tui_app_instance()
900
- if tui_app:
901
- try:
902
- status_bar = tui_app.query_one("StatusBar")
903
- status_bar.update_token_info(
904
- final_token_count, model_max, final_token_count / model_max
905
- )
906
- except Exception:
907
- emit_info(
908
- f"Final token count after processing: {final_token_count}",
909
- message_group="token_context_status",
910
- )
911
- else:
912
- emit_info(
913
- f"Final token count after processing: {final_token_count}",
914
- message_group="token_context_status",
915
- )
916
905
  self.set_message_history(result_messages)
917
906
  for m in summarized_messages:
918
907
  self.add_compacted_message_hash(self.hash_message(m))
@@ -974,60 +963,71 @@ class BaseAgent(ABC):
974
963
 
975
964
  # ===== Agent wiring formerly in code_puppy/agent.py =====
976
965
  def load_puppy_rules(self) -> Optional[str]:
977
- """Load AGENT(S).md if present and cache the contents."""
966
+ """Load AGENT(S).md from both global config and project directory.
967
+
968
+ Checks for AGENTS.md/AGENT.md/agents.md/agent.md in this order:
969
+ 1. Global config directory (~/.code_puppy/ or XDG config)
970
+ 2. Current working directory (project-specific)
971
+
972
+ If both exist, they are combined with global rules first, then project rules.
973
+ This allows project-specific rules to override or extend global rules.
974
+ """
978
975
  if self._puppy_rules is not None:
979
976
  return self._puppy_rules
980
977
  from pathlib import Path
981
978
 
982
979
  possible_paths = ["AGENTS.md", "AGENT.md", "agents.md", "agent.md"]
980
+
981
+ # Load global rules from CONFIG_DIR
982
+ global_rules = None
983
+ from code_puppy.config import CONFIG_DIR
984
+
983
985
  for path_str in possible_paths:
984
- puppy_rules_path = Path(path_str)
985
- if puppy_rules_path.exists():
986
- with open(puppy_rules_path, "r") as f:
987
- self._puppy_rules = f.read()
988
- break
986
+ global_path = Path(CONFIG_DIR) / path_str
987
+ if global_path.exists():
988
+ global_rules = global_path.read_text(encoding="utf-8-sig")
989
+ break
990
+
991
+ # Load project-local rules from current working directory
992
+ project_rules = None
993
+ for path_str in possible_paths:
994
+ project_path = Path(path_str)
995
+ if project_path.exists():
996
+ project_rules = project_path.read_text(encoding="utf-8-sig")
997
+ break
998
+
999
+ # Combine global and project rules
1000
+ # Global rules come first, project rules second (allowing project to override)
1001
+ rules = [r for r in [global_rules, project_rules] if r]
1002
+ self._puppy_rules = "\n\n".join(rules) if rules else None
989
1003
  return self._puppy_rules
990
1004
 
991
1005
  def load_mcp_servers(self, extra_headers: Optional[Dict[str, str]] = None):
992
- """Load MCP servers through the manager and return pydantic-ai compatible servers."""
1006
+ """Load MCP servers through the manager and return pydantic-ai compatible servers.
1007
+
1008
+ Note: The manager automatically syncs from mcp_servers.json during initialization,
1009
+ so we don't need to sync here. Use reload_mcp_servers() to force a re-sync.
1010
+ """
993
1011
 
994
1012
  mcp_disabled = get_value("disable_mcp_servers")
995
1013
  if mcp_disabled and str(mcp_disabled).lower() in ("1", "true", "yes", "on"):
996
1014
  return []
997
1015
 
998
1016
  manager = get_mcp_manager()
999
- configs = load_mcp_server_configs()
1000
- if not configs:
1001
- existing_servers = manager.list_servers()
1002
- if not existing_servers:
1003
- return []
1004
- else:
1005
- for name, conf in configs.items():
1006
- try:
1007
- server_config = ServerConfig(
1008
- id=conf.get("id", f"{name}_{hash(name)}"),
1009
- name=name,
1010
- type=conf.get("type", "sse"),
1011
- enabled=conf.get("enabled", True),
1012
- config=conf,
1013
- )
1014
- existing = manager.get_server_by_name(name)
1015
- if not existing:
1016
- manager.register_server(server_config)
1017
- else:
1018
- if existing.config != server_config.config:
1019
- manager.update_server(existing.id, server_config)
1020
- except Exception:
1021
- continue
1022
-
1023
1017
  return manager.get_servers_for_agent()
1024
1018
 
1025
1019
  def reload_mcp_servers(self):
1026
- """Reload MCP servers and return updated servers."""
1020
+ """Reload MCP servers and return updated servers.
1021
+
1022
+ Forces a re-sync from mcp_servers.json to pick up any configuration changes.
1023
+ """
1027
1024
  # Clear the MCP tool cache when servers are reloaded
1028
1025
  self._mcp_tool_definitions_cache = []
1029
- self.load_mcp_servers()
1026
+
1027
+ # Force re-sync from mcp_servers.json
1030
1028
  manager = get_mcp_manager()
1029
+ manager.sync_from_config()
1030
+
1031
1031
  return manager.get_servers_for_agent()
1032
1032
 
1033
1033
  def _load_model_with_fallback(
@@ -1049,8 +1049,8 @@ class BaseAgent(ABC):
1049
1049
  )
1050
1050
  emit_warning(
1051
1051
  (
1052
- f"[yellow]Model '{requested_model_name}' not found. "
1053
- f"Available models: {available_str}[/yellow]"
1052
+ f"Model '{requested_model_name}' not found. "
1053
+ f"Available models: {available_str}"
1054
1054
  ),
1055
1055
  message_group=message_group,
1056
1056
  )
@@ -1070,7 +1070,7 @@ class BaseAgent(ABC):
1070
1070
  try:
1071
1071
  model = ModelFactory.get_model(candidate, models_config)
1072
1072
  emit_info(
1073
- f"[bold cyan]Using fallback model: {candidate}[/bold cyan]",
1073
+ f"Using fallback model: {candidate}",
1074
1074
  message_group=message_group,
1075
1075
  )
1076
1076
  return model, candidate
@@ -1082,7 +1082,7 @@ class BaseAgent(ABC):
1082
1082
  "a valid model with `config set`."
1083
1083
  )
1084
1084
  emit_error(
1085
- f"[bold red]{friendly_message}[/bold red]",
1085
+ friendly_message,
1086
1086
  message_group=message_group,
1087
1087
  )
1088
1088
  raise ValueError(friendly_message) from exc
@@ -1110,13 +1110,7 @@ class BaseAgent(ABC):
1110
1110
 
1111
1111
  mcp_servers = self.load_mcp_servers()
1112
1112
 
1113
- output_tokens = max(
1114
- 2048,
1115
- min(int(0.05 * self.get_model_context_length()) - 1024, 16384),
1116
- )
1117
- model_settings = make_model_settings(
1118
- resolved_model_name, max_tokens=output_tokens
1119
- )
1113
+ model_settings = make_model_settings(resolved_model_name)
1120
1114
 
1121
1115
  # Handle claude-code models: swap instructions (prompt prepending happens in run_with_mcp)
1122
1116
  from code_puppy.model_utils import prepare_prompt_for_model
@@ -1189,7 +1183,9 @@ class BaseAgent(ABC):
1189
1183
 
1190
1184
  if len(filtered_mcp_servers) != len(mcp_servers):
1191
1185
  emit_info(
1192
- f"[dim]Filtered {len(mcp_servers) - len(filtered_mcp_servers)} conflicting MCP tools[/dim]"
1186
+ Text.from_markup(
1187
+ f"[dim]Filtered {len(mcp_servers) - len(filtered_mcp_servers)} conflicting MCP tools[/dim]"
1188
+ )
1193
1189
  )
1194
1190
 
1195
1191
  self._last_model_name = resolved_model_name
@@ -1246,6 +1242,74 @@ class BaseAgent(ABC):
1246
1242
  self._mcp_servers = mcp_servers
1247
1243
  return self._code_generation_agent
1248
1244
 
1245
+ def _create_agent_with_output_type(self, output_type: Type[Any]) -> PydanticAgent:
1246
+ """Create a temporary agent configured with a custom output_type.
1247
+
1248
+ This is used when structured output is requested via run_with_mcp.
1249
+ The agent is created fresh with the same configuration as the main agent
1250
+ but with the specified output_type instead of str.
1251
+
1252
+ Args:
1253
+ output_type: The Pydantic model or type for structured output.
1254
+
1255
+ Returns:
1256
+ A configured PydanticAgent (or DBOSAgent wrapper) with the custom output_type.
1257
+ """
1258
+ from code_puppy.model_utils import prepare_prompt_for_model
1259
+ from code_puppy.tools import register_tools_for_agent
1260
+
1261
+ model_name = self.get_model_name()
1262
+ models_config = ModelFactory.load_config()
1263
+ model, resolved_model_name = self._load_model_with_fallback(
1264
+ model_name, models_config, str(uuid.uuid4())
1265
+ )
1266
+
1267
+ instructions = self.get_system_prompt()
1268
+ puppy_rules = self.load_puppy_rules()
1269
+ if puppy_rules:
1270
+ instructions += f"\n{puppy_rules}"
1271
+
1272
+ mcp_servers = getattr(self, "_mcp_servers", []) or []
1273
+ model_settings = make_model_settings(resolved_model_name)
1274
+
1275
+ prepared = prepare_prompt_for_model(
1276
+ model_name, instructions, "", prepend_system_to_user=False
1277
+ )
1278
+ instructions = prepared.instructions
1279
+
1280
+ global _reload_count
1281
+ _reload_count += 1
1282
+
1283
+ if get_use_dbos():
1284
+ temp_agent = PydanticAgent(
1285
+ model=model,
1286
+ instructions=instructions,
1287
+ output_type=output_type,
1288
+ retries=3,
1289
+ toolsets=[],
1290
+ history_processors=[self.message_history_accumulator],
1291
+ model_settings=model_settings,
1292
+ )
1293
+ agent_tools = self.get_available_tools()
1294
+ register_tools_for_agent(temp_agent, agent_tools)
1295
+ dbos_agent = DBOSAgent(
1296
+ temp_agent, name=f"{self.name}-structured-{_reload_count}"
1297
+ )
1298
+ return dbos_agent
1299
+ else:
1300
+ temp_agent = PydanticAgent(
1301
+ model=model,
1302
+ instructions=instructions,
1303
+ output_type=output_type,
1304
+ retries=3,
1305
+ toolsets=mcp_servers,
1306
+ history_processors=[self.message_history_accumulator],
1307
+ model_settings=model_settings,
1308
+ )
1309
+ agent_tools = self.get_available_tools()
1310
+ register_tools_for_agent(temp_agent, agent_tools)
1311
+ return temp_agent
1312
+
1249
1313
  # It's okay to decorate it with DBOS.step even if not using DBOS; the decorator is a no-op in that case.
1250
1314
  @DBOS.step()
1251
1315
  def message_history_accumulator(self, ctx: RunContext, messages: List[Any]):
@@ -1271,12 +1335,204 @@ class BaseAgent(ABC):
1271
1335
  self.set_message_history(result_messages_filtered_empty_thinking)
1272
1336
  return self.get_message_history()
1273
1337
 
1338
+ async def _event_stream_handler(
1339
+ self, ctx: RunContext, events: AsyncIterable[Any]
1340
+ ) -> None:
1341
+ """Handle streaming events from the agent run.
1342
+
1343
+ This method processes streaming events and emits TextPart and ThinkingPart
1344
+ content with styled banners as they stream in.
1345
+
1346
+ Args:
1347
+ ctx: The run context.
1348
+ events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
1349
+ """
1350
+ from pydantic_ai import PartDeltaEvent, PartStartEvent
1351
+ from pydantic_ai.messages import TextPartDelta, ThinkingPartDelta
1352
+ from rich.console import Console
1353
+ from rich.markdown import Markdown
1354
+ from rich.markup import escape
1355
+
1356
+ from code_puppy.messaging.spinner import pause_all_spinners
1357
+
1358
+ # IMPORTANT: Use the shared console (set by cli_runner) to avoid conflicts
1359
+ # with the spinner's Live display. Multiple Console instances with separate
1360
+ # Live displays cause cursor positioning chaos and line duplication.
1361
+ if self._console is not None:
1362
+ console = self._console
1363
+ else:
1364
+ # Fallback if console not set (shouldn't happen in normal use)
1365
+ console = Console()
1366
+
1367
+ # Track which part indices we're currently streaming (for Text/Thinking parts)
1368
+ streaming_parts: set[int] = set()
1369
+ thinking_parts: set[int] = (
1370
+ set()
1371
+ ) # Track which parts are thinking (for dim style)
1372
+ text_parts: set[int] = set() # Track which parts are text
1373
+ banner_printed: set[int] = set() # Track if banner was already printed
1374
+ text_buffer: dict[int, list[str]] = {} # Buffer text for final markdown render
1375
+ token_count: dict[int, int] = {} # Track token count per text part
1376
+ did_stream_anything = False # Track if we streamed any content
1377
+
1378
+ def _print_thinking_banner() -> None:
1379
+ """Print the THINKING banner with spinner pause and line clear."""
1380
+ nonlocal did_stream_anything
1381
+ import sys
1382
+ import time
1383
+
1384
+ from code_puppy.config import get_banner_color
1385
+
1386
+ pause_all_spinners()
1387
+ time.sleep(0.1) # Delay to let spinner fully clear
1388
+ sys.stdout.write("\r\x1b[K") # Clear line
1389
+ sys.stdout.flush()
1390
+ console.print() # Newline before banner
1391
+ # Bold banner with configurable color and lightning bolt
1392
+ thinking_color = get_banner_color("thinking")
1393
+ console.print(
1394
+ Text.from_markup(
1395
+ f"[bold white on {thinking_color}] THINKING [/bold white on {thinking_color}] [dim]⚡ "
1396
+ ),
1397
+ end="",
1398
+ )
1399
+ sys.stdout.flush()
1400
+ did_stream_anything = True
1401
+
1402
+ def _print_response_banner() -> None:
1403
+ """Print the AGENT RESPONSE banner with spinner pause and line clear."""
1404
+ nonlocal did_stream_anything
1405
+ import sys
1406
+ import time
1407
+
1408
+ from code_puppy.config import get_banner_color
1409
+
1410
+ pause_all_spinners()
1411
+ time.sleep(0.1) # Delay to let spinner fully clear
1412
+ sys.stdout.write("\r\x1b[K") # Clear line
1413
+ sys.stdout.flush()
1414
+ console.print() # Newline before banner
1415
+ response_color = get_banner_color("agent_response")
1416
+ console.print(
1417
+ Text.from_markup(
1418
+ f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
1419
+ )
1420
+ )
1421
+ sys.stdout.flush()
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
+ token_count[event.index] += 1
1446
+
1447
+ # PartDeltaEvent - stream the content as it arrives
1448
+ elif isinstance(event, PartDeltaEvent):
1449
+ if event.index in streaming_parts:
1450
+ delta = event.delta
1451
+ if isinstance(delta, (TextPartDelta, ThinkingPartDelta)):
1452
+ if delta.content_delta:
1453
+ # For text parts, show token counter then render at end
1454
+ if event.index in text_parts:
1455
+ import sys
1456
+
1457
+ # Print banner on first content
1458
+ if event.index not in banner_printed:
1459
+ _print_response_banner()
1460
+ banner_printed.add(event.index)
1461
+ # Accumulate text for final markdown render
1462
+ text_buffer[event.index].append(delta.content_delta)
1463
+ token_count[event.index] += 1
1464
+ # Update token counter in place (single line)
1465
+ count = token_count[event.index]
1466
+ sys.stdout.write(
1467
+ f"\r\x1b[K ⏳ Receiving... {count} tokens"
1468
+ )
1469
+ sys.stdout.flush()
1470
+ else:
1471
+ # For thinking parts, stream immediately (dim)
1472
+ if event.index not in banner_printed:
1473
+ _print_thinking_banner()
1474
+ banner_printed.add(event.index)
1475
+ escaped = escape(delta.content_delta)
1476
+ console.print(f"[dim]{escaped}[/dim]", end="")
1477
+
1478
+ # PartEndEvent - finish the streaming with a newline
1479
+ elif isinstance(event, PartEndEvent):
1480
+ if event.index in streaming_parts:
1481
+ # For text parts, clear counter line and render markdown
1482
+ if event.index in text_parts:
1483
+ import sys
1484
+
1485
+ # Clear the token counter line
1486
+ sys.stdout.write("\r\x1b[K")
1487
+ sys.stdout.flush()
1488
+ # Render the final markdown nicely
1489
+ if event.index in text_buffer:
1490
+ try:
1491
+ final_content = "".join(text_buffer[event.index])
1492
+ if final_content.strip():
1493
+ console.print(Markdown(final_content))
1494
+ except Exception:
1495
+ pass
1496
+ del text_buffer[event.index]
1497
+ # Clean up token count
1498
+ token_count.pop(event.index, None)
1499
+ # For thinking parts, just print newline
1500
+ elif event.index in banner_printed:
1501
+ console.print() # Final newline after streaming
1502
+ # Clean up all tracking sets
1503
+ streaming_parts.discard(event.index)
1504
+ thinking_parts.discard(event.index)
1505
+ text_parts.discard(event.index)
1506
+ banner_printed.discard(event.index)
1507
+
1508
+ # Resume spinner if next part is NOT text/thinking (avoid race condition)
1509
+ # If next part is a tool call or None, it's safe to resume
1510
+ # Note: spinner itself handles blank line before appearing
1511
+ from code_puppy.messaging.spinner import resume_all_spinners
1512
+
1513
+ next_kind = getattr(event, "next_part_kind", None)
1514
+ if next_kind not in ("text", "thinking"):
1515
+ resume_all_spinners()
1516
+
1517
+ # Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind)
1518
+
1274
1519
  def _spawn_ctrl_x_key_listener(
1275
1520
  self,
1276
1521
  stop_event: threading.Event,
1277
1522
  on_escape: Callable[[], None],
1523
+ on_cancel_agent: Optional[Callable[[], None]] = None,
1278
1524
  ) -> Optional[threading.Thread]:
1279
- """Start a Ctrl+X key listener thread for CLI sessions."""
1525
+ """Start a keyboard listener thread for CLI sessions.
1526
+
1527
+ Listens for Ctrl+X (shell command cancel) and optionally the configured
1528
+ cancel_agent_key (when not using SIGINT/Ctrl+C).
1529
+
1530
+ Args:
1531
+ stop_event: Event to signal the listener to stop.
1532
+ on_escape: Callback for Ctrl+X (shell command cancel).
1533
+ on_cancel_agent: Optional callback for cancel_agent_key (only used
1534
+ when cancel_agent_uses_signal() returns False).
1535
+ """
1280
1536
  try:
1281
1537
  import sys
1282
1538
  except ImportError:
@@ -1294,16 +1550,20 @@ class BaseAgent(ABC):
1294
1550
  def listener() -> None:
1295
1551
  try:
1296
1552
  if sys.platform.startswith("win"):
1297
- self._listen_for_ctrl_x_windows(stop_event, on_escape)
1553
+ self._listen_for_ctrl_x_windows(
1554
+ stop_event, on_escape, on_cancel_agent
1555
+ )
1298
1556
  else:
1299
- self._listen_for_ctrl_x_posix(stop_event, on_escape)
1557
+ self._listen_for_ctrl_x_posix(
1558
+ stop_event, on_escape, on_cancel_agent
1559
+ )
1300
1560
  except Exception:
1301
1561
  emit_warning(
1302
- "Ctrl+X key listener stopped unexpectedly; press Ctrl+C to cancel."
1562
+ "Key listener stopped unexpectedly; press Ctrl+C to cancel."
1303
1563
  )
1304
1564
 
1305
1565
  thread = threading.Thread(
1306
- target=listener, name="code-puppy-esc-listener", daemon=True
1566
+ target=listener, name="code-puppy-key-listener", daemon=True
1307
1567
  )
1308
1568
  thread.start()
1309
1569
  return thread
@@ -1312,10 +1572,16 @@ class BaseAgent(ABC):
1312
1572
  self,
1313
1573
  stop_event: threading.Event,
1314
1574
  on_escape: Callable[[], None],
1575
+ on_cancel_agent: Optional[Callable[[], None]] = None,
1315
1576
  ) -> None:
1316
1577
  import msvcrt
1317
1578
  import time
1318
1579
 
1580
+ # Get the cancel agent char code if we're using keyboard-based cancel
1581
+ cancel_agent_char: Optional[str] = None
1582
+ if on_cancel_agent is not None and not cancel_agent_uses_signal():
1583
+ cancel_agent_char = get_cancel_agent_char_code()
1584
+
1319
1585
  while not stop_event.is_set():
1320
1586
  try:
1321
1587
  if msvcrt.kbhit():
@@ -1327,9 +1593,18 @@ class BaseAgent(ABC):
1327
1593
  emit_warning(
1328
1594
  "Ctrl+X handler raised unexpectedly; Ctrl+C still works."
1329
1595
  )
1596
+ elif (
1597
+ cancel_agent_char
1598
+ and on_cancel_agent
1599
+ and key == cancel_agent_char
1600
+ ):
1601
+ try:
1602
+ on_cancel_agent()
1603
+ except Exception:
1604
+ emit_warning("Cancel agent handler raised unexpectedly.")
1330
1605
  except Exception:
1331
1606
  emit_warning(
1332
- "Windows Ctrl+X listener error; Ctrl+C is still available for cancel."
1607
+ "Windows key listener error; Ctrl+C is still available for cancel."
1333
1608
  )
1334
1609
  return
1335
1610
  time.sleep(0.05)
@@ -1338,12 +1613,18 @@ class BaseAgent(ABC):
1338
1613
  self,
1339
1614
  stop_event: threading.Event,
1340
1615
  on_escape: Callable[[], None],
1616
+ on_cancel_agent: Optional[Callable[[], None]] = None,
1341
1617
  ) -> None:
1342
1618
  import select
1343
1619
  import sys
1344
1620
  import termios
1345
1621
  import tty
1346
1622
 
1623
+ # Get the cancel agent char code if we're using keyboard-based cancel
1624
+ cancel_agent_char: Optional[str] = None
1625
+ if on_cancel_agent is not None and not cancel_agent_uses_signal():
1626
+ cancel_agent_char = get_cancel_agent_char_code()
1627
+
1347
1628
  stdin = sys.stdin
1348
1629
  try:
1349
1630
  fd = stdin.fileno()
@@ -1373,6 +1654,13 @@ class BaseAgent(ABC):
1373
1654
  emit_warning(
1374
1655
  "Ctrl+X handler raised unexpectedly; Ctrl+C still works."
1375
1656
  )
1657
+ elif (
1658
+ cancel_agent_char and on_cancel_agent and data == cancel_agent_char
1659
+ ):
1660
+ try:
1661
+ on_cancel_agent()
1662
+ except Exception:
1663
+ emit_warning("Cancel agent handler raised unexpectedly.")
1376
1664
  finally:
1377
1665
  termios.tcsetattr(fd, termios.TCSADRAIN, original_attrs)
1378
1666
 
@@ -1382,6 +1670,7 @@ class BaseAgent(ABC):
1382
1670
  *,
1383
1671
  attachments: Optional[Sequence[BinaryContent]] = None,
1384
1672
  link_attachments: Optional[Sequence[Union[ImageUrl, DocumentUrl]]] = None,
1673
+ output_type: Optional[Type[Any]] = None,
1385
1674
  **kwargs,
1386
1675
  ) -> Any:
1387
1676
  """Run the agent with MCP servers, attachments, and full cancellation support.
@@ -1390,10 +1679,13 @@ class BaseAgent(ABC):
1390
1679
  prompt: Primary user prompt text (may be empty when attachments present).
1391
1680
  attachments: Local binary payloads (e.g., dragged images) to include.
1392
1681
  link_attachments: Remote assets (image/document URLs) to include.
1682
+ output_type: Optional Pydantic model or type for structured output.
1683
+ When provided, creates a temporary agent configured to return
1684
+ this type instead of the default string output.
1393
1685
  **kwargs: Additional arguments forwarded to `pydantic_ai.Agent.run`.
1394
1686
 
1395
1687
  Returns:
1396
- The agent's response.
1688
+ The agent's response (typed according to output_type if specified).
1397
1689
 
1398
1690
  Raises:
1399
1691
  asyncio.CancelledError: When execution is cancelled by user.
@@ -1417,12 +1709,23 @@ class BaseAgent(ABC):
1417
1709
  pydantic_agent = (
1418
1710
  self._code_generation_agent or self.reload_code_generation_agent()
1419
1711
  )
1420
- # Handle claude-code models: prepend system prompt to first user message
1421
- from code_puppy.model_utils import is_claude_code_model
1422
1712
 
1423
- if is_claude_code_model(self.get_model_name()):
1713
+ # If a custom output_type is specified, create a temporary agent with that type
1714
+ if output_type is not None:
1715
+ pydantic_agent = self._create_agent_with_output_type(output_type)
1716
+
1717
+ # Handle claude-code and chatgpt-codex models: prepend system prompt to first user message
1718
+ from code_puppy.model_utils import is_chatgpt_codex_model, is_claude_code_model
1719
+
1720
+ if is_claude_code_model(self.get_model_name()) or is_chatgpt_codex_model(
1721
+ self.get_model_name()
1722
+ ):
1424
1723
  if len(self.get_message_history()) == 0:
1425
- prompt = self.get_system_prompt() + "\n\n" + prompt
1724
+ system_prompt = self.get_system_prompt()
1725
+ puppy_rules = self.load_puppy_rules()
1726
+ if puppy_rules:
1727
+ system_prompt += f"\n{puppy_rules}"
1728
+ prompt = system_prompt + "\n\n" + prompt
1426
1729
 
1427
1730
  # Build combined prompt payload when attachments are provided.
1428
1731
  attachment_parts: List[Any] = []
@@ -1480,6 +1783,7 @@ class BaseAgent(ABC):
1480
1783
  prompt_payload,
1481
1784
  message_history=self.get_message_history(),
1482
1785
  usage_limits=usage_limits,
1786
+ event_stream_handler=self._event_stream_handler,
1483
1787
  **kwargs,
1484
1788
  )
1485
1789
  finally:
@@ -1492,6 +1796,7 @@ class BaseAgent(ABC):
1492
1796
  prompt_payload,
1493
1797
  message_history=self.get_message_history(),
1494
1798
  usage_limits=usage_limits,
1799
+ event_stream_handler=self._event_stream_handler,
1495
1800
  **kwargs,
1496
1801
  )
1497
1802
  else:
@@ -1500,6 +1805,7 @@ class BaseAgent(ABC):
1500
1805
  prompt_payload,
1501
1806
  message_history=self.get_message_history(),
1502
1807
  usage_limits=usage_limits,
1808
+ event_stream_handler=self._event_stream_handler,
1503
1809
  **kwargs,
1504
1810
  )
1505
1811
  return result_
@@ -1537,6 +1843,12 @@ class BaseAgent(ABC):
1537
1843
  remaining_exceptions.append(exc)
1538
1844
  emit_info(f"Unexpected error: {str(exc)}", group_id=group_id)
1539
1845
  emit_info(f"{str(exc.args)}", group_id=group_id)
1846
+ # Log to file for debugging
1847
+ log_error(
1848
+ exc,
1849
+ context=f"Agent run (group_id={group_id})",
1850
+ include_traceback=True,
1851
+ )
1540
1852
 
1541
1853
  collect_non_cancelled_exceptions(other_error)
1542
1854
 
@@ -1595,10 +1907,78 @@ class BaseAgent(ABC):
1595
1907
 
1596
1908
  schedule_agent_cancel()
1597
1909
 
1910
+ def graceful_sigint_handler(_sig, _frame):
1911
+ # When using keyboard-based cancel, SIGINT should be a no-op
1912
+ # (just show a hint to user about the configured cancel key)
1913
+ from code_puppy.keymap import get_cancel_agent_display_name
1914
+ import sys
1915
+
1916
+ cancel_key = get_cancel_agent_display_name()
1917
+ if sys.platform == "win32":
1918
+ # On Windows, we use keyboard listener, so SIGINT might still fire
1919
+ # but we handle cancellation via the key listener
1920
+ pass # Silent on Windows - the key listener handles it
1921
+ else:
1922
+ emit_info(f"Use {cancel_key} to cancel the agent task.")
1923
+
1598
1924
  original_handler = None
1925
+ key_listener_stop_event = None
1926
+ _key_listener_thread = None
1927
+ _windows_ctrl_handler = None # Store reference to prevent garbage collection
1928
+
1599
1929
  try:
1600
- # Save original handler and set our custom one AFTER task is created
1601
- original_handler = signal.signal(signal.SIGINT, keyboard_interrupt_handler)
1930
+ if sys.platform == "win32":
1931
+ # Windows: Use SetConsoleCtrlHandler for reliable Ctrl+C handling
1932
+ import ctypes
1933
+
1934
+ # Define the handler function type
1935
+ HANDLER_ROUTINE = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_ulong)
1936
+
1937
+ def windows_ctrl_handler(ctrl_type):
1938
+ """Handle Windows console control events."""
1939
+ CTRL_C_EVENT = 0
1940
+ CTRL_BREAK_EVENT = 1
1941
+
1942
+ if ctrl_type in (CTRL_C_EVENT, CTRL_BREAK_EVENT):
1943
+ # Check if we're awaiting user input
1944
+ if is_awaiting_user_input():
1945
+ return False # Let default handler run
1946
+
1947
+ # Schedule agent cancellation
1948
+ schedule_agent_cancel()
1949
+ return True # We handled it, don't terminate
1950
+
1951
+ return False # Let other handlers process it
1952
+
1953
+ # Create the callback - must keep reference alive!
1954
+ _windows_ctrl_handler = HANDLER_ROUTINE(windows_ctrl_handler)
1955
+
1956
+ # Register the handler
1957
+ kernel32 = ctypes.windll.kernel32
1958
+ if not kernel32.SetConsoleCtrlHandler(_windows_ctrl_handler, True):
1959
+ emit_warning("Failed to set Windows Ctrl+C handler")
1960
+
1961
+ # Also spawn keyboard listener for Ctrl+X (shell cancel) and other keys
1962
+ key_listener_stop_event = threading.Event()
1963
+ _key_listener_thread = self._spawn_ctrl_x_key_listener(
1964
+ key_listener_stop_event,
1965
+ on_escape=lambda: None, # Ctrl+X handled by command_runner
1966
+ on_cancel_agent=None, # Ctrl+C handled by SetConsoleCtrlHandler above
1967
+ )
1968
+ elif cancel_agent_uses_signal():
1969
+ # Unix with Ctrl+C: Use SIGINT-based cancellation
1970
+ original_handler = signal.signal(
1971
+ signal.SIGINT, keyboard_interrupt_handler
1972
+ )
1973
+ else:
1974
+ # Unix with different cancel key: Use keyboard listener
1975
+ original_handler = signal.signal(signal.SIGINT, graceful_sigint_handler)
1976
+ key_listener_stop_event = threading.Event()
1977
+ _key_listener_thread = self._spawn_ctrl_x_key_listener(
1978
+ key_listener_stop_event,
1979
+ on_escape=lambda: None,
1980
+ on_cancel_agent=schedule_agent_cancel,
1981
+ )
1602
1982
 
1603
1983
  # Wait for the task to complete or be cancelled
1604
1984
  result = await agent_task
@@ -1618,6 +1998,20 @@ class BaseAgent(ABC):
1618
1998
  if not agent_task.done():
1619
1999
  agent_task.cancel()
1620
2000
  finally:
1621
- # Restore original signal handler
1622
- if original_handler:
2001
+ # Stop keyboard listener if it was started
2002
+ if key_listener_stop_event is not None:
2003
+ key_listener_stop_event.set()
2004
+
2005
+ # Unregister Windows Ctrl handler
2006
+ if sys.platform == "win32" and _windows_ctrl_handler is not None:
2007
+ try:
2008
+ import ctypes
2009
+
2010
+ kernel32 = ctypes.windll.kernel32
2011
+ kernel32.SetConsoleCtrlHandler(_windows_ctrl_handler, False)
2012
+ except Exception:
2013
+ pass # Best effort cleanup
2014
+
2015
+ # Restore original signal handler (Unix)
2016
+ if original_handler is not None:
1623
2017
  signal.signal(signal.SIGINT, original_handler)