"""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