"""Tools page - main view for managing tools.""" from collections import defaultdict from pathlib import Path from typing import Optional, Tuple, Dict, Any import yaml from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QSplitter, QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel, QPushButton, QGroupBox, QMessageBox, QFrame, QLineEdit ) from PySide6.QtCore import Qt, QThread, Signal, QTimer from PySide6.QtGui import QFont, QColor, QBrush, QShortcut, QKeySequence from ...tool import ( Tool, ToolArgument, PromptStep, CodeStep, ToolStep, list_tools, load_tool, delete_tool, get_all_categories, get_tools_dir ) from ...config import load_config class StatusSyncWorker(QThread): """Background worker to sync tool statuses from registry using hash-based batch lookup.""" finished = Signal() tool_updated = Signal(str) # Emits tool name when status changes slug_fetched = Signal(str) # Emits current user's slug def __init__(self, tool_names: list): super().__init__() self.tool_names = tool_names def run(self): """Sync status for all tools with registry_hash using batch hash lookup.""" from ...registry_client import RegistryClient, RegistryError from collections import defaultdict try: config = load_config() if not config.registry.token: return client = RegistryClient() client.token = config.registry.token # Fetch current user slug (needed for rating ownership checks) try: me = client.get_me() slug = me.get("slug") if slug: self.slug_fetched.emit(slug) except Exception: pass # Collect all tools that have a registry_hash # Multiple tools can share the same hash (copies/forks) tools_dir = get_tools_dir() tool_hashes = defaultdict(list) # hash -> [(tool_name, config_path), ...] for tool_name in self.tool_names: config_path = tools_dir / tool_name / "config.yaml" if not config_path.exists(): continue try: config_data = yaml.safe_load(config_path.read_text()) or {} h = config_data.get("registry_hash") if h: tool_hashes[h].append((tool_name, config_path)) except Exception: continue if not tool_hashes: return # Batch requests in chunks of 100 (server limit) all_hashes = list(tool_hashes.keys()) results = {} for i in range(0, len(all_hashes), 100): chunk = all_hashes[i:i + 100] results.update(client.get_tool_status_by_hashes(chunk)) # Update local configs from results for h, tool_entries in tool_hashes.items(): if h in results: for tool_name, config_path in tool_entries: try: self._update_local_config(tool_name, config_path, results[h]) except Exception: pass except Exception: pass # Silently fail - this is background sync finally: self.finished.emit() def _update_local_config(self, tool_name: str, config_path, status_data: dict): """Update a tool's local config with registry status.""" config_data = yaml.safe_load(config_path.read_text()) or {} new_status = status_data.get("status", "pending") new_feedback = status_data.get("feedback") new_owner = status_data.get("owner") old_status = config_data.get("registry_status", "pending") old_feedback = config_data.get("registry_feedback") changed = False if old_status != new_status: config_data["registry_status"] = new_status changed = True if new_feedback != old_feedback: if new_feedback: config_data["registry_feedback"] = new_feedback elif "registry_feedback" in config_data: del config_data["registry_feedback"] changed = True # Backfill registry_owner from sync data if new_owner and config_data.get("registry_owner") != new_owner: config_data["registry_owner"] = new_owner changed = True if changed: config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False)) self.tool_updated.emit(tool_name) def get_tool_publish_state(tool_name: str) -> Tuple[str, Optional[str]]: """ Get the publish state of a tool. Returns: Tuple of (state, registry_hash) where state is: - "published" - approved in registry - "pending" - submitted but awaiting moderation - "changes_requested" - admin requested changes before approval - "rejected" - rejected by admin - "installed" - installed from registry (has hash but no explicit status) - "local" - no registry_hash (never published) """ config_path = get_tools_dir() / tool_name / "config.yaml" if not config_path.exists(): return ("local", None) try: config = yaml.safe_load(config_path.read_text()) registry_hash = config.get("registry_hash") if not registry_hash: return ("local", None) # Only use explicit registry_status - don't default to "pending" registry_status = config.get("registry_status") if not registry_status: # Has registry_hash but no status = installed from registry return ("installed", registry_hash) if registry_status == "approved": return ("published", registry_hash) elif registry_status == "changes_requested": return ("changes_requested", registry_hash) elif registry_status == "rejected": return ("rejected", registry_hash) elif registry_status == "pending": return ("pending", registry_hash) else: return ("installed", registry_hash) except Exception: return ("local", None) def get_tool_registry_info(tool_name: str, fallback_owner: Optional[str] = None) -> Optional[Tuple[str, str]]: """ Get registry owner/name for a tool if it's registry-sourced. Args: tool_name: Qualified or simple tool name. fallback_owner: Owner slug to use when the tool has a registry_hash but no owner can be determined from the path or config (e.g. the user's own published tools stored in a flat directory). Returns: (owner, name) tuple if tool is from registry, None if local-only. """ tools_dir = get_tools_dir() config_path = tools_dir / tool_name / "config.yaml" if not config_path.exists(): return None try: config_data = yaml.safe_load(config_path.read_text()) or {} if not config_data.get("registry_hash"): return None # Check if stored under owner subdir: ~/.cmdforge/// if "/" in tool_name: owner, name = tool_name.split("/", 1) return (owner, name) # Flat dir: check registry_owner (saved at publish time) registry_owner = config_data.get("registry_owner") if registry_owner: return (registry_owner, tool_name) # Check source.author or installed_from in config source = config_data.get("source", {}) if isinstance(source, dict) and source.get("author"): return (source["author"], tool_name) if config_data.get("installed_from"): parts = config_data["installed_from"].split("/", 1) if len(parts) == 2: return (parts[0], parts[1]) # Last resort: use fallback owner (current user's slug) if fallback_owner: return (fallback_owner, tool_name) return None except Exception: return None class RatingWorker(QThread): """Background worker to fetch rating summary and user's review for a tool.""" finished = Signal(str, dict) # tool_name, result dict error = Signal(str, str) # tool_name, error message def __init__(self, tool_name: str, owner: str, name: str): super().__init__() self.tool_name = tool_name self.owner = owner self.name = name def run(self): from ...registry_client import RegistryClient, RegistryError try: config = load_config() client = RegistryClient() client.token = config.registry.token result: Dict[str, Any] = {"rating": None, "my_review": None} try: result["rating"] = client.get_tool_rating(self.owner, self.name) except RegistryError: pass if client.token: try: result["my_review"] = client.get_my_review(self.owner, self.name) except RegistryError: pass self.finished.emit(self.tool_name, result) except Exception as e: self.error.emit(self.tool_name, str(e)) class SubmitReviewWorker(QThread): """Background worker to submit or update a review.""" finished = Signal(str) # success message error = Signal(str) # error message def __init__(self, owner: str, name: str, rating: int, title: str, content: str, review_id: Optional[int] = None): super().__init__() self.owner = owner self.name = name self.rating = rating self.title = title self.content = content self.review_id = review_id # None = new, int = update def run(self): from ...registry_client import RegistryClient, RegistryError, RateLimitError try: config = load_config() client = RegistryClient() client.token = config.registry.token if self.review_id is not None: client.update_review(self.review_id, self.rating, self.title, self.content) self.finished.emit("Review updated successfully") else: client.submit_review(self.owner, self.name, self.rating, self.title, self.content) self.finished.emit("Review submitted successfully") except RateLimitError as e: minutes = max(1, e.retry_after // 60) self.error.emit(f"Too many reviews. Try again in {minutes} minute(s).") except RegistryError as e: self.error.emit(e.message) except Exception as e: self.error.emit(str(e)) class DeleteReviewWorker(QThread): """Background worker to delete a review.""" finished = Signal(str) error = Signal(str) def __init__(self, review_id: int): super().__init__() self.review_id = review_id def run(self): from ...registry_client import RegistryClient, RegistryError try: config = load_config() client = RegistryClient() client.token = config.registry.token client.delete_review(self.review_id) self.finished.emit("Review deleted successfully") except RegistryError as e: self.error.emit(e.message) except Exception as e: self.error.emit(str(e)) class SubmitIssueWorker(QThread): """Background worker to submit an issue.""" finished = Signal(str) error = Signal(str) def __init__(self, owner: str, name: str, issue_type: str, severity: str, title: str, description: str): super().__init__() self.owner = owner self.name = name self.issue_type = issue_type self.severity = severity self.title = title self.description = description def run(self): from ...registry_client import RegistryClient, RegistryError try: config = load_config() client = RegistryClient() client.token = config.registry.token client.submit_issue( self.owner, self.name, self.issue_type, self.severity, self.title, self.description ) self.finished.emit("Issue reported successfully") except RegistryError as e: self.error.emit(e.message) except Exception as e: self.error.emit(str(e)) class ToolsPage(QWidget): """Main tools management page.""" def __init__(self, main_window): super().__init__() self.main_window = main_window self._current_tool = None self._sync_worker = None self._syncing = False # Prevent re-sync during update self._poll_timer = None # Timer for automatic status polling self._has_pending_tools = False # Track if we need to poll self._rating_cache: Dict[str, Dict] = {} # tool_name -> {rating, my_review} self._rating_worker = None self._review_worker = None self._issue_worker = None self._my_slug: Optional[str] = None # Cached current user slug self._my_slug_fetched = False self._readme_loaded_for: Optional[str] = None # Track which tool's README is loaded 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 self.connection_status = QLabel() self._update_connection_status() header_layout.addWidget(self.connection_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) # Search box self.search_box = QLineEdit() self.search_box.setPlaceholderText("Search tools...") self.search_box.setClearButtonEnabled(True) self.search_box.textChanged.connect(self._filter_tools) left_layout.addWidget(self.search_box) 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) # Rating bar at bottom of details panel self.rating_bar = QWidget() rating_bar_layout = QHBoxLayout(self.rating_bar) rating_bar_layout.setContentsMargins(4, 4, 4, 4) rating_bar_layout.setSpacing(12) self.rating_label = QLabel("") self.rating_label.setTextFormat(Qt.RichText) self.rating_label.setStyleSheet("color: #4a5568; font-size: 12px;") rating_bar_layout.addWidget(self.rating_label) rating_bar_layout.addStretch() self.btn_rate = QPushButton("Rate Tool") self.btn_rate.setObjectName("secondary") self.btn_rate.clicked.connect(self._rate_tool) self.btn_rate.setEnabled(False) self.btn_rate.setToolTip("Local tools can't be rated") rating_bar_layout.addWidget(self.btn_rate) self.btn_report_issue = QPushButton("Report Issue") self.btn_report_issue.setObjectName("secondary") self.btn_report_issue.clicked.connect(self._report_issue) rating_bar_layout.addWidget(self.btn_report_issue) self.rating_bar.setVisible(False) # Collapsible README section self.readme_container = QWidget() readme_container_layout = QVBoxLayout(self.readme_container) readme_container_layout.setContentsMargins(0, 8, 0, 0) readme_container_layout.setSpacing(0) self.readme_toggle = QPushButton("▶ README") self.readme_toggle.setStyleSheet( "QPushButton { text-align: left; padding: 6px 10px; " "background: #edf2f7; border: 1px solid #e2e8f0; border-radius: 4px; " "color: #4a5568; font-weight: 600; font-size: 12px; }" "QPushButton:hover { background: #e2e8f0; }" ) self.readme_toggle.setCursor(Qt.PointingHandCursor) self.readme_toggle.clicked.connect(self._on_readme_toggled) readme_container_layout.addWidget(self.readme_toggle) self.readme_text = QTextEdit() self.readme_text.setReadOnly(True) self.readme_text.setMinimumHeight(200) self.readme_text.setStyleSheet( "QTextEdit { font-family: monospace; font-size: 12px; " "border: 1px solid #e2e8f0; border-top: none; border-radius: 0 0 4px 4px; }" ) self.readme_text.setVisible(False) readme_container_layout.addWidget(self.readme_text) self.readme_container.setVisible(False) info_layout.addWidget(self.readme_container) info_layout.addWidget(self.rating_bar) 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_configure = QPushButton("Configure") self.btn_configure.setObjectName("secondary") self.btn_configure.setToolTip("Edit tool settings (only available for tools with configurable settings)") self.btn_configure.clicked.connect(self._configure_tool) self.btn_configure.setEnabled(False) btn_layout.addWidget(self.btn_configure) 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) # Keyboard shortcut: F5 to sync status self._sync_shortcut = QShortcut(QKeySequence("F5"), self) self._sync_shortcut.activated.connect(self._sync_tool_status) def _update_connection_status(self): """Update the connection status label.""" config = load_config() if config.registry.token: self.connection_status.setText("Connected to Registry") self.connection_status.setStyleSheet("color: #38a169; font-weight: 500;") else: self.connection_status.setText("Not connected") self.connection_status.setStyleSheet("color: #718096;") def refresh(self): """Refresh the tool list.""" self.search_box.clear() self.tool_tree.clear() self._current_tool = None self.info_text.clear() self.rating_bar.setVisible(False) self.readme_container.setVisible(False) self._has_pending_tools = False # Reset, will be set during tree building 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 (get_all_categories returns defaults + custom) all_categories = get_all_categories() 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]): # Get publish state state, registry_hash = get_tool_publish_state(name) # Track if we have pending tools for polling if state in ("pending", "changes_requested"): self._has_pending_tools = True # Show state indicator in display name if state == "published": display_name = f"{name} ✓" tooltip = "Published to registry - approved" color = QColor(56, 161, 105) # Green elif state == "pending": display_name = f"{name} ◐" tooltip = "Submitted to registry - pending review" color = QColor(214, 158, 46) # Yellow/amber elif state == "changes_requested": display_name = f"{name} ⚠" tooltip = "Changes requested - see feedback" color = QColor(245, 158, 11) # Orange/amber elif state == "rejected": display_name = f"{name} ✗" tooltip = "Rejected by moderator" color = QColor(220, 38, 38) # Red elif state == "installed": display_name = f"{name} ↓" tooltip = "Installed from registry" color = QColor(56, 178, 172) # Teal else: display_name = name tooltip = "Local tool - not published" color = None tool_item = QTreeWidgetItem([display_name]) tool_item.setData(0, Qt.UserRole, name) if color: tool_item.setForeground(0, QBrush(color)) # Build tooltip if tool.source and tool.source.type == "imported": tooltip = f"Imported from {tool.source.url or 'registry'}" tool_item.setToolTip(0, tooltip) 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." ) # Start background sync for published tools self._start_background_sync(tools) # Manage polling timer based on pending state self._manage_poll_timer() def _start_background_sync(self, tools: list): """Start background sync for tools that have been published.""" if self._syncing: return # Avoid re-syncing during an update config = load_config() if not config.registry.token: return # Not connected # Collect tools with registry_hash published_tools = [] for name in tools: config_path = get_tools_dir() / name / "config.yaml" if config_path.exists(): try: config_data = yaml.safe_load(config_path.read_text()) or {} if config_data.get("registry_hash"): published_tools.append(name) except Exception: pass if not published_tools: return # Stop any existing sync if self._sync_worker and self._sync_worker.isRunning(): self._sync_worker.wait(1000) # Start new sync self._sync_worker = StatusSyncWorker(published_tools) self._sync_worker.tool_updated.connect(self._on_tool_status_updated) self._sync_worker.slug_fetched.connect(self._on_slug_fetched) self._sync_worker.finished.connect(self._on_sync_finished) self._syncing = True self._sync_worker.start() def _on_sync_finished(self): """Handle background sync completion.""" self._syncing = False def _manage_poll_timer(self): """Start or stop the polling timer based on pending tools.""" config = load_config() should_poll = self._has_pending_tools and config.registry.token if should_poll: if not self._poll_timer: # Create timer that polls every 30 seconds self._poll_timer = QTimer(self) self._poll_timer.timeout.connect(self._poll_status) if not self._poll_timer.isActive(): self._poll_timer.start(30000) # 30 seconds else: # No pending tools, stop polling if self._poll_timer and self._poll_timer.isActive(): self._poll_timer.stop() def _poll_status(self): """Timer callback to poll status for pending tools.""" if self._syncing: return # Already syncing # Get list of tools with pending status tools = list_tools() pending_tools = [] for name in tools: config_path = get_tools_dir() / name / "config.yaml" if config_path.exists(): try: config_data = yaml.safe_load(config_path.read_text()) or {} status = config_data.get("registry_status") if status in ("pending", "changes_requested") and config_data.get("registry_hash"): pending_tools.append(name) except Exception: pass if pending_tools: self._start_background_sync(pending_tools) else: # No more pending tools, stop timer if self._poll_timer and self._poll_timer.isActive(): self._poll_timer.stop() self._has_pending_tools = False def _on_tool_status_updated(self, tool_name: str): """Handle background sync updating a tool's status.""" # Refresh the display - _syncing flag prevents re-triggering sync self.refresh() self.main_window.show_status(f"Status updated for '{tool_name}'") def _filter_tools(self, text: str): """Filter tools by name or description based on search text.""" search = text.lower().strip() for i in range(self.tool_tree.topLevelItemCount()): category_item = self.tool_tree.topLevelItem(i) visible_children = 0 for j in range(category_item.childCount()): tool_item = category_item.child(j) tool_name = tool_item.data(0, Qt.UserRole) if not search: # No filter - show all tool_item.setHidden(False) visible_children += 1 else: # Check if name matches matches = search in tool_name.lower() # Also check description if we have the tool loaded if not matches: tool = load_tool(tool_name) if tool and tool.description: matches = search in tool.description.lower() tool_item.setHidden(not matches) if matches: visible_children += 1 # Hide category if no children match category_item.setHidden(visible_children == 0) # Expand categories when filtering to show results if search and visible_children > 0: category_item.setExpanded(True) 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.rating_bar.setVisible(False) 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.rating_bar.setVisible(False) self.readme_container.setVisible(False) self._update_buttons() return tool = load_tool(tool_name) if tool: self._current_tool = tool self._show_tool_info(tool, tool_name) self._fetch_rating_if_needed(tool_name) self._update_buttons() def _get_qualified_name(self) -> Optional[str]: """Get the qualified name (e.g., 'official/summarize') of the selected tool.""" items = self.tool_tree.selectedItems() if items: return items[0].data(0, Qt.UserRole) return self._current_tool.name if self._current_tool else None 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 _get_tool_feedback(self, tool_name: str) -> Optional[str]: """Get the feedback for a tool from its config.""" config_path = get_tools_dir() / tool_name / "config.yaml" if config_path.exists(): try: config_data = yaml.safe_load(config_path.read_text()) or {} return config_data.get("registry_feedback") except Exception: pass return None def _show_tool_info(self, tool: Tool, qualified_name: Optional[str] = None): """Display tool information. Args: tool: The Tool object. qualified_name: The qualified name (e.g. 'official/foo') used for config path lookups. Falls back to tool.name if not provided. """ qname = qualified_name or tool.name lines = [] # Name and description lines.append(f"

{tool.name}

") if tool.description: lines.append(f"

{tool.description}

") # Publish state state, registry_hash = get_tool_publish_state(qname) if state == "published": lines.append( "

" "✓ Published to registry - approved

" ) elif state == "installed": lines.append( "

" "↓ Installed from registry

" ) elif state == "pending": lines.append( "

" "◐ Submitted to registry - pending review

" ) elif state == "changes_requested": lines.append( "

" "⚠ Changes requested - please address feedback and republish

" ) # Show feedback if available feedback = self._get_tool_feedback(qname) if feedback: feedback_escaped = feedback.replace("<", "<").replace(">", ">").replace("\n", "
") lines.append( f"
" f"Feedback:
{feedback_escaped}
" ) elif state == "rejected": lines.append( "

" "✗ Rejected by moderator

" ) # Show feedback if available feedback = self._get_tool_feedback(qname) if feedback: feedback_escaped = feedback.replace("<", "<").replace(">", ">").replace("\n", "
") lines.append( f"
" f"Reason:
{feedback_escaped}
" ) # Source info if tool.source: source_type = tool.source.type if source_type == "imported": source_url = tool.source.url or "registry" lines.append(f"

Imported from {source_url}

") elif source_type == "forked": lines.append(f"

Forked from {tool.source.original_tool}

") # Arguments if tool.arguments: lines.append("

Arguments

") lines.append("") # Steps if tool.steps: lines.append("

Steps

") lines.append("
    ") for i, step in enumerate(tool.steps, 1): if isinstance(step, PromptStep): lines.append(f"
  1. Prompt using {step.provider}${step.output_var}
  2. ") elif isinstance(step, CodeStep): lines.append(f"
  3. Code (python) → ${step.output_var}
  4. ") elif isinstance(step, ToolStep): lines.append(f"
  5. Tool: {step.tool}${step.output_var}
  6. ") lines.append("
") # Output if tool.output: lines.append("

Output Template

") output_escaped = tool.output.replace("<", "<").replace(">", ">") lines.append(f"
{output_escaped}
") # Category if tool.category: lines.append(f"

Category: {tool.category}

") self.info_text.setHtml("\n".join(lines)) # Update rating bar below the detail text self._update_rating_bar(qname) # Reset README section (collapsed, visible, lazy-loaded on expand) self._current_tool = tool self._readme_loaded_for = None self.readme_text.clear() self.readme_text.setVisible(False) self.readme_toggle.setText("▶ README") self.readme_container.setVisible(tool.path is not None) def _on_readme_toggled(self): """Toggle README section visibility and lazy-load content on first expand.""" expanding = not self.readme_text.isVisible() self.readme_text.setVisible(expanding) self.readme_toggle.setText("▼ README" if expanding else "▶ README") if not expanding: return tool = self._current_tool if not tool or not tool.path: return # Avoid reloading if already loaded for this tool if self._readme_loaded_for == tool.name: return readme_path = tool.path.parent / "README.md" if readme_path.exists(): try: content = readme_path.read_text() escaped = content.replace("&", "&").replace("<", "<").replace(">", ">") self.readme_text.setHtml( f"
{escaped}
" ) except Exception as e: self.readme_text.setPlainText(f"Error reading README: {e}") else: self.readme_text.setHtml( "

No README.md found for this tool.

" ) self._readme_loaded_for = tool.name def _update_rating_bar(self, qname: str): """Update the rating bar widget at the bottom of the detail panel.""" registry_info = get_tool_registry_info(qname, self._my_slug) if not registry_info: self.rating_bar.setVisible(False) return self.rating_bar.setVisible(True) cached = self._rating_cache.get(qname) if cached: rating_data = cached.get("rating") my_review = cached.get("my_review") parts = [] if rating_data: avg = rating_data.get("average_rating", 0) count = rating_data.get("rating_count", 0) if count > 0: filled = round(avg) stars = "".join("★" if i < filled else "☆" for i in range(5)) parts.append(f"{stars}" f" {avg:.1f}/5 from {count} review{'s' if count != 1 else ''}") else: parts.append("No ratings yet") if my_review: my_stars = "★" * my_review["rating"] + "☆" * (5 - my_review["rating"]) parts.append(f"Your rating: {my_stars}") self.rating_label.setText(" · ".join(parts) if parts else "") else: self.rating_label.setText("Loading ratings...") def _has_settings(self, tool: Tool) -> bool: """Check if a tool has configurable settings.""" if not tool or not tool.path: return False defaults_path = tool.path.parent / "defaults.yaml" settings_path = tool.path.parent / "settings.yaml" return defaults_path.exists() or settings_path.exists() 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) # Configure button only for tools with settings has_settings = has_selection and self._has_settings(self._current_tool) self.btn_configure.setEnabled(has_settings) config = load_config() if config.registry.token: # Connected - enable Publish when tool selected self.btn_publish.setEnabled(has_selection) else: # Not connected - Connect button is always enabled self.btn_publish.setEnabled(True) # Rate button state self._update_rate_button() 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._get_qualified_name()) def _configure_tool(self): """Open settings dialog for the selected tool.""" if not self._current_tool: return if not self._has_settings(self._current_tool): QMessageBox.information( self, "No Settings", f"Tool '{self._current_tool.name}' has no configurable settings.\n\n" "Add a defaults.yaml to the tool to enable settings." ) return from ..dialogs.settings_dialog import SettingsDialog qualified_name = self._get_qualified_name() dialog = SettingsDialog(self, qualified_name) if dialog.exec(): self.main_window.show_status(f"Settings saved for '{qualified_name}'") def _delete_tool(self): """Delete the selected tool.""" if not self._current_tool: return qualified_name = self._get_qualified_name() reply = QMessageBox.question( self, "Delete Tool", f"Are you sure you want to delete '{qualified_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(qualified_name) self.main_window.show_status(f"Deleted tool '{qualified_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._update_connection_status() 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 tool_name = self._current_tool.name # Save before refresh clears it from ..dialogs.publish_dialog import PublishDialog dialog = PublishDialog(self, self._current_tool) result = dialog.exec() # Always refresh after dialog closes - tool config may have been updated # even if user canceled (e.g., server processed request before client timeout) self.refresh() if result: self.main_window.show_status(f"Published '{tool_name}'") def _update_rate_button(self): """Update the Rate button visibility and state based on current selection.""" if not self._current_tool: self.btn_rate.setVisible(False) return tool_name = self._get_qualified_name() if not tool_name: self.btn_rate.setVisible(False) return registry_info = get_tool_registry_info(tool_name, self._my_slug) if not registry_info: self.btn_rate.setVisible(False) return config = load_config() if not config.registry.token: self.btn_rate.setVisible(False) return owner, name = registry_info # Hide button entirely for your own tools if self._my_slug and owner == self._my_slug: self.btn_rate.setVisible(False) return # Show and enable for other registry tools self.btn_rate.setVisible(True) self.btn_rate.setEnabled(True) cached = self._rating_cache.get(tool_name) if cached and cached.get("my_review"): self.btn_rate.setText("Edit Rating") else: self.btn_rate.setText("Rate Tool") def _on_slug_fetched(self, slug: str): """Handle slug fetched from background sync.""" self._my_slug = slug self._my_slug_fetched = True # Re-evaluate the rate button now that we know the owner self._update_rate_button() def _fetch_rating_if_needed(self, tool_name: str): """Fetch rating data for a registry tool if not cached.""" registry_info = get_tool_registry_info(tool_name, self._my_slug) if not registry_info: return if tool_name in self._rating_cache: # Already cached - just update buttons self._update_rate_button() return owner, name = registry_info # Stop any existing rating worker if self._rating_worker and self._rating_worker.isRunning(): self._rating_worker.wait(500) self._rating_worker = RatingWorker(tool_name, owner, name) self._rating_worker.finished.connect(self._on_rating_fetched) self._rating_worker.error.connect(self._on_rating_error) self._rating_worker.start() def _on_rating_fetched(self, tool_name: str, result: Dict): """Handle rating data fetched from registry.""" self._rating_cache[tool_name] = result # If this is still the selected tool, refresh its display qualified = self._get_qualified_name() current_name = qualified or (self._current_tool.name if self._current_tool else None) if current_name == tool_name and self._current_tool: self._show_tool_info(self._current_tool, tool_name) self._update_rate_button() def _on_rating_error(self, tool_name: str, error: str): """Handle rating fetch error (non-critical).""" pass def _rate_tool(self): """Open the review dialog for the selected tool.""" if not self._current_tool: return tool_name = self._get_qualified_name() or self._current_tool.name registry_info = get_tool_registry_info(tool_name, self._my_slug) if not registry_info: return owner, name = registry_info cached = self._rating_cache.get(tool_name, {}) existing_review = cached.get("my_review") from ..dialogs.review_dialog import ReviewDialog dialog = ReviewDialog(self, owner, name, existing_review) result = dialog.exec() if dialog.delete_requested and existing_review: # User wants to delete their review self._delete_review(tool_name, existing_review["id"]) elif result: # User submitted/updated data = dialog.get_review_data() review_id = existing_review["id"] if existing_review else None self._submit_review(tool_name, owner, name, data, review_id) def _submit_review(self, tool_name: str, owner: str, name: str, data: Dict, review_id: Optional[int]): """Submit or update a review in background.""" if self._review_worker and self._review_worker.isRunning(): self._review_worker.wait(1000) self._review_worker = SubmitReviewWorker( owner, name, data["rating"], data["title"], data["content"], review_id=review_id ) self._review_worker.finished.connect( lambda msg: self._on_review_action_done(tool_name, msg) ) self._review_worker.error.connect( lambda err: self._on_review_action_error(err) ) self._review_worker.start() def _delete_review(self, tool_name: str, review_id: int): """Delete a review in background.""" if self._review_worker and self._review_worker.isRunning(): self._review_worker.wait(1000) self._review_worker = DeleteReviewWorker(review_id) self._review_worker.finished.connect( lambda msg: self._on_review_action_done(tool_name, msg) ) self._review_worker.error.connect( lambda err: self._on_review_action_error(err) ) self._review_worker.start() def _on_review_action_done(self, tool_name: str, message: str): """Handle review submit/update/delete success.""" self.main_window.show_status(message) # Invalidate cache and re-fetch self._rating_cache.pop(tool_name, None) qualified = self._get_qualified_name() current_name = qualified or (self._current_tool.name if self._current_tool else None) if current_name == tool_name: self._fetch_rating_if_needed(tool_name) def _on_review_action_error(self, error: str): """Handle review action error.""" self.main_window.show_status(f"Review error: {error}") def _report_issue(self): """Open the issue report dialog for the selected tool.""" if not self._current_tool: return tool_name = self._get_qualified_name() or self._current_tool.name registry_info = get_tool_registry_info(tool_name, self._my_slug) if not registry_info: return owner, name = registry_info from ..dialogs.issue_dialog import IssueDialog dialog = IssueDialog(self, owner, name) if not dialog.exec(): return data = dialog.get_issue_data() self._issue_worker = SubmitIssueWorker( owner, name, data["issue_type"], data["severity"], data["title"], data["description"] ) self._issue_worker.finished.connect( lambda msg: self.main_window.show_status(msg) ) self._issue_worker.error.connect( lambda err: self.main_window.show_status(f"Issue report failed: {err}") ) self._issue_worker.start() def _sync_tool_status(self): """Sync the moderation status of the selected tool from the registry.""" if not self._current_tool: return tool_name = self._get_qualified_name() or self._current_tool.name config_path = get_tools_dir() / tool_name / "config.yaml" if not config_path.exists(): return try: from ...registry_client import RegistryClient, RegistryError config = load_config() client = RegistryClient() client.token = config.registry.token # Get status from registry status_data = client.get_my_tool_status(tool_name) new_status = status_data.get("status", "pending") new_hash = status_data.get("config_hash") new_feedback = status_data.get("feedback") # Update local config config_data = yaml.safe_load(config_path.read_text()) or {} old_status = config_data.get("registry_status", "pending") old_hash = config_data.get("registry_hash") old_feedback = config_data.get("registry_feedback") changed = False if old_status != new_status: config_data["registry_status"] = new_status changed = True if new_hash and old_hash != new_hash: config_data["registry_hash"] = new_hash changed = True if new_feedback != old_feedback: if new_feedback: config_data["registry_feedback"] = new_feedback elif "registry_feedback" in config_data: del config_data["registry_feedback"] changed = True if changed: config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False)) self.refresh() if new_status == "approved": self.main_window.show_status(f"Tool '{tool_name}' has been approved!") elif new_status == "rejected": self.main_window.show_status(f"Tool '{tool_name}' was rejected") elif new_status == "changes_requested": self.main_window.show_status(f"Changes requested for '{tool_name}' - see feedback") else: self.main_window.show_status(f"Status updated: {new_status}") else: self.main_window.show_status(f"Status unchanged: {new_status}") except RegistryError as e: QMessageBox.warning(self, "Sync Error", f"Could not sync status:\n{e.message}") except Exception as e: QMessageBox.warning(self, "Sync Error", f"Could not sync status:\n{e}")