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
@@ -0,0 +1,385 @@
1
+ """Gemini Code Assist Model for pydantic_ai.
2
+
3
+ This module provides a custom Model implementation that uses Google's
4
+ Code Assist API (cloudcode-pa.googleapis.com) instead of the standard
5
+ Generative Language API. The Code Assist API supports OAuth authentication
6
+ and has a different request/response format.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import logging
13
+ import uuid
14
+ from collections.abc import AsyncIterator
15
+ from contextlib import asynccontextmanager
16
+ from datetime import datetime, timezone
17
+ from typing import Any, Dict, Optional
18
+
19
+ import httpx
20
+ from pydantic_ai.messages import (
21
+ ModelMessage,
22
+ ModelRequest,
23
+ ModelResponse,
24
+ ModelResponsePart,
25
+ SystemPromptPart,
26
+ TextPart,
27
+ ToolCallPart,
28
+ ToolReturnPart,
29
+ UserPromptPart,
30
+ )
31
+ from pydantic_ai.models import Model, ModelRequestParameters
32
+ from pydantic_ai.settings import ModelSettings
33
+ from pydantic_ai.tools import ToolDefinition
34
+ from pydantic_ai.usage import RequestUsage
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+
39
+ class GeminiCodeAssistModel(Model):
40
+ """Model implementation for Google's Code Assist API.
41
+
42
+ This uses the cloudcode-pa.googleapis.com endpoint which accepts OAuth
43
+ tokens and has a wrapped request/response format.
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ model_name: str,
49
+ access_token: str,
50
+ project_id: str,
51
+ api_base_url: str = "https://cloudcode-pa.googleapis.com",
52
+ api_version: str = "v1internal",
53
+ ):
54
+ self._model_name = model_name
55
+ self.access_token = access_token
56
+ self.project_id = project_id
57
+ self.api_base_url = api_base_url
58
+ self.api_version = api_version
59
+
60
+ def model_name(self) -> str:
61
+ """Return the model name."""
62
+ return self._model_name
63
+
64
+ @property
65
+ def system(self) -> str:
66
+ return "google"
67
+
68
+ async def request(
69
+ self,
70
+ messages: list[ModelMessage],
71
+ model_settings: ModelSettings | None,
72
+ model_request_parameters: ModelRequestParameters,
73
+ ) -> ModelResponse:
74
+ """Make a non-streaming request to the Code Assist API."""
75
+ request_body = self._build_request(
76
+ messages, model_settings, model_request_parameters
77
+ )
78
+
79
+ url = f"{self.api_base_url}/{self.api_version}:generateContent"
80
+ headers = self._get_headers()
81
+
82
+ async with httpx.AsyncClient(timeout=180) as client:
83
+ response = await client.post(url, json=request_body, headers=headers)
84
+
85
+ if response.status_code != 200:
86
+ error_text = response.text
87
+ raise RuntimeError(
88
+ f"Code Assist API error {response.status_code}: {error_text}"
89
+ )
90
+
91
+ data = response.json()
92
+
93
+ return self._parse_response(data)
94
+
95
+ @asynccontextmanager
96
+ async def request_stream(
97
+ self,
98
+ messages: list[ModelMessage],
99
+ model_settings: ModelSettings | None,
100
+ model_request_parameters: ModelRequestParameters,
101
+ ) -> AsyncIterator[StreamedResponse]:
102
+ """Make a streaming request to the Code Assist API."""
103
+ request_body = self._build_request(
104
+ messages, model_settings, model_request_parameters
105
+ )
106
+
107
+ url = f"{self.api_base_url}/{self.api_version}:streamGenerateContent?alt=sse"
108
+ headers = self._get_headers()
109
+
110
+ async with httpx.AsyncClient(timeout=180) as client:
111
+ async with client.stream(
112
+ "POST", url, json=request_body, headers=headers
113
+ ) as response:
114
+ if response.status_code != 200:
115
+ error_text = await response.aread()
116
+ raise RuntimeError(
117
+ f"Code Assist API error {response.status_code}: {error_text.decode()}"
118
+ )
119
+
120
+ yield StreamedResponse(response, self._model_name)
121
+
122
+ def _get_headers(self) -> Dict[str, str]:
123
+ """Get HTTP headers for the request."""
124
+ return {
125
+ "Authorization": f"Bearer {self.access_token}",
126
+ "Content-Type": "application/json",
127
+ "Accept": "application/json",
128
+ }
129
+
130
+ def _build_request(
131
+ self,
132
+ messages: list[ModelMessage],
133
+ model_settings: ModelSettings | None,
134
+ model_request_parameters: ModelRequestParameters,
135
+ ) -> Dict[str, Any]:
136
+ """Build the Code Assist API request body."""
137
+ contents = []
138
+ system_instruction = None
139
+
140
+ for msg in messages:
141
+ if isinstance(msg, ModelRequest):
142
+ for part in msg.parts:
143
+ if isinstance(part, SystemPromptPart):
144
+ # Collect system prompt
145
+ if system_instruction is None:
146
+ system_instruction = {
147
+ "role": "user",
148
+ "parts": [{"text": part.content}],
149
+ }
150
+ else:
151
+ system_instruction["parts"].append({"text": part.content})
152
+ elif isinstance(part, UserPromptPart):
153
+ contents.append(
154
+ {
155
+ "role": "user",
156
+ "parts": [{"text": part.content}],
157
+ }
158
+ )
159
+ elif isinstance(part, ToolReturnPart):
160
+ # Serialize content to string if it's not already
161
+ content = part.content
162
+ if not isinstance(content, (str, int, float, bool, type(None))):
163
+ try:
164
+ content = json.dumps(content, default=str)
165
+ except (TypeError, ValueError):
166
+ content = str(content)
167
+ contents.append(
168
+ {
169
+ "role": "user",
170
+ "parts": [
171
+ {
172
+ "functionResponse": {
173
+ "name": part.tool_name,
174
+ "response": {"result": content},
175
+ }
176
+ }
177
+ ],
178
+ }
179
+ )
180
+ elif isinstance(msg, ModelResponse):
181
+ parts = []
182
+ first_func_call = True
183
+ for part in msg.parts:
184
+ if isinstance(part, TextPart):
185
+ parts.append({"text": part.content})
186
+ elif isinstance(part, ToolCallPart):
187
+ func_call_part = {
188
+ "functionCall": {
189
+ "name": part.tool_name,
190
+ "args": part.args_as_dict(),
191
+ }
192
+ }
193
+ # Code Assist API requires thoughtSignature on function calls
194
+ # Use synthetic signature to skip validation
195
+ if first_func_call:
196
+ func_call_part["thoughtSignature"] = (
197
+ "skip_thought_signature_validator"
198
+ )
199
+ first_func_call = False
200
+ parts.append(func_call_part)
201
+ if parts:
202
+ contents.append({"role": "model", "parts": parts})
203
+
204
+ # Build the inner request (Vertex-style format)
205
+ inner_request: Dict[str, Any] = {
206
+ "contents": contents,
207
+ }
208
+
209
+ if system_instruction:
210
+ inner_request["systemInstruction"] = system_instruction
211
+
212
+ # Add tools if available
213
+ if model_request_parameters.function_tools:
214
+ inner_request["tools"] = [
215
+ self._build_tools(model_request_parameters.function_tools)
216
+ ]
217
+
218
+ # Add generation config
219
+ generation_config = self._build_generation_config(model_settings)
220
+ if generation_config:
221
+ inner_request["generationConfig"] = generation_config
222
+
223
+ # Wrap in Code Assist format
224
+ return {
225
+ "model": self._model_name,
226
+ "project": self.project_id,
227
+ "user_prompt_id": str(uuid.uuid4()),
228
+ "request": inner_request,
229
+ }
230
+
231
+ def _build_tools(self, tools: list[ToolDefinition]) -> Dict[str, Any]:
232
+ """Build tool definitions for the API."""
233
+ function_declarations = []
234
+
235
+ for tool in tools:
236
+ func_decl: Dict[str, Any] = {
237
+ "name": tool.name,
238
+ "description": tool.description or "",
239
+ }
240
+
241
+ if tool.parameters_json_schema:
242
+ func_decl["parametersJsonSchema"] = tool.parameters_json_schema
243
+
244
+ function_declarations.append(func_decl)
245
+
246
+ return {"functionDeclarations": function_declarations}
247
+
248
+ def _build_generation_config(
249
+ self, model_settings: ModelSettings | None
250
+ ) -> Optional[Dict[str, Any]]:
251
+ """Build generation config from model settings."""
252
+ if not model_settings:
253
+ return None
254
+
255
+ config: Dict[str, Any] = {}
256
+
257
+ if (
258
+ hasattr(model_settings, "temperature")
259
+ and model_settings.temperature is not None
260
+ ):
261
+ config["temperature"] = model_settings.temperature
262
+
263
+ if hasattr(model_settings, "top_p") and model_settings.top_p is not None:
264
+ config["topP"] = model_settings.top_p
265
+
266
+ if (
267
+ hasattr(model_settings, "max_tokens")
268
+ and model_settings.max_tokens is not None
269
+ ):
270
+ config["maxOutputTokens"] = model_settings.max_tokens
271
+
272
+ return config if config else None
273
+
274
+ def _parse_response(self, data: Dict[str, Any]) -> ModelResponse:
275
+ """Parse the Code Assist API response."""
276
+ # Unwrap the Code Assist response format
277
+ inner_response = data.get("response", data)
278
+
279
+ candidates = inner_response.get("candidates", [])
280
+ if not candidates:
281
+ raise RuntimeError("No candidates in response")
282
+
283
+ candidate = candidates[0]
284
+ content = candidate.get("content", {})
285
+ parts = content.get("parts", [])
286
+
287
+ response_parts: list[ModelResponsePart] = []
288
+
289
+ for part in parts:
290
+ if "text" in part:
291
+ response_parts.append(TextPart(content=part["text"]))
292
+ elif "functionCall" in part:
293
+ func_call = part["functionCall"]
294
+ response_parts.append(
295
+ ToolCallPart(
296
+ tool_name=func_call["name"],
297
+ args=func_call.get("args", {}),
298
+ tool_call_id=str(uuid.uuid4()),
299
+ )
300
+ )
301
+
302
+ # Extract usage metadata
303
+ usage_meta = inner_response.get("usageMetadata", {})
304
+ usage = RequestUsage(
305
+ input_tokens=usage_meta.get("promptTokenCount", 0),
306
+ output_tokens=usage_meta.get("candidatesTokenCount", 0),
307
+ )
308
+
309
+ return ModelResponse(
310
+ parts=response_parts, model_name=self._model_name, usage=usage
311
+ )
312
+
313
+
314
+ class StreamedResponse:
315
+ """Handler for streaming responses from Code Assist API."""
316
+
317
+ def __init__(self, response: httpx.Response, model_name: str):
318
+ self._response = response
319
+ self._model_name = model_name
320
+ self._usage: Optional[RequestUsage] = None
321
+ self._timestamp = datetime.now(timezone.utc)
322
+
323
+ def __aiter__(self) -> AsyncIterator[str]:
324
+ return self._iter_chunks()
325
+
326
+ async def _iter_chunks(self) -> AsyncIterator[str]:
327
+ """Iterate over SSE chunks from the response."""
328
+ async for line in self._response.aiter_lines():
329
+ line = line.strip()
330
+
331
+ if line.startswith("data: "):
332
+ data_str = line[6:]
333
+ if data_str == "[DONE]":
334
+ break
335
+
336
+ try:
337
+ data = json.loads(data_str)
338
+ # Unwrap Code Assist format
339
+ inner = data.get("response", data)
340
+
341
+ # Extract usage if available
342
+ if "usageMetadata" in inner:
343
+ meta = inner["usageMetadata"]
344
+ self._usage = RequestUsage(
345
+ input_tokens=meta.get("promptTokenCount", 0),
346
+ output_tokens=meta.get("candidatesTokenCount", 0),
347
+ )
348
+
349
+ # Extract text from candidates
350
+ for candidate in inner.get("candidates", []):
351
+ content = candidate.get("content", {})
352
+ for part in content.get("parts", []):
353
+ if "text" in part:
354
+ yield part["text"]
355
+
356
+ except json.JSONDecodeError:
357
+ logger.warning("Failed to parse SSE data: %s", data_str)
358
+ continue
359
+
360
+ async def get_response_parts(self) -> list[ModelResponsePart]:
361
+ """Get all response parts after streaming is complete."""
362
+ text_content = ""
363
+ tool_calls = []
364
+
365
+ async for chunk in self:
366
+ text_content += chunk
367
+
368
+ parts: list[ModelResponsePart] = []
369
+ if text_content:
370
+ parts.append(TextPart(content=text_content))
371
+ parts.extend(tool_calls)
372
+
373
+ return parts
374
+
375
+ def usage(self) -> RequestUsage:
376
+ """Get usage statistics."""
377
+ return self._usage or RequestUsage()
378
+
379
+ def model_name(self) -> str:
380
+ """Get the model name."""
381
+ return self._model_name
382
+
383
+ def timestamp(self) -> datetime:
384
+ """Get the response timestamp."""
385
+ return self._timestamp
code_puppy/keymap.py ADDED
@@ -0,0 +1,126 @@
1
+ """Keymap configuration for code-puppy.
2
+
3
+ This module handles configurable keyboard shortcuts, starting with the
4
+ cancel_agent_key feature that allows users to override Ctrl+C with a
5
+ different key for cancelling agent tasks.
6
+ """
7
+
8
+ # Character codes for Ctrl+letter combinations (Ctrl+A = 0x01, Ctrl+Z = 0x1A)
9
+ KEY_CODES: dict[str, str] = {
10
+ "ctrl+a": "\x01",
11
+ "ctrl+b": "\x02",
12
+ "ctrl+c": "\x03",
13
+ "ctrl+d": "\x04",
14
+ "ctrl+e": "\x05",
15
+ "ctrl+f": "\x06",
16
+ "ctrl+g": "\x07",
17
+ "ctrl+h": "\x08",
18
+ "ctrl+i": "\x09",
19
+ "ctrl+j": "\x0a",
20
+ "ctrl+k": "\x0b",
21
+ "ctrl+l": "\x0c",
22
+ "ctrl+m": "\x0d",
23
+ "ctrl+n": "\x0e",
24
+ "ctrl+o": "\x0f",
25
+ "ctrl+p": "\x10",
26
+ "ctrl+q": "\x11",
27
+ "ctrl+r": "\x12",
28
+ "ctrl+s": "\x13",
29
+ "ctrl+t": "\x14",
30
+ "ctrl+u": "\x15",
31
+ "ctrl+v": "\x16",
32
+ "ctrl+w": "\x17",
33
+ "ctrl+x": "\x18",
34
+ "ctrl+y": "\x19",
35
+ "ctrl+z": "\x1a",
36
+ "escape": "\x1b",
37
+ }
38
+
39
+ # Valid keys for cancel_agent_key configuration
40
+ # NOTE: "escape" is excluded because it conflicts with ANSI escape sequences
41
+ # (arrow keys, F-keys, etc. all start with \x1b)
42
+ VALID_CANCEL_KEYS: set[str] = {
43
+ "ctrl+c",
44
+ "ctrl+k",
45
+ "ctrl+q",
46
+ }
47
+
48
+ DEFAULT_CANCEL_AGENT_KEY: str = "ctrl+c"
49
+
50
+
51
+ class KeymapError(Exception):
52
+ """Exception raised for keymap configuration errors."""
53
+
54
+
55
+ def get_cancel_agent_key() -> str:
56
+ """Get the configured cancel agent key from config.
57
+
58
+ Returns:
59
+ The key name (e.g., "ctrl+c", "ctrl+k") from config,
60
+ or the default if not configured.
61
+ """
62
+ from code_puppy.config import get_value
63
+
64
+ key = get_value("cancel_agent_key")
65
+ if key is None or key.strip() == "":
66
+ return DEFAULT_CANCEL_AGENT_KEY
67
+ return key.strip().lower()
68
+
69
+
70
+ def validate_cancel_agent_key() -> None:
71
+ """Validate the configured cancel agent key.
72
+
73
+ Raises:
74
+ KeymapError: If the configured key is invalid.
75
+ """
76
+ key = get_cancel_agent_key()
77
+ if key not in VALID_CANCEL_KEYS:
78
+ valid_keys_str = ", ".join(sorted(VALID_CANCEL_KEYS))
79
+ raise KeymapError(
80
+ f"Invalid cancel_agent_key '{key}' in puppy.cfg. "
81
+ f"Valid options are: {valid_keys_str}"
82
+ )
83
+
84
+
85
+ def cancel_agent_uses_signal() -> bool:
86
+ """Check if the cancel agent key uses SIGINT (Ctrl+C).
87
+
88
+ Returns:
89
+ True if the cancel key is ctrl+c AND we're not on Windows
90
+ (uses SIGINT handler), False if it uses keyboard listener approach.
91
+ """
92
+ import sys
93
+
94
+ # On Windows, always use keyboard listener - SIGINT is unreliable
95
+ if sys.platform == "win32":
96
+ return False
97
+
98
+ return get_cancel_agent_key() == "ctrl+c"
99
+
100
+
101
+ def get_cancel_agent_char_code() -> str:
102
+ """Get the character code for the cancel agent key.
103
+
104
+ Returns:
105
+ The character code (e.g., "\x0b" for ctrl+k).
106
+
107
+ Raises:
108
+ KeymapError: If the key is not found in KEY_CODES.
109
+ """
110
+ key = get_cancel_agent_key()
111
+ if key not in KEY_CODES:
112
+ raise KeymapError(f"Unknown key '{key}' - no character code mapping found.")
113
+ return KEY_CODES[key]
114
+
115
+
116
+ def get_cancel_agent_display_name() -> str:
117
+ """Get a human-readable display name for the cancel agent key.
118
+
119
+ Returns:
120
+ A formatted display name like "Ctrl+K".
121
+ """
122
+ key = get_cancel_agent_key()
123
+ if key.startswith("ctrl+"):
124
+ letter = key.split("+")[1].upper()
125
+ return f"Ctrl+{letter}"
126
+ return key.upper()