code-puppy 0.0.373__py3-none-any.whl → 0.0.374__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/agent_creator_agent.py +49 -1
- code_puppy/agents/agent_helios.py +122 -0
- code_puppy/agents/agent_manager.py +26 -2
- code_puppy/agents/json_agent.py +30 -7
- code_puppy/command_line/colors_menu.py +2 -0
- code_puppy/command_line/command_handler.py +1 -0
- code_puppy/command_line/config_commands.py +3 -1
- code_puppy/command_line/uc_menu.py +890 -0
- code_puppy/config.py +29 -0
- code_puppy/messaging/messages.py +18 -0
- code_puppy/messaging/rich_renderer.py +35 -0
- code_puppy/messaging/subagent_console.py +0 -1
- code_puppy/plugins/universal_constructor/__init__.py +13 -0
- code_puppy/plugins/universal_constructor/models.py +138 -0
- code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
- code_puppy/plugins/universal_constructor/registry.py +304 -0
- code_puppy/plugins/universal_constructor/sandbox.py +584 -0
- code_puppy/tools/__init__.py +138 -1
- code_puppy/tools/universal_constructor.py +889 -0
- {code_puppy-0.0.373.dist-info → code_puppy-0.0.374.dist-info}/METADATA +1 -1
- {code_puppy-0.0.373.dist-info → code_puppy-0.0.374.dist-info}/RECORD +26 -18
- {code_puppy-0.0.373.data → code_puppy-0.0.374.data}/data/code_puppy/models.json +0 -0
- {code_puppy-0.0.373.data → code_puppy-0.0.374.data}/data/code_puppy/models_dev_api.json +0 -0
- {code_puppy-0.0.373.dist-info → code_puppy-0.0.374.dist-info}/WHEEL +0 -0
- {code_puppy-0.0.373.dist-info → code_puppy-0.0.374.dist-info}/entry_points.txt +0 -0
- {code_puppy-0.0.373.dist-info → code_puppy-0.0.374.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,890 @@
|
|
|
1
|
+
"""Universal Constructor (UC) interactive TUI menu.
|
|
2
|
+
|
|
3
|
+
Provides a split-panel interface for browsing and managing UC tools
|
|
4
|
+
with live preview of tool details and inline source code viewing.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import time
|
|
9
|
+
import unicodedata
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
|
|
13
|
+
from prompt_toolkit.application import Application
|
|
14
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
15
|
+
from prompt_toolkit.layout import Dimension, HSplit, Layout, VSplit, Window
|
|
16
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
17
|
+
from prompt_toolkit.widgets import Frame
|
|
18
|
+
|
|
19
|
+
from code_puppy.command_line.command_registry import register_command
|
|
20
|
+
from code_puppy.messaging import emit_error, emit_info, emit_success
|
|
21
|
+
from code_puppy.plugins.universal_constructor.models import UCToolInfo
|
|
22
|
+
from code_puppy.plugins.universal_constructor.registry import get_registry
|
|
23
|
+
from code_puppy.tools.command_runner import set_awaiting_user_input
|
|
24
|
+
|
|
25
|
+
PAGE_SIZE = 10 # Tools per page
|
|
26
|
+
SOURCE_PAGE_SIZE = 30 # Lines of source per page
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _sanitize_display_text(text: str) -> str:
|
|
30
|
+
"""Remove or replace characters that cause terminal rendering issues.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
text: Text that may contain emojis or wide characters
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Sanitized text safe for prompt_toolkit rendering
|
|
37
|
+
"""
|
|
38
|
+
result = []
|
|
39
|
+
for char in text:
|
|
40
|
+
cat = unicodedata.category(char)
|
|
41
|
+
safe_categories = (
|
|
42
|
+
"Lu",
|
|
43
|
+
"Ll",
|
|
44
|
+
"Lt",
|
|
45
|
+
"Lm",
|
|
46
|
+
"Lo", # Letters
|
|
47
|
+
"Nd",
|
|
48
|
+
"Nl",
|
|
49
|
+
"No", # Numbers
|
|
50
|
+
"Pc",
|
|
51
|
+
"Pd",
|
|
52
|
+
"Ps",
|
|
53
|
+
"Pe",
|
|
54
|
+
"Pi",
|
|
55
|
+
"Pf",
|
|
56
|
+
"Po", # Punctuation
|
|
57
|
+
"Zs", # Space
|
|
58
|
+
"Sm",
|
|
59
|
+
"Sc",
|
|
60
|
+
"Sk", # Safe symbols
|
|
61
|
+
)
|
|
62
|
+
if cat in safe_categories:
|
|
63
|
+
result.append(char)
|
|
64
|
+
|
|
65
|
+
cleaned = " ".join("".join(result).split())
|
|
66
|
+
return cleaned
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_tool_entries() -> List[UCToolInfo]:
|
|
70
|
+
"""Get all UC tools sorted by name.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
List of UCToolInfo sorted by full_name.
|
|
74
|
+
"""
|
|
75
|
+
registry = get_registry()
|
|
76
|
+
registry.scan() # Force fresh scan
|
|
77
|
+
return registry.list_tools(include_disabled=True)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _toggle_tool_enabled(tool: UCToolInfo) -> bool:
|
|
81
|
+
"""Toggle a tool's enabled status by modifying its source file.
|
|
82
|
+
|
|
83
|
+
Args:
|
|
84
|
+
tool: The tool to toggle.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
True if successful, False otherwise.
|
|
88
|
+
"""
|
|
89
|
+
try:
|
|
90
|
+
source_path = Path(tool.source_path)
|
|
91
|
+
content = source_path.read_text()
|
|
92
|
+
|
|
93
|
+
# Find and flip the enabled flag in TOOL_META
|
|
94
|
+
new_enabled = not tool.meta.enabled
|
|
95
|
+
|
|
96
|
+
# Try to find and replace the enabled line
|
|
97
|
+
import re
|
|
98
|
+
|
|
99
|
+
# Match 'enabled': True/False or "enabled": True/False
|
|
100
|
+
pattern = r'(["\']enabled["\']\s*:\s*)(True|False)'
|
|
101
|
+
|
|
102
|
+
def replacer(m):
|
|
103
|
+
return m.group(1) + str(new_enabled)
|
|
104
|
+
|
|
105
|
+
new_content, count = re.subn(pattern, replacer, content)
|
|
106
|
+
|
|
107
|
+
if count == 0:
|
|
108
|
+
# No explicit enabled field - add it to TOOL_META
|
|
109
|
+
# Find TOOL_META = { and add enabled after the opening brace
|
|
110
|
+
meta_pattern = r"(TOOL_META\s*=\s*\{)"
|
|
111
|
+
new_content, meta_count = re.subn(
|
|
112
|
+
meta_pattern, f'\\1\n "enabled": {new_enabled},', content
|
|
113
|
+
)
|
|
114
|
+
if meta_count == 0:
|
|
115
|
+
emit_error("TOOL_META not found; cannot toggle enabled flag.")
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
source_path.write_text(new_content)
|
|
119
|
+
|
|
120
|
+
status = "enabled" if new_enabled else "disabled"
|
|
121
|
+
emit_success(f"Tool '{tool.full_name}' is now {status}")
|
|
122
|
+
return True
|
|
123
|
+
|
|
124
|
+
except Exception as e:
|
|
125
|
+
emit_error(f"Failed to toggle tool: {e}")
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _delete_tool(tool: UCToolInfo) -> bool:
|
|
130
|
+
"""Delete a UC tool by removing its source file.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
tool: The tool to delete.
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
True if successful, False otherwise.
|
|
137
|
+
"""
|
|
138
|
+
try:
|
|
139
|
+
source_path = Path(tool.source_path)
|
|
140
|
+
if not source_path.exists():
|
|
141
|
+
emit_error(f"Tool file not found: {source_path}")
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
# Delete the file
|
|
145
|
+
source_path.unlink()
|
|
146
|
+
|
|
147
|
+
# Try to clean up empty parent directories (namespace folders)
|
|
148
|
+
parent = source_path.parent
|
|
149
|
+
from code_puppy.plugins.universal_constructor import USER_UC_DIR
|
|
150
|
+
|
|
151
|
+
while parent != USER_UC_DIR and parent.exists():
|
|
152
|
+
try:
|
|
153
|
+
if not any(parent.iterdir()):
|
|
154
|
+
parent.rmdir()
|
|
155
|
+
parent = parent.parent
|
|
156
|
+
else:
|
|
157
|
+
break
|
|
158
|
+
except OSError:
|
|
159
|
+
break
|
|
160
|
+
|
|
161
|
+
emit_success(f"Deleted tool '{tool.full_name}'")
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
except Exception as e:
|
|
165
|
+
emit_error(f"Failed to delete tool: {e}")
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _load_source_code(tool: UCToolInfo) -> Tuple[List[str], Optional[str]]:
|
|
170
|
+
"""Load source code lines from a tool's file.
|
|
171
|
+
|
|
172
|
+
Args:
|
|
173
|
+
tool: The tool to load source for.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Tuple of (lines list, error message or None)
|
|
177
|
+
"""
|
|
178
|
+
try:
|
|
179
|
+
source_path = Path(tool.source_path)
|
|
180
|
+
content = source_path.read_text()
|
|
181
|
+
return content.splitlines(), None
|
|
182
|
+
except Exception as e:
|
|
183
|
+
return [], f"Could not read source: {e}"
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _render_menu_panel(
|
|
187
|
+
tools: List[UCToolInfo],
|
|
188
|
+
page: int,
|
|
189
|
+
selected_idx: int,
|
|
190
|
+
) -> List:
|
|
191
|
+
"""Render the left menu panel with pagination.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
tools: List of UCToolInfo objects
|
|
195
|
+
page: Current page number (0-indexed)
|
|
196
|
+
selected_idx: Currently selected index (global)
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
List of (style, text) tuples for FormattedTextControl
|
|
200
|
+
"""
|
|
201
|
+
lines = []
|
|
202
|
+
total_pages = (len(tools) + PAGE_SIZE - 1) // PAGE_SIZE if tools else 1
|
|
203
|
+
start_idx = page * PAGE_SIZE
|
|
204
|
+
end_idx = min(start_idx + PAGE_SIZE, len(tools))
|
|
205
|
+
|
|
206
|
+
lines.append(("bold", "UC Tools"))
|
|
207
|
+
lines.append(("fg:ansibrightblack", f" (Page {page + 1}/{total_pages})"))
|
|
208
|
+
lines.append(("", "\n\n"))
|
|
209
|
+
|
|
210
|
+
if not tools:
|
|
211
|
+
lines.append(("fg:yellow", " No UC tools found.\n"))
|
|
212
|
+
lines.append(("fg:ansibrightblack", " Ask the LLM to create one!\n"))
|
|
213
|
+
lines.append(("", "\n"))
|
|
214
|
+
else:
|
|
215
|
+
for i in range(start_idx, end_idx):
|
|
216
|
+
tool = tools[i]
|
|
217
|
+
is_selected = i == selected_idx
|
|
218
|
+
|
|
219
|
+
safe_name = _sanitize_display_text(tool.full_name)
|
|
220
|
+
|
|
221
|
+
# Selection indicator
|
|
222
|
+
if is_selected:
|
|
223
|
+
lines.append(("fg:ansigreen", "> "))
|
|
224
|
+
lines.append(("fg:ansigreen bold", safe_name))
|
|
225
|
+
else:
|
|
226
|
+
lines.append(("", " "))
|
|
227
|
+
lines.append(("", safe_name))
|
|
228
|
+
|
|
229
|
+
# Status indicator
|
|
230
|
+
if tool.meta.enabled:
|
|
231
|
+
lines.append(("fg:ansigreen", " [on]"))
|
|
232
|
+
else:
|
|
233
|
+
lines.append(("fg:ansired", " [off]"))
|
|
234
|
+
|
|
235
|
+
# Namespace tag if present
|
|
236
|
+
if tool.meta.namespace:
|
|
237
|
+
lines.append(("fg:ansiblue", f" ({tool.meta.namespace})"))
|
|
238
|
+
|
|
239
|
+
lines.append(("", "\n"))
|
|
240
|
+
|
|
241
|
+
# Navigation hints
|
|
242
|
+
lines.append(("", "\n"))
|
|
243
|
+
lines.append(("fg:ansibrightblack", " [up]/[down] "))
|
|
244
|
+
lines.append(("", "Navigate\n"))
|
|
245
|
+
lines.append(("fg:ansibrightblack", " [left]/[right] "))
|
|
246
|
+
lines.append(("", "Page\n"))
|
|
247
|
+
lines.append(("fg:green", " Enter "))
|
|
248
|
+
lines.append(("", "View source\n"))
|
|
249
|
+
lines.append(("fg:ansiyellow", " E "))
|
|
250
|
+
lines.append(("", "Toggle enabled\n"))
|
|
251
|
+
lines.append(("fg:ansired", " D "))
|
|
252
|
+
lines.append(("", "Delete tool\n"))
|
|
253
|
+
lines.append(("fg:ansibrightblack", " Esc "))
|
|
254
|
+
lines.append(("", "Exit"))
|
|
255
|
+
|
|
256
|
+
return lines
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _render_preview_panel(tool: Optional[UCToolInfo]) -> List:
|
|
260
|
+
"""Render the right preview panel with tool details.
|
|
261
|
+
|
|
262
|
+
Args:
|
|
263
|
+
tool: UCToolInfo or None
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
List of (style, text) tuples for FormattedTextControl
|
|
267
|
+
"""
|
|
268
|
+
lines = []
|
|
269
|
+
|
|
270
|
+
lines.append(("dim cyan", " TOOL DETAILS"))
|
|
271
|
+
lines.append(("", "\n\n"))
|
|
272
|
+
|
|
273
|
+
if not tool:
|
|
274
|
+
lines.append(("fg:yellow", " No tool selected.\n"))
|
|
275
|
+
lines.append(("fg:ansibrightblack", " Create some with the LLM!\n"))
|
|
276
|
+
return lines
|
|
277
|
+
|
|
278
|
+
safe_name = _sanitize_display_text(tool.meta.name)
|
|
279
|
+
safe_desc = _sanitize_display_text(tool.meta.description)
|
|
280
|
+
|
|
281
|
+
# Tool name
|
|
282
|
+
lines.append(("bold", "Name: "))
|
|
283
|
+
lines.append(("fg:ansicyan", safe_name))
|
|
284
|
+
lines.append(("", "\n\n"))
|
|
285
|
+
|
|
286
|
+
# Full name (with namespace)
|
|
287
|
+
if tool.meta.namespace:
|
|
288
|
+
lines.append(("bold", "Full Name: "))
|
|
289
|
+
lines.append(("", tool.full_name))
|
|
290
|
+
lines.append(("", "\n\n"))
|
|
291
|
+
|
|
292
|
+
# Status
|
|
293
|
+
lines.append(("bold", "Status: "))
|
|
294
|
+
if tool.meta.enabled:
|
|
295
|
+
lines.append(("fg:ansigreen bold", "ENABLED"))
|
|
296
|
+
else:
|
|
297
|
+
lines.append(("fg:ansired bold", "DISABLED"))
|
|
298
|
+
lines.append(("", "\n\n"))
|
|
299
|
+
|
|
300
|
+
# Version
|
|
301
|
+
lines.append(("bold", "Version: "))
|
|
302
|
+
lines.append(("", tool.meta.version))
|
|
303
|
+
lines.append(("", "\n\n"))
|
|
304
|
+
|
|
305
|
+
# Author (if present)
|
|
306
|
+
if tool.meta.author:
|
|
307
|
+
lines.append(("bold", "Author: "))
|
|
308
|
+
lines.append(("", tool.meta.author))
|
|
309
|
+
lines.append(("", "\n\n"))
|
|
310
|
+
|
|
311
|
+
# Signature
|
|
312
|
+
lines.append(("bold", "Signature: "))
|
|
313
|
+
lines.append(("fg:ansiyellow", tool.signature))
|
|
314
|
+
lines.append(("", "\n\n"))
|
|
315
|
+
|
|
316
|
+
# Description (word-wrapped)
|
|
317
|
+
lines.append(("bold", "Description:"))
|
|
318
|
+
lines.append(("", "\n"))
|
|
319
|
+
|
|
320
|
+
words = safe_desc.split()
|
|
321
|
+
current_line = ""
|
|
322
|
+
for word in words:
|
|
323
|
+
if len(current_line) + len(word) + 1 > 50:
|
|
324
|
+
lines.append(("fg:ansibrightblack", f" {current_line}"))
|
|
325
|
+
lines.append(("", "\n"))
|
|
326
|
+
current_line = word
|
|
327
|
+
else:
|
|
328
|
+
current_line = word if not current_line else current_line + " " + word
|
|
329
|
+
if current_line:
|
|
330
|
+
lines.append(("fg:ansibrightblack", f" {current_line}"))
|
|
331
|
+
lines.append(("", "\n"))
|
|
332
|
+
|
|
333
|
+
lines.append(("", "\n"))
|
|
334
|
+
|
|
335
|
+
# Docstring preview (if available)
|
|
336
|
+
if tool.docstring:
|
|
337
|
+
lines.append(("bold", "Docstring:"))
|
|
338
|
+
lines.append(("", "\n"))
|
|
339
|
+
doc_preview = tool.docstring[:150]
|
|
340
|
+
if len(tool.docstring) > 150:
|
|
341
|
+
doc_preview += "..."
|
|
342
|
+
lines.append(("fg:ansibrightblack", f" {doc_preview}"))
|
|
343
|
+
lines.append(("", "\n\n"))
|
|
344
|
+
|
|
345
|
+
# Source path
|
|
346
|
+
lines.append(("bold", "Source:"))
|
|
347
|
+
lines.append(("", "\n"))
|
|
348
|
+
lines.append(("fg:ansibrightblack", f" {tool.source_path}"))
|
|
349
|
+
lines.append(("", "\n"))
|
|
350
|
+
|
|
351
|
+
return lines
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _render_source_panel(
|
|
355
|
+
tool: UCToolInfo,
|
|
356
|
+
source_lines: List[str],
|
|
357
|
+
scroll_offset: int,
|
|
358
|
+
error: Optional[str] = None,
|
|
359
|
+
) -> List:
|
|
360
|
+
"""Render source code panel with syntax highlighting.
|
|
361
|
+
|
|
362
|
+
Args:
|
|
363
|
+
tool: The tool being viewed
|
|
364
|
+
source_lines: List of source code lines
|
|
365
|
+
scroll_offset: Current scroll position (line number)
|
|
366
|
+
error: Error message if source couldn't be loaded
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
List of (style, text) tuples for FormattedTextControl
|
|
370
|
+
"""
|
|
371
|
+
lines = []
|
|
372
|
+
|
|
373
|
+
# Header
|
|
374
|
+
lines.append(("bold cyan", f" SOURCE: {tool.full_name}"))
|
|
375
|
+
lines.append(("", "\n"))
|
|
376
|
+
lines.append(("fg:ansibrightblack", f" {tool.source_path}"))
|
|
377
|
+
lines.append(("", "\n"))
|
|
378
|
+
lines.append(("fg:ansibrightblack", "─" * 70))
|
|
379
|
+
lines.append(("", "\n"))
|
|
380
|
+
|
|
381
|
+
if error:
|
|
382
|
+
lines.append(("fg:ansired", f" Error: {error}\n"))
|
|
383
|
+
return lines
|
|
384
|
+
|
|
385
|
+
if not source_lines:
|
|
386
|
+
lines.append(("fg:yellow", " (empty file)\n"))
|
|
387
|
+
return lines
|
|
388
|
+
|
|
389
|
+
# Calculate visible range
|
|
390
|
+
total_lines = len(source_lines)
|
|
391
|
+
visible_lines = SOURCE_PAGE_SIZE
|
|
392
|
+
end_offset = min(scroll_offset + visible_lines, total_lines)
|
|
393
|
+
|
|
394
|
+
# Line number width for padding
|
|
395
|
+
line_num_width = len(str(total_lines))
|
|
396
|
+
|
|
397
|
+
# Render visible source lines with basic syntax highlighting
|
|
398
|
+
for i in range(scroll_offset, end_offset):
|
|
399
|
+
line_num = i + 1
|
|
400
|
+
line_content = source_lines[i]
|
|
401
|
+
|
|
402
|
+
# Line number
|
|
403
|
+
lines.append(("fg:ansibrightblack", f" {line_num:>{line_num_width}} │ "))
|
|
404
|
+
|
|
405
|
+
# Basic syntax highlighting
|
|
406
|
+
highlighted = _highlight_python_line(line_content)
|
|
407
|
+
lines.extend(highlighted)
|
|
408
|
+
lines.append(("", "\n"))
|
|
409
|
+
|
|
410
|
+
# Footer with scroll info
|
|
411
|
+
lines.append(("fg:ansibrightblack", "─" * 70))
|
|
412
|
+
lines.append(("", "\n"))
|
|
413
|
+
|
|
414
|
+
# Scroll position indicator
|
|
415
|
+
current_page = scroll_offset // SOURCE_PAGE_SIZE + 1
|
|
416
|
+
total_pages = (total_lines + SOURCE_PAGE_SIZE - 1) // SOURCE_PAGE_SIZE
|
|
417
|
+
lines.append(
|
|
418
|
+
(
|
|
419
|
+
"fg:ansibrightblack",
|
|
420
|
+
f" Lines {scroll_offset + 1}-{end_offset} of {total_lines}",
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
lines.append(("fg:ansibrightblack", f" (Page {current_page}/{total_pages})"))
|
|
424
|
+
lines.append(("", "\n\n"))
|
|
425
|
+
|
|
426
|
+
# Navigation hints for source view
|
|
427
|
+
lines.append(("fg:ansibrightblack", " [up]/[down] "))
|
|
428
|
+
lines.append(("", "Scroll\n"))
|
|
429
|
+
lines.append(("fg:ansibrightblack", " [PgUp]/[PgDn] "))
|
|
430
|
+
lines.append(("", "Page\n"))
|
|
431
|
+
lines.append(("fg:ansiyellow", " Esc/Q "))
|
|
432
|
+
lines.append(("", "Back to list\n"))
|
|
433
|
+
lines.append(("fg:ansibrightred", " Ctrl+C "))
|
|
434
|
+
lines.append(("", "Exit"))
|
|
435
|
+
|
|
436
|
+
return lines
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _highlight_python_line(line: str) -> List[Tuple[str, str]]:
|
|
440
|
+
"""Apply basic Python syntax highlighting to a line.
|
|
441
|
+
|
|
442
|
+
Args:
|
|
443
|
+
line: A single line of Python code
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
List of (style, text) tuples
|
|
447
|
+
"""
|
|
448
|
+
result = []
|
|
449
|
+
|
|
450
|
+
# Keywords
|
|
451
|
+
keywords = {
|
|
452
|
+
"def",
|
|
453
|
+
"class",
|
|
454
|
+
"return",
|
|
455
|
+
"if",
|
|
456
|
+
"else",
|
|
457
|
+
"elif",
|
|
458
|
+
"for",
|
|
459
|
+
"while",
|
|
460
|
+
"try",
|
|
461
|
+
"except",
|
|
462
|
+
"finally",
|
|
463
|
+
"with",
|
|
464
|
+
"as",
|
|
465
|
+
"import",
|
|
466
|
+
"from",
|
|
467
|
+
"True",
|
|
468
|
+
"False",
|
|
469
|
+
"None",
|
|
470
|
+
"and",
|
|
471
|
+
"or",
|
|
472
|
+
"not",
|
|
473
|
+
"in",
|
|
474
|
+
"is",
|
|
475
|
+
"lambda",
|
|
476
|
+
"yield",
|
|
477
|
+
"raise",
|
|
478
|
+
"pass",
|
|
479
|
+
"break",
|
|
480
|
+
"continue",
|
|
481
|
+
"async",
|
|
482
|
+
"await",
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
# Simple tokenization
|
|
486
|
+
if not line.strip():
|
|
487
|
+
result.append(("", line))
|
|
488
|
+
return result
|
|
489
|
+
|
|
490
|
+
# Check for comments
|
|
491
|
+
if line.lstrip().startswith("#"):
|
|
492
|
+
result.append(("fg:ansibrightblack italic", line))
|
|
493
|
+
return result
|
|
494
|
+
|
|
495
|
+
# Check for strings (simplified)
|
|
496
|
+
stripped = line.lstrip()
|
|
497
|
+
if stripped.startswith('"""') or stripped.startswith("'''"):
|
|
498
|
+
result.append(("fg:ansigreen", line))
|
|
499
|
+
return result
|
|
500
|
+
|
|
501
|
+
# Word-by-word highlighting
|
|
502
|
+
import re
|
|
503
|
+
|
|
504
|
+
tokens = re.split(r"(\s+|[()\[\]{}:,=.])", line)
|
|
505
|
+
|
|
506
|
+
in_string = False
|
|
507
|
+
string_char = None
|
|
508
|
+
|
|
509
|
+
for token in tokens:
|
|
510
|
+
if not token:
|
|
511
|
+
continue
|
|
512
|
+
|
|
513
|
+
# Track string state
|
|
514
|
+
if not in_string and (token.startswith('"') or token.startswith("'")):
|
|
515
|
+
in_string = True
|
|
516
|
+
string_char = token[0]
|
|
517
|
+
result.append(("fg:ansigreen", token))
|
|
518
|
+
if (
|
|
519
|
+
len(token) > 1
|
|
520
|
+
and token.endswith(string_char)
|
|
521
|
+
and not token.endswith("\\" + string_char)
|
|
522
|
+
):
|
|
523
|
+
in_string = False
|
|
524
|
+
continue
|
|
525
|
+
|
|
526
|
+
if in_string:
|
|
527
|
+
result.append(("fg:ansigreen", token))
|
|
528
|
+
if token.endswith(string_char) and not token.endswith("\\" + string_char):
|
|
529
|
+
in_string = False
|
|
530
|
+
continue
|
|
531
|
+
|
|
532
|
+
# Keywords
|
|
533
|
+
if token in keywords:
|
|
534
|
+
result.append(("fg:ansimagenta bold", token))
|
|
535
|
+
# Numbers
|
|
536
|
+
elif token.isdigit():
|
|
537
|
+
result.append(("fg:ansicyan", token))
|
|
538
|
+
# Function/class names (after def/class)
|
|
539
|
+
elif result and len(result) >= 1:
|
|
540
|
+
prev_text = result[-1][1].strip() if result[-1][1] else ""
|
|
541
|
+
if prev_text in ("def", "class"):
|
|
542
|
+
result.append(("fg:ansiyellow bold", token))
|
|
543
|
+
else:
|
|
544
|
+
result.append(("", token))
|
|
545
|
+
else:
|
|
546
|
+
result.append(("", token))
|
|
547
|
+
|
|
548
|
+
return result
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _show_source_code(tool: UCToolInfo) -> None:
|
|
552
|
+
"""Display the full source code of a tool (legacy, for external use).
|
|
553
|
+
|
|
554
|
+
Args:
|
|
555
|
+
tool: The tool to show source for.
|
|
556
|
+
"""
|
|
557
|
+
from rich.panel import Panel
|
|
558
|
+
from rich.syntax import Syntax
|
|
559
|
+
|
|
560
|
+
try:
|
|
561
|
+
source_code = Path(tool.source_path).read_text()
|
|
562
|
+
syntax = Syntax(
|
|
563
|
+
source_code,
|
|
564
|
+
"python",
|
|
565
|
+
theme="monokai",
|
|
566
|
+
line_numbers=True,
|
|
567
|
+
word_wrap=True,
|
|
568
|
+
)
|
|
569
|
+
panel = Panel(
|
|
570
|
+
syntax,
|
|
571
|
+
title=f"[bold cyan]{tool.full_name}[/bold cyan]",
|
|
572
|
+
border_style="cyan",
|
|
573
|
+
padding=(0, 1),
|
|
574
|
+
)
|
|
575
|
+
emit_info(panel)
|
|
576
|
+
except Exception as e:
|
|
577
|
+
emit_error(f"Could not read source: {e}")
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
async def interactive_uc_picker() -> Optional[str]:
|
|
581
|
+
"""Show interactive TUI to browse UC tools.
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
Tool name that was selected for viewing, or None if cancelled.
|
|
585
|
+
"""
|
|
586
|
+
tools = _get_tool_entries()
|
|
587
|
+
|
|
588
|
+
# State
|
|
589
|
+
selected_idx = [0]
|
|
590
|
+
current_page = [0]
|
|
591
|
+
result = [None] # Tool name to view
|
|
592
|
+
pending_action = [None] # 'toggle', 'view', or None
|
|
593
|
+
view_mode = ["list"] # 'list' or 'source'
|
|
594
|
+
source_scroll = [0] # Scroll offset in source view
|
|
595
|
+
source_lines = [[]] # Cached source lines
|
|
596
|
+
source_error = [None] # Error loading source
|
|
597
|
+
|
|
598
|
+
total_pages = [max(1, (len(tools) + PAGE_SIZE - 1) // PAGE_SIZE)]
|
|
599
|
+
|
|
600
|
+
def get_current_tool() -> Optional[UCToolInfo]:
|
|
601
|
+
if 0 <= selected_idx[0] < len(tools):
|
|
602
|
+
return tools[selected_idx[0]]
|
|
603
|
+
return None
|
|
604
|
+
|
|
605
|
+
def refresh_tools(selected_name: Optional[str] = None) -> None:
|
|
606
|
+
nonlocal tools
|
|
607
|
+
tools = _get_tool_entries()
|
|
608
|
+
total_pages[0] = max(1, (len(tools) + PAGE_SIZE - 1) // PAGE_SIZE)
|
|
609
|
+
|
|
610
|
+
if not tools:
|
|
611
|
+
selected_idx[0] = 0
|
|
612
|
+
current_page[0] = 0
|
|
613
|
+
return
|
|
614
|
+
|
|
615
|
+
if selected_name:
|
|
616
|
+
for idx, t in enumerate(tools):
|
|
617
|
+
if t.full_name == selected_name:
|
|
618
|
+
selected_idx[0] = idx
|
|
619
|
+
break
|
|
620
|
+
else:
|
|
621
|
+
selected_idx[0] = min(selected_idx[0], len(tools) - 1)
|
|
622
|
+
else:
|
|
623
|
+
selected_idx[0] = min(selected_idx[0], len(tools) - 1)
|
|
624
|
+
|
|
625
|
+
current_page[0] = selected_idx[0] // PAGE_SIZE
|
|
626
|
+
|
|
627
|
+
# Build UI controls
|
|
628
|
+
menu_control = FormattedTextControl(text="")
|
|
629
|
+
preview_control = FormattedTextControl(text="")
|
|
630
|
+
source_control = FormattedTextControl(text="")
|
|
631
|
+
|
|
632
|
+
def update_list_display():
|
|
633
|
+
"""Update the list view panels."""
|
|
634
|
+
menu_control.text = _render_menu_panel(tools, current_page[0], selected_idx[0])
|
|
635
|
+
preview_control.text = _render_preview_panel(get_current_tool())
|
|
636
|
+
|
|
637
|
+
def update_source_display():
|
|
638
|
+
"""Update the source view panel."""
|
|
639
|
+
tool = get_current_tool()
|
|
640
|
+
if tool:
|
|
641
|
+
source_control.text = _render_source_panel(
|
|
642
|
+
tool, source_lines[0], source_scroll[0], source_error[0]
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
# Windows for list view
|
|
646
|
+
menu_window = Window(
|
|
647
|
+
content=menu_control, wrap_lines=False, width=Dimension(weight=40)
|
|
648
|
+
)
|
|
649
|
+
preview_window = Window(
|
|
650
|
+
content=preview_control, wrap_lines=False, width=Dimension(weight=60)
|
|
651
|
+
)
|
|
652
|
+
|
|
653
|
+
# Window for source view (full width)
|
|
654
|
+
source_window = Window(
|
|
655
|
+
content=source_control, wrap_lines=True, width=Dimension(weight=100)
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
# Frames
|
|
659
|
+
menu_frame = Frame(menu_window, width=Dimension(weight=40), title="UC Tools")
|
|
660
|
+
preview_frame = Frame(preview_window, width=Dimension(weight=60), title="Preview")
|
|
661
|
+
source_frame = Frame(
|
|
662
|
+
source_window, width=Dimension(weight=100), title="Source Code"
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
# Containers
|
|
666
|
+
list_container = VSplit([menu_frame, preview_frame])
|
|
667
|
+
source_container = HSplit([source_frame])
|
|
668
|
+
|
|
669
|
+
# Key bindings for LIST mode
|
|
670
|
+
list_kb = KeyBindings()
|
|
671
|
+
|
|
672
|
+
@list_kb.add("up")
|
|
673
|
+
def _list_up(event):
|
|
674
|
+
if selected_idx[0] > 0:
|
|
675
|
+
selected_idx[0] -= 1
|
|
676
|
+
current_page[0] = selected_idx[0] // PAGE_SIZE
|
|
677
|
+
update_list_display()
|
|
678
|
+
|
|
679
|
+
@list_kb.add("down")
|
|
680
|
+
def _list_down(event):
|
|
681
|
+
if selected_idx[0] < len(tools) - 1:
|
|
682
|
+
selected_idx[0] += 1
|
|
683
|
+
current_page[0] = selected_idx[0] // PAGE_SIZE
|
|
684
|
+
update_list_display()
|
|
685
|
+
|
|
686
|
+
@list_kb.add("left")
|
|
687
|
+
def _list_left(event):
|
|
688
|
+
if current_page[0] > 0:
|
|
689
|
+
current_page[0] -= 1
|
|
690
|
+
selected_idx[0] = current_page[0] * PAGE_SIZE
|
|
691
|
+
update_list_display()
|
|
692
|
+
|
|
693
|
+
@list_kb.add("right")
|
|
694
|
+
def _list_right(event):
|
|
695
|
+
if current_page[0] < total_pages[0] - 1:
|
|
696
|
+
current_page[0] += 1
|
|
697
|
+
selected_idx[0] = current_page[0] * PAGE_SIZE
|
|
698
|
+
update_list_display()
|
|
699
|
+
|
|
700
|
+
@list_kb.add("e")
|
|
701
|
+
def _list_toggle(event):
|
|
702
|
+
if get_current_tool():
|
|
703
|
+
pending_action[0] = "toggle"
|
|
704
|
+
event.app.exit()
|
|
705
|
+
|
|
706
|
+
@list_kb.add("d")
|
|
707
|
+
def _list_delete(event):
|
|
708
|
+
if get_current_tool():
|
|
709
|
+
pending_action[0] = "delete"
|
|
710
|
+
event.app.exit()
|
|
711
|
+
|
|
712
|
+
@list_kb.add("escape")
|
|
713
|
+
def _list_escape(event):
|
|
714
|
+
result[0] = None
|
|
715
|
+
pending_action[0] = "exit"
|
|
716
|
+
event.app.exit()
|
|
717
|
+
|
|
718
|
+
@list_kb.add("enter")
|
|
719
|
+
def _list_enter(event):
|
|
720
|
+
tool = get_current_tool()
|
|
721
|
+
if tool:
|
|
722
|
+
# Switch to source view
|
|
723
|
+
view_mode[0] = "source"
|
|
724
|
+
source_scroll[0] = 0
|
|
725
|
+
source_lines[0], source_error[0] = _load_source_code(tool)
|
|
726
|
+
pending_action[0] = "switch_to_source"
|
|
727
|
+
event.app.exit()
|
|
728
|
+
|
|
729
|
+
@list_kb.add("c-c")
|
|
730
|
+
def _list_exit(event):
|
|
731
|
+
result[0] = None
|
|
732
|
+
pending_action[0] = "exit"
|
|
733
|
+
event.app.exit()
|
|
734
|
+
|
|
735
|
+
# Key bindings for SOURCE mode
|
|
736
|
+
source_kb = KeyBindings()
|
|
737
|
+
|
|
738
|
+
@source_kb.add("up")
|
|
739
|
+
def _source_up(event):
|
|
740
|
+
if source_scroll[0] > 0:
|
|
741
|
+
source_scroll[0] -= 1
|
|
742
|
+
update_source_display()
|
|
743
|
+
|
|
744
|
+
@source_kb.add("down")
|
|
745
|
+
def _source_down(event):
|
|
746
|
+
max_scroll = max(0, len(source_lines[0]) - SOURCE_PAGE_SIZE)
|
|
747
|
+
if source_scroll[0] < max_scroll:
|
|
748
|
+
source_scroll[0] += 1
|
|
749
|
+
update_source_display()
|
|
750
|
+
|
|
751
|
+
@source_kb.add("pageup")
|
|
752
|
+
def _source_pageup(event):
|
|
753
|
+
source_scroll[0] = max(0, source_scroll[0] - SOURCE_PAGE_SIZE)
|
|
754
|
+
update_source_display()
|
|
755
|
+
|
|
756
|
+
@source_kb.add("pagedown")
|
|
757
|
+
def _source_pagedown(event):
|
|
758
|
+
max_scroll = max(0, len(source_lines[0]) - SOURCE_PAGE_SIZE)
|
|
759
|
+
source_scroll[0] = min(max_scroll, source_scroll[0] + SOURCE_PAGE_SIZE)
|
|
760
|
+
update_source_display()
|
|
761
|
+
|
|
762
|
+
@source_kb.add("escape")
|
|
763
|
+
def _source_escape(event):
|
|
764
|
+
view_mode[0] = "list"
|
|
765
|
+
pending_action[0] = "switch_to_list"
|
|
766
|
+
event.app.exit()
|
|
767
|
+
|
|
768
|
+
@source_kb.add("q")
|
|
769
|
+
def _source_q(event):
|
|
770
|
+
view_mode[0] = "list"
|
|
771
|
+
pending_action[0] = "switch_to_list"
|
|
772
|
+
event.app.exit()
|
|
773
|
+
|
|
774
|
+
@source_kb.add("c-c")
|
|
775
|
+
def _source_exit(event):
|
|
776
|
+
result[0] = None
|
|
777
|
+
pending_action[0] = "exit"
|
|
778
|
+
event.app.exit()
|
|
779
|
+
|
|
780
|
+
set_awaiting_user_input(True)
|
|
781
|
+
|
|
782
|
+
# Enter alternate screen buffer
|
|
783
|
+
sys.stdout.write("\033[?1049h")
|
|
784
|
+
sys.stdout.write("\033[2J\033[H")
|
|
785
|
+
sys.stdout.flush()
|
|
786
|
+
time.sleep(0.05)
|
|
787
|
+
|
|
788
|
+
try:
|
|
789
|
+
while True:
|
|
790
|
+
# Clear screen
|
|
791
|
+
sys.stdout.write("\033[2J\033[H")
|
|
792
|
+
sys.stdout.flush()
|
|
793
|
+
|
|
794
|
+
if view_mode[0] == "list":
|
|
795
|
+
# List view
|
|
796
|
+
update_list_display()
|
|
797
|
+
layout = Layout(list_container)
|
|
798
|
+
app = Application(
|
|
799
|
+
layout=layout,
|
|
800
|
+
key_bindings=list_kb,
|
|
801
|
+
full_screen=False,
|
|
802
|
+
mouse_support=False,
|
|
803
|
+
)
|
|
804
|
+
else:
|
|
805
|
+
# Source view
|
|
806
|
+
update_source_display()
|
|
807
|
+
layout = Layout(source_container)
|
|
808
|
+
app = Application(
|
|
809
|
+
layout=layout,
|
|
810
|
+
key_bindings=source_kb,
|
|
811
|
+
full_screen=False,
|
|
812
|
+
mouse_support=False,
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
await app.run_async()
|
|
816
|
+
|
|
817
|
+
# Handle actions
|
|
818
|
+
if pending_action[0] == "toggle":
|
|
819
|
+
tool = get_current_tool()
|
|
820
|
+
if tool:
|
|
821
|
+
selected_name = tool.full_name
|
|
822
|
+
_toggle_tool_enabled(tool)
|
|
823
|
+
refresh_tools(selected_name=selected_name)
|
|
824
|
+
pending_action[0] = None
|
|
825
|
+
continue
|
|
826
|
+
|
|
827
|
+
if pending_action[0] == "delete":
|
|
828
|
+
tool = get_current_tool()
|
|
829
|
+
if tool:
|
|
830
|
+
_delete_tool(tool)
|
|
831
|
+
refresh_tools() # Don't try to keep selection on deleted tool
|
|
832
|
+
pending_action[0] = None
|
|
833
|
+
continue
|
|
834
|
+
|
|
835
|
+
if pending_action[0] == "switch_to_source":
|
|
836
|
+
pending_action[0] = None
|
|
837
|
+
continue
|
|
838
|
+
|
|
839
|
+
if pending_action[0] == "switch_to_list":
|
|
840
|
+
pending_action[0] = None
|
|
841
|
+
continue
|
|
842
|
+
|
|
843
|
+
if pending_action[0] == "exit":
|
|
844
|
+
break
|
|
845
|
+
|
|
846
|
+
# Default: exit
|
|
847
|
+
break
|
|
848
|
+
|
|
849
|
+
finally:
|
|
850
|
+
# Exit alternate screen buffer
|
|
851
|
+
sys.stdout.write("\033[?1049l")
|
|
852
|
+
sys.stdout.flush()
|
|
853
|
+
set_awaiting_user_input(False)
|
|
854
|
+
|
|
855
|
+
emit_info("Exited UC tool browser")
|
|
856
|
+
return result[0]
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
@register_command(
|
|
860
|
+
name="uc",
|
|
861
|
+
description="Universal Constructor - browse and manage custom tools",
|
|
862
|
+
usage="/uc",
|
|
863
|
+
category="tools",
|
|
864
|
+
)
|
|
865
|
+
def handle_uc_command(command: str) -> bool:
|
|
866
|
+
"""Handle the /uc command - opens the interactive TUI.
|
|
867
|
+
|
|
868
|
+
Args:
|
|
869
|
+
command: The full command string.
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
True always (command completed).
|
|
873
|
+
"""
|
|
874
|
+
import asyncio
|
|
875
|
+
|
|
876
|
+
try:
|
|
877
|
+
loop = asyncio.get_event_loop()
|
|
878
|
+
if loop.is_running():
|
|
879
|
+
# We're already in an async context - create a task
|
|
880
|
+
import concurrent.futures
|
|
881
|
+
|
|
882
|
+
with concurrent.futures.ThreadPoolExecutor() as pool:
|
|
883
|
+
future = pool.submit(asyncio.run, interactive_uc_picker())
|
|
884
|
+
future.result()
|
|
885
|
+
else:
|
|
886
|
+
asyncio.run(interactive_uc_picker())
|
|
887
|
+
except Exception as e:
|
|
888
|
+
emit_error(f"Failed to open UC menu: {e}")
|
|
889
|
+
|
|
890
|
+
return True
|