code-puppy 0.0.302__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 (65) hide show
  1. code_puppy/agents/base_agent.py +373 -46
  2. code_puppy/chatgpt_codex_client.py +283 -0
  3. code_puppy/cli_runner.py +795 -0
  4. code_puppy/command_line/add_model_menu.py +8 -1
  5. code_puppy/command_line/autosave_menu.py +266 -35
  6. code_puppy/command_line/colors_menu.py +515 -0
  7. code_puppy/command_line/command_handler.py +8 -2
  8. code_puppy/command_line/config_commands.py +59 -10
  9. code_puppy/command_line/core_commands.py +19 -7
  10. code_puppy/command_line/mcp/edit_command.py +3 -1
  11. code_puppy/command_line/mcp/handler.py +7 -2
  12. code_puppy/command_line/mcp/install_command.py +8 -3
  13. code_puppy/command_line/mcp/logs_command.py +173 -64
  14. code_puppy/command_line/mcp/restart_command.py +7 -2
  15. code_puppy/command_line/mcp/search_command.py +10 -4
  16. code_puppy/command_line/mcp/start_all_command.py +16 -6
  17. code_puppy/command_line/mcp/start_command.py +3 -1
  18. code_puppy/command_line/mcp/status_command.py +2 -1
  19. code_puppy/command_line/mcp/stop_all_command.py +5 -1
  20. code_puppy/command_line/mcp/stop_command.py +3 -1
  21. code_puppy/command_line/mcp/wizard_utils.py +10 -4
  22. code_puppy/command_line/model_settings_menu.py +53 -7
  23. code_puppy/command_line/prompt_toolkit_completion.py +16 -2
  24. code_puppy/command_line/session_commands.py +11 -4
  25. code_puppy/config.py +103 -15
  26. code_puppy/keymap.py +8 -2
  27. code_puppy/main.py +5 -828
  28. code_puppy/mcp_/__init__.py +17 -0
  29. code_puppy/mcp_/blocking_startup.py +61 -32
  30. code_puppy/mcp_/config_wizard.py +5 -1
  31. code_puppy/mcp_/managed_server.py +23 -3
  32. code_puppy/mcp_/manager.py +65 -0
  33. code_puppy/mcp_/mcp_logs.py +224 -0
  34. code_puppy/messaging/__init__.py +20 -4
  35. code_puppy/messaging/bus.py +64 -0
  36. code_puppy/messaging/markdown_patches.py +57 -0
  37. code_puppy/messaging/messages.py +16 -0
  38. code_puppy/messaging/renderers.py +21 -9
  39. code_puppy/messaging/rich_renderer.py +113 -67
  40. code_puppy/messaging/spinner/console_spinner.py +34 -0
  41. code_puppy/model_factory.py +185 -30
  42. code_puppy/model_utils.py +57 -48
  43. code_puppy/models.json +19 -5
  44. code_puppy/plugins/chatgpt_oauth/config.py +5 -1
  45. code_puppy/plugins/chatgpt_oauth/oauth_flow.py +5 -6
  46. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +3 -3
  47. code_puppy/plugins/chatgpt_oauth/test_plugin.py +26 -11
  48. code_puppy/plugins/chatgpt_oauth/utils.py +180 -65
  49. code_puppy/plugins/claude_code_oauth/register_callbacks.py +28 -0
  50. code_puppy/plugins/claude_code_oauth/utils.py +1 -0
  51. code_puppy/plugins/shell_safety/agent_shell_safety.py +1 -118
  52. code_puppy/plugins/shell_safety/register_callbacks.py +44 -3
  53. code_puppy/prompts/codex_system_prompt.md +310 -0
  54. code_puppy/pydantic_patches.py +131 -0
  55. code_puppy/terminal_utils.py +126 -0
  56. code_puppy/tools/agent_tools.py +34 -9
  57. code_puppy/tools/command_runner.py +361 -32
  58. code_puppy/tools/file_operations.py +33 -45
  59. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models.json +19 -5
  60. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/METADATA +1 -1
  61. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/RECORD +65 -57
  62. {code_puppy-0.0.302.data → code_puppy-0.0.323.data}/data/code_puppy/models_dev_api.json +0 -0
  63. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/WHEEL +0 -0
  64. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/entry_points.txt +0 -0
  65. {code_puppy-0.0.302.dist-info → code_puppy-0.0.323.dist-info}/licenses/LICENSE +0 -0
@@ -576,9 +576,16 @@ class AddModelMenu:
576
576
  # Determine the model type
577
577
  model_type = type_mapping.get(provider.id, "custom_openai")
578
578
 
579
+ # Special case: kimi-for-coding provider uses "kimi-for-coding" as the model name
580
+ # instead of the model_id from models.dev (which is "kimi-k2-thinking")
581
+ if provider.id == "kimi-for-coding":
582
+ model_name = "kimi-for-coding"
583
+ else:
584
+ model_name = model.model_id
585
+
579
586
  config: dict = {
580
587
  "type": model_type,
581
- "name": model.model_id,
588
+ "name": model_name,
582
589
  }
583
590
 
584
591
  # Add custom endpoint for non-standard providers
@@ -5,7 +5,6 @@ autosave sessions with live preview of message content.
5
5
  """
6
6
 
7
7
  import json
8
- import re
9
8
  import sys
10
9
  import time
11
10
  from datetime import datetime
@@ -79,8 +78,77 @@ def _extract_last_user_message(history: list) -> str:
79
78
  return "[No messages found]"
80
79
 
81
80
 
81
+ def _extract_message_content(msg) -> Tuple[str, str]:
82
+ """Extract role and content from a message.
83
+
84
+ Returns:
85
+ Tuple of (role, content) where role is 'user', 'assistant', or 'tool'
86
+ """
87
+ # Determine role based on message kind AND part types
88
+ # tool-return comes in a 'request' message but it's not from the user
89
+ part_kinds = [getattr(p, "part_kind", "unknown") for p in msg.parts]
90
+
91
+ if msg.kind == "request":
92
+ # Check if this is a tool return (not actually user input)
93
+ if all(pk == "tool-return" for pk in part_kinds):
94
+ role = "tool"
95
+ else:
96
+ role = "user"
97
+ else:
98
+ # Response from assistant
99
+ if all(pk == "tool-call" for pk in part_kinds):
100
+ role = "tool" # Pure tool call, label as tool activity
101
+ else:
102
+ role = "assistant"
103
+
104
+ # Extract content from parts, handling different part types
105
+ content_parts = []
106
+ for part in msg.parts:
107
+ part_kind = getattr(part, "part_kind", "unknown")
108
+
109
+ if part_kind == "tool-call":
110
+ # Assistant is calling a tool - show tool name and args preview
111
+ tool_name = getattr(part, "tool_name", "unknown")
112
+ args = getattr(part, "args", {})
113
+ # Create a condensed args preview
114
+ if args:
115
+ args_preview = str(args)[:100]
116
+ if len(str(args)) > 100:
117
+ args_preview += "..."
118
+ content_parts.append(
119
+ f"🔧 Tool Call: {tool_name}\n Args: {args_preview}"
120
+ )
121
+ else:
122
+ content_parts.append(f"🔧 Tool Call: {tool_name}")
123
+
124
+ elif part_kind == "tool-return":
125
+ # Tool result being returned - show tool name and truncated result
126
+ tool_name = getattr(part, "tool_name", "unknown")
127
+ result = getattr(part, "content", "")
128
+ if isinstance(result, str) and result.strip():
129
+ # Truncate long results
130
+ preview = result[:200].replace("\n", " ")
131
+ if len(result) > 200:
132
+ preview += "..."
133
+ content_parts.append(f"📥 Tool Result: {tool_name}\n {preview}")
134
+ else:
135
+ content_parts.append(f"📥 Tool Result: {tool_name}")
136
+
137
+ elif hasattr(part, "content"):
138
+ # Regular text content (user-prompt, text, thinking, etc.)
139
+ content = part.content
140
+ if isinstance(content, str) and content.strip():
141
+ content_parts.append(content)
142
+
143
+ content = "\n\n".join(content_parts) if content_parts else "[No content]"
144
+ return role, content
145
+
146
+
82
147
  def _render_menu_panel(
83
- entries: List[Tuple[str, dict]], page: int, selected_idx: int
148
+ entries: List[Tuple[str, dict]],
149
+ page: int,
150
+ selected_idx: int,
151
+ browse_mode: bool = False,
84
152
  ) -> List:
85
153
  """Render the left menu panel with pagination."""
86
154
  lines = []
@@ -130,12 +198,20 @@ def _render_menu_panel(
130
198
 
131
199
  lines.append(("", "\n"))
132
200
 
133
- # Navigation hints
201
+ # Navigation hints - change based on browse mode
134
202
  lines.append(("", "\n"))
135
- lines.append(("fg:ansibrightblack", " ↑/↓ "))
136
- lines.append(("", "Navigate\n"))
137
- lines.append(("fg:ansibrightblack", " ←/→ "))
138
- lines.append(("", "Page\n"))
203
+ if browse_mode:
204
+ lines.append(("fg:ansicyan", " ↑/↓ "))
205
+ lines.append(("", "Browse msgs\n"))
206
+ lines.append(("fg:ansiyellow", " Esc "))
207
+ lines.append(("", "Exit browser\n"))
208
+ else:
209
+ lines.append(("fg:ansibrightblack", " ↑/↓ "))
210
+ lines.append(("", "Navigate\n"))
211
+ lines.append(("fg:ansibrightblack", " ←/→ "))
212
+ lines.append(("", "Page\n"))
213
+ lines.append(("fg:ansicyan", " e "))
214
+ lines.append(("", "Browse msgs\n"))
139
215
  lines.append(("fg:green", " Enter "))
140
216
  lines.append(("", "Load\n"))
141
217
  lines.append(("fg:ansibrightred", " Ctrl+C "))
@@ -144,6 +220,109 @@ def _render_menu_panel(
144
220
  return lines
145
221
 
146
222
 
223
+ def _render_message_browser_panel(
224
+ history: list,
225
+ message_idx: int,
226
+ session_name: str,
227
+ ) -> List:
228
+ """Render the message browser panel showing a single message.
229
+
230
+ Args:
231
+ history: Full message history list
232
+ message_idx: Index into history (0 = most recent)
233
+ session_name: Name of the session being browsed
234
+ """
235
+ lines = []
236
+
237
+ lines.append(("fg:ansicyan bold", " MESSAGE BROWSER"))
238
+ lines.append(("", "\n\n"))
239
+
240
+ total_messages = len(history)
241
+ if total_messages == 0:
242
+ lines.append(("fg:yellow", " No messages in this session."))
243
+ lines.append(("", "\n"))
244
+ return lines
245
+
246
+ # Clamp index to valid range
247
+ message_idx = max(0, min(message_idx, total_messages - 1))
248
+
249
+ # Get message (reverse index so 0 = most recent)
250
+ actual_idx = total_messages - 1 - message_idx
251
+ msg = history[actual_idx]
252
+
253
+ # Extract role and content
254
+ role, content = _extract_message_content(msg)
255
+
256
+ # Session info
257
+ lines.append(("fg:ansibrightblack", f" Session: {session_name}"))
258
+ lines.append(("", "\n"))
259
+
260
+ # Message position indicator
261
+ display_num = message_idx + 1 # 1-based for display
262
+ lines.append(("bold", f" Message {display_num} of {total_messages}"))
263
+ lines.append(("", "\n\n"))
264
+
265
+ # Role indicator with icon and color
266
+ if role == "user":
267
+ lines.append(("fg:ansicyan bold", " 🧑 USER"))
268
+ elif role == "tool":
269
+ lines.append(("fg:ansiyellow bold", " 🔧 TOOL"))
270
+ else:
271
+ lines.append(("fg:ansigreen bold", " 🤖 ASSISTANT"))
272
+ lines.append(("", "\n"))
273
+
274
+ # Separator line
275
+ lines.append(("fg:ansibrightblack", " " + "─" * 40))
276
+ lines.append(("", "\n"))
277
+
278
+ # Render content - use markdown for user/assistant, plain text for tool
279
+ try:
280
+ if role == "tool":
281
+ # Tool messages are already formatted, don't pass through markdown
282
+ # Use yellow color for tool output
283
+ rendered = content
284
+ text_color = "fg:ansiyellow"
285
+ else:
286
+ # User and assistant messages should be rendered as markdown
287
+ # Rich will handle the styling via ANSI codes
288
+ console = Console(
289
+ file=StringIO(),
290
+ legacy_windows=False,
291
+ no_color=False,
292
+ force_terminal=False,
293
+ width=72,
294
+ )
295
+ md = Markdown(content)
296
+ console.print(md)
297
+ rendered = console.file.getvalue()
298
+ # Don't override Rich's ANSI styling - use empty style
299
+ text_color = ""
300
+
301
+ # Truncate if too long (max 35 lines)
302
+ message_lines = rendered.split("\n")[:35]
303
+ is_truncated = len(rendered.split("\n")) > 35
304
+
305
+ for line in message_lines:
306
+ lines.append((text_color, f" {line}"))
307
+ lines.append(("", "\n"))
308
+
309
+ if is_truncated:
310
+ lines.append(("", "\n"))
311
+ lines.append(("fg:yellow", " ... truncated (message too long)"))
312
+ lines.append(("", "\n"))
313
+
314
+ except Exception as e:
315
+ lines.append(("fg:red", f" Error rendering message: {e}"))
316
+ lines.append(("", "\n"))
317
+
318
+ # Navigation hint at bottom
319
+ lines.append(("", "\n"))
320
+ lines.append(("fg:ansibrightblack", " ↑ older ↓ newer Esc exit"))
321
+ lines.append(("", "\n"))
322
+
323
+ return lines
324
+
325
+
147
326
  def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) -> List:
148
327
  """Render the right preview panel with message content using rich markdown."""
149
328
  lines = []
@@ -180,6 +359,7 @@ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) ->
180
359
  lines.append(("", "\n\n"))
181
360
 
182
361
  lines.append(("bold", " Last Message:"))
362
+ lines.append(("fg:ansibrightblack", " (press 'e' to browse all)"))
183
363
  lines.append(("", "\n"))
184
364
 
185
365
  # Try to load and preview the last message
@@ -207,22 +387,8 @@ def _render_preview_panel(base_dir: Path, entry: Optional[Tuple[str, dict]]) ->
207
387
  message_lines = rendered.split("\n")[:30]
208
388
 
209
389
  for line in message_lines:
210
- # Apply basic styling based on markdown patterns
211
- styled_line = line
212
-
213
- # Headers - make cyan and bold (dimmed)
214
- if line.strip().startswith("#"):
215
- lines.append(("fg:cyan", f" {styled_line}"))
216
- # Code blocks - make them green (dimmed)
217
- elif line.strip().startswith("│"):
218
- lines.append(("fg:ansibrightblack", f" {styled_line}"))
219
- # List items - make them dimmed
220
- elif re.match(r"^\s*[•\-\*]", line):
221
- lines.append(("fg:ansibrightblack", f" {styled_line}"))
222
- # Regular text - dimmed
223
- else:
224
- lines.append(("fg:ansibrightblack", f" {styled_line}"))
225
-
390
+ # Rich already rendered the markdown, just display it dimmed
391
+ lines.append(("fg:ansibrightblack", f" {line}"))
226
392
  lines.append(("", "\n"))
227
393
 
228
394
  if is_long:
@@ -257,6 +423,11 @@ async def interactive_autosave_picker() -> Optional[str]:
257
423
  current_page = [0] # Current page
258
424
  result = [None] # Selected session name
259
425
 
426
+ # Browse mode state
427
+ browse_mode = [False] # Are we browsing messages within a session?
428
+ message_idx = [0] # Current message index (0 = most recent)
429
+ cached_history = [None] # Cached history for current session in browse mode
430
+
260
431
  total_pages = (len(entries) + PAGE_SIZE - 1) // PAGE_SIZE
261
432
 
262
433
  def get_current_entry() -> Optional[Tuple[str, dict]]:
@@ -271,9 +442,17 @@ async def interactive_autosave_picker() -> Optional[str]:
271
442
  def update_display():
272
443
  """Update both panels."""
273
444
  menu_control.text = _render_menu_panel(
274
- entries, current_page[0], selected_idx[0]
445
+ entries, current_page[0], selected_idx[0], browse_mode[0]
275
446
  )
276
- preview_control.text = _render_preview_panel(base_dir, get_current_entry())
447
+ # Show message browser if in browse mode, otherwise show preview
448
+ if browse_mode[0] and cached_history[0] is not None:
449
+ entry = get_current_entry()
450
+ session_name = entry[0] if entry else "unknown"
451
+ preview_control.text = _render_message_browser_panel(
452
+ cached_history[0], message_idx[0], session_name
453
+ )
454
+ else:
455
+ preview_control.text = _render_preview_panel(base_dir, get_current_entry())
277
456
 
278
457
  menu_window = Window(
279
458
  content=menu_control, wrap_lines=True, width=Dimension(weight=30)
@@ -298,19 +477,33 @@ async def interactive_autosave_picker() -> Optional[str]:
298
477
 
299
478
  @kb.add("up")
300
479
  def _(event):
301
- if selected_idx[0] > 0:
302
- selected_idx[0] -= 1
303
- # Update page if needed
304
- current_page[0] = selected_idx[0] // PAGE_SIZE
305
- update_display()
480
+ if browse_mode[0]:
481
+ # In browse mode: go to older message
482
+ if cached_history[0] and message_idx[0] < len(cached_history[0]) - 1:
483
+ message_idx[0] += 1
484
+ update_display()
485
+ else:
486
+ # Normal mode: navigate sessions
487
+ if selected_idx[0] > 0:
488
+ selected_idx[0] -= 1
489
+ # Update page if needed
490
+ current_page[0] = selected_idx[0] // PAGE_SIZE
491
+ update_display()
306
492
 
307
493
  @kb.add("down")
308
494
  def _(event):
309
- if selected_idx[0] < len(entries) - 1:
310
- selected_idx[0] += 1
311
- # Update page if needed
312
- current_page[0] = selected_idx[0] // PAGE_SIZE
313
- update_display()
495
+ if browse_mode[0]:
496
+ # In browse mode: go to newer message
497
+ if message_idx[0] > 0:
498
+ message_idx[0] -= 1
499
+ update_display()
500
+ else:
501
+ # Normal mode: navigate sessions
502
+ if selected_idx[0] < len(entries) - 1:
503
+ selected_idx[0] += 1
504
+ # Update page if needed
505
+ current_page[0] = selected_idx[0] // PAGE_SIZE
506
+ update_display()
314
507
 
315
508
  @kb.add("left")
316
509
  def _(event):
@@ -326,6 +519,44 @@ async def interactive_autosave_picker() -> Optional[str]:
326
519
  selected_idx[0] = current_page[0] * PAGE_SIZE
327
520
  update_display()
328
521
 
522
+ @kb.add("e")
523
+ def _(event):
524
+ """Enter message browse mode."""
525
+ if browse_mode[0]:
526
+ return # Already in browse mode
527
+ entry = get_current_entry()
528
+ if entry:
529
+ session_name = entry[0]
530
+ try:
531
+ cached_history[0] = load_session(session_name, base_dir)
532
+ browse_mode[0] = True
533
+ message_idx[0] = 0 # Start at most recent
534
+ update_display()
535
+ except Exception:
536
+ pass # Silently fail if can't load
537
+
538
+ @kb.add("escape")
539
+ def _(event):
540
+ """Exit browse mode or cancel."""
541
+ if browse_mode[0]:
542
+ browse_mode[0] = False
543
+ cached_history[0] = None
544
+ message_idx[0] = 0
545
+ update_display()
546
+ else:
547
+ # Not in browse mode - treat as cancel
548
+ result[0] = None
549
+ event.app.exit()
550
+
551
+ @kb.add("q")
552
+ def _(event):
553
+ """Exit browse mode (only when in browse mode)."""
554
+ if browse_mode[0]:
555
+ browse_mode[0] = False
556
+ cached_history[0] = None
557
+ message_idx[0] = 0
558
+ update_display()
559
+
329
560
  @kb.add("enter")
330
561
  def _(event):
331
562
  entry = get_current_entry()