Add tool marketplace UI enhancements

- Browse all tools on page load without search
- Category filter dropdown (Text, Developer, Data, etc.)
- Sort options (downloads, rating, newest, name)
- Star ratings display in results table
- Clickable tags for filtering
- Installed indicator (✓) for local tools
- Update available indicator (↑) for newer versions
- Pagination controls for large result sets
- Publisher reputation info in details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-14 04:51:10 -04:00
parent 4fe2d26244
commit 518a04a8b0
2 changed files with 380 additions and 45 deletions

View File

@ -431,9 +431,16 @@ The graphical interface provides a modern desktop experience:
- Test tools before saving
### Registry Browser
- Search community tools by name or keyword
- View tool details, downloads, and ratings
- One-click install to your local machine
- **Browse all** tools on page load (no search required)
- **Search** by name, description, or keyword
- **Filter** by category (Text, Developer, Data, Other)
- **Sort** by popularity, rating, newest, or name
- **Star ratings** displayed in results and details
- **Clickable tags** to filter by tag
- **Installed indicator** (✓) shows which tools you have
- **Update available** (↑) when newer version exists
- **Pagination** for browsing large result sets
- **Publisher info** showing reputation and total downloads
### Provider Management
- Add and configure AI providers

View File

@ -3,30 +3,45 @@
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit,
QPushButton, QTableWidget, QTableWidgetItem, QLabel,
QHeaderView, QGroupBox, QTextEdit, QSplitter, QMessageBox
QHeaderView, QGroupBox, QTextEdit, QSplitter, QMessageBox,
QComboBox, QFrame
)
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QColor
from ...registry_client import RegistryClient, RegistryError
from ...config import load_config
from ...tool import list_tools, load_tool
class SearchWorker(QThread):
"""Background worker for registry search."""
finished = Signal(list)
finished = Signal(object) # PaginatedResponse
error = Signal(str)
def __init__(self, query: str, page: int = 1):
def __init__(self, query: str = "", category: str = None, sort: str = "downloads",
tags: list = None, page: int = 1, per_page: int = 20):
super().__init__()
self.query = query
self.category = category
self.sort = sort
self.tags = tags
self.page = page
self.per_page = per_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)
result = client.search_tools(
self.query,
category=self.category if self.category and self.category != "All" else None,
tags=self.tags,
page=self.page,
per_page=self.per_page,
sort=self.sort,
include_facets=True
)
self.finished.emit(result)
except Exception as e:
self.error.emit(str(e))
@ -59,6 +74,10 @@ class RegistryPage(QWidget):
self._search_worker = None
self._install_worker = None
self._selected_tool = None
self._current_page = 1
self._total_pages = 1
self._current_tags = []
self._installed_tools = {} # name -> version
self._setup_ui()
def _setup_ui(self):
@ -68,26 +87,70 @@ class RegistryPage(QWidget):
layout.setSpacing(16)
# Header
header = QHBoxLayout()
title = QLabel("Tool Registry")
title.setObjectName("heading")
layout.addWidget(title)
header.addWidget(title)
header.addStretch()
# Search bar
search_box = QWidget()
search_layout = QHBoxLayout(search_box)
search_layout.setContentsMargins(0, 0, 0, 0)
search_layout.setSpacing(8)
# Browse all button
self.btn_browse = QPushButton("Browse All")
self.btn_browse.clicked.connect(self._browse_all)
header.addWidget(self.btn_browse)
layout.addLayout(header)
# Search and filters row
filters_box = QWidget()
filters_layout = QHBoxLayout(filters_box)
filters_layout.setContentsMargins(0, 0, 0, 0)
filters_layout.setSpacing(12)
# Search input
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)
filters_layout.addWidget(self.search_input, 2)
# Category filter
cat_label = QLabel("Category:")
filters_layout.addWidget(cat_label)
self.category_combo = QComboBox()
self.category_combo.addItems(["All", "Text", "Developer", "Data", "Other"])
self.category_combo.setMinimumWidth(100)
self.category_combo.currentTextChanged.connect(self._on_filter_changed)
filters_layout.addWidget(self.category_combo)
# Sort dropdown
sort_label = QLabel("Sort:")
filters_layout.addWidget(sort_label)
self.sort_combo = QComboBox()
self.sort_combo.addItem("Most Popular", "downloads")
self.sort_combo.addItem("Highest Rated", "rating")
self.sort_combo.addItem("Newest", "newest")
self.sort_combo.addItem("Name (A-Z)", "name")
self.sort_combo.setMinimumWidth(120)
self.sort_combo.currentIndexChanged.connect(self._on_filter_changed)
filters_layout.addWidget(self.sort_combo)
# Search button
self.btn_search = QPushButton("Search")
self.btn_search.clicked.connect(self._do_search)
search_layout.addWidget(self.btn_search)
filters_layout.addWidget(self.btn_search)
layout.addWidget(search_box)
layout.addWidget(filters_box)
# Active tags display
self.tags_widget = QWidget()
self.tags_layout = QHBoxLayout(self.tags_widget)
self.tags_layout.setContentsMargins(0, 0, 0, 0)
self.tags_layout.setSpacing(8)
self.tags_label = QLabel("Filtering by tags:")
self.tags_label.setStyleSheet("color: #718096;")
self.tags_layout.addWidget(self.tags_label)
self.tags_layout.addStretch()
self.tags_widget.hide()
layout.addWidget(self.tags_widget)
# Content splitter
splitter = QSplitter(Qt.Horizontal)
@ -98,18 +161,46 @@ class RegistryPage(QWidget):
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.setColumnCount(6)
self.results_table.setHorizontalHeaderLabels(["", "Name", "Owner", "Rating", "Downloads", "Version"])
self.results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Fixed)
self.results_table.setColumnWidth(0, 30) # Installed indicator
self.results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
self.results_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(4, QHeaderView.ResizeToContents)
self.results_table.horizontalHeader().setSectionResizeMode(5, 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)
# Pagination controls
pagination = QWidget()
pag_layout = QHBoxLayout(pagination)
pag_layout.setContentsMargins(0, 8, 0, 0)
self.btn_prev = QPushButton("← Previous")
self.btn_prev.clicked.connect(self._prev_page)
self.btn_prev.setEnabled(False)
pag_layout.addWidget(self.btn_prev)
pag_layout.addStretch()
self.page_label = QLabel("Page 1 of 1")
self.page_label.setStyleSheet("color: #718096;")
pag_layout.addWidget(self.page_label)
pag_layout.addStretch()
self.btn_next = QPushButton("Next →")
self.btn_next.clicked.connect(self._next_page)
self.btn_next.setEnabled(False)
pag_layout.addWidget(self.btn_next)
left_layout.addWidget(pagination)
splitter.addWidget(left)
# Right: Tool details
@ -123,18 +214,31 @@ class RegistryPage(QWidget):
self.details_text = QTextEdit()
self.details_text.setReadOnly(True)
self.details_text.setPlaceholderText("Select a tool to view details")
self.details_text.anchorClicked = self._on_tag_clicked
self.details_text.setOpenExternalLinks(False)
details_layout.addWidget(self.details_text)
right_layout.addWidget(details_box, 1)
# Install button
# Action buttons
actions = QHBoxLayout()
self.btn_install = QPushButton("Install")
self.btn_install.clicked.connect(self._install_tool)
self.btn_install.setEnabled(False)
right_layout.addWidget(self.btn_install)
actions.addWidget(self.btn_install)
self.btn_update = QPushButton("Update Available")
self.btn_update.clicked.connect(self._install_tool)
self.btn_update.setEnabled(False)
self.btn_update.setStyleSheet("background-color: #48bb78; color: white;")
self.btn_update.hide()
actions.addWidget(self.btn_update)
right_layout.addLayout(actions)
splitter.addWidget(right)
splitter.setSizes([500, 500])
splitter.setSizes([550, 450])
layout.addWidget(splitter, 1)
@ -144,43 +248,147 @@ class RegistryPage(QWidget):
layout.addWidget(self.status_label)
def refresh(self):
"""Refresh on page enter."""
pass # Could auto-search popular tools
"""Refresh on page enter - load popular tools."""
self._load_installed_tools()
self._browse_all()
def _load_installed_tools(self):
"""Load list of installed tools and their versions."""
self._installed_tools = {}
for name in list_tools():
tool = load_tool(name)
if tool:
self._installed_tools[name] = tool.version or "1.0.0"
def _browse_all(self):
"""Browse all tools (no search query)."""
self.search_input.clear()
self._current_page = 1
self._do_search()
def _on_filter_changed(self):
"""Handle filter/sort change."""
self._current_page = 1
self._do_search()
def _do_search(self):
"""Perform search."""
"""Perform search with current filters."""
query = self.search_input.text().strip()
if not query:
return
category = self.category_combo.currentText()
sort = self.sort_combo.currentData()
self.btn_search.setEnabled(False)
self.btn_browse.setEnabled(False)
self.status_label.setText("Searching...")
self.results_table.setRowCount(0)
self._search_worker = SearchWorker(query)
self._search_worker = SearchWorker(
query=query,
category=category,
sort=sort,
tags=self._current_tags if self._current_tags else None,
page=self._current_page,
per_page=20
)
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):
def _on_search_complete(self, result):
"""Handle search results."""
self.btn_search.setEnabled(True)
self.status_label.setText(f"Found {len(tools)} tools")
self.btn_browse.setEnabled(True)
tools = result.data
self._total_pages = result.total_pages or 1
total = result.total or len(tools)
self.status_label.setText(f"Found {total} tools")
self.page_label.setText(f"Page {self._current_page} of {self._total_pages}")
# Update pagination buttons
self.btn_prev.setEnabled(self._current_page > 1)
self.btn_next.setEnabled(self._current_page < self._total_pages)
self.results_table.setRowCount(len(tools))
for row, tool in enumerate(tools):
name_item = QTableWidgetItem(tool.get("name", ""))
tool_name = tool.get("name", "")
# Installed indicator
installed_item = QTableWidgetItem()
if tool_name in self._installed_tools:
installed_version = self._installed_tools[tool_name]
registry_version = tool.get("version", "1.0.0")
if installed_version != registry_version:
installed_item.setText("") # Update available
installed_item.setToolTip(f"Update available: {installed_version}{registry_version}")
installed_item.setForeground(QColor("#48bb78"))
else:
installed_item.setText("")
installed_item.setToolTip("Installed")
installed_item.setForeground(QColor("#4299e1"))
installed_item.setTextAlignment(Qt.AlignCenter)
self.results_table.setItem(row, 0, installed_item)
# Name
name_item = QTableWidgetItem(tool_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")))
self.results_table.setItem(row, 1, name_item)
# Owner
self.results_table.setItem(row, 2, QTableWidgetItem(tool.get("owner", "")))
# Rating (stars)
rating = tool.get("average_rating", 0) or 0
rating_count = tool.get("rating_count", 0) or 0
stars = self._rating_to_stars(rating)
rating_item = QTableWidgetItem(f"{stars} ({rating_count})")
rating_item.setToolTip(f"{rating:.1f}/5 from {rating_count} ratings")
self.results_table.setItem(row, 3, rating_item)
# Downloads
downloads = tool.get("downloads", 0)
downloads_str = self._format_downloads(downloads)
self.results_table.setItem(row, 4, QTableWidgetItem(downloads_str))
# Version
self.results_table.setItem(row, 5, QTableWidgetItem(tool.get("version", "1.0.0")))
def _rating_to_stars(self, rating: float) -> str:
"""Convert rating to star display."""
if rating <= 0:
return "☆☆☆☆☆"
full_stars = int(rating)
half_star = rating - full_stars >= 0.5
empty_stars = 5 - full_stars - (1 if half_star else 0)
return "" * full_stars + ("½" if half_star else "") + "" * empty_stars
def _format_downloads(self, downloads: int) -> str:
"""Format download count."""
if downloads >= 1000000:
return f"{downloads/1000000:.1f}M"
elif downloads >= 1000:
return f"{downloads/1000:.1f}K"
return str(downloads)
def _on_search_error(self, error: str):
"""Handle search error."""
self.btn_search.setEnabled(True)
self.btn_browse.setEnabled(True)
self.status_label.setText(f"Error: {error}")
def _prev_page(self):
"""Go to previous page."""
if self._current_page > 1:
self._current_page -= 1
self._do_search()
def _next_page(self):
"""Go to next page."""
if self._current_page < self._total_pages:
self._current_page += 1
self._do_search()
def _on_selection_changed(self):
"""Handle selection change."""
items = self.results_table.selectedItems()
@ -188,35 +396,141 @@ class RegistryPage(QWidget):
self._selected_tool = None
self.details_text.clear()
self.btn_install.setEnabled(False)
self.btn_update.hide()
return
row = items[0].row()
name_item = self.results_table.item(row, 0)
name_item = self.results_table.item(row, 1)
tool = name_item.data(Qt.UserRole)
self._selected_tool = tool
self._show_tool_details(tool)
self.btn_install.setEnabled(True)
# Check if installed / update available
tool_name = tool.get("name", "")
if tool_name in self._installed_tools:
installed_version = self._installed_tools[tool_name]
registry_version = tool.get("version", "1.0.0")
if installed_version != registry_version:
self.btn_install.hide()
self.btn_update.show()
self.btn_update.setEnabled(True)
self.btn_update.setText(f"Update ({installed_version}{registry_version})")
else:
self.btn_install.setText("Reinstall")
self.btn_install.setEnabled(True)
self.btn_install.show()
self.btn_update.hide()
else:
self.btn_install.setText("Install")
self.btn_install.setEnabled(True)
self.btn_install.show()
self.btn_update.hide()
def _show_tool_details(self, tool: dict):
"""Show tool details."""
lines = []
lines.append(f"<h2>{tool.get('owner', '')}/{tool.get('name', '')}</h2>")
owner = tool.get('owner', '')
name = tool.get('name', '')
lines.append(f"<h2>{owner}/{name}</h2>")
if tool.get("description"):
lines.append(f"<p>{tool.get('description')}</p>")
lines.append(f"<p style='color: #4a5568;'>{tool.get('description')}</p>")
# Rating display
rating = tool.get("average_rating", 0) or 0
rating_count = tool.get("rating_count", 0) or 0
stars = self._rating_to_stars(rating)
lines.append(f"<p><strong>Rating:</strong> {stars} ({rating:.1f}/5 from {rating_count} reviews)</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>")
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>")
# Clickable tags
if tool.get("tags"):
tags = ", ".join(tool.get("tags", []))
lines.append(f"<p><strong>Tags:</strong> {tags}</p>")
tags_html = []
for tag in tool.get("tags", []):
tags_html.append(
f'<a href="tag:{tag}" style="background: #edf2f7; padding: 2px 8px; '
f'border-radius: 4px; text-decoration: none; color: #4a5568; margin-right: 4px;">{tag}</a>'
)
lines.append(f"<p><strong>Tags:</strong> {''.join(tags_html)}</p>")
# Publisher info
if tool.get("publisher_reputation"):
rep = tool.get("publisher_reputation", {})
lines.append(f"<p style='margin-top: 16px; color: #718096; font-size: 12px;'>"
f"Publisher: @{owner} · {rep.get('total_tools', 0)} tools · "
f"{rep.get('total_downloads', 0):,} total downloads</p>")
# Installed status
tool_name = tool.get("name", "")
if tool_name in self._installed_tools:
installed_version = self._installed_tools[tool_name]
registry_version = tool.get("version", "1.0.0")
if installed_version != registry_version:
lines.append(f"<p style='color: #48bb78; font-weight: 600;'>"
f"✓ Installed (v{installed_version}) - Update available!</p>")
else:
lines.append(f"<p style='color: #4299e1;'>✓ Installed (v{installed_version})</p>")
self.details_text.setHtml("\n".join(lines))
def _on_tag_clicked(self, url):
"""Handle tag click to filter by tag."""
tag = url.toString().replace("tag:", "")
if tag not in self._current_tags:
self._current_tags.append(tag)
self._update_tags_display()
self._current_page = 1
self._do_search()
def _update_tags_display(self):
"""Update the active tags display."""
# Clear existing tag buttons
while self.tags_layout.count() > 2:
item = self.tags_layout.takeAt(2)
if item.widget():
item.widget().deleteLater()
if self._current_tags:
self.tags_widget.show()
for tag in self._current_tags:
tag_btn = QPushButton(f"{tag} ×")
tag_btn.setStyleSheet(
"background: #4299e1; color: white; border: none; "
"padding: 4px 8px; border-radius: 4px;"
)
tag_btn.clicked.connect(lambda checked, t=tag: self._remove_tag(t))
self.tags_layout.insertWidget(self.tags_layout.count() - 1, tag_btn)
# Add clear all button
clear_btn = QPushButton("Clear all")
clear_btn.setStyleSheet("color: #e53e3e;")
clear_btn.setFlat(True)
clear_btn.clicked.connect(self._clear_tags)
self.tags_layout.insertWidget(self.tags_layout.count() - 1, clear_btn)
else:
self.tags_widget.hide()
def _remove_tag(self, tag: str):
"""Remove a tag from the filter."""
if tag in self._current_tags:
self._current_tags.remove(tag)
self._update_tags_display()
self._current_page = 1
self._do_search()
def _clear_tags(self):
"""Clear all tag filters."""
self._current_tags = []
self._update_tags_display()
self._current_page = 1
self._do_search()
def _install_tool(self):
"""Install selected tool."""
if not self._selected_tool:
@ -226,6 +540,7 @@ class RegistryPage(QWidget):
name = self._selected_tool.get("name", "")
self.btn_install.setEnabled(False)
self.btn_update.setEnabled(False)
self.status_label.setText(f"Installing {owner}/{name}...")
self._install_worker = InstallWorker(owner, name)
@ -238,10 +553,23 @@ class RegistryPage(QWidget):
self.btn_install.setEnabled(True)
self.status_label.setText(f"Installed {tool_id}")
self.main_window.show_status(f"Installed {tool_id}")
# Refresh installed tools list
self._load_installed_tools()
# Update the display
if self._selected_tool:
self._show_tool_details(self._selected_tool)
self._on_selection_changed()
# Refresh the table to update indicators
self._do_search()
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.btn_update.setEnabled(True)
self.status_label.setText(f"Error: {error}")
QMessageBox.critical(self, "Install Error", f"Failed to install tool:\n{error}")