code-puppy 0.0.348__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.
- code_puppy/agents/__init__.py +8 -0
- code_puppy/agents/agent_manager.py +272 -1
- code_puppy/agents/agent_pack_leader.py +383 -0
- code_puppy/agents/agent_qa_kitten.py +12 -7
- code_puppy/agents/agent_terminal_qa.py +323 -0
- code_puppy/agents/base_agent.py +11 -8
- code_puppy/agents/event_stream_handler.py +101 -8
- code_puppy/agents/pack/__init__.py +34 -0
- code_puppy/agents/pack/bloodhound.py +304 -0
- code_puppy/agents/pack/husky.py +321 -0
- code_puppy/agents/pack/retriever.py +393 -0
- code_puppy/agents/pack/shepherd.py +348 -0
- code_puppy/agents/pack/terrier.py +287 -0
- code_puppy/agents/pack/watchdog.py +367 -0
- code_puppy/agents/subagent_stream_handler.py +276 -0
- code_puppy/api/__init__.py +13 -0
- code_puppy/api/app.py +169 -0
- code_puppy/api/main.py +21 -0
- code_puppy/api/pty_manager.py +446 -0
- code_puppy/api/routers/__init__.py +12 -0
- code_puppy/api/routers/agents.py +36 -0
- code_puppy/api/routers/commands.py +217 -0
- code_puppy/api/routers/config.py +74 -0
- code_puppy/api/routers/sessions.py +232 -0
- code_puppy/api/templates/terminal.html +361 -0
- code_puppy/api/websocket.py +154 -0
- code_puppy/callbacks.py +73 -0
- code_puppy/chatgpt_codex_client.py +53 -0
- code_puppy/claude_cache_client.py +294 -41
- code_puppy/command_line/add_model_menu.py +13 -4
- code_puppy/command_line/agent_menu.py +662 -0
- code_puppy/command_line/core_commands.py +89 -112
- code_puppy/command_line/model_picker_completion.py +3 -20
- code_puppy/command_line/model_settings_menu.py +21 -3
- code_puppy/config.py +145 -70
- code_puppy/gemini_model.py +706 -0
- code_puppy/http_utils.py +6 -3
- code_puppy/messaging/__init__.py +15 -0
- code_puppy/messaging/messages.py +27 -0
- code_puppy/messaging/queue_console.py +1 -1
- code_puppy/messaging/rich_renderer.py +36 -1
- code_puppy/messaging/spinner/__init__.py +20 -2
- code_puppy/messaging/subagent_console.py +461 -0
- code_puppy/model_factory.py +50 -16
- code_puppy/model_switching.py +63 -0
- code_puppy/model_utils.py +27 -24
- code_puppy/models.json +12 -12
- code_puppy/plugins/antigravity_oauth/antigravity_model.py +206 -172
- code_puppy/plugins/antigravity_oauth/register_callbacks.py +15 -8
- code_puppy/plugins/antigravity_oauth/transport.py +236 -45
- code_puppy/plugins/chatgpt_oauth/register_callbacks.py +2 -2
- code_puppy/plugins/claude_code_oauth/register_callbacks.py +2 -30
- code_puppy/plugins/claude_code_oauth/utils.py +4 -1
- code_puppy/plugins/frontend_emitter/__init__.py +25 -0
- code_puppy/plugins/frontend_emitter/emitter.py +121 -0
- code_puppy/plugins/frontend_emitter/register_callbacks.py +261 -0
- code_puppy/prompts/antigravity_system_prompt.md +1 -0
- code_puppy/pydantic_patches.py +52 -0
- code_puppy/status_display.py +6 -2
- code_puppy/tools/__init__.py +37 -1
- code_puppy/tools/agent_tools.py +83 -33
- code_puppy/tools/browser/__init__.py +37 -0
- code_puppy/tools/browser/browser_control.py +6 -6
- code_puppy/tools/browser/browser_interactions.py +21 -20
- code_puppy/tools/browser/browser_locators.py +9 -9
- code_puppy/tools/browser/browser_manager.py +316 -0
- code_puppy/tools/browser/browser_navigation.py +7 -7
- code_puppy/tools/browser/browser_screenshot.py +78 -140
- code_puppy/tools/browser/browser_scripts.py +15 -13
- code_puppy/tools/browser/chromium_terminal_manager.py +259 -0
- code_puppy/tools/browser/terminal_command_tools.py +521 -0
- code_puppy/tools/browser/terminal_screenshot_tools.py +556 -0
- code_puppy/tools/browser/terminal_tools.py +525 -0
- code_puppy/tools/command_runner.py +292 -101
- code_puppy/tools/common.py +176 -1
- code_puppy/tools/display.py +84 -0
- code_puppy/tools/subagent_context.py +158 -0
- {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models.json +12 -12
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/METADATA +17 -16
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/RECORD +84 -51
- code_puppy/prompts/codex_system_prompt.md +0 -310
- code_puppy/tools/browser/camoufox_manager.py +0 -235
- code_puppy/tools/browser/vqa_agent.py +0 -90
- {code_puppy-0.0.348.data → code_puppy-0.0.372.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.348.dist-info → code_puppy-0.0.372.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.348.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]
|