Convert TUI to PySide6 desktop GUI

Major UI overhaul replacing the urwid-based Terminal UI with a modern
PySide6 desktop application.

New GUI features:
- Sidebar navigation (My Tools, Registry, Providers)
- Tool Builder with visual form for creating/editing tools
- Registry browser with search and one-click install
- Provider management page
- Connect dialog for account pairing
- Publish dialog for sharing tools

- Keyboard shortcuts (Ctrl+N, Ctrl+S, Ctrl+R, Ctrl+1/2/3, Escape, Ctrl+Q)
- Window geometry persistence (remembers size/position)
- Modern clean stylesheet

Removed:
- ui.py, ui_snack.py, ui_registry.py, ui_urwid.py
- ui_urwid/ directory (urwid TUI implementation)

Updated:
- pyproject.toml: PySide6 now required, removed urwid
- CLI entry points to launch GUI
- All documentation (README, CLAUDE.md, AGENTS.md, wiki)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-14 04:38:35 -04:00
co-authored by Claude Opus 4.5
parent 604b473806
commit 4fe2d26244
30 changed files with 2726 additions and 5102 deletions
+247
View File
@@ -0,0 +1,247 @@
"""Registry page - browse and install tools from registry."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit,
QPushButton, QTableWidget, QTableWidgetItem, QLabel,
QHeaderView, QGroupBox, QTextEdit, QSplitter, QMessageBox
)
from PySide6.QtCore import Qt, QThread, Signal
from ...registry_client import RegistryClient, RegistryError
from ...config import load_config
class SearchWorker(QThread):
"""Background worker for registry search."""
finished = Signal(list)
error = Signal(str)
def __init__(self, query: str, page: int = 1):
super().__init__()
self.query = query
self.page = page
def run(self):
try:
client = RegistryClient()
result = client.search_tools(self.query, page=self.page, per_page=20)
# result is a PaginatedResponse with data attribute
self.finished.emit(result.data)
except Exception as e:
self.error.emit(str(e))
class InstallWorker(QThread):
"""Background worker for tool installation."""
finished = Signal(str)
error = Signal(str)
def __init__(self, owner: str, name: str):
super().__init__()
self.owner = owner
self.name = name
def run(self):
try:
client = RegistryClient()
client.install_tool(self.owner, self.name)
self.finished.emit(f"{self.owner}/{self.name}")
except Exception as e:
self.error.emit(str(e))
class RegistryPage(QWidget):
"""Registry browser page."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._search_worker = None
self._install_worker = None
self._selected_tool = None
self._setup_ui()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# Header
title = QLabel("Tool Registry")
title.setObjectName("heading")
layout.addWidget(title)
# Search bar
search_box = QWidget()
search_layout = QHBoxLayout(search_box)
search_layout.setContentsMargins(0, 0, 0, 0)
search_layout.setSpacing(8)
self.search_input = QLineEdit()
self.search_input.setPlaceholderText("Search tools...")
self.search_input.returnPressed.connect(self._do_search)
search_layout.addWidget(self.search_input, 1)
self.btn_search = QPushButton("Search")
self.btn_search.clicked.connect(self._do_search)
search_layout.addWidget(self.btn_search)
layout.addWidget(search_box)
# Content splitter
splitter = QSplitter(Qt.Horizontal)
# Left: Results table
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
self.results_table = QTableWidget()
self.results_table.setColumnCount(4)
self.results_table.setHorizontalHeaderLabels(["Name", "Owner", "Downloads", "Version"])
self.results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
self.results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.results_table.setSelectionBehavior(QTableWidget.SelectRows)
self.results_table.setSelectionMode(QTableWidget.SingleSelection)
self.results_table.verticalHeader().setVisible(False)
self.results_table.itemSelectionChanged.connect(self._on_selection_changed)
left_layout.addWidget(self.results_table)
splitter.addWidget(left)
# Right: Tool details
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
details_box = QGroupBox("Tool Details")
details_layout = QVBoxLayout(details_box)
self.details_text = QTextEdit()
self.details_text.setReadOnly(True)
self.details_text.setPlaceholderText("Select a tool to view details")
details_layout.addWidget(self.details_text)
right_layout.addWidget(details_box, 1)
# Install button
self.btn_install = QPushButton("Install")
self.btn_install.clicked.connect(self._install_tool)
self.btn_install.setEnabled(False)
right_layout.addWidget(self.btn_install)
splitter.addWidget(right)
splitter.setSizes([500, 500])
layout.addWidget(splitter, 1)
# Status label
self.status_label = QLabel("")
self.status_label.setStyleSheet("color: #718096;")
layout.addWidget(self.status_label)
def refresh(self):
"""Refresh on page enter."""
pass # Could auto-search popular tools
def _do_search(self):
"""Perform search."""
query = self.search_input.text().strip()
if not query:
return
self.btn_search.setEnabled(False)
self.status_label.setText("Searching...")
self.results_table.setRowCount(0)
self._search_worker = SearchWorker(query)
self._search_worker.finished.connect(self._on_search_complete)
self._search_worker.error.connect(self._on_search_error)
self._search_worker.start()
def _on_search_complete(self, tools: list):
"""Handle search results."""
self.btn_search.setEnabled(True)
self.status_label.setText(f"Found {len(tools)} tools")
self.results_table.setRowCount(len(tools))
for row, tool in enumerate(tools):
name_item = QTableWidgetItem(tool.get("name", ""))
name_item.setData(Qt.UserRole, tool)
self.results_table.setItem(row, 0, name_item)
self.results_table.setItem(row, 1, QTableWidgetItem(tool.get("owner", "")))
self.results_table.setItem(row, 2, QTableWidgetItem(str(tool.get("downloads", 0))))
self.results_table.setItem(row, 3, QTableWidgetItem(tool.get("version", "1.0.0")))
def _on_search_error(self, error: str):
"""Handle search error."""
self.btn_search.setEnabled(True)
self.status_label.setText(f"Error: {error}")
def _on_selection_changed(self):
"""Handle selection change."""
items = self.results_table.selectedItems()
if not items:
self._selected_tool = None
self.details_text.clear()
self.btn_install.setEnabled(False)
return
row = items[0].row()
name_item = self.results_table.item(row, 0)
tool = name_item.data(Qt.UserRole)
self._selected_tool = tool
self._show_tool_details(tool)
self.btn_install.setEnabled(True)
def _show_tool_details(self, tool: dict):
"""Show tool details."""
lines = []
lines.append(f"<h2>{tool.get('owner', '')}/{tool.get('name', '')}</h2>")
if tool.get("description"):
lines.append(f"<p>{tool.get('description')}</p>")
lines.append(f"<p><strong>Version:</strong> {tool.get('version', '1.0.0')}</p>")
lines.append(f"<p><strong>Downloads:</strong> {tool.get('downloads', 0)}</p>")
if tool.get("category"):
lines.append(f"<p><strong>Category:</strong> {tool.get('category')}</p>")
if tool.get("tags"):
tags = ", ".join(tool.get("tags", []))
lines.append(f"<p><strong>Tags:</strong> {tags}</p>")
self.details_text.setHtml("\n".join(lines))
def _install_tool(self):
"""Install selected tool."""
if not self._selected_tool:
return
owner = self._selected_tool.get("owner", "")
name = self._selected_tool.get("name", "")
self.btn_install.setEnabled(False)
self.status_label.setText(f"Installing {owner}/{name}...")
self._install_worker = InstallWorker(owner, name)
self._install_worker.finished.connect(self._on_install_complete)
self._install_worker.error.connect(self._on_install_error)
self._install_worker.start()
def _on_install_complete(self, tool_id: str):
"""Handle install complete."""
self.btn_install.setEnabled(True)
self.status_label.setText(f"Installed {tool_id}")
self.main_window.show_status(f"Installed {tool_id}")
QMessageBox.information(self, "Success", f"Successfully installed {tool_id}")
def _on_install_error(self, error: str):
"""Handle install error."""
self.btn_install.setEnabled(True)
self.status_label.setText(f"Error: {error}")
QMessageBox.critical(self, "Install Error", f"Failed to install tool:\n{error}")