code-puppy 0.0.323__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 (45) hide show
  1. code_puppy/agents/base_agent.py +74 -93
  2. code_puppy/cli_runner.py +105 -2
  3. code_puppy/command_line/add_model_menu.py +15 -0
  4. code_puppy/command_line/autosave_menu.py +5 -0
  5. code_puppy/command_line/colors_menu.py +5 -0
  6. code_puppy/command_line/config_commands.py +24 -1
  7. code_puppy/command_line/core_commands.py +51 -0
  8. code_puppy/command_line/diff_menu.py +5 -0
  9. code_puppy/command_line/mcp/custom_server_form.py +4 -0
  10. code_puppy/command_line/mcp/install_menu.py +5 -1
  11. code_puppy/command_line/model_settings_menu.py +5 -0
  12. code_puppy/command_line/motd.py +13 -7
  13. code_puppy/command_line/onboarding_slides.py +180 -0
  14. code_puppy/command_line/onboarding_wizard.py +340 -0
  15. code_puppy/config.py +3 -2
  16. code_puppy/http_utils.py +155 -196
  17. code_puppy/keymap.py +10 -8
  18. code_puppy/model_factory.py +86 -15
  19. code_puppy/models.json +2 -2
  20. code_puppy/plugins/__init__.py +12 -0
  21. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  22. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  23. code_puppy/plugins/antigravity_oauth/antigravity_model.py +612 -0
  24. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  25. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  26. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  27. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  28. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  29. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  30. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  31. code_puppy/plugins/antigravity_oauth/transport.py +595 -0
  32. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  33. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
  34. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
  35. code_puppy/reopenable_async_client.py +8 -8
  36. code_puppy/terminal_utils.py +168 -3
  37. code_puppy/tools/command_runner.py +42 -54
  38. code_puppy/uvx_detection.py +242 -0
  39. {code_puppy-0.0.323.data → code_puppy-0.0.335.data}/data/code_puppy/models.json +2 -2
  40. {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/METADATA +30 -1
  41. {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/RECORD +45 -30
  42. {code_puppy-0.0.323.data → code_puppy-0.0.335.data}/data/code_puppy/models_dev_api.json +0 -0
  43. {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/WHEEL +0 -0
  44. {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/entry_points.txt +0 -0
  45. {code_puppy-0.0.323.dist-info → code_puppy-0.0.335.dist-info}/licenses/LICENSE +0 -0
@@ -4,7 +4,6 @@ import asyncio
4
4
  import json
5
5
  import math
6
6
  import signal
7
- import sys
8
7
  import threading
9
8
  import uuid
10
9
  from abc import ABC, abstractmethod
@@ -1340,15 +1339,19 @@ class BaseAgent(ABC):
1340
1339
  ) -> None:
1341
1340
  """Handle streaming events from the agent run.
1342
1341
 
1343
- This method processes streaming events and emits TextPart and ThinkingPart
1344
- content with styled banners as they stream in.
1342
+ This method processes streaming events and emits TextPart, ThinkingPart,
1343
+ and ToolCallPart content with styled banners/tokens as they stream in.
1345
1344
 
1346
1345
  Args:
1347
1346
  ctx: The run context.
1348
1347
  events: Async iterable of streaming events (PartStartEvent, PartDeltaEvent, etc.).
1349
1348
  """
1350
1349
  from pydantic_ai import PartDeltaEvent, PartStartEvent
1351
- from pydantic_ai.messages import TextPartDelta, ThinkingPartDelta
1350
+ from pydantic_ai.messages import (
1351
+ TextPartDelta,
1352
+ ThinkingPartDelta,
1353
+ ToolCallPartDelta,
1354
+ )
1352
1355
  from rich.console import Console
1353
1356
  from rich.markdown import Markdown
1354
1357
  from rich.markup import escape
@@ -1364,29 +1367,29 @@ class BaseAgent(ABC):
1364
1367
  # Fallback if console not set (shouldn't happen in normal use)
1365
1368
  console = Console()
1366
1369
 
1367
- # Track which part indices we're currently streaming (for Text/Thinking parts)
1370
+ # Track which part indices we're currently streaming (for Text/Thinking/Tool parts)
1368
1371
  streaming_parts: set[int] = set()
1369
1372
  thinking_parts: set[int] = (
1370
1373
  set()
1371
1374
  ) # Track which parts are thinking (for dim style)
1372
1375
  text_parts: set[int] = set() # Track which parts are text
1376
+ tool_parts: set[int] = set() # Track which parts are tool calls
1373
1377
  banner_printed: set[int] = set() # Track if banner was already printed
1374
1378
  text_buffer: dict[int, list[str]] = {} # Buffer text for final markdown render
1375
- token_count: dict[int, int] = {} # Track token count per text part
1379
+ token_count: dict[int, int] = {} # Track token count per text/tool part
1376
1380
  did_stream_anything = False # Track if we streamed any content
1377
1381
 
1378
1382
  def _print_thinking_banner() -> None:
1379
1383
  """Print the THINKING banner with spinner pause and line clear."""
1380
1384
  nonlocal did_stream_anything
1381
- import sys
1382
1385
  import time
1383
1386
 
1384
1387
  from code_puppy.config import get_banner_color
1385
1388
 
1386
1389
  pause_all_spinners()
1387
1390
  time.sleep(0.1) # Delay to let spinner fully clear
1388
- sys.stdout.write("\r\x1b[K") # Clear line
1389
- sys.stdout.flush()
1391
+ # Clear line and print newline before banner
1392
+ console.print(" " * 50, end="\r")
1390
1393
  console.print() # Newline before banner
1391
1394
  # Bold banner with configurable color and lightning bolt
1392
1395
  thinking_color = get_banner_color("thinking")
@@ -1396,21 +1399,19 @@ class BaseAgent(ABC):
1396
1399
  ),
1397
1400
  end="",
1398
1401
  )
1399
- sys.stdout.flush()
1400
1402
  did_stream_anything = True
1401
1403
 
1402
1404
  def _print_response_banner() -> None:
1403
1405
  """Print the AGENT RESPONSE banner with spinner pause and line clear."""
1404
1406
  nonlocal did_stream_anything
1405
- import sys
1406
1407
  import time
1407
1408
 
1408
1409
  from code_puppy.config import get_banner_color
1409
1410
 
1410
1411
  pause_all_spinners()
1411
1412
  time.sleep(0.1) # Delay to let spinner fully clear
1412
- sys.stdout.write("\r\x1b[K") # Clear line
1413
- sys.stdout.flush()
1413
+ # Clear line and print newline before banner
1414
+ console.print(" " * 50, end="\r")
1414
1415
  console.print() # Newline before banner
1415
1416
  response_color = get_banner_color("agent_response")
1416
1417
  console.print(
@@ -1418,7 +1419,6 @@ class BaseAgent(ABC):
1418
1419
  f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
1419
1420
  )
1420
1421
  )
1421
- sys.stdout.flush()
1422
1422
  did_stream_anything = True
1423
1423
 
1424
1424
  async for event in events:
@@ -1442,7 +1442,16 @@ class BaseAgent(ABC):
1442
1442
  # Buffer initial content if present
1443
1443
  if part.content and part.content.strip():
1444
1444
  text_buffer[event.index].append(part.content)
1445
+ # Count chunks (each part counts as 1)
1445
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
1446
1455
 
1447
1456
  # PartDeltaEvent - stream the content as it arrives
1448
1457
  elif isinstance(event, PartDeltaEvent):
@@ -1452,21 +1461,20 @@ class BaseAgent(ABC):
1452
1461
  if delta.content_delta:
1453
1462
  # For text parts, show token counter then render at end
1454
1463
  if event.index in text_parts:
1455
- import sys
1456
-
1457
1464
  # Print banner on first content
1458
1465
  if event.index not in banner_printed:
1459
1466
  _print_response_banner()
1460
1467
  banner_printed.add(event.index)
1461
1468
  # Accumulate text for final markdown render
1462
1469
  text_buffer[event.index].append(delta.content_delta)
1470
+ # Count chunks received
1463
1471
  token_count[event.index] += 1
1464
- # Update token counter in place (single line)
1472
+ # Update chunk counter in place (single line)
1465
1473
  count = token_count[event.index]
1466
- sys.stdout.write(
1467
- f"\r\x1b[K ⏳ Receiving... {count} tokens"
1474
+ console.print(
1475
+ f" ⏳ Receiving... {count} chunks ",
1476
+ end="\r",
1468
1477
  )
1469
- sys.stdout.flush()
1470
1478
  else:
1471
1479
  # For thinking parts, stream immediately (dim)
1472
1480
  if event.index not in banner_printed:
@@ -1474,17 +1482,31 @@ class BaseAgent(ABC):
1474
1482
  banner_printed.add(event.index)
1475
1483
  escaped = escape(delta.content_delta)
1476
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
+ )
1477
1502
 
1478
1503
  # PartEndEvent - finish the streaming with a newline
1479
1504
  elif isinstance(event, PartEndEvent):
1480
1505
  if event.index in streaming_parts:
1481
1506
  # For text parts, clear counter line and render markdown
1482
1507
  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()
1508
+ # Clear the chunk counter line by printing spaces and returning
1509
+ console.print(" " * 50, end="\r")
1488
1510
  # Render the final markdown nicely
1489
1511
  if event.index in text_buffer:
1490
1512
  try:
@@ -1494,24 +1516,30 @@ class BaseAgent(ABC):
1494
1516
  except Exception:
1495
1517
  pass
1496
1518
  del text_buffer[event.index]
1497
- # Clean up token count
1498
- token_count.pop(event.index, None)
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")
1499
1523
  # For thinking parts, just print newline
1500
1524
  elif event.index in banner_printed:
1501
1525
  console.print() # Final newline after streaming
1526
+
1527
+ # Clean up token count
1528
+ token_count.pop(event.index, None)
1502
1529
  # Clean up all tracking sets
1503
1530
  streaming_parts.discard(event.index)
1504
1531
  thinking_parts.discard(event.index)
1505
1532
  text_parts.discard(event.index)
1533
+ tool_parts.discard(event.index)
1506
1534
  banner_printed.discard(event.index)
1507
1535
 
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
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
1510
1538
  # Note: spinner itself handles blank line before appearing
1511
1539
  from code_puppy.messaging.spinner import resume_all_spinners
1512
1540
 
1513
1541
  next_kind = getattr(event, "next_part_kind", None)
1514
- if next_kind not in ("text", "thinking"):
1542
+ if next_kind not in ("text", "thinking", "tool-call"):
1515
1543
  resume_all_spinners()
1516
1544
 
1517
1545
  # Spinner is resumed in PartEndEvent when appropriate (based on next_part_kind)
@@ -1910,73 +1938,35 @@ class BaseAgent(ABC):
1910
1938
  def graceful_sigint_handler(_sig, _frame):
1911
1939
  # When using keyboard-based cancel, SIGINT should be a no-op
1912
1940
  # (just show a hint to user about the configured cancel key)
1941
+ # Also reset terminal to prevent bricking on Windows+uvx
1913
1942
  from code_puppy.keymap import get_cancel_agent_display_name
1914
- import sys
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()
1915
1947
 
1916
1948
  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.")
1949
+ emit_info(f"Use {cancel_key} to cancel the agent task.")
1923
1950
 
1924
1951
  original_handler = None
1925
1952
  key_listener_stop_event = None
1926
1953
  _key_listener_thread = None
1927
- _windows_ctrl_handler = None # Store reference to prevent garbage collection
1928
1954
 
1929
1955
  try:
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
1956
+ if cancel_agent_uses_signal():
1957
+ # Use SIGINT-based cancellation (default Ctrl+C behavior)
1970
1958
  original_handler = signal.signal(
1971
1959
  signal.SIGINT, keyboard_interrupt_handler
1972
1960
  )
1973
1961
  else:
1974
- # Unix with different cancel key: Use keyboard listener
1962
+ # Use keyboard listener for agent cancellation
1963
+ # Set a graceful SIGINT handler that shows a hint
1975
1964
  original_handler = signal.signal(signal.SIGINT, graceful_sigint_handler)
1965
+ # Spawn keyboard listener with the cancel agent callback
1976
1966
  key_listener_stop_event = threading.Event()
1977
1967
  _key_listener_thread = self._spawn_ctrl_x_key_listener(
1978
1968
  key_listener_stop_event,
1979
- on_escape=lambda: None,
1969
+ on_escape=lambda: None, # Ctrl+X handled by command_runner
1980
1970
  on_cancel_agent=schedule_agent_cancel,
1981
1971
  )
1982
1972
 
@@ -2001,17 +1991,8 @@ class BaseAgent(ABC):
2001
1991
  # Stop keyboard listener if it was started
2002
1992
  if key_listener_stop_event is not None:
2003
1993
  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:
1994
+ # Restore original signal handler
1995
+ if (
1996
+ original_handler is not None
1997
+ ): # Explicit None check - SIG_DFL can be 0/falsy!
2017
1998
  signal.signal(signal.SIGINT, original_handler)
code_puppy/cli_runner.py CHANGED
@@ -171,6 +171,45 @@ async def main():
171
171
  emit_error(str(e))
172
172
  sys.exit(1)
173
173
 
174
+ # Show uvx detection notice if we're on Windows + uvx
175
+ # Also disable Ctrl+C at the console level to prevent terminal bricking
176
+ try:
177
+ from code_puppy.uvx_detection import should_use_alternate_cancel_key
178
+
179
+ if should_use_alternate_cancel_key():
180
+ from code_puppy.terminal_utils import (
181
+ disable_windows_ctrl_c,
182
+ set_keep_ctrl_c_disabled,
183
+ )
184
+
185
+ # Disable Ctrl+C at the console input level
186
+ # This prevents Ctrl+C from being processed as a signal at all
187
+ disable_windows_ctrl_c()
188
+
189
+ # Set flag to keep it disabled (prompt_toolkit may re-enable it)
190
+ set_keep_ctrl_c_disabled(True)
191
+
192
+ # Use print directly - emit_system_message can get cleared by ANSI codes
193
+ print(
194
+ "🔧 Detected uvx launch on Windows - using Ctrl+K for cancellation "
195
+ "(Ctrl+C is disabled to prevent terminal issues)"
196
+ )
197
+
198
+ # Also install a SIGINT handler as backup
199
+ import signal
200
+
201
+ from code_puppy.terminal_utils import reset_windows_terminal_full
202
+
203
+ def _uvx_protective_sigint_handler(_sig, _frame):
204
+ """Protective SIGINT handler for Windows+uvx."""
205
+ reset_windows_terminal_full()
206
+ # Re-disable Ctrl+C in case something re-enabled it
207
+ disable_windows_ctrl_c()
208
+
209
+ signal.signal(signal.SIGINT, _uvx_protective_sigint_handler)
210
+ except ImportError:
211
+ pass # uvx_detection module not available, ignore
212
+
174
213
  # Load API keys from puppy.cfg into environment variables
175
214
  from code_puppy.config import load_api_keys_to_environment
176
215
 
@@ -325,6 +364,7 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
325
364
  emit_system_message(
326
365
  "Use /diff to configure diff highlighting colors for file changes."
327
366
  )
367
+ emit_system_message("To re-run the tutorial, use /tutorial.")
328
368
  try:
329
369
  from code_puppy.command_line.motd import print_motd
330
370
 
@@ -417,6 +457,45 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
417
457
 
418
458
  # Autosave loading is now manual - use /autosave_load command
419
459
 
460
+ # Auto-run tutorial on first startup
461
+ try:
462
+ from code_puppy.command_line.onboarding_wizard import should_show_onboarding
463
+
464
+ if should_show_onboarding():
465
+ import asyncio
466
+ import concurrent.futures
467
+
468
+ from code_puppy.command_line.onboarding_wizard import run_onboarding_wizard
469
+ from code_puppy.config import set_model_name
470
+ from code_puppy.messaging import emit_info
471
+
472
+ with concurrent.futures.ThreadPoolExecutor() as executor:
473
+ future = executor.submit(lambda: asyncio.run(run_onboarding_wizard()))
474
+ result = future.result(timeout=300)
475
+
476
+ if result == "chatgpt":
477
+ emit_info("🔐 Starting ChatGPT OAuth flow...")
478
+ from code_puppy.plugins.chatgpt_oauth.oauth_flow import run_oauth_flow
479
+
480
+ run_oauth_flow()
481
+ set_model_name("chatgpt-gpt-5.2-codex")
482
+ elif result == "claude":
483
+ emit_info("🔐 Starting Claude Code OAuth flow...")
484
+ from code_puppy.plugins.claude_code_oauth.register_callbacks import (
485
+ _perform_authentication,
486
+ )
487
+
488
+ _perform_authentication()
489
+ set_model_name("claude-code-claude-opus-4-5-20251101")
490
+ elif result == "completed":
491
+ emit_info("🎉 Tutorial complete! Happy coding!")
492
+ elif result == "skipped":
493
+ emit_info("⏭️ Tutorial skipped. Run /tutorial anytime!")
494
+ except Exception as e:
495
+ from code_puppy.messaging import emit_warning
496
+
497
+ emit_warning(f"Tutorial auto-start failed: {e}")
498
+
420
499
  # Track the current agent task for cancellation on quit
421
500
  current_agent_task = None
422
501
 
@@ -440,6 +519,15 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
440
519
  task = await get_input_with_combined_completion(
441
520
  get_prompt_with_active_model(), history_file=COMMAND_HISTORY_FILE
442
521
  )
522
+
523
+ # Windows+uvx: Re-disable Ctrl+C after prompt_toolkit
524
+ # (prompt_toolkit restores console mode which re-enables Ctrl+C)
525
+ try:
526
+ from code_puppy.terminal_utils import ensure_ctrl_c_disabled
527
+
528
+ ensure_ctrl_c_disabled()
529
+ except ImportError:
530
+ pass
443
531
  except ImportError:
444
532
  # Fall back to basic input if prompt_toolkit is not available
445
533
  task = input(">>> ")
@@ -605,6 +693,13 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
605
693
  if result is None:
606
694
  # Windows-specific: Reset terminal state after cancellation
607
695
  reset_windows_terminal_ansi()
696
+ # Re-disable Ctrl+C if needed (uvx mode)
697
+ try:
698
+ from code_puppy.terminal_utils import ensure_ctrl_c_disabled
699
+
700
+ ensure_ctrl_c_disabled()
701
+ except ImportError:
702
+ pass
608
703
  continue
609
704
  # Get the structured response
610
705
  agent_response = result.output
@@ -645,6 +740,15 @@ async def interactive_mode(message_renderer, initial_command: str = None) -> Non
645
740
 
646
741
  auto_save_session_if_enabled()
647
742
 
743
+ # Re-disable Ctrl+C if needed (uvx mode) - must be done after
744
+ # each iteration as various operations may restore console mode
745
+ try:
746
+ from code_puppy.terminal_utils import ensure_ctrl_c_disabled
747
+
748
+ ensure_ctrl_c_disabled()
749
+ except ImportError:
750
+ pass
751
+
648
752
 
649
753
  def prettier_code_blocks():
650
754
  """Configure Rich to use prettier code block rendering."""
@@ -790,6 +894,5 @@ def main_entry():
790
894
  DBOS.destroy()
791
895
  return 0
792
896
  finally:
793
- # Reset terminal on all platforms for clean state
794
- reset_windows_terminal_full() # Safe no-op on non-Windows
897
+ # Reset terminal on Unix-like systems (not Windows)
795
898
  reset_unix_terminal()
@@ -571,6 +571,7 @@ class AddModelMenu:
571
571
  "cerebras": "cerebras",
572
572
  "cohere": "custom_openai",
573
573
  "perplexity": "custom_openai",
574
+ "minimax": "custom_anthropic",
574
575
  }
575
576
 
576
577
  # Determine the model type
@@ -600,6 +601,16 @@ class AddModelMenu:
600
601
  api_key_env = f"${provider.env[0]}" if provider.env else "$API_KEY"
601
602
  config["custom_endpoint"] = {"url": api_url, "api_key": api_key_env}
602
603
 
604
+ # Special handling for minimax: uses custom_anthropic but needs custom_endpoint
605
+ # and the URL needs /v1 stripped (comes as https://api.minimax.io/anthropic/v1)
606
+ if provider.id == "minimax" and provider.api:
607
+ api_url = provider.api
608
+ # Strip /v1 suffix if present
609
+ if api_url.endswith("/v1"):
610
+ api_url = api_url[:-3]
611
+ api_key_env = f"${provider.env[0]}" if provider.env else "$API_KEY"
612
+ config["custom_endpoint"] = {"url": api_url, "api_key": api_key_env}
613
+
603
614
  # Add context length if available
604
615
  if model.context_length and model.context_length > 0:
605
616
  config["context_length"] = model.context_length
@@ -984,6 +995,10 @@ class AddModelMenu:
984
995
  # Reset awaiting input flag
985
996
  set_awaiting_user_input(False)
986
997
 
998
+ # Clear exit message (unless we're about to prompt for more input)
999
+ if self.result not in ("pending_credentials", "pending_custom_model"):
1000
+ emit_info("✓ Exited model browser")
1001
+
987
1002
  # Handle unsupported provider
988
1003
  if self.result == "unsupported" and self.current_provider:
989
1004
  reason = UNSUPPORTED_PROVIDERS.get(
@@ -603,4 +603,9 @@ async def interactive_autosave_picker() -> Optional[str]:
603
603
  # Reset awaiting input flag
604
604
  set_awaiting_user_input(False)
605
605
 
606
+ # Clear exit message
607
+ from code_puppy.messaging import emit_info
608
+
609
+ emit_info("✓ Exited session browser")
610
+
606
611
  return result[0]
@@ -230,6 +230,11 @@ async def interactive_colors_picker() -> Optional[dict]:
230
230
  sys.stdout.write("\033[?1049l") # Exit alternate buffer
231
231
  sys.stdout.flush()
232
232
 
233
+ # Clear exit message
234
+ from code_puppy.messaging import emit_info
235
+
236
+ emit_info("✓ Exited banner color configuration")
237
+
233
238
  # Return changes if any
234
239
  if config.has_changes():
235
240
  return config.current_colors
@@ -46,6 +46,7 @@ def handle_show_command(command: str) -> bool:
46
46
  get_use_dbos,
47
47
  get_yolo_mode,
48
48
  )
49
+ from code_puppy.keymap import get_cancel_agent_display_name
49
50
  from code_puppy.messaging import emit_info
50
51
 
51
52
  puppy_name = get_puppy_name()
@@ -79,6 +80,7 @@ def handle_show_command(command: str) -> bool:
79
80
  [bold]reasoning_effort:[/bold] [cyan]{get_openai_reasoning_effort()}[/cyan]
80
81
  [bold]verbosity:[/bold] [cyan]{get_openai_verbosity()}[/cyan]
81
82
  [bold]temperature:[/bold] [cyan]{effective_temperature if effective_temperature is not None else "(model default)"}[/cyan]{" (per-model)" if effective_temperature != global_temperature and effective_temperature is not None else ""}
83
+ [bold]cancel_agent_key:[/bold] [cyan]{get_cancel_agent_display_name()}[/cyan] (options: ctrl+c, ctrl+k, ctrl+q)
82
84
 
83
85
  """
84
86
  emit_info(Text.from_markup(status_msg))
@@ -200,9 +202,13 @@ def handle_set_command(command: str) -> bool:
200
202
  "\n[yellow]Session Management[/yellow]"
201
203
  "\n [cyan]auto_save_session[/cyan] Auto-save chat after every response (true/false)"
202
204
  )
205
+ keymap_help = (
206
+ "\n[yellow]Keyboard Shortcuts[/yellow]"
207
+ "\n [cyan]cancel_agent_key[/cyan] Key to cancel agent tasks (ctrl+c, ctrl+k, or ctrl+q)"
208
+ )
203
209
  emit_warning(
204
210
  Text.from_markup(
205
- f"Usage: /set KEY=VALUE or /set KEY VALUE\nConfig keys: {', '.join(config_keys)}\n[dim]Note: compaction_strategy can be 'summarization' or 'truncation'[/dim]{session_help}"
211
+ f"Usage: /set KEY=VALUE or /set KEY VALUE\nConfig keys: {', '.join(config_keys)}\n[dim]Note: compaction_strategy can be 'summarization' or 'truncation'[/dim]{session_help}{keymap_help}"
206
212
  )
207
213
  )
208
214
  return True
@@ -215,6 +221,23 @@ def handle_set_command(command: str) -> bool:
215
221
  )
216
222
  )
217
223
 
224
+ # Validate cancel_agent_key before setting
225
+ if key == "cancel_agent_key":
226
+ from code_puppy.keymap import VALID_CANCEL_KEYS
227
+
228
+ normalized_value = value.strip().lower()
229
+ if normalized_value not in VALID_CANCEL_KEYS:
230
+ emit_error(
231
+ f"Invalid cancel_agent_key '{value}'. Valid options: {', '.join(sorted(VALID_CANCEL_KEYS))}"
232
+ )
233
+ return True
234
+ value = normalized_value # Use normalized value
235
+ emit_info(
236
+ Text.from_markup(
237
+ "[yellow]⚠️ cancel_agent_key changed. Please restart Code Puppy for this change to take effect.[/yellow]"
238
+ )
239
+ )
240
+
218
241
  set_config_value(key, value)
219
242
  emit_success(f'Set {key} = "{value}" in puppy.cfg!')
220
243
 
@@ -115,6 +115,57 @@ def handle_motd_command(command: str) -> bool:
115
115
  return True
116
116
 
117
117
 
118
+ @register_command(
119
+ name="tutorial",
120
+ description="Run the interactive tutorial wizard",
121
+ usage="/tutorial",
122
+ category="core",
123
+ )
124
+ def handle_tutorial_command(command: str) -> bool:
125
+ """Run the interactive tutorial wizard.
126
+
127
+ Usage:
128
+ /tutorial - Run the tutorial (can be run anytime)
129
+ """
130
+ import asyncio
131
+ import concurrent.futures
132
+
133
+ from code_puppy.command_line.onboarding_wizard import (
134
+ reset_onboarding,
135
+ run_onboarding_wizard,
136
+ )
137
+ from code_puppy.config import set_model_name
138
+
139
+ # Always reset so user can re-run the tutorial anytime
140
+ reset_onboarding()
141
+
142
+ # Run the async wizard in a thread pool (same pattern as agent picker)
143
+ with concurrent.futures.ThreadPoolExecutor() as executor:
144
+ future = executor.submit(lambda: asyncio.run(run_onboarding_wizard()))
145
+ result = future.result(timeout=300) # 5 min timeout
146
+
147
+ if result == "chatgpt":
148
+ emit_info("🔐 Starting ChatGPT OAuth flow...")
149
+ from code_puppy.plugins.chatgpt_oauth.oauth_flow import run_oauth_flow
150
+
151
+ run_oauth_flow()
152
+ set_model_name("chatgpt-gpt-5.2-codex")
153
+ elif result == "claude":
154
+ emit_info("🔐 Starting Claude Code OAuth flow...")
155
+ from code_puppy.plugins.claude_code_oauth.register_callbacks import (
156
+ _perform_authentication,
157
+ )
158
+
159
+ _perform_authentication()
160
+ set_model_name("claude-code-claude-opus-4-5-20251101")
161
+ elif result == "completed":
162
+ emit_info("🎉 Tutorial complete! Happy coding!")
163
+ elif result == "skipped":
164
+ emit_info("⏭️ Tutorial skipped. Run /tutorial anytime!")
165
+
166
+ return True
167
+
168
+
118
169
  @register_command(
119
170
  name="exit",
120
171
  description="Exit interactive mode",
@@ -456,6 +456,11 @@ async def interactive_diff_picker() -> Optional[dict]:
456
456
  sys.stdout.write("\033[?1049l") # Exit alternate buffer
457
457
  sys.stdout.flush()
458
458
 
459
+ # Clear exit message
460
+ from code_puppy.messaging import emit_info
461
+
462
+ emit_info("✓ Exited diff color configuration")
463
+
459
464
  # Return changes if any
460
465
  if config.has_changes():
461
466
  return {
@@ -604,6 +604,10 @@ class CustomServerForm:
604
604
  sys.stdout.flush()
605
605
  set_awaiting_user_input(False)
606
606
 
607
+ # Clear exit message if not installing
608
+ if self.result != "installed":
609
+ emit_info("✓ Exited custom server form")
610
+
607
611
  # Handle result
608
612
  if self.result == "installed":
609
613
  if self.edit_mode: