orchestrated-discussions/.venv/lib/python3.12/site-packages/cmdforge/ui_registry.py

497 lines
15 KiB
Python

"""TUI for browsing the CmdForge Registry using urwid.
Uses threading for non-blocking network operations.
"""
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, List, Dict, Any, Callable
import urwid
from .registry_client import (
RegistryClient, RegistryError, ToolInfo,
get_client, PaginatedResponse
)
from .resolver import install_from_registry
# Color palette - matching the main UI style
PALETTE = [
('body', 'white', 'dark blue'),
('header', 'white', 'dark red', 'bold'),
('footer', 'black', 'light gray'),
('button', 'black', 'light gray'),
('button_focus', 'white', 'dark red', 'bold'),
('edit', 'black', 'light gray'),
('edit_focus', 'black', 'yellow'),
('listbox', 'black', 'light gray'),
('listbox_focus', 'white', 'dark red'),
('dialog', 'black', 'light gray'),
('label', 'yellow', 'dark blue', 'bold'),
('error', 'white', 'dark red', 'bold'),
('success', 'light green', 'dark blue', 'bold'),
('info', 'light cyan', 'dark blue'),
('downloads', 'light green', 'light gray'),
('category', 'dark cyan', 'light gray'),
('version', 'brown', 'light gray'),
('loading', 'yellow', 'dark blue'),
]
class ToolListItem(urwid.WidgetWrap):
"""A selectable tool item in the browse list."""
def __init__(self, tool_data: Dict[str, Any], on_select=None, on_install=None):
self.tool_data = tool_data
self.on_select = on_select
self.on_install = on_install
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
version = tool_data.get("version", "")
description = tool_data.get("description", "")[:50]
downloads = tool_data.get("downloads", 0)
category = tool_data.get("category", "")
# Format: owner/name v1.0.0 [category] ↓123
main_line = urwid.Text([
('listbox', f" {owner}/"),
('listbox', f"{name} "),
('version', f"v{version}"),
])
desc_line = urwid.Text([
('listbox', f" {description}{'...' if len(tool_data.get('description', '')) > 50 else ''}"),
])
meta_line = urwid.Text([
('category', f" [{category}]" if category else ""),
('downloads', f"{downloads}"),
])
pile = urwid.Pile([main_line, desc_line, meta_line])
self.attr_map = urwid.AttrMap(pile, 'listbox', 'listbox_focus')
super().__init__(self.attr_map)
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter' and self.on_select:
self.on_select(self.tool_data)
return None
if key == 'i' and self.on_install:
self.on_install(self.tool_data)
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if event == 'mouse press' and button == 1 and self.on_select:
self.on_select(self.tool_data)
return True
return False
class SearchEdit(urwid.Edit):
"""Search box that triggers callback on enter."""
def __init__(self, on_search=None):
self.on_search = on_search
super().__init__(caption="Search: ", edit_text="")
def keypress(self, size, key):
if key == 'enter' and self.on_search:
self.on_search(self.edit_text)
return None
return super().keypress(size, key)
class AsyncOperation:
"""Manages async operations with UI callbacks."""
def __init__(self, executor: ThreadPoolExecutor):
self.executor = executor
self._write_fd: Optional[int] = None
self._read_fd: Optional[int] = None
self._pending_callbacks: List[Callable] = []
self._lock = threading.Lock()
def setup_pipe(self, loop: urwid.MainLoop):
"""Setup a pipe for thread-safe UI updates."""
self._read_fd, self._write_fd = os.pipe()
loop.watch_file(self._read_fd, self._handle_callback)
def cleanup(self):
"""Cleanup pipe file descriptors."""
if self._read_fd is not None:
os.close(self._read_fd)
if self._write_fd is not None:
os.close(self._write_fd)
def _handle_callback(self):
"""Handle pending callbacks from worker threads."""
# Read and discard the notification byte
os.read(self._read_fd, 1)
# Process pending callbacks
with self._lock:
callbacks = self._pending_callbacks[:]
self._pending_callbacks.clear()
for callback in callbacks:
callback()
def _schedule_callback(self, callback: Callable):
"""Schedule a callback to run on the main thread."""
with self._lock:
self._pending_callbacks.append(callback)
# Wake up the main loop
if self._write_fd is not None:
os.write(self._write_fd, b'x')
def run_async(
self,
operation: Callable,
on_success: Callable[[Any], None],
on_error: Callable[[Exception], None]
):
"""Run an operation asynchronously."""
def worker():
try:
result = operation()
self._schedule_callback(lambda: on_success(result))
except Exception as e:
self._schedule_callback(lambda: on_error(e))
self.executor.submit(worker)
class RegistryBrowser:
"""TUI browser for the CmdForge Registry."""
def __init__(self):
self.client = get_client()
self.tools: List[Dict] = []
self.categories: List[Dict] = []
self.current_category: Optional[str] = None
self.current_query: str = ""
self.current_page: int = 1
self.total_pages: int = 1
self.status_message: str = ""
self.loop: Optional[urwid.MainLoop] = None
self.loading: bool = False
# Thread pool for async operations
self.executor = ThreadPoolExecutor(max_workers=2)
self.async_ops = AsyncOperation(self.executor)
# Build UI
self._build_ui()
def _build_ui(self):
"""Build the main UI layout."""
# Header
self.header = urwid.AttrMap(
urwid.Text(" CmdForge Registry Browser ", align='center'),
'header'
)
# Search box
self.search_edit = SearchEdit(on_search=self._do_search)
search_widget = urwid.AttrMap(self.search_edit, 'edit', 'edit_focus')
# Category selector
self.category_text = urwid.Text("Category: All")
category_widget = urwid.AttrMap(self.category_text, 'info')
# Top bar with search and category
top_bar = urwid.Columns([
('weight', 2, search_widget),
('weight', 1, category_widget),
], dividechars=2)
# Tools list
self.list_walker = urwid.SimpleFocusListWalker([])
self.listbox = urwid.ListBox(self.list_walker)
list_frame = urwid.LineBox(self.listbox, title="Tools")
# Detail panel (right side)
self.detail_text = urwid.Text("Select a tool to view details\n\nPress 'i' to install")
self.detail_box = urwid.LineBox(
urwid.Filler(self.detail_text, valign='top'),
title="Details"
)
# Main content area with list and details
self.main_columns = urwid.Columns([
('weight', 2, list_frame),
('weight', 1, self.detail_box),
], dividechars=1)
# Status bar
self.status_text = urwid.Text(" Loading...")
self.footer = urwid.AttrMap(
urwid.Columns([
self.status_text,
urwid.Text("↑↓:Navigate Enter:Details i:Install /:Search c:Category q:Quit", align='right'),
]),
'footer'
)
# Main frame
body = urwid.Pile([
('pack', urwid.AttrMap(top_bar, 'body')),
('pack', urwid.Divider()),
self.main_columns,
])
self.frame = urwid.Frame(
urwid.AttrMap(body, 'body'),
header=self.header,
footer=self.footer
)
def _load_tools(self, query: str = "", category: str = None, page: int = 1):
"""Load tools from the registry asynchronously."""
if self.loading:
return
self.loading = True
self._set_status("Loading...", loading=True)
def fetch():
if query:
return self.client.search_tools(
query=query,
category=category,
page=page,
per_page=20
)
else:
return self.client.list_tools(
category=category,
page=page,
per_page=20
)
def on_success(result: PaginatedResponse):
self.loading = False
self.tools = result.data
self.current_page = result.page
self.total_pages = result.total_pages
self._update_list()
self._set_status(f"Found {result.total} tools (page {result.page}/{result.total_pages})")
def on_error(e: Exception):
self.loading = False
if isinstance(e, RegistryError):
self._set_status(f"Error: {e.message}")
else:
self._set_status(f"Error: {e}")
self.async_ops.run_async(fetch, on_success, on_error)
def _load_categories(self):
"""Load categories from the registry asynchronously."""
def fetch():
return self.client.get_categories()
def on_success(categories):
self.categories = categories
def on_error(e):
self.categories = []
self.async_ops.run_async(fetch, on_success, on_error)
def _update_list(self):
"""Update the tool list display."""
self.list_walker.clear()
if not self.tools:
self.list_walker.append(urwid.Text(" No tools found"))
return
for tool in self.tools:
item = ToolListItem(
tool,
on_select=self._show_detail,
on_install=self._install_tool
)
self.list_walker.append(item)
self.list_walker.append(urwid.Divider(''))
def _show_detail(self, tool_data: Dict):
"""Show tool details in the detail panel."""
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
version = tool_data.get("version", "")
description = tool_data.get("description", "No description")
category = tool_data.get("category", "")
tags = tool_data.get("tags", [])
downloads = tool_data.get("downloads", 0)
detail = f"""{owner}/{name}
Version: {version}
Category: {category}
Downloads: {downloads}
{description}
Tags: {', '.join(tags) if tags else 'None'}
Install command:
cmdforge registry install {owner}/{name}
Press 'i' to install this tool
"""
self.detail_text.set_text(detail)
def _install_tool(self, tool_data: Dict):
"""Install the selected tool asynchronously."""
if self.loading:
return
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
self.loading = True
self._set_status(f"Installing {owner}/{name}...", loading=True)
def install():
return install_from_registry(f"{owner}/{name}")
def on_success(resolved):
self.loading = False
self._set_status(f"Installed: {resolved.full_name}@{resolved.version}")
def on_error(e):
self.loading = False
self._set_status(f"Install failed: {e}")
self.async_ops.run_async(install, on_success, on_error)
def _do_search(self, query: str):
"""Perform search."""
self.current_query = query
self.current_page = 1
self._load_tools(query=query, category=self.current_category)
def _cycle_category(self):
"""Cycle through categories."""
if not self.categories:
self._load_categories()
# Schedule the cycle after categories load
return
if not self.categories:
return
cat_names = [None] + [c.get("name") for c in self.categories]
try:
idx = cat_names.index(self.current_category)
idx = (idx + 1) % len(cat_names)
except ValueError:
idx = 0
self.current_category = cat_names[idx]
cat_display = self.current_category or "All"
self.category_text.set_text(f"Category: {cat_display}")
self._load_tools(query=self.current_query, category=self.current_category)
def _next_page(self):
"""Go to next page."""
if self.current_page < self.total_pages:
self.current_page += 1
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
def _prev_page(self):
"""Go to previous page."""
if self.current_page > 1:
self.current_page -= 1
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
def _set_status(self, message: str, loading: bool = False):
"""Update status bar message."""
if loading:
self.status_text.set_text(('loading', f"{message}"))
else:
self.status_text.set_text(f" {message}")
def _handle_input(self, key):
"""Handle global key input."""
if key in ('q', 'Q'):
raise urwid.ExitMainLoop()
elif key == '/':
# Focus search box
self.frame.body.base_widget.set_focus(0)
return None
elif key == 'c':
self._cycle_category()
return None
elif key == 'n':
self._next_page()
return None
elif key == 'p':
self._prev_page()
return None
elif key == 'r':
# Refresh current view
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
return None
return key
def run(self):
"""Run the TUI browser."""
# Create main loop
self.loop = urwid.MainLoop(
self.frame,
palette=PALETTE,
unhandled_input=self._handle_input,
handle_mouse=True
)
# Setup async pipe for thread-safe callbacks
self.async_ops.setup_pipe(self.loop)
try:
# Initial load (async)
self._load_categories()
self._load_tools()
# Run main loop
self.loop.run()
finally:
# Cleanup
self.async_ops.cleanup()
self.executor.shutdown(wait=False)
def run_registry_browser():
"""Entry point for the registry browser TUI."""
try:
browser = RegistryBrowser()
browser.run()
except RegistryError as e:
print(f"Error connecting to registry: {e.message}")
return 1
except Exception as e:
print(f"Error: {e}")
return 1
return 0