code-puppy 0.0.361__py3-none-any.whl → 0.0.372__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 (41) hide show
  1. code_puppy/agents/__init__.py +6 -0
  2. code_puppy/agents/agent_manager.py +223 -1
  3. code_puppy/agents/base_agent.py +2 -12
  4. code_puppy/chatgpt_codex_client.py +53 -0
  5. code_puppy/claude_cache_client.py +45 -7
  6. code_puppy/command_line/add_model_menu.py +13 -4
  7. code_puppy/command_line/agent_menu.py +662 -0
  8. code_puppy/command_line/core_commands.py +4 -112
  9. code_puppy/command_line/model_picker_completion.py +3 -20
  10. code_puppy/command_line/model_settings_menu.py +21 -3
  11. code_puppy/config.py +79 -8
  12. code_puppy/gemini_model.py +706 -0
  13. code_puppy/http_utils.py +6 -3
  14. code_puppy/model_factory.py +50 -16
  15. code_puppy/model_switching.py +63 -0
  16. code_puppy/model_utils.py +1 -52
  17. code_puppy/models.json +12 -12
  18. code_puppy/plugins/antigravity_oauth/antigravity_model.py +128 -165
  19. code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
  20. code_puppy/plugins/antigravity_oauth/transport.py +235 -45
  21. code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
  22. code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
  23. code_puppy/plugins/claude_code_oauth/utils.py +4 -1
  24. code_puppy/pydantic_patches.py +52 -0
  25. code_puppy/tools/agent_tools.py +3 -3
  26. code_puppy/tools/browser/__init__.py +1 -1
  27. code_puppy/tools/browser/browser_control.py +1 -1
  28. code_puppy/tools/browser/browser_interactions.py +1 -1
  29. code_puppy/tools/browser/browser_locators.py +1 -1
  30. code_puppy/tools/browser/{camoufox_manager.py → browser_manager.py} +29 -110
  31. code_puppy/tools/browser/browser_navigation.py +1 -1
  32. code_puppy/tools/browser/browser_screenshot.py +1 -1
  33. code_puppy/tools/browser/browser_scripts.py +1 -1
  34. {code_puppy-0.0.361.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
  35. {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/METADATA +5 -6
  36. {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/RECORD +40 -38
  37. code_puppy/prompts/codex_system_prompt.md +0 -310
  38. {code_puppy-0.0.361.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
  39. {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
  40. {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
  41. {code_puppy-0.0.361.dist-info → code_puppy-0.0.372.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,662 @@
1
+ """Interactive terminal UI for selecting agents.
2
+
3
+ Provides a split-panel interface for browsing and selecting agents
4
+ with live preview of agent details.
5
+ """
6
+
7
+ import sys
8
+ import time
9
+ import unicodedata
10
+ from typing import List, Optional, Tuple
11
+
12
+ from prompt_toolkit.application import Application
13
+ from prompt_toolkit.key_binding import KeyBindings
14
+ from prompt_toolkit.layout import Dimension, Layout, VSplit, Window
15
+ from prompt_toolkit.layout.controls import FormattedTextControl
16
+ from prompt_toolkit.widgets import Frame
17
+
18
+ from code_puppy.agents import (
19
+ clone_agent,
20
+ delete_clone_agent,
21
+ get_agent_descriptions,
22
+ get_available_agents,
23
+ get_current_agent,
24
+ is_clone_agent_name,
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
33
+ from code_puppy.tools.command_runner import set_awaiting_user_input
34
+ from code_puppy.tools.common import arrow_select_async
35
+
36
+ PAGE_SIZE = 10 # Agents per page
37
+
38
+
39
+ def _sanitize_display_text(text: str) -> str:
40
+ """Remove or replace characters that cause terminal rendering issues.
41
+
42
+ Args:
43
+ text: Text that may contain emojis or wide characters
44
+
45
+ Returns:
46
+ Sanitized text safe for prompt_toolkit rendering
47
+ """
48
+ # Keep only characters that render cleanly in terminals
49
+ # Be aggressive about stripping anything that could cause width issues
50
+ result = []
51
+ for char in text:
52
+ # Get unicode category
53
+ cat = unicodedata.category(char)
54
+ # Categories to KEEP:
55
+ # - L* (Letters): Lu, Ll, Lt, Lm, Lo
56
+ # - N* (Numbers): Nd, Nl, No
57
+ # - P* (Punctuation): Pc, Pd, Ps, Pe, Pi, Pf, Po
58
+ # - Zs (Space separator)
59
+ # - Sm (Math symbols like +, -, =)
60
+ # - Sc (Currency symbols like $, €)
61
+ # - Sk (Modifier symbols)
62
+ #
63
+ # Categories to SKIP (cause rendering issues):
64
+ # - So (Symbol, other) - emojis
65
+ # - Cf (Format) - ZWJ, etc.
66
+ # - Mn (Mark, nonspacing) - combining characters
67
+ # - Mc (Mark, spacing combining)
68
+ # - Me (Mark, enclosing)
69
+ # - Cn (Not assigned)
70
+ # - Co (Private use)
71
+ # - Cs (Surrogate)
72
+ safe_categories = (
73
+ "Lu",
74
+ "Ll",
75
+ "Lt",
76
+ "Lm",
77
+ "Lo", # Letters
78
+ "Nd",
79
+ "Nl",
80
+ "No", # Numbers
81
+ "Pc",
82
+ "Pd",
83
+ "Ps",
84
+ "Pe",
85
+ "Pi",
86
+ "Pf",
87
+ "Po", # Punctuation
88
+ "Zs", # Space
89
+ "Sm",
90
+ "Sc",
91
+ "Sk", # Safe symbols (math, currency, modifier)
92
+ )
93
+ if cat in safe_categories:
94
+ result.append(char)
95
+
96
+ # Clean up any double spaces left behind and strip
97
+ cleaned = " ".join("".join(result).split())
98
+ return cleaned
99
+
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
+
261
+ def _get_agent_entries() -> List[Tuple[str, str, str]]:
262
+ """Get all agents with their display names and descriptions.
263
+
264
+ Returns:
265
+ List of tuples (agent_name, display_name, description) sorted by name.
266
+ """
267
+ available = get_available_agents()
268
+ descriptions = get_agent_descriptions()
269
+
270
+ entries = []
271
+ for name, display_name in available.items():
272
+ description = descriptions.get(name, "No description available")
273
+ entries.append((name, display_name, description))
274
+
275
+ # Sort alphabetically by agent name
276
+ entries.sort(key=lambda x: x[0].lower())
277
+ return entries
278
+
279
+
280
+ def _render_menu_panel(
281
+ entries: List[Tuple[str, str, str]],
282
+ page: int,
283
+ selected_idx: int,
284
+ current_agent_name: str,
285
+ ) -> List:
286
+ """Render the left menu panel with pagination.
287
+
288
+ Args:
289
+ entries: List of (name, display_name, description) tuples
290
+ page: Current page number (0-indexed)
291
+ selected_idx: Currently selected index (global)
292
+ current_agent_name: Name of the current active agent
293
+
294
+ Returns:
295
+ List of (style, text) tuples for FormattedTextControl
296
+ """
297
+ lines = []
298
+ total_pages = (len(entries) + PAGE_SIZE - 1) // PAGE_SIZE if entries else 1
299
+ start_idx = page * PAGE_SIZE
300
+ end_idx = min(start_idx + PAGE_SIZE, len(entries))
301
+
302
+ lines.append(("bold", "Agents"))
303
+ lines.append(("fg:ansibrightblack", f" (Page {page + 1}/{total_pages})"))
304
+ lines.append(("", "\n\n"))
305
+
306
+ if not entries:
307
+ lines.append(("fg:yellow", " No agents found."))
308
+ lines.append(("", "\n\n"))
309
+ else:
310
+ # Show agents for current page
311
+ for i in range(start_idx, end_idx):
312
+ name, display_name, _ = entries[i]
313
+ is_selected = i == selected_idx
314
+ is_current = name == current_agent_name
315
+ pinned_model = _get_pinned_model(name)
316
+
317
+ # Sanitize display name to avoid emoji rendering issues
318
+ safe_display_name = _sanitize_display_text(display_name)
319
+
320
+ # Build the line
321
+ if is_selected:
322
+ lines.append(("fg:ansigreen", "▶ "))
323
+ lines.append(("fg:ansigreen bold", safe_display_name))
324
+ else:
325
+ lines.append(("", " "))
326
+ lines.append(("", safe_display_name))
327
+
328
+ if pinned_model:
329
+ safe_pinned_model = _sanitize_display_text(pinned_model)
330
+ lines.append(("fg:ansiyellow", f" → {safe_pinned_model}"))
331
+
332
+ # Add current marker
333
+ if is_current:
334
+ lines.append(("fg:ansicyan", " ← current"))
335
+
336
+ lines.append(("", "\n"))
337
+
338
+ # Navigation hints
339
+ lines.append(("", "\n"))
340
+ lines.append(("fg:ansibrightblack", " ↑↓ "))
341
+ lines.append(("", "Navigate\n"))
342
+ lines.append(("fg:ansibrightblack", " ←→ "))
343
+ lines.append(("", "Page\n"))
344
+ lines.append(("fg:green", " Enter "))
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"))
352
+ lines.append(("fg:ansibrightred", " Ctrl+C "))
353
+ lines.append(("", "Cancel"))
354
+
355
+ return lines
356
+
357
+
358
+ def _render_preview_panel(
359
+ entry: Optional[Tuple[str, str, str]],
360
+ current_agent_name: str,
361
+ ) -> List:
362
+ """Render the right preview panel with agent details.
363
+
364
+ Args:
365
+ entry: Tuple of (name, display_name, description) or None
366
+ current_agent_name: Name of the current active agent
367
+
368
+ Returns:
369
+ List of (style, text) tuples for FormattedTextControl
370
+ """
371
+ lines = []
372
+
373
+ lines.append(("dim cyan", " AGENT DETAILS"))
374
+ lines.append(("", "\n\n"))
375
+
376
+ if not entry:
377
+ lines.append(("fg:yellow", " No agent selected."))
378
+ lines.append(("", "\n"))
379
+ return lines
380
+
381
+ name, display_name, description = entry
382
+ is_current = name == current_agent_name
383
+ pinned_model = _get_pinned_model(name)
384
+
385
+ # Sanitize text to avoid emoji rendering issues
386
+ safe_display_name = _sanitize_display_text(display_name)
387
+ safe_description = _sanitize_display_text(description)
388
+
389
+ # Agent name (identifier)
390
+ lines.append(("bold", "Name: "))
391
+ lines.append(("", name))
392
+ lines.append(("", "\n\n"))
393
+
394
+ # Display name
395
+ lines.append(("bold", "Display Name: "))
396
+ lines.append(("fg:ansicyan", safe_display_name))
397
+ lines.append(("", "\n\n"))
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
+
408
+ # Description
409
+ lines.append(("bold", "Description:"))
410
+ lines.append(("", "\n"))
411
+
412
+ # Wrap description to fit panel
413
+ desc_lines = safe_description.split("\n")
414
+ for desc_line in desc_lines:
415
+ # Word wrap long lines
416
+ words = desc_line.split()
417
+ current_line = ""
418
+ for word in words:
419
+ if len(current_line) + len(word) + 1 > 55:
420
+ lines.append(("fg:ansibrightblack", current_line))
421
+ lines.append(("", "\n"))
422
+ current_line = word
423
+ else:
424
+ if current_line == "":
425
+ current_line = word
426
+ else:
427
+ current_line += " " + word
428
+ if current_line.strip():
429
+ lines.append(("fg:ansibrightblack", current_line))
430
+ lines.append(("", "\n"))
431
+
432
+ lines.append(("", "\n"))
433
+
434
+ # Current status
435
+ lines.append(("bold", " Status: "))
436
+ if is_current:
437
+ lines.append(("fg:ansigreen bold", "✓ Currently Active"))
438
+ else:
439
+ lines.append(("fg:ansibrightblack", "Not active"))
440
+ lines.append(("", "\n"))
441
+
442
+ return lines
443
+
444
+
445
+ async def interactive_agent_picker() -> Optional[str]:
446
+ """Show interactive terminal UI to select an agent.
447
+
448
+ Returns:
449
+ Agent name to switch to, or None if cancelled.
450
+ """
451
+ entries = _get_agent_entries()
452
+ current_agent = get_current_agent()
453
+ current_agent_name = current_agent.name if current_agent else ""
454
+
455
+ if not entries:
456
+ emit_info("No agents found.")
457
+ return None
458
+
459
+ # State
460
+ selected_idx = [0] # Current selection (global index)
461
+ current_page = [0] # Current page
462
+ result = [None] # Selected agent name
463
+ pending_action = [None] # 'pin', 'clone', 'delete', or None
464
+
465
+ total_pages = [max(1, (len(entries) + PAGE_SIZE - 1) // PAGE_SIZE)]
466
+
467
+ def get_current_entry() -> Optional[Tuple[str, str, str]]:
468
+ if 0 <= selected_idx[0] < len(entries):
469
+ return entries[selected_idx[0]]
470
+ return None
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
+
495
+ # Build UI
496
+ menu_control = FormattedTextControl(text="")
497
+ preview_control = FormattedTextControl(text="")
498
+
499
+ def update_display():
500
+ """Update both panels."""
501
+ menu_control.text = _render_menu_panel(
502
+ entries, current_page[0], selected_idx[0], current_agent_name
503
+ )
504
+ preview_control.text = _render_preview_panel(
505
+ get_current_entry(), current_agent_name
506
+ )
507
+
508
+ menu_window = Window(
509
+ content=menu_control, wrap_lines=False, width=Dimension(weight=35)
510
+ )
511
+ preview_window = Window(
512
+ content=preview_control, wrap_lines=False, width=Dimension(weight=65)
513
+ )
514
+
515
+ menu_frame = Frame(menu_window, width=Dimension(weight=35), title="Agents")
516
+ preview_frame = Frame(preview_window, width=Dimension(weight=65), title="Preview")
517
+
518
+ root_container = VSplit(
519
+ [
520
+ menu_frame,
521
+ preview_frame,
522
+ ]
523
+ )
524
+
525
+ # Key bindings
526
+ kb = KeyBindings()
527
+
528
+ @kb.add("up")
529
+ def _(event):
530
+ if selected_idx[0] > 0:
531
+ selected_idx[0] -= 1
532
+ # Update page if needed
533
+ current_page[0] = selected_idx[0] // PAGE_SIZE
534
+ update_display()
535
+
536
+ @kb.add("down")
537
+ def _(event):
538
+ if selected_idx[0] < len(entries) - 1:
539
+ selected_idx[0] += 1
540
+ # Update page if needed
541
+ current_page[0] = selected_idx[0] // PAGE_SIZE
542
+ update_display()
543
+
544
+ @kb.add("left")
545
+ def _(event):
546
+ if current_page[0] > 0:
547
+ current_page[0] -= 1
548
+ selected_idx[0] = current_page[0] * PAGE_SIZE
549
+ update_display()
550
+
551
+ @kb.add("right")
552
+ def _(event):
553
+ if current_page[0] < total_pages[0] - 1:
554
+ current_page[0] += 1
555
+ selected_idx[0] = current_page[0] * PAGE_SIZE
556
+ update_display()
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
+
576
+ @kb.add("enter")
577
+ def _(event):
578
+ entry = get_current_entry()
579
+ if entry:
580
+ result[0] = entry[0] # Store agent name
581
+ event.app.exit()
582
+
583
+ @kb.add("c-c")
584
+ def _(event):
585
+ result[0] = None
586
+ event.app.exit()
587
+
588
+ layout = Layout(root_container)
589
+ app = Application(
590
+ layout=layout,
591
+ key_bindings=kb,
592
+ full_screen=False,
593
+ mouse_support=False,
594
+ )
595
+
596
+ set_awaiting_user_input(True)
597
+
598
+ # Enter alternate screen buffer once for entire session
599
+ sys.stdout.write("\033[?1049h") # Enter alternate buffer
600
+ sys.stdout.write("\033[2J\033[H") # Clear and home
601
+ sys.stdout.flush()
602
+ time.sleep(0.05)
603
+
604
+ try:
605
+ while True:
606
+ pending_action[0] = None
607
+ result[0] = None
608
+ update_display()
609
+
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
651
+
652
+ finally:
653
+ # Exit alternate screen buffer once at end
654
+ sys.stdout.write("\033[?1049l") # Exit alternate buffer
655
+ sys.stdout.flush()
656
+ # Reset awaiting input flag
657
+ set_awaiting_user_input(False)
658
+
659
+ # Clear exit message
660
+ emit_info("✓ Exited agent picker")
661
+
662
+ return result[0]