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.
Files changed (26) hide show
  1. code_puppy/agents/agent_creator_agent.py +49 -1
  2. code_puppy/agents/agent_helios.py +122 -0
  3. code_puppy/agents/agent_manager.py +26 -2
  4. code_puppy/agents/json_agent.py +30 -7
  5. code_puppy/command_line/colors_menu.py +2 -0
  6. code_puppy/command_line/command_handler.py +1 -0
  7. code_puppy/command_line/config_commands.py +3 -1
  8. code_puppy/command_line/uc_menu.py +890 -0
  9. code_puppy/config.py +29 -0
  10. code_puppy/messaging/messages.py +18 -0
  11. code_puppy/messaging/rich_renderer.py +35 -0
  12. code_puppy/messaging/subagent_console.py +0 -1
  13. code_puppy/plugins/universal_constructor/__init__.py +13 -0
  14. code_puppy/plugins/universal_constructor/models.py +138 -0
  15. code_puppy/plugins/universal_constructor/register_callbacks.py +47 -0
  16. code_puppy/plugins/universal_constructor/registry.py +304 -0
  17. code_puppy/plugins/universal_constructor/sandbox.py +584 -0
  18. code_puppy/tools/__init__.py +138 -1
  19. code_puppy/tools/universal_constructor.py +889 -0
  20. {code_puppy-0.0.373.dist-info → code_puppy-0.0.374.dist-info}/METADATA +1 -1
  21. {code_puppy-0.0.373.dist-info → code_puppy-0.0.374.dist-info}/RECORD +26 -18
  22. {code_puppy-0.0.373.data → code_puppy-0.0.374.data}/data/code_puppy/models.json +0 -0
  23. {code_puppy-0.0.373.data → code_puppy-0.0.374.data}/data/code_puppy/models_dev_api.json +0 -0
  24. {code_puppy-0.0.373.dist-info → code_puppy-0.0.374.dist-info}/WHEEL +0 -0
  25. {code_puppy-0.0.373.dist-info → code_puppy-0.0.374.dist-info}/entry_points.txt +0 -0
  26. {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