code-puppy 0.0.369__py3-none-any.whl → 0.0.371__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.
@@ -16,11 +16,22 @@ from prompt_toolkit.layout.controls import FormattedTextControl
16
16
  from prompt_toolkit.widgets import Frame
17
17
 
18
18
  from code_puppy.agents import (
19
+ clone_agent,
20
+ delete_clone_agent,
19
21
  get_agent_descriptions,
20
22
  get_available_agents,
21
23
  get_current_agent,
24
+ is_clone_agent_name,
22
25
  )
26
+ from code_puppy.command_line.model_picker_completion import load_model_names
27
+ from code_puppy.config import (
28
+ clear_agent_pinned_model,
29
+ get_agent_pinned_model,
30
+ set_agent_pinned_model,
31
+ )
32
+ from code_puppy.messaging import emit_info, emit_success, emit_warning
23
33
  from code_puppy.tools.command_runner import set_awaiting_user_input
34
+ from code_puppy.tools.common import arrow_select_async
24
35
 
25
36
  PAGE_SIZE = 10 # Agents per page
26
37
 
@@ -87,6 +98,166 @@ def _sanitize_display_text(text: str) -> str:
87
98
  return cleaned
88
99
 
89
100
 
101
+ def _get_pinned_model(agent_name: str) -> Optional[str]:
102
+ """Return the pinned model for an agent, if any.
103
+
104
+ Checks both built-in agent config and JSON agent files.
105
+ """
106
+ import json
107
+
108
+ # First check built-in agent config
109
+ try:
110
+ pinned = get_agent_pinned_model(agent_name)
111
+ if pinned:
112
+ return pinned
113
+ except Exception:
114
+ pass # Continue to check JSON agents
115
+
116
+ # Check if it's a JSON agent
117
+ try:
118
+ from code_puppy.agents.json_agent import discover_json_agents
119
+
120
+ json_agents = discover_json_agents()
121
+ if agent_name in json_agents:
122
+ agent_file_path = json_agents[agent_name]
123
+ with open(agent_file_path, "r", encoding="utf-8") as f:
124
+ agent_config = json.load(f)
125
+ model = agent_config.get("model")
126
+ return model if model else None
127
+ except Exception:
128
+ pass # Return None if we can't read the JSON file
129
+
130
+ return None
131
+
132
+
133
+ def _build_model_picker_choices(
134
+ pinned_model: Optional[str],
135
+ model_names: List[str],
136
+ ) -> List[str]:
137
+ """Build model picker choices with pinned/unpin indicators."""
138
+ choices = ["✓ (unpin)" if not pinned_model else " (unpin)"]
139
+
140
+ for model_name in model_names:
141
+ if model_name == pinned_model:
142
+ choices.append(f"✓ {model_name} (pinned)")
143
+ else:
144
+ choices.append(f" {model_name}")
145
+
146
+ return choices
147
+
148
+
149
+ def _normalize_model_choice(choice: str) -> str:
150
+ """Normalize a picker choice into a model name or '(unpin)' string."""
151
+ cleaned = choice.strip()
152
+ if cleaned.startswith("✓"):
153
+ cleaned = cleaned.lstrip("✓").strip()
154
+ if cleaned.endswith(" (pinned)"):
155
+ cleaned = cleaned[: -len(" (pinned)")].strip()
156
+ return cleaned
157
+
158
+
159
+ async def _select_pinned_model(agent_name: str) -> Optional[str]:
160
+ """Prompt for a model to pin to the agent."""
161
+ try:
162
+ model_names = load_model_names() or []
163
+ except Exception as exc:
164
+ emit_warning(f"Failed to load models: {exc}")
165
+ return None
166
+
167
+ pinned_model = _get_pinned_model(agent_name)
168
+ choices = _build_model_picker_choices(pinned_model, model_names)
169
+ if not choices:
170
+ emit_warning("No models available to pin.")
171
+ return None
172
+
173
+ try:
174
+ choice = await arrow_select_async(
175
+ f"Select a model to pin for '{agent_name}'",
176
+ choices,
177
+ )
178
+ except KeyboardInterrupt:
179
+ emit_info("Model pinning cancelled")
180
+ return None
181
+
182
+ return _normalize_model_choice(choice)
183
+
184
+
185
+ def _reload_agent_if_current(
186
+ agent_name: str,
187
+ pinned_model: Optional[str],
188
+ ) -> None:
189
+ """Reload the current agent when its pinned model changes."""
190
+ current_agent = get_current_agent()
191
+ if not current_agent or current_agent.name != agent_name:
192
+ return
193
+
194
+ try:
195
+ if hasattr(current_agent, "refresh_config"):
196
+ current_agent.refresh_config()
197
+ current_agent.reload_code_generation_agent()
198
+ if pinned_model:
199
+ emit_info(f"Active agent reloaded with pinned model '{pinned_model}'")
200
+ else:
201
+ emit_info("Active agent reloaded with default model")
202
+ except Exception as exc:
203
+ emit_warning(f"Pinned model applied but reload failed: {exc}")
204
+
205
+
206
+ def _apply_pinned_model(agent_name: str, model_choice: str) -> None:
207
+ """Persist a pinned model selection for an agent.
208
+
209
+ Handles both built-in agents (via config) and JSON agents (via JSON file).
210
+ """
211
+ import json
212
+
213
+ # Check if this is a JSON agent or a built-in agent
214
+ try:
215
+ from code_puppy.agents.json_agent import discover_json_agents
216
+
217
+ json_agents = discover_json_agents()
218
+ is_json_agent = agent_name in json_agents
219
+ except Exception:
220
+ is_json_agent = False
221
+
222
+ try:
223
+ if is_json_agent:
224
+ # Handle JSON agent - modify the JSON file
225
+ agent_file_path = json_agents[agent_name]
226
+
227
+ with open(agent_file_path, "r", encoding="utf-8") as f:
228
+ agent_config = json.load(f)
229
+
230
+ if model_choice == "(unpin)":
231
+ # Remove the model key if it exists
232
+ if "model" in agent_config:
233
+ del agent_config["model"]
234
+ emit_success(f"Model pin cleared for '{agent_name}'")
235
+ pinned_model = None
236
+ else:
237
+ # Set the model
238
+ agent_config["model"] = model_choice
239
+ emit_success(f"Pinned '{model_choice}' to '{agent_name}'")
240
+ pinned_model = model_choice
241
+
242
+ # Save the updated configuration
243
+ with open(agent_file_path, "w", encoding="utf-8") as f:
244
+ json.dump(agent_config, f, indent=2, ensure_ascii=False)
245
+ else:
246
+ # Handle built-in Python agent - use config functions
247
+ if model_choice == "(unpin)":
248
+ clear_agent_pinned_model(agent_name)
249
+ emit_success(f"Model pin cleared for '{agent_name}'")
250
+ pinned_model = None
251
+ else:
252
+ set_agent_pinned_model(agent_name, model_choice)
253
+ emit_success(f"Pinned '{model_choice}' to '{agent_name}'")
254
+ pinned_model = model_choice
255
+
256
+ _reload_agent_if_current(agent_name, pinned_model)
257
+ except Exception as exc:
258
+ emit_warning(f"Failed to apply pinned model: {exc}")
259
+
260
+
90
261
  def _get_agent_entries() -> List[Tuple[str, str, str]]:
91
262
  """Get all agents with their display names and descriptions.
92
263
 
@@ -141,6 +312,7 @@ def _render_menu_panel(
141
312
  name, display_name, _ = entries[i]
142
313
  is_selected = i == selected_idx
143
314
  is_current = name == current_agent_name
315
+ pinned_model = _get_pinned_model(name)
144
316
 
145
317
  # Sanitize display name to avoid emoji rendering issues
146
318
  safe_display_name = _sanitize_display_text(display_name)
@@ -153,6 +325,10 @@ def _render_menu_panel(
153
325
  lines.append(("", " "))
154
326
  lines.append(("", safe_display_name))
155
327
 
328
+ if pinned_model:
329
+ safe_pinned_model = _sanitize_display_text(pinned_model)
330
+ lines.append(("fg:ansiyellow", f" → {safe_pinned_model}"))
331
+
156
332
  # Add current marker
157
333
  if is_current:
158
334
  lines.append(("fg:ansicyan", " ← current"))
@@ -167,6 +343,12 @@ def _render_menu_panel(
167
343
  lines.append(("", "Page\n"))
168
344
  lines.append(("fg:green", " Enter "))
169
345
  lines.append(("", "Select\n"))
346
+ lines.append(("fg:ansibrightblack", " P "))
347
+ lines.append(("", "Pin model\n"))
348
+ lines.append(("fg:ansibrightblack", " C "))
349
+ lines.append(("", "Clone\n"))
350
+ lines.append(("fg:ansibrightblack", " D "))
351
+ lines.append(("", "Delete clone\n"))
170
352
  lines.append(("fg:ansibrightred", " Ctrl+C "))
171
353
  lines.append(("", "Cancel"))
172
354
 
@@ -198,6 +380,7 @@ def _render_preview_panel(
198
380
 
199
381
  name, display_name, description = entry
200
382
  is_current = name == current_agent_name
383
+ pinned_model = _get_pinned_model(name)
201
384
 
202
385
  # Sanitize text to avoid emoji rendering issues
203
386
  safe_display_name = _sanitize_display_text(display_name)
@@ -213,6 +396,15 @@ def _render_preview_panel(
213
396
  lines.append(("fg:ansicyan", safe_display_name))
214
397
  lines.append(("", "\n\n"))
215
398
 
399
+ # Pinned model
400
+ lines.append(("bold", "Pinned Model: "))
401
+ if pinned_model:
402
+ safe_pinned_model = _sanitize_display_text(pinned_model)
403
+ lines.append(("fg:ansiyellow", safe_pinned_model))
404
+ else:
405
+ lines.append(("fg:ansibrightblack", "default"))
406
+ lines.append(("", "\n\n"))
407
+
216
408
  # Description
217
409
  lines.append(("bold", "Description:"))
218
410
  lines.append(("", "\n"))
@@ -261,8 +453,6 @@ async def interactive_agent_picker() -> Optional[str]:
261
453
  current_agent_name = current_agent.name if current_agent else ""
262
454
 
263
455
  if not entries:
264
- from code_puppy.messaging import emit_info
265
-
266
456
  emit_info("No agents found.")
267
457
  return None
268
458
 
@@ -270,14 +460,38 @@ async def interactive_agent_picker() -> Optional[str]:
270
460
  selected_idx = [0] # Current selection (global index)
271
461
  current_page = [0] # Current page
272
462
  result = [None] # Selected agent name
463
+ pending_action = [None] # 'pin', 'clone', 'delete', or None
273
464
 
274
- total_pages = (len(entries) + PAGE_SIZE - 1) // PAGE_SIZE
465
+ total_pages = [max(1, (len(entries) + PAGE_SIZE - 1) // PAGE_SIZE)]
275
466
 
276
467
  def get_current_entry() -> Optional[Tuple[str, str, str]]:
277
468
  if 0 <= selected_idx[0] < len(entries):
278
469
  return entries[selected_idx[0]]
279
470
  return None
280
471
 
472
+ def refresh_entries(selected_name: Optional[str] = None) -> None:
473
+ nonlocal entries
474
+
475
+ entries = _get_agent_entries()
476
+ total_pages[0] = max(1, (len(entries) + PAGE_SIZE - 1) // PAGE_SIZE)
477
+
478
+ if not entries:
479
+ selected_idx[0] = 0
480
+ current_page[0] = 0
481
+ return
482
+
483
+ if selected_name:
484
+ for idx, (name, _, _) in enumerate(entries):
485
+ if name == selected_name:
486
+ selected_idx[0] = idx
487
+ break
488
+ else:
489
+ selected_idx[0] = min(selected_idx[0], len(entries) - 1)
490
+ else:
491
+ selected_idx[0] = min(selected_idx[0], len(entries) - 1)
492
+
493
+ current_page[0] = selected_idx[0] // PAGE_SIZE
494
+
281
495
  # Build UI
282
496
  menu_control = FormattedTextControl(text="")
283
497
  preview_control = FormattedTextControl(text="")
@@ -336,11 +550,29 @@ async def interactive_agent_picker() -> Optional[str]:
336
550
 
337
551
  @kb.add("right")
338
552
  def _(event):
339
- if current_page[0] < total_pages - 1:
553
+ if current_page[0] < total_pages[0] - 1:
340
554
  current_page[0] += 1
341
555
  selected_idx[0] = current_page[0] * PAGE_SIZE
342
556
  update_display()
343
557
 
558
+ @kb.add("p")
559
+ def _(event):
560
+ if get_current_entry():
561
+ pending_action[0] = "pin"
562
+ event.app.exit()
563
+
564
+ @kb.add("c")
565
+ def _(event):
566
+ if get_current_entry():
567
+ pending_action[0] = "clone"
568
+ event.app.exit()
569
+
570
+ @kb.add("d")
571
+ def _(event):
572
+ if get_current_entry():
573
+ pending_action[0] = "delete"
574
+ event.app.exit()
575
+
344
576
  @kb.add("enter")
345
577
  def _(event):
346
578
  entry = get_current_entry()
@@ -370,15 +602,52 @@ async def interactive_agent_picker() -> Optional[str]:
370
602
  time.sleep(0.05)
371
603
 
372
604
  try:
373
- # Initial display
374
- update_display()
375
-
376
- # Clear the current buffer
377
- sys.stdout.write("\033[2J\033[H")
378
- sys.stdout.flush()
605
+ while True:
606
+ pending_action[0] = None
607
+ result[0] = None
608
+ update_display()
379
609
 
380
- # Run application
381
- await app.run_async()
610
+ # Clear the current buffer
611
+ sys.stdout.write("\033[2J\033[H")
612
+ sys.stdout.flush()
613
+
614
+ # Run application
615
+ await app.run_async()
616
+
617
+ if pending_action[0] == "pin":
618
+ entry = get_current_entry()
619
+ if entry:
620
+ selected_model = await _select_pinned_model(entry[0])
621
+ if selected_model:
622
+ _apply_pinned_model(entry[0], selected_model)
623
+ continue
624
+
625
+ if pending_action[0] == "clone":
626
+ entry = get_current_entry()
627
+ selected_name = None
628
+ if entry:
629
+ cloned_name = clone_agent(entry[0])
630
+ selected_name = cloned_name or entry[0]
631
+ refresh_entries(selected_name=selected_name)
632
+ continue
633
+
634
+ if pending_action[0] == "delete":
635
+ entry = get_current_entry()
636
+ selected_name = None
637
+ if entry:
638
+ agent_name = entry[0]
639
+ selected_name = agent_name
640
+ if not is_clone_agent_name(agent_name):
641
+ emit_warning("Only cloned agents can be deleted.")
642
+ elif agent_name == current_agent_name:
643
+ emit_warning("Cannot delete the active agent. Switch first.")
644
+ else:
645
+ if delete_clone_agent(agent_name):
646
+ selected_name = None
647
+ refresh_entries(selected_name=selected_name)
648
+ continue
649
+
650
+ break
382
651
 
383
652
  finally:
384
653
  # Exit alternate screen buffer once at end
@@ -388,8 +657,6 @@ async def interactive_agent_picker() -> Optional[str]:
388
657
  set_awaiting_user_input(False)
389
658
 
390
659
  # Clear exit message
391
- from code_puppy.messaging import emit_info
392
-
393
660
  emit_info("✓ Exited agent picker")
394
661
 
395
662
  return result[0]
@@ -169,7 +169,7 @@ def handle_tutorial_command(command: str) -> bool:
169
169
  reset_onboarding,
170
170
  run_onboarding_wizard,
171
171
  )
172
- from code_puppy.config import set_model_name
172
+ from code_puppy.model_switching import set_model_and_reload_agent
173
173
 
174
174
  # Always reset so user can re-run the tutorial anytime
175
175
  reset_onboarding()
@@ -184,7 +184,7 @@ def handle_tutorial_command(command: str) -> bool:
184
184
  from code_puppy.plugins.chatgpt_oauth.oauth_flow import run_oauth_flow
185
185
 
186
186
  run_oauth_flow()
187
- set_model_name("chatgpt-gpt-5.2-codex")
187
+ set_model_and_reload_agent("chatgpt-gpt-5.2-codex")
188
188
  elif result == "claude":
189
189
  emit_info("🔐 Starting Claude Code OAuth flow...")
190
190
  from code_puppy.plugins.claude_code_oauth.register_callbacks import (
@@ -192,7 +192,7 @@ def handle_tutorial_command(command: str) -> bool:
192
192
  )
193
193
 
194
194
  _perform_authentication()
195
- set_model_name("claude-code-claude-opus-4-5-20251101")
195
+ set_model_and_reload_agent("claude-code-claude-opus-4-5-20251101")
196
196
  elif result == "completed":
197
197
  emit_info("🎉 Tutorial complete! Happy coding!")
198
198
  elif result == "skipped":
@@ -6,8 +6,9 @@ from prompt_toolkit.completion import Completer, Completion
6
6
  from prompt_toolkit.document import Document
7
7
  from prompt_toolkit.history import FileHistory
8
8
 
9
- from code_puppy.config import get_global_model_name, set_model_name
9
+ from code_puppy.config import get_global_model_name
10
10
  from code_puppy.model_factory import ModelFactory
11
+ from code_puppy.model_switching import set_model_and_reload_agent
11
12
 
12
13
 
13
14
  def load_model_names():
@@ -28,25 +29,7 @@ def set_active_model(model_name: str):
28
29
  """
29
30
  Sets the active model name by updating the config (for persistence).
30
31
  """
31
- from code_puppy.messaging import emit_info, emit_warning
32
-
33
- set_model_name(model_name)
34
- # Reload the currently active agent so the new model takes effect immediately
35
- try:
36
- from code_puppy.agents import get_current_agent
37
-
38
- current_agent = get_current_agent()
39
- # JSON agents may need to refresh their config before reload
40
- if hasattr(current_agent, "refresh_config"):
41
- try:
42
- current_agent.refresh_config()
43
- except Exception:
44
- # Non-fatal, continue to reload
45
- ...
46
- current_agent.reload_code_generation_agent()
47
- emit_info("Active agent reloaded")
48
- except Exception as e:
49
- emit_warning(f"Model changed but agent reload failed: {e}")
32
+ set_model_and_reload_agent(model_name)
50
33
 
51
34
 
52
35
  class ModelNameCompleter(Completer):
code_puppy/config.py CHANGED
@@ -123,6 +123,9 @@ REQUIRED_KEYS = ["puppy_name", "owner_name"]
123
123
  # Runtime-only autosave session ID (per-process)
124
124
  _CURRENT_AUTOSAVE_ID: Optional[str] = None
125
125
 
126
+ # Session-local model name (initialized from file on first access, then cached)
127
+ _SESSION_MODEL: Optional[str] = None
128
+
126
129
  # Cache containers for model validation and defaults
127
130
  _model_validation_cache = {}
128
131
  _default_model_cache = None
@@ -419,6 +422,16 @@ def clear_model_cache():
419
422
  _default_vision_model_cache = None
420
423
 
421
424
 
425
+ def reset_session_model():
426
+ """Reset the session-local model cache.
427
+
428
+ This is primarily for testing purposes. In normal operation, the session
429
+ model is set once at startup and only changes via set_model_name().
430
+ """
431
+ global _SESSION_MODEL
432
+ _SESSION_MODEL = None
433
+
434
+
422
435
  def model_supports_setting(model_name: str, setting: str) -> bool:
423
436
  """Check if a model supports a particular setting (e.g., 'temperature', 'seed').
424
437
 
@@ -459,26 +472,49 @@ def model_supports_setting(model_name: str, setting: str) -> bool:
459
472
  def get_global_model_name():
460
473
  """Return a valid model name for Code Puppy to use.
461
474
 
462
- 1. Look at ``model`` in *puppy.cfg*.
463
- 2. If that value exists **and** is present in *models.json*, use it.
464
- 3. Otherwise return the first model listed in *models.json*.
465
- 4. As a last resort (e.g.
466
- *models.json* unreadable) fall back to ``claude-4-0-sonnet``.
475
+ Uses session-local caching so that model changes in other terminals
476
+ don't affect this running instance. The file is only read once at startup.
477
+
478
+ 1. If _SESSION_MODEL is set, return it (session cache)
479
+ 2. Otherwise, look at ``model`` in *puppy.cfg*
480
+ 3. If that value exists **and** is present in *models.json*, use it
481
+ 4. Otherwise return the first model listed in *models.json*
482
+ 5. As a last resort fall back to ``claude-4-0-sonnet``
483
+
484
+ The result is cached in _SESSION_MODEL for subsequent calls.
467
485
  """
486
+ global _SESSION_MODEL
487
+
488
+ # Return cached session model if already initialized
489
+ if _SESSION_MODEL is not None:
490
+ return _SESSION_MODEL
468
491
 
492
+ # First access - initialize from file
469
493
  stored_model = get_value("model")
470
494
 
471
495
  if stored_model:
472
496
  # Use cached validation to avoid hitting ModelFactory every time
473
497
  if _validate_model_exists(stored_model):
474
- return stored_model
498
+ _SESSION_MODEL = stored_model
499
+ return _SESSION_MODEL
475
500
 
476
501
  # Either no stored model or it's not valid – choose default from models.json
477
- return _default_model_from_models_json()
502
+ _SESSION_MODEL = _default_model_from_models_json()
503
+ return _SESSION_MODEL
478
504
 
479
505
 
480
506
  def set_model_name(model: str):
481
- """Sets the model name in the persistent config file."""
507
+ """Sets the model name in both the session cache and persistent config file.
508
+
509
+ Updates _SESSION_MODEL immediately for this process, and writes to the
510
+ config file so new terminals will pick up this model as their default.
511
+ """
512
+ global _SESSION_MODEL
513
+
514
+ # Update session cache immediately
515
+ _SESSION_MODEL = model
516
+
517
+ # Also persist to file for new terminal sessions
482
518
  config = configparser.ConfigParser()
483
519
  config.read(CONFIG_FILE)
484
520
  if DEFAULT_SECTION not in config:
@@ -0,0 +1,63 @@
1
+ """Shared helpers for switching models and reloading agents safely."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Optional
6
+
7
+ from code_puppy.config import set_model_name
8
+
9
+
10
+ def _get_effective_agent_model(agent) -> Optional[str]:
11
+ """Safely fetch the effective model name for an agent."""
12
+ try:
13
+ return agent.get_model_name()
14
+ except Exception:
15
+ return None
16
+
17
+
18
+ def set_model_and_reload_agent(
19
+ model_name: str,
20
+ *,
21
+ warn_on_pinned_mismatch: bool = True,
22
+ ) -> None:
23
+ """Set the global model and reload the active agent.
24
+
25
+ This keeps model switching consistent across commands while avoiding
26
+ direct imports that can trigger circular dependencies.
27
+ """
28
+ from code_puppy.messaging import emit_info, emit_warning
29
+
30
+ set_model_name(model_name)
31
+
32
+ try:
33
+ from code_puppy.agents import get_current_agent
34
+
35
+ current_agent = get_current_agent()
36
+ if current_agent is None:
37
+ emit_warning("Model changed but no active agent was found to reload")
38
+ return
39
+
40
+ # JSON agents may need to refresh their config before reload
41
+ if hasattr(current_agent, "refresh_config"):
42
+ try:
43
+ current_agent.refresh_config()
44
+ except Exception:
45
+ # Non-fatal, continue to reload
46
+ ...
47
+
48
+ if warn_on_pinned_mismatch:
49
+ effective_model = _get_effective_agent_model(current_agent)
50
+ if effective_model and effective_model != model_name:
51
+ display_name = getattr(
52
+ current_agent, "display_name", current_agent.name
53
+ )
54
+ emit_warning(
55
+ "Active agent "
56
+ f"'{display_name}' is pinned to '{effective_model}', "
57
+ f"so '{model_name}' will not take effect until unpinned."
58
+ )
59
+
60
+ current_agent.reload_code_generation_agent()
61
+ emit_info("Active agent reloaded")
62
+ except Exception as exc:
63
+ emit_warning(f"Model changed but agent reload failed: {exc}")