code-puppy 0.0.325__py3-none-any.whl → 0.0.336__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 (44) hide show
  1. code_puppy/agents/base_agent.py +41 -103
  2. code_puppy/cli_runner.py +105 -2
  3. code_puppy/command_line/add_model_menu.py +4 -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/messaging/rich_renderer.py +101 -19
  19. code_puppy/model_factory.py +86 -15
  20. code_puppy/plugins/antigravity_oauth/__init__.py +10 -0
  21. code_puppy/plugins/antigravity_oauth/accounts.py +406 -0
  22. code_puppy/plugins/antigravity_oauth/antigravity_model.py +653 -0
  23. code_puppy/plugins/antigravity_oauth/config.py +42 -0
  24. code_puppy/plugins/antigravity_oauth/constants.py +136 -0
  25. code_puppy/plugins/antigravity_oauth/oauth.py +478 -0
  26. code_puppy/plugins/antigravity_oauth/register_callbacks.py +406 -0
  27. code_puppy/plugins/antigravity_oauth/storage.py +271 -0
  28. code_puppy/plugins/antigravity_oauth/test_plugin.py +319 -0
  29. code_puppy/plugins/antigravity_oauth/token.py +167 -0
  30. code_puppy/plugins/antigravity_oauth/transport.py +664 -0
  31. code_puppy/plugins/antigravity_oauth/utils.py +169 -0
  32. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -0
  33. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -0
  34. code_puppy/reopenable_async_client.py +8 -8
  35. code_puppy/terminal_utils.py +168 -3
  36. code_puppy/tools/command_runner.py +42 -54
  37. code_puppy/uvx_detection.py +242 -0
  38. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/METADATA +30 -1
  39. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/RECORD +44 -29
  40. {code_puppy-0.0.325.data → code_puppy-0.0.336.data}/data/code_puppy/models.json +0 -0
  41. {code_puppy-0.0.325.data → code_puppy-0.0.336.data}/data/code_puppy/models_dev_api.json +0 -0
  42. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/WHEEL +0 -0
  43. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.dist-info}/entry_points.txt +0 -0
  44. {code_puppy-0.0.325.dist-info → code_puppy-0.0.336.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
@@ -1383,15 +1382,14 @@ class BaseAgent(ABC):
1383
1382
  def _print_thinking_banner() -> None:
1384
1383
  """Print the THINKING banner with spinner pause and line clear."""
1385
1384
  nonlocal did_stream_anything
1386
- import sys
1387
1385
  import time
1388
1386
 
1389
1387
  from code_puppy.config import get_banner_color
1390
1388
 
1391
1389
  pause_all_spinners()
1392
1390
  time.sleep(0.1) # Delay to let spinner fully clear
1393
- sys.stdout.write("\r\x1b[K") # Clear line
1394
- sys.stdout.flush()
1391
+ # Clear line and print newline before banner
1392
+ console.print(" " * 50, end="\r")
1395
1393
  console.print() # Newline before banner
1396
1394
  # Bold banner with configurable color and lightning bolt
1397
1395
  thinking_color = get_banner_color("thinking")
@@ -1401,21 +1399,19 @@ class BaseAgent(ABC):
1401
1399
  ),
1402
1400
  end="",
1403
1401
  )
1404
- sys.stdout.flush()
1405
1402
  did_stream_anything = True
1406
1403
 
1407
1404
  def _print_response_banner() -> None:
1408
1405
  """Print the AGENT RESPONSE banner with spinner pause and line clear."""
1409
1406
  nonlocal did_stream_anything
1410
- import sys
1411
1407
  import time
1412
1408
 
1413
1409
  from code_puppy.config import get_banner_color
1414
1410
 
1415
1411
  pause_all_spinners()
1416
1412
  time.sleep(0.1) # Delay to let spinner fully clear
1417
- sys.stdout.write("\r\x1b[K") # Clear line
1418
- sys.stdout.flush()
1413
+ # Clear line and print newline before banner
1414
+ console.print(" " * 50, end="\r")
1419
1415
  console.print() # Newline before banner
1420
1416
  response_color = get_banner_color("agent_response")
1421
1417
  console.print(
@@ -1423,7 +1419,6 @@ class BaseAgent(ABC):
1423
1419
  f"[bold white on {response_color}] AGENT RESPONSE [/bold white on {response_color}]"
1424
1420
  )
1425
1421
  )
1426
- sys.stdout.flush()
1427
1422
  did_stream_anything = True
1428
1423
 
1429
1424
  async for event in events:
@@ -1447,8 +1442,8 @@ class BaseAgent(ABC):
1447
1442
  # Buffer initial content if present
1448
1443
  if part.content and part.content.strip():
1449
1444
  text_buffer[event.index].append(part.content)
1450
- # Use len(content) / 3 for token estimation (more accurate than chunk counting)
1451
- token_count[event.index] += len(part.content) // 3
1445
+ # Count chunks (each part counts as 1)
1446
+ token_count[event.index] += 1
1452
1447
  elif isinstance(part, ToolCallPart):
1453
1448
  streaming_parts.add(event.index)
1454
1449
  tool_parts.add(event.index)
@@ -1466,24 +1461,20 @@ class BaseAgent(ABC):
1466
1461
  if delta.content_delta:
1467
1462
  # For text parts, show token counter then render at end
1468
1463
  if event.index in text_parts:
1469
- import sys
1470
-
1471
1464
  # Print banner on first content
1472
1465
  if event.index not in banner_printed:
1473
1466
  _print_response_banner()
1474
1467
  banner_printed.add(event.index)
1475
1468
  # Accumulate text for final markdown render
1476
1469
  text_buffer[event.index].append(delta.content_delta)
1477
- # Use len(content) / 3 for token estimation
1478
- token_count[event.index] += (
1479
- len(delta.content_delta) // 3
1480
- )
1481
- # Update token counter in place (single line)
1470
+ # Count chunks received
1471
+ token_count[event.index] += 1
1472
+ # Update chunk counter in place (single line)
1482
1473
  count = token_count[event.index]
1483
- sys.stdout.write(
1484
- f"\r\x1b[K ⏳ Receiving... {count} tokens"
1474
+ console.print(
1475
+ f" ⏳ Receiving... {count} chunks ",
1476
+ end="\r",
1485
1477
  )
1486
- sys.stdout.flush()
1487
1478
  else:
1488
1479
  # For thinking parts, stream immediately (dim)
1489
1480
  if event.index not in banner_printed:
@@ -1492,34 +1483,30 @@ class BaseAgent(ABC):
1492
1483
  escaped = escape(delta.content_delta)
1493
1484
  console.print(f"[dim]{escaped}[/dim]", end="")
1494
1485
  elif isinstance(delta, ToolCallPartDelta):
1495
- import sys
1496
-
1497
- # For tool calls, show token counter (use string repr for estimation)
1498
- token_count[event.index] += len(str(delta)) // 3
1486
+ # For tool calls, count chunks received
1487
+ token_count[event.index] += 1
1499
1488
  # Get tool name if available
1500
1489
  tool_name = getattr(delta, "tool_name_delta", "")
1501
1490
  count = token_count[event.index]
1502
1491
  # Display with tool wrench icon and tool name
1503
1492
  if tool_name:
1504
- sys.stdout.write(
1505
- f"\r\x1b[K 🔧 Calling {tool_name}... {count} tokens"
1493
+ console.print(
1494
+ f" 🔧 Calling {tool_name}... {count} chunks ",
1495
+ end="\r",
1506
1496
  )
1507
1497
  else:
1508
- sys.stdout.write(
1509
- f"\r\x1b[K 🔧 Calling tool... {count} tokens"
1498
+ console.print(
1499
+ f" 🔧 Calling tool... {count} chunks ",
1500
+ end="\r",
1510
1501
  )
1511
- sys.stdout.flush()
1512
1502
 
1513
1503
  # PartEndEvent - finish the streaming with a newline
1514
1504
  elif isinstance(event, PartEndEvent):
1515
1505
  if event.index in streaming_parts:
1516
- import sys
1517
-
1518
1506
  # For text parts, clear counter line and render markdown
1519
1507
  if event.index in text_parts:
1520
- # Clear the token counter line
1521
- sys.stdout.write("\r\x1b[K")
1522
- sys.stdout.flush()
1508
+ # Clear the chunk counter line by printing spaces and returning
1509
+ console.print(" " * 50, end="\r")
1523
1510
  # Render the final markdown nicely
1524
1511
  if event.index in text_buffer:
1525
1512
  try:
@@ -1529,11 +1516,10 @@ class BaseAgent(ABC):
1529
1516
  except Exception:
1530
1517
  pass
1531
1518
  del text_buffer[event.index]
1532
- # For tool parts, clear the token counter line
1519
+ # For tool parts, clear the chunk counter line
1533
1520
  elif event.index in tool_parts:
1534
- # Clear the token counter line
1535
- sys.stdout.write("\r\x1b[K")
1536
- sys.stdout.flush()
1521
+ # Clear the chunk counter line by printing spaces and returning
1522
+ console.print(" " * 50, end="\r")
1537
1523
  # For thinking parts, just print newline
1538
1524
  elif event.index in banner_printed:
1539
1525
  console.print() # Final newline after streaming
@@ -1952,74 +1938,35 @@ class BaseAgent(ABC):
1952
1938
  def graceful_sigint_handler(_sig, _frame):
1953
1939
  # When using keyboard-based cancel, SIGINT should be a no-op
1954
1940
  # (just show a hint to user about the configured cancel key)
1955
- import sys
1956
-
1941
+ # Also reset terminal to prevent bricking on Windows+uvx
1957
1942
  from code_puppy.keymap import get_cancel_agent_display_name
1943
+ from code_puppy.terminal_utils import reset_windows_terminal_full
1944
+
1945
+ # Reset terminal state first to prevent bricking
1946
+ reset_windows_terminal_full()
1958
1947
 
1959
1948
  cancel_key = get_cancel_agent_display_name()
1960
- if sys.platform == "win32":
1961
- # On Windows, we use keyboard listener, so SIGINT might still fire
1962
- # but we handle cancellation via the key listener
1963
- pass # Silent on Windows - the key listener handles it
1964
- else:
1965
- emit_info(f"Use {cancel_key} to cancel the agent task.")
1949
+ emit_info(f"Use {cancel_key} to cancel the agent task.")
1966
1950
 
1967
1951
  original_handler = None
1968
1952
  key_listener_stop_event = None
1969
1953
  _key_listener_thread = None
1970
- _windows_ctrl_handler = None # Store reference to prevent garbage collection
1971
1954
 
1972
1955
  try:
1973
- if sys.platform == "win32":
1974
- # Windows: Use SetConsoleCtrlHandler for reliable Ctrl+C handling
1975
- import ctypes
1976
-
1977
- # Define the handler function type
1978
- HANDLER_ROUTINE = ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_ulong)
1979
-
1980
- def windows_ctrl_handler(ctrl_type):
1981
- """Handle Windows console control events."""
1982
- CTRL_C_EVENT = 0
1983
- CTRL_BREAK_EVENT = 1
1984
-
1985
- if ctrl_type in (CTRL_C_EVENT, CTRL_BREAK_EVENT):
1986
- # Check if we're awaiting user input
1987
- if is_awaiting_user_input():
1988
- return False # Let default handler run
1989
-
1990
- # Schedule agent cancellation
1991
- schedule_agent_cancel()
1992
- return True # We handled it, don't terminate
1993
-
1994
- return False # Let other handlers process it
1995
-
1996
- # Create the callback - must keep reference alive!
1997
- _windows_ctrl_handler = HANDLER_ROUTINE(windows_ctrl_handler)
1998
-
1999
- # Register the handler
2000
- kernel32 = ctypes.windll.kernel32
2001
- if not kernel32.SetConsoleCtrlHandler(_windows_ctrl_handler, True):
2002
- emit_warning("Failed to set Windows Ctrl+C handler")
2003
-
2004
- # Also spawn keyboard listener for Ctrl+X (shell cancel) and other keys
2005
- key_listener_stop_event = threading.Event()
2006
- _key_listener_thread = self._spawn_ctrl_x_key_listener(
2007
- key_listener_stop_event,
2008
- on_escape=lambda: None, # Ctrl+X handled by command_runner
2009
- on_cancel_agent=None, # Ctrl+C handled by SetConsoleCtrlHandler above
2010
- )
2011
- elif cancel_agent_uses_signal():
2012
- # Unix with Ctrl+C: Use SIGINT-based cancellation
1956
+ if cancel_agent_uses_signal():
1957
+ # Use SIGINT-based cancellation (default Ctrl+C behavior)
2013
1958
  original_handler = signal.signal(
2014
1959
  signal.SIGINT, keyboard_interrupt_handler
2015
1960
  )
2016
1961
  else:
2017
- # 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
2018
1964
  original_handler = signal.signal(signal.SIGINT, graceful_sigint_handler)
1965
+ # Spawn keyboard listener with the cancel agent callback
2019
1966
  key_listener_stop_event = threading.Event()
2020
1967
  _key_listener_thread = self._spawn_ctrl_x_key_listener(
2021
1968
  key_listener_stop_event,
2022
- on_escape=lambda: None,
1969
+ on_escape=lambda: None, # Ctrl+X handled by command_runner
2023
1970
  on_cancel_agent=schedule_agent_cancel,
2024
1971
  )
2025
1972
 
@@ -2044,17 +1991,8 @@ class BaseAgent(ABC):
2044
1991
  # Stop keyboard listener if it was started
2045
1992
  if key_listener_stop_event is not None:
2046
1993
  key_listener_stop_event.set()
2047
-
2048
- # Unregister Windows Ctrl handler
2049
- if sys.platform == "win32" and _windows_ctrl_handler is not None:
2050
- try:
2051
- import ctypes
2052
-
2053
- kernel32 = ctypes.windll.kernel32
2054
- kernel32.SetConsoleCtrlHandler(_windows_ctrl_handler, False)
2055
- except Exception:
2056
- pass # Best effort cleanup
2057
-
2058
- # Restore original signal handler (Unix)
2059
- 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!
2060
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()
@@ -995,6 +995,10 @@ class AddModelMenu:
995
995
  # Reset awaiting input flag
996
996
  set_awaiting_user_input(False)
997
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
+
998
1002
  # Handle unsupported provider
999
1003
  if self.result == "unsupported" and self.current_provider:
1000
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:
@@ -16,7 +16,7 @@ from prompt_toolkit.layout import Dimension, Layout, VSplit, Window
16
16
  from prompt_toolkit.layout.controls import FormattedTextControl
17
17
  from prompt_toolkit.widgets import Frame
18
18
 
19
- from code_puppy.messaging import emit_error, emit_warning
19
+ from code_puppy.messaging import emit_error, emit_info, emit_warning
20
20
  from code_puppy.tools.command_runner import set_awaiting_user_input
21
21
 
22
22
  from .catalog_server_installer import (
@@ -635,6 +635,10 @@ class MCPInstallMenu:
635
635
  sys.stdout.flush()
636
636
  set_awaiting_user_input(False)
637
637
 
638
+ # Clear exit message (unless we're about to prompt for more input)
639
+ if self.result not in ("pending_custom", "pending_install"):
640
+ emit_info("✓ Exited MCP server browser")
641
+
638
642
  # Handle custom server after TUI exits
639
643
  if self.result == "pending_custom":
640
644
  success = run_custom_server_form(self.manager)
@@ -835,6 +835,11 @@ class ModelSettingsMenu:
835
835
  sys.stdout.flush()
836
836
  set_awaiting_user_input(False)
837
837
 
838
+ # Clear exit message
839
+ from code_puppy.messaging import emit_info
840
+
841
+ emit_info("✓ Exited model settings")
842
+
838
843
  return self.result_changed
839
844
 
840
845
 
@@ -8,13 +8,19 @@ import os
8
8
  from code_puppy.config import CONFIG_DIR
9
9
  from code_puppy.messaging import emit_info
10
10
 
11
- MOTD_VERSION = "2025-11-27"
12
- MOTD_MESSAGE = """🐕‍🦺
13
- 🐾```
14
- # 🐶🦃🐕 November 27th, 2025 - Happy Thanksgiving! 🦃🐶
15
- 122k Downloads! 🎉
16
- Thank you for all the support!
17
- -Mike
11
+ MOTD_VERSION = "2026-01-01"
12
+ MOTD_MESSAGE = """
13
+ # 🐶 Happy New Year! January 1st, 2026 🎉
14
+ Reminder that Code Puppy supports three different OAuth subscriptions:
15
+
16
+ ### Claude Code - `/claude-code-auth`
17
+ - Opus / Haiku / Sonnet
18
+
19
+ ### ChatGPT Pro/Plus - `/chatgpt-auth`
20
+ - gpt-5.2 and gpt-5.2 codex
21
+
22
+ ### Google Antigravity - `/antigravity-auth`
23
+ - Gemini 3 Pro, Flash, and Anthropic models including Opus and Sonnet.
18
24
  """
19
25
  MOTD_TRACK_FILE = os.path.join(CONFIG_DIR, "motd.txt")
20
26