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
+331
View File
@@ -0,0 +1,331 @@
"""Tools page - main view for managing tools."""
from collections import defaultdict
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
QPushButton, QGroupBox, QMessageBox, QFrame
)
from PySide6.QtCore import Qt
from PySide6.QtGui import QFont
from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
list_tools, load_tool, delete_tool, DEFAULT_CATEGORIES
)
from ...config import load_config
class ToolsPage(QWidget):
"""Main tools management page."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._current_tool = None
self._setup_ui()
self.refresh()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(24, 24, 24, 24)
layout.setSpacing(16)
# Header
header = QWidget()
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("My Tools")
title.setObjectName("heading")
header_layout.addWidget(title)
header_layout.addStretch()
# Connection status
config = load_config()
if config.registry.token:
status = QLabel("Connected to Registry")
status.setStyleSheet("color: #38a169; font-weight: 500;")
else:
status = QLabel("Not connected")
status.setStyleSheet("color: #718096;")
header_layout.addWidget(status)
layout.addWidget(header)
# Main content splitter
splitter = QSplitter(Qt.Horizontal)
# Left side: Tool list
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
left_layout.setSpacing(8)
self.tool_tree = QTreeWidget()
self.tool_tree.setHeaderHidden(True)
self.tool_tree.setIndentation(16)
self.tool_tree.setRootIsDecorated(True)
self.tool_tree.itemSelectionChanged.connect(self._on_selection_changed)
self.tool_tree.itemDoubleClicked.connect(self._on_double_click)
left_layout.addWidget(self.tool_tree, 1)
splitter.addWidget(left)
# Right side: Info panel
right = QWidget()
right_layout = QVBoxLayout(right)
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(16)
# Tool info
info_box = QGroupBox("Tool Details")
info_layout = QVBoxLayout(info_box)
self.info_text = QTextEdit()
self.info_text.setReadOnly(True)
self.info_text.setPlaceholderText("Select a tool to view details")
info_layout.addWidget(self.info_text)
right_layout.addWidget(info_box, 1)
splitter.addWidget(right)
# Set splitter sizes
splitter.setSizes([350, 650])
layout.addWidget(splitter, 1)
# Action buttons
buttons = QWidget()
btn_layout = QHBoxLayout(buttons)
btn_layout.setContentsMargins(0, 0, 0, 0)
btn_layout.setSpacing(12)
self.btn_create = QPushButton("Create")
self.btn_create.clicked.connect(self._create_tool)
btn_layout.addWidget(self.btn_create)
self.btn_edit = QPushButton("Edit")
self.btn_edit.setObjectName("secondary")
self.btn_edit.clicked.connect(self._edit_tool)
self.btn_edit.setEnabled(False)
btn_layout.addWidget(self.btn_edit)
self.btn_delete = QPushButton("Delete")
self.btn_delete.setObjectName("danger")
self.btn_delete.clicked.connect(self._delete_tool)
self.btn_delete.setEnabled(False)
btn_layout.addWidget(self.btn_delete)
btn_layout.addStretch()
# Connect/Publish button
config = load_config()
if config.registry.token:
self.btn_publish = QPushButton("Publish")
self.btn_publish.clicked.connect(self._publish_tool)
self.btn_publish.setEnabled(False)
else:
self.btn_publish = QPushButton("Connect")
self.btn_publish.clicked.connect(self._connect_registry)
btn_layout.addWidget(self.btn_publish)
layout.addWidget(buttons)
def refresh(self):
"""Refresh the tool list."""
self.tool_tree.clear()
self._current_tool = None
self.info_text.clear()
tools = list_tools()
# Group tools by category
tools_by_category = defaultdict(list)
for name in tools:
tool = load_tool(name)
if tool:
category = tool.category if tool.category else "Other"
tools_by_category[category].append((name, tool))
# Build tree with categories
all_categories = list(DEFAULT_CATEGORIES)
for cat in tools_by_category:
if cat not in all_categories:
all_categories.append(cat)
for category in all_categories:
if category in tools_by_category and tools_by_category[category]:
# Category item
cat_item = QTreeWidgetItem([category])
cat_item.setExpanded(True)
font = cat_item.font(0)
font.setBold(True)
cat_item.setFont(0, font)
cat_item.setFlags(cat_item.flags() & ~Qt.ItemIsSelectable)
# Tools in category
for name, tool in sorted(tools_by_category[category], key=lambda x: x[0]):
tool_item = QTreeWidgetItem([name])
tool_item.setData(0, Qt.UserRole, name)
if tool.source and tool.source.type == "imported":
tool_item.setToolTip(0, f"Imported from {tool.source.url or 'registry'}")
cat_item.addChild(tool_item)
self.tool_tree.addTopLevelItem(cat_item)
# Update button states
self._update_buttons()
if not tools:
self.info_text.setPlaceholderText(
"No tools found.\n\nClick 'Create' to build your first tool, "
"or browse the Registry to install community tools."
)
def _on_selection_changed(self):
"""Handle tool selection change."""
items = self.tool_tree.selectedItems()
if not items:
self._current_tool = None
self.info_text.clear()
self._update_buttons()
return
item = items[0]
tool_name = item.data(0, Qt.UserRole)
if not tool_name:
self._current_tool = None
self.info_text.clear()
self._update_buttons()
return
tool = load_tool(tool_name)
if tool:
self._current_tool = tool
self._show_tool_info(tool)
self._update_buttons()
def _on_double_click(self, item, column):
"""Handle double-click on tool."""
tool_name = item.data(0, Qt.UserRole)
if tool_name:
self._edit_tool()
def _show_tool_info(self, tool: Tool):
"""Display tool information."""
lines = []
# Name and description
lines.append(f"<h2 style='margin: 0 0 8px 0; color: #2d3748;'>{tool.name}</h2>")
if tool.description:
lines.append(f"<p style='color: #4a5568; margin-bottom: 16px;'>{tool.description}</p>")
# Source info
if tool.source:
source_type = tool.source.type
if source_type == "imported":
source_url = tool.source.url or "registry"
lines.append(f"<p style='color: #718096; font-size: 12px;'>Imported from {source_url}</p>")
elif source_type == "forked":
lines.append(f"<p style='color: #718096; font-size: 12px;'>Forked from {tool.source.original_tool}</p>")
# Arguments
if tool.arguments:
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Arguments</h3>")
lines.append("<ul style='margin: 8px 0;'>")
for arg in tool.arguments:
default = f" (default: {arg.default})" if arg.default else ""
lines.append(f"<li><code>{arg.flag}</code> → <code>${arg.variable}</code>{default}</li>")
lines.append("</ul>")
# Steps
if tool.steps:
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Steps</h3>")
lines.append("<ol style='margin: 8px 0;'>")
for i, step in enumerate(tool.steps, 1):
if isinstance(step, PromptStep):
lines.append(f"<li><strong>Prompt</strong> using <code>{step.provider}</code> → <code>${step.output_var}</code></li>")
elif isinstance(step, CodeStep):
lines.append(f"<li><strong>Code</strong> (python) → <code>${step.output_var}</code></li>")
elif isinstance(step, ToolStep):
lines.append(f"<li><strong>Tool</strong>: <code>{step.tool}</code> → <code>${step.output_var}</code></li>")
lines.append("</ol>")
# Output
if tool.output:
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Output Template</h3>")
output_escaped = tool.output.replace("<", "&lt;").replace(">", "&gt;")
lines.append(f"<pre style='background: #edf2f7; padding: 8px; border-radius: 4px;'>{output_escaped}</pre>")
# Category
if tool.category:
lines.append(f"<p style='color: #718096; font-size: 12px; margin-top: 16px;'>Category: {tool.category}</p>")
self.info_text.setHtml("\n".join(lines))
def _update_buttons(self):
"""Update button enabled states."""
has_selection = self._current_tool is not None
self.btn_edit.setEnabled(has_selection)
self.btn_delete.setEnabled(has_selection)
config = load_config()
if config.registry.token:
self.btn_publish.setEnabled(has_selection)
def _create_tool(self):
"""Create a new tool."""
self.main_window.open_tool_builder()
def _edit_tool(self):
"""Edit the selected tool."""
if self._current_tool:
self.main_window.open_tool_builder(self._current_tool.name)
def _delete_tool(self):
"""Delete the selected tool."""
if not self._current_tool:
return
reply = QMessageBox.question(
self,
"Delete Tool",
f"Are you sure you want to delete '{self._current_tool.name}'?\n\n"
"This will remove the tool configuration and wrapper script.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
try:
delete_tool(self._current_tool.name)
self.main_window.show_status(f"Deleted tool '{self._current_tool.name}'")
self.refresh()
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to delete tool:\n{e}")
def _connect_registry(self):
"""Open connect dialog."""
from ..dialogs.connect_dialog import ConnectDialog
dialog = ConnectDialog(self)
if dialog.exec():
self.refresh()
self.main_window.show_status("Connected to registry")
# Recreate publish button
self.btn_publish.setText("Publish")
self.btn_publish.clicked.disconnect()
self.btn_publish.clicked.connect(self._publish_tool)
def _publish_tool(self):
"""Publish the selected tool."""
if not self._current_tool:
return
from ..dialogs.publish_dialog import PublishDialog
dialog = PublishDialog(self, self._current_tool)
if dialog.exec():
self.main_window.show_status(f"Published '{self._current_tool.name}'")