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:
@@ -0,0 +1,18 @@
|
||||
"""PySide6 GUI for CmdForge."""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def run_gui():
|
||||
"""Launch the CmdForge GUI application."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from .main_window import MainWindow
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("CmdForge")
|
||||
app.setOrganizationName("CmdForge")
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
return app.exec()
|
||||
@@ -0,0 +1 @@
|
||||
"""GUI dialogs."""
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Argument editor dialog."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
||||
QCheckBox, QPushButton, QHBoxLayout, QLabel
|
||||
)
|
||||
|
||||
from ...tool import ToolArgument
|
||||
|
||||
|
||||
class ArgumentDialog(QDialog):
|
||||
"""Dialog for editing tool arguments."""
|
||||
|
||||
def __init__(self, parent, argument: ToolArgument = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit Argument" if argument else "Add Argument")
|
||||
self.setMinimumWidth(400)
|
||||
self._argument = argument
|
||||
self._setup_ui()
|
||||
|
||||
if argument:
|
||||
self._load_argument(argument)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
self.flag_input = QLineEdit()
|
||||
self.flag_input.setPlaceholderText("--input, -i")
|
||||
form.addRow("Flag:", self.flag_input)
|
||||
|
||||
self.var_input = QLineEdit()
|
||||
self.var_input.setPlaceholderText("input_text")
|
||||
form.addRow("Variable:", self.var_input)
|
||||
|
||||
self.default_input = QLineEdit()
|
||||
self.default_input.setPlaceholderText("(optional)")
|
||||
form.addRow("Default:", self.default_input)
|
||||
|
||||
self.desc_input = QLineEdit()
|
||||
self.desc_input.setPlaceholderText("(optional)")
|
||||
form.addRow("Description:", self.desc_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(
|
||||
"The flag is how users specify this argument (e.g. --input).\n"
|
||||
"The variable name is used in prompts and templates as ${variable}."
|
||||
)
|
||||
help_text.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
help_text.setWordWrap(True)
|
||||
layout.addWidget(help_text)
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_ok = QPushButton("OK")
|
||||
self.btn_ok.clicked.connect(self._validate_and_accept)
|
||||
buttons.addWidget(self.btn_ok)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _load_argument(self, arg: ToolArgument):
|
||||
"""Load argument data into form."""
|
||||
self.flag_input.setText(arg.flag)
|
||||
self.var_input.setText(arg.variable)
|
||||
self.default_input.setText(arg.default or "")
|
||||
self.desc_input.setText(arg.description or "")
|
||||
|
||||
def _validate_and_accept(self):
|
||||
"""Validate input and accept."""
|
||||
flag = self.flag_input.text().strip()
|
||||
variable = self.var_input.text().strip()
|
||||
|
||||
if not flag:
|
||||
self.flag_input.setFocus()
|
||||
return
|
||||
|
||||
if not variable:
|
||||
self.var_input.setFocus()
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_argument(self) -> ToolArgument:
|
||||
"""Get the argument from form data."""
|
||||
return ToolArgument(
|
||||
flag=self.flag_input.text().strip(),
|
||||
variable=self.var_input.text().strip(),
|
||||
default=self.default_input.text().strip() or None,
|
||||
description=self.desc_input.text().strip() or ""
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
"""Connect to registry dialog."""
|
||||
|
||||
import socket
|
||||
import time
|
||||
import requests
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QLabel, QPushButton,
|
||||
QHBoxLayout, QLineEdit, QProgressBar, QTextEdit
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||
|
||||
from ...config import load_config, save_config, get_registry_url, set_registry_token
|
||||
|
||||
|
||||
class PairingWorker(QThread):
|
||||
"""Background worker for pairing flow."""
|
||||
success = Signal(str) # token
|
||||
error = Signal(str)
|
||||
status = Signal(str)
|
||||
|
||||
def __init__(self, username: str):
|
||||
super().__init__()
|
||||
self.username = username
|
||||
self.hostname = socket.gethostname()
|
||||
self._stop = False
|
||||
|
||||
def stop(self):
|
||||
self._stop = True
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
registry_url = get_registry_url()
|
||||
|
||||
# Remove trailing /api/v1 if present to get base URL
|
||||
base_url = registry_url.rstrip("/")
|
||||
if base_url.endswith("/api/v1"):
|
||||
base_url = base_url[:-7]
|
||||
|
||||
pairing_url = f"{base_url}/api/v1/pairing/check/{self.username}"
|
||||
|
||||
self.status.emit(f"Connecting to {base_url}...")
|
||||
|
||||
# Poll for pairing status
|
||||
max_attempts = 150 # 5 minutes at 2-second intervals
|
||||
attempt = 0
|
||||
|
||||
while attempt < max_attempts:
|
||||
if self._stop:
|
||||
return
|
||||
|
||||
attempt += 1
|
||||
try:
|
||||
response = requests.get(
|
||||
pairing_url,
|
||||
params={"hostname": self.hostname},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
status = data.get("data", {}).get("status")
|
||||
|
||||
if status == "connected":
|
||||
token = data.get("data", {}).get("token")
|
||||
if token:
|
||||
self.success.emit(token)
|
||||
return
|
||||
else:
|
||||
self.error.emit("Pairing completed but no token received")
|
||||
return
|
||||
elif status == "expired":
|
||||
self.error.emit("Pairing request expired. Please try again.")
|
||||
return
|
||||
elif status == "pending":
|
||||
self.status.emit(f"Pairing pending, approve in web UI... ({attempt})")
|
||||
elif status == "not_found":
|
||||
self.status.emit(f"Waiting for web UI pairing... ({attempt})")
|
||||
else:
|
||||
self.status.emit(f"Status: {status} ({attempt})")
|
||||
elif response.status_code == 404:
|
||||
self.status.emit(f"Waiting for web approval... ({attempt})")
|
||||
else:
|
||||
self.status.emit(f"Server returned {response.status_code}")
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
self.status.emit(f"Network error, retrying... ({attempt})")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
self.error.emit("Timed out waiting for approval")
|
||||
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class ConnectDialog(QDialog):
|
||||
"""Dialog for connecting to registry."""
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Connect to Registry")
|
||||
self.setMinimumWidth(450)
|
||||
self._worker = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Title
|
||||
title = QLabel("Connect Your Account")
|
||||
title.setStyleSheet("font-size: 16px; font-weight: 600;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Instructions
|
||||
self.instructions = QLabel(
|
||||
"To publish tools, connect your CmdForge account.\n\n"
|
||||
"1. Enter your CmdForge username below\n"
|
||||
"2. Click Connect\n"
|
||||
"3. Go to cmdforge.brrd.tech/dashboard/connected-apps\n"
|
||||
"4. Click 'Connect New App' and approve this device"
|
||||
)
|
||||
self.instructions.setWordWrap(True)
|
||||
layout.addWidget(self.instructions)
|
||||
|
||||
# Username input
|
||||
username_layout = QHBoxLayout()
|
||||
username_label = QLabel("Username:")
|
||||
self.username_input = QLineEdit()
|
||||
self.username_input.setPlaceholderText("your-username")
|
||||
self.username_input.returnPressed.connect(self._start_pairing)
|
||||
username_layout.addWidget(username_label)
|
||||
username_layout.addWidget(self.username_input, 1)
|
||||
layout.addLayout(username_layout)
|
||||
|
||||
# Device info
|
||||
hostname = socket.gethostname()
|
||||
device_label = QLabel(f"Device: {hostname}")
|
||||
device_label.setStyleSheet("color: #718096; font-size: 12px;")
|
||||
layout.addWidget(device_label)
|
||||
|
||||
# Status
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #718096;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Log area for debugging
|
||||
self.log_area = QTextEdit()
|
||||
self.log_area.setReadOnly(True)
|
||||
self.log_area.setMaximumHeight(120)
|
||||
self.log_area.setStyleSheet("font-family: monospace; font-size: 11px; background: #f7fafc;")
|
||||
self.log_area.hide()
|
||||
layout.addWidget(self.log_area)
|
||||
|
||||
# Progress
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 0) # Indeterminate
|
||||
self.progress.hide()
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self._cancel)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_connect = QPushButton("Connect")
|
||||
self.btn_connect.clicked.connect(self._start_pairing)
|
||||
buttons.addWidget(self.btn_connect)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _start_pairing(self):
|
||||
"""Start the pairing flow."""
|
||||
username = self.username_input.text().strip()
|
||||
if not username:
|
||||
self.status_label.setText("Please enter your username")
|
||||
self.status_label.setStyleSheet("color: #e53e3e;")
|
||||
return
|
||||
|
||||
self.btn_connect.setEnabled(False)
|
||||
self.username_input.setEnabled(False)
|
||||
self.progress.show()
|
||||
self.log_area.clear()
|
||||
self.log_area.show()
|
||||
self.status_label.setStyleSheet("color: #718096;")
|
||||
|
||||
self._worker = PairingWorker(username)
|
||||
self._worker.success.connect(self._on_success)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.status.connect(self._on_status)
|
||||
self._worker.start()
|
||||
|
||||
def _on_success(self, token: str):
|
||||
"""Handle successful pairing."""
|
||||
set_registry_token(token)
|
||||
|
||||
self.progress.hide()
|
||||
self.status_label.setText("Connected successfully!")
|
||||
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
|
||||
|
||||
QTimer.singleShot(1000, self.accept)
|
||||
|
||||
def _on_error(self, error: str):
|
||||
"""Handle error."""
|
||||
self.progress.hide()
|
||||
self.btn_connect.setEnabled(True)
|
||||
self.username_input.setEnabled(True)
|
||||
self.status_label.setText(f"Error: {error}")
|
||||
self.status_label.setStyleSheet("color: #e53e3e;")
|
||||
|
||||
def _on_status(self, status: str):
|
||||
"""Handle status update."""
|
||||
self.status_label.setText(status)
|
||||
# Also append to log for debugging
|
||||
self.log_area.append(status)
|
||||
|
||||
def _cancel(self):
|
||||
"""Cancel pairing."""
|
||||
if self._worker:
|
||||
self._worker.stop()
|
||||
self._worker.wait(1000)
|
||||
self.reject()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle close."""
|
||||
if self._worker:
|
||||
self._worker.stop()
|
||||
self._worker.wait(1000)
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Provider editor dialog."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
||||
QPushButton, QHBoxLayout, QLabel
|
||||
)
|
||||
|
||||
from ...providers import Provider, add_provider
|
||||
|
||||
|
||||
class ProviderDialog(QDialog):
|
||||
"""Dialog for adding/editing providers."""
|
||||
|
||||
def __init__(self, parent, name: str = None, provider: Provider = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit Provider" if provider else "Add Provider")
|
||||
self.setMinimumWidth(450)
|
||||
self._editing = provider is not None
|
||||
self._original_name = name
|
||||
self._setup_ui()
|
||||
|
||||
if provider:
|
||||
self._load_provider(name, provider)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
self.name_input = QLineEdit()
|
||||
self.name_input.setPlaceholderText("claude")
|
||||
form.addRow("Name:", self.name_input)
|
||||
|
||||
self.cmd_input = QLineEdit()
|
||||
self.cmd_input.setPlaceholderText("claude-cli")
|
||||
form.addRow("Command:", self.cmd_input)
|
||||
|
||||
self.desc_input = QLineEdit()
|
||||
self.desc_input.setPlaceholderText("Claude AI via claude-cli")
|
||||
form.addRow("Description:", self.desc_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(
|
||||
"The command should accept input on stdin and output to stdout.\n"
|
||||
"Example commands: claude-cli, sgpt, llm, mods"
|
||||
)
|
||||
help_text.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
help_text.setWordWrap(True)
|
||||
layout.addWidget(help_text)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_ok = QPushButton("Save")
|
||||
self.btn_ok.clicked.connect(self._save)
|
||||
buttons.addWidget(self.btn_ok)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _load_provider(self, name: str, provider: Provider):
|
||||
"""Load provider data into form."""
|
||||
self.name_input.setText(name)
|
||||
self.name_input.setEnabled(False) # Can't rename
|
||||
self.cmd_input.setText(provider.command)
|
||||
self.desc_input.setText(provider.description or "")
|
||||
|
||||
def _save(self):
|
||||
"""Save the provider."""
|
||||
name = self.name_input.text().strip()
|
||||
command = self.cmd_input.text().strip()
|
||||
|
||||
if not name:
|
||||
self.name_input.setFocus()
|
||||
return
|
||||
|
||||
if not command:
|
||||
self.cmd_input.setFocus()
|
||||
return
|
||||
|
||||
description = self.desc_input.text().strip() or None
|
||||
|
||||
try:
|
||||
add_provider(name, command, description)
|
||||
self.accept()
|
||||
except Exception as e:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
QMessageBox.critical(self, "Error", f"Failed to save provider:\n{e}")
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Publish tool dialog."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QLabel, QPushButton,
|
||||
QHBoxLayout, QLineEdit, QTextEdit, QFormLayout,
|
||||
QProgressBar, QMessageBox, QComboBox
|
||||
)
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from ...tool import Tool
|
||||
from ...registry_client import RegistryClient, RegistryError
|
||||
from ...config import load_config
|
||||
|
||||
|
||||
class PublishWorker(QThread):
|
||||
"""Background worker for publishing."""
|
||||
success = Signal(dict)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, tool_data: dict):
|
||||
super().__init__()
|
||||
self.tool_data = tool_data
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
result = client.publish_tool(self.tool_data)
|
||||
self.success.emit(result)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class PublishDialog(QDialog):
|
||||
"""Dialog for publishing a tool."""
|
||||
|
||||
def __init__(self, parent, tool: Tool):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Publish Tool")
|
||||
self.setMinimumSize(500, 400)
|
||||
self._tool = tool
|
||||
self._worker = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Title
|
||||
title = QLabel(f"Publish '{self._tool.name}'")
|
||||
title.setStyleSheet("font-size: 16px; font-weight: 600;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
# Version
|
||||
self.version_input = QLineEdit()
|
||||
self.version_input.setText("1.0.0")
|
||||
self.version_input.setPlaceholderText("1.0.0")
|
||||
form.addRow("Version:", self.version_input)
|
||||
|
||||
# Category
|
||||
self.category_combo = QComboBox()
|
||||
self.category_combo.setEditable(True)
|
||||
categories = [
|
||||
"Text Processing", "Code", "Data", "System",
|
||||
"Writing", "Analysis", "Productivity", "Other"
|
||||
]
|
||||
self.category_combo.addItems(categories)
|
||||
if self._tool.category:
|
||||
idx = self.category_combo.findText(self._tool.category)
|
||||
if idx >= 0:
|
||||
self.category_combo.setCurrentIndex(idx)
|
||||
else:
|
||||
self.category_combo.setCurrentText(self._tool.category)
|
||||
form.addRow("Category:", self.category_combo)
|
||||
|
||||
# Tags
|
||||
self.tags_input = QLineEdit()
|
||||
self.tags_input.setPlaceholderText("ai, text, productivity (comma separated)")
|
||||
form.addRow("Tags:", self.tags_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Description
|
||||
desc_label = QLabel("Description:")
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
self.desc_input = QTextEdit()
|
||||
self.desc_input.setPlaceholderText("Describe what your tool does...")
|
||||
self.desc_input.setPlainText(self._tool.description or "")
|
||||
self.desc_input.setMaximumHeight(100)
|
||||
layout.addWidget(self.desc_input)
|
||||
|
||||
# Status
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #718096;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Progress
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 0)
|
||||
self.progress.hide()
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_publish = QPushButton("Publish")
|
||||
self.btn_publish.clicked.connect(self._publish)
|
||||
buttons.addWidget(self.btn_publish)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _publish(self):
|
||||
"""Publish the tool."""
|
||||
version = self.version_input.text().strip()
|
||||
if not version:
|
||||
QMessageBox.warning(self, "Validation", "Version is required")
|
||||
return
|
||||
|
||||
description = self.desc_input.toPlainText().strip()
|
||||
if not description:
|
||||
QMessageBox.warning(self, "Validation", "Description is required")
|
||||
return
|
||||
|
||||
category = self.category_combo.currentText()
|
||||
tags = [t.strip() for t in self.tags_input.text().split(",") if t.strip()]
|
||||
|
||||
# Build tool data for publishing
|
||||
tool_data = {
|
||||
"name": self._tool.name,
|
||||
"version": version,
|
||||
"description": description,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"definition": self._tool.to_dict() if hasattr(self._tool, 'to_dict') else {}
|
||||
}
|
||||
|
||||
self.btn_publish.setEnabled(False)
|
||||
self.btn_cancel.setEnabled(False)
|
||||
self.progress.show()
|
||||
self.status_label.setText("Publishing...")
|
||||
|
||||
self._worker = PublishWorker(tool_data)
|
||||
self._worker.success.connect(self._on_success)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_success(self, result: dict):
|
||||
"""Handle publish success."""
|
||||
self.progress.hide()
|
||||
self.status_label.setText("Published successfully!")
|
||||
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
|
||||
|
||||
QMessageBox.information(
|
||||
self, "Success",
|
||||
f"Tool '{self._tool.name}' has been published.\n\n"
|
||||
f"It will be available after review."
|
||||
)
|
||||
self.accept()
|
||||
|
||||
def _on_error(self, error: str):
|
||||
"""Handle publish error."""
|
||||
self.progress.hide()
|
||||
self.btn_publish.setEnabled(True)
|
||||
self.btn_cancel.setEnabled(True)
|
||||
self.status_label.setText(f"Error: {error}")
|
||||
self.status_label.setStyleSheet("color: #e53e3e;")
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Step editor dialogs."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
||||
QComboBox, QPushButton, QHBoxLayout, QLabel,
|
||||
QPlainTextEdit
|
||||
)
|
||||
|
||||
from ...tool import PromptStep, CodeStep
|
||||
from ...providers import load_providers
|
||||
|
||||
|
||||
class PromptStepDialog(QDialog):
|
||||
"""Dialog for editing prompt steps."""
|
||||
|
||||
def __init__(self, parent, step: PromptStep = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit Prompt Step" if step else "Add Prompt Step")
|
||||
self.setMinimumSize(500, 400)
|
||||
self._step = step
|
||||
self._setup_ui()
|
||||
|
||||
if step:
|
||||
self._load_step(step)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
# Provider selection
|
||||
self.provider_combo = QComboBox()
|
||||
providers = load_providers()
|
||||
for provider in sorted(providers, key=lambda p: p.name):
|
||||
self.provider_combo.addItem(provider.name)
|
||||
# Add common defaults if not present
|
||||
for default in ["claude", "gpt", "mock"]:
|
||||
if self.provider_combo.findText(default) < 0:
|
||||
self.provider_combo.addItem(default)
|
||||
form.addRow("Provider:", self.provider_combo)
|
||||
|
||||
# Output variable
|
||||
self.output_input = QLineEdit()
|
||||
self.output_input.setPlaceholderText("response")
|
||||
self.output_input.setText("response")
|
||||
form.addRow("Output variable:", self.output_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Prompt text
|
||||
prompt_label = QLabel("Prompt template:")
|
||||
layout.addWidget(prompt_label)
|
||||
|
||||
self.prompt_input = QPlainTextEdit()
|
||||
self.prompt_input.setPlaceholderText(
|
||||
"Enter your prompt here...\n\n"
|
||||
"Use {input} for stdin input.\n"
|
||||
"Use {variable} for argument values.\n"
|
||||
"Use {prev_output} for previous step output."
|
||||
)
|
||||
layout.addWidget(self.prompt_input, 1)
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_ok = QPushButton("OK")
|
||||
self.btn_ok.clicked.connect(self._validate_and_accept)
|
||||
buttons.addWidget(self.btn_ok)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _load_step(self, step: PromptStep):
|
||||
"""Load step data into form."""
|
||||
idx = self.provider_combo.findText(step.provider)
|
||||
if idx >= 0:
|
||||
self.provider_combo.setCurrentIndex(idx)
|
||||
else:
|
||||
self.provider_combo.setCurrentText(step.provider)
|
||||
|
||||
self.output_input.setText(step.output_var)
|
||||
self.prompt_input.setPlainText(step.prompt)
|
||||
|
||||
def _validate_and_accept(self):
|
||||
"""Validate and accept."""
|
||||
prompt = self.prompt_input.toPlainText().strip()
|
||||
if not prompt:
|
||||
self.prompt_input.setFocus()
|
||||
return
|
||||
|
||||
output = self.output_input.text().strip()
|
||||
if not output:
|
||||
self.output_input.setFocus()
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_step(self) -> PromptStep:
|
||||
"""Get the step from form data."""
|
||||
return PromptStep(
|
||||
prompt=self.prompt_input.toPlainText(),
|
||||
provider=self.provider_combo.currentText(),
|
||||
output_var=self.output_input.text().strip()
|
||||
)
|
||||
|
||||
|
||||
class CodeStepDialog(QDialog):
|
||||
"""Dialog for editing code steps."""
|
||||
|
||||
def __init__(self, parent, step: CodeStep = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit Code Step" if step else "Add Code Step")
|
||||
self.setMinimumSize(600, 500)
|
||||
self._step = step
|
||||
self._setup_ui()
|
||||
|
||||
if step:
|
||||
self._load_step(step)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
# Output variable
|
||||
self.output_input = QLineEdit()
|
||||
self.output_input.setPlaceholderText("result")
|
||||
self.output_input.setText("result")
|
||||
form.addRow("Output variable:", self.output_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Code editor
|
||||
code_label = QLabel("Code:")
|
||||
layout.addWidget(code_label)
|
||||
|
||||
self.code_input = QPlainTextEdit()
|
||||
self.code_input.setPlaceholderText(
|
||||
"# Python code here\n"
|
||||
"# Access input with: input_text\n"
|
||||
"# Access args with their variable names\n"
|
||||
"# Access previous step outputs with their variable names\n\n"
|
||||
"result = input_text.upper()"
|
||||
)
|
||||
font = self.code_input.font()
|
||||
font.setFamily("Courier New, Consolas, monospace")
|
||||
self.code_input.setFont(font)
|
||||
layout.addWidget(self.code_input, 1)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(
|
||||
"The code will be executed with variables in scope. "
|
||||
"The last expression or the output variable will be captured."
|
||||
)
|
||||
help_text.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
help_text.setWordWrap(True)
|
||||
layout.addWidget(help_text)
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_ok = QPushButton("OK")
|
||||
self.btn_ok.clicked.connect(self._validate_and_accept)
|
||||
buttons.addWidget(self.btn_ok)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _load_step(self, step: CodeStep):
|
||||
"""Load step data into form."""
|
||||
self.output_input.setText(step.output_var)
|
||||
self.code_input.setPlainText(step.code)
|
||||
|
||||
def _validate_and_accept(self):
|
||||
"""Validate and accept."""
|
||||
code = self.code_input.toPlainText().strip()
|
||||
if not code:
|
||||
self.code_input.setFocus()
|
||||
return
|
||||
|
||||
output = self.output_input.text().strip()
|
||||
if not output:
|
||||
self.output_input.setFocus()
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_step(self) -> CodeStep:
|
||||
"""Get the step from form data."""
|
||||
return CodeStep(
|
||||
code=self.code_input.toPlainText(),
|
||||
output_var=self.output_input.text().strip()
|
||||
)
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Main window for CmdForge GUI."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
|
||||
QListWidget, QListWidgetItem, QStackedWidget,
|
||||
QStatusBar, QLabel, QSplitter
|
||||
)
|
||||
from PySide6.QtCore import Qt, QSize, QSettings
|
||||
from PySide6.QtGui import QIcon, QFont, QShortcut, QKeySequence
|
||||
|
||||
from .styles import STYLESHEET
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window with sidebar navigation."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("CmdForge")
|
||||
self.setMinimumSize(1000, 700)
|
||||
self.resize(1200, 800)
|
||||
|
||||
# Apply stylesheet
|
||||
self.setStyleSheet(STYLESHEET)
|
||||
|
||||
# Settings for persistence
|
||||
self._settings = QSettings("CmdForge", "CmdForge")
|
||||
|
||||
# Central widget
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
|
||||
# Main layout
|
||||
layout = QHBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Sidebar
|
||||
self.sidebar = QListWidget()
|
||||
self.sidebar.setObjectName("sidebar")
|
||||
self.sidebar.setFixedWidth(180)
|
||||
self.sidebar.setFocusPolicy(Qt.NoFocus)
|
||||
self._setup_sidebar()
|
||||
|
||||
# Stacked pages
|
||||
self.pages = QStackedWidget()
|
||||
self.pages.setObjectName("content_area")
|
||||
|
||||
# Add pages (lazy load to avoid import issues)
|
||||
self._setup_pages()
|
||||
|
||||
# Add to layout
|
||||
layout.addWidget(self.sidebar)
|
||||
layout.addWidget(self.pages, 1)
|
||||
|
||||
# Status bar
|
||||
self.status_bar = QStatusBar()
|
||||
self.setStatusBar(self.status_bar)
|
||||
self.status_bar.showMessage("Ready")
|
||||
|
||||
# Connect sidebar
|
||||
self.sidebar.currentRowChanged.connect(self._on_page_changed)
|
||||
self.sidebar.setCurrentRow(0)
|
||||
|
||||
# Setup keyboard shortcuts
|
||||
self._setup_shortcuts()
|
||||
|
||||
# Restore window geometry
|
||||
self._restore_geometry()
|
||||
|
||||
def _setup_sidebar(self):
|
||||
"""Set up sidebar navigation items."""
|
||||
items = [
|
||||
("Tools", "Manage your tools"),
|
||||
("Registry", "Browse and install tools"),
|
||||
("Providers", "Configure AI providers"),
|
||||
]
|
||||
|
||||
font = QFont()
|
||||
font.setPointSize(11)
|
||||
|
||||
for name, tooltip in items:
|
||||
item = QListWidgetItem(name)
|
||||
item.setFont(font)
|
||||
item.setToolTip(tooltip)
|
||||
item.setSizeHint(QSize(180, 48))
|
||||
self.sidebar.addItem(item)
|
||||
|
||||
def _setup_pages(self):
|
||||
"""Set up content pages."""
|
||||
# Import pages here to avoid circular imports
|
||||
from .pages.tools_page import ToolsPage
|
||||
from .pages.registry_page import RegistryPage
|
||||
from .pages.providers_page import ProvidersPage
|
||||
|
||||
self.tools_page = ToolsPage(self)
|
||||
self.registry_page = RegistryPage(self)
|
||||
self.providers_page = ProvidersPage(self)
|
||||
|
||||
self.pages.addWidget(self.tools_page)
|
||||
self.pages.addWidget(self.registry_page)
|
||||
self.pages.addWidget(self.providers_page)
|
||||
|
||||
def _on_page_changed(self, index: int):
|
||||
"""Handle page changes."""
|
||||
self.pages.setCurrentIndex(index)
|
||||
|
||||
# Refresh page if it has a refresh method
|
||||
current_page = self.pages.currentWidget()
|
||||
if hasattr(current_page, 'refresh'):
|
||||
current_page.refresh()
|
||||
|
||||
def show_status(self, message: str, timeout: int = 5000):
|
||||
"""Show a status bar message."""
|
||||
self.status_bar.showMessage(message, timeout)
|
||||
|
||||
def navigate_to(self, page_name: str):
|
||||
"""Navigate to a specific page by name."""
|
||||
page_map = {
|
||||
"tools": 0,
|
||||
"registry": 1,
|
||||
"providers": 2,
|
||||
}
|
||||
if page_name.lower() in page_map:
|
||||
self.sidebar.setCurrentRow(page_map[page_name.lower()])
|
||||
|
||||
def open_tool_builder(self, tool_name: str = None):
|
||||
"""Open the tool builder page for creating or editing a tool."""
|
||||
from .pages.tool_builder_page import ToolBuilderPage
|
||||
|
||||
# Create builder page
|
||||
builder = ToolBuilderPage(self, tool_name)
|
||||
|
||||
# Add to stack and switch to it
|
||||
index = self.pages.addWidget(builder)
|
||||
self.pages.setCurrentIndex(index)
|
||||
|
||||
# Hide sidebar selection
|
||||
self.sidebar.clearSelection()
|
||||
|
||||
def close_tool_builder(self):
|
||||
"""Close the tool builder and return to tools page."""
|
||||
# Remove builder page from stack
|
||||
current = self.pages.currentWidget()
|
||||
if hasattr(current, '__class__') and current.__class__.__name__ == 'ToolBuilderPage':
|
||||
self.pages.removeWidget(current)
|
||||
current.deleteLater()
|
||||
|
||||
# Return to tools page
|
||||
self.sidebar.setCurrentRow(0)
|
||||
self.tools_page.refresh()
|
||||
|
||||
def _setup_shortcuts(self):
|
||||
"""Set up keyboard shortcuts."""
|
||||
# Ctrl+N: New tool
|
||||
shortcut_new = QShortcut(QKeySequence("Ctrl+N"), self)
|
||||
shortcut_new.activated.connect(self._shortcut_new_tool)
|
||||
|
||||
# Ctrl+S: Save (when in tool builder)
|
||||
shortcut_save = QShortcut(QKeySequence("Ctrl+S"), self)
|
||||
shortcut_save.activated.connect(self._shortcut_save)
|
||||
|
||||
# Escape: Go back
|
||||
shortcut_escape = QShortcut(QKeySequence("Escape"), self)
|
||||
shortcut_escape.activated.connect(self._shortcut_escape)
|
||||
|
||||
# Ctrl+1/2/3: Navigate pages
|
||||
shortcut_page1 = QShortcut(QKeySequence("Ctrl+1"), self)
|
||||
shortcut_page1.activated.connect(lambda: self.sidebar.setCurrentRow(0))
|
||||
|
||||
shortcut_page2 = QShortcut(QKeySequence("Ctrl+2"), self)
|
||||
shortcut_page2.activated.connect(lambda: self.sidebar.setCurrentRow(1))
|
||||
|
||||
shortcut_page3 = QShortcut(QKeySequence("Ctrl+3"), self)
|
||||
shortcut_page3.activated.connect(lambda: self.sidebar.setCurrentRow(2))
|
||||
|
||||
# Ctrl+R: Refresh current page
|
||||
shortcut_refresh = QShortcut(QKeySequence("Ctrl+R"), self)
|
||||
shortcut_refresh.activated.connect(self._shortcut_refresh)
|
||||
|
||||
# Ctrl+Q: Quit
|
||||
shortcut_quit = QShortcut(QKeySequence("Ctrl+Q"), self)
|
||||
shortcut_quit.activated.connect(self.close)
|
||||
|
||||
def _shortcut_new_tool(self):
|
||||
"""Handle Ctrl+N: Create new tool."""
|
||||
self.open_tool_builder()
|
||||
self.show_status("Creating new tool... (Ctrl+S to save)")
|
||||
|
||||
def _shortcut_save(self):
|
||||
"""Handle Ctrl+S: Save current tool."""
|
||||
current = self.pages.currentWidget()
|
||||
if hasattr(current, 'save_tool'):
|
||||
current.save_tool()
|
||||
|
||||
def _shortcut_escape(self):
|
||||
"""Handle Escape: Go back or close builder."""
|
||||
current = self.pages.currentWidget()
|
||||
if hasattr(current, '__class__') and current.__class__.__name__ == 'ToolBuilderPage':
|
||||
self.close_tool_builder()
|
||||
else:
|
||||
# Could close dialogs if any are open
|
||||
pass
|
||||
|
||||
def _shortcut_refresh(self):
|
||||
"""Handle Ctrl+R: Refresh current page."""
|
||||
current = self.pages.currentWidget()
|
||||
if hasattr(current, 'refresh'):
|
||||
current.refresh()
|
||||
self.show_status("Refreshed")
|
||||
|
||||
def _restore_geometry(self):
|
||||
"""Restore window geometry from settings."""
|
||||
geometry = self._settings.value("geometry")
|
||||
if geometry:
|
||||
self.restoreGeometry(geometry)
|
||||
|
||||
state = self._settings.value("windowState")
|
||||
if state:
|
||||
self.restoreState(state)
|
||||
|
||||
def _save_geometry(self):
|
||||
"""Save window geometry to settings."""
|
||||
self._settings.setValue("geometry", self.saveGeometry())
|
||||
self._settings.setValue("windowState", self.saveState())
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close - save geometry."""
|
||||
self._save_geometry()
|
||||
super().closeEvent(event)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""GUI pages."""
|
||||
|
||||
from .tools_page import ToolsPage
|
||||
from .tool_builder_page import ToolBuilderPage
|
||||
from .registry_page import RegistryPage
|
||||
from .providers_page import ProvidersPage
|
||||
|
||||
__all__ = ["ToolsPage", "ToolBuilderPage", "RegistryPage", "ProvidersPage"]
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Providers page - manage AI providers."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget,
|
||||
QTableWidgetItem, QHeaderView, QPushButton, QLabel,
|
||||
QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from ...providers import Provider, load_providers, delete_provider
|
||||
|
||||
|
||||
class ProvidersPage(QWidget):
|
||||
"""Provider management page."""
|
||||
|
||||
def __init__(self, main_window):
|
||||
super().__init__()
|
||||
self.main_window = main_window
|
||||
self._setup_ui()
|
||||
|
||||
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("AI Providers")
|
||||
title.setObjectName("heading")
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
self.btn_add = QPushButton("Add Provider")
|
||||
self.btn_add.clicked.connect(self._add_provider)
|
||||
header_layout.addWidget(self.btn_add)
|
||||
|
||||
layout.addWidget(header)
|
||||
|
||||
# Description
|
||||
desc = QLabel(
|
||||
"Providers are external AI commands that CmdForge tools can use. "
|
||||
"Each provider wraps a CLI tool that accepts input on stdin and outputs to stdout."
|
||||
)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet("color: #718096;")
|
||||
layout.addWidget(desc)
|
||||
|
||||
# Providers table
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(3)
|
||||
self.table.setHorizontalHeaderLabels(["Name", "Command", "Description"])
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.table.setSelectionMode(QTableWidget.SingleSelection)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.itemSelectionChanged.connect(self._on_selection_changed)
|
||||
layout.addWidget(self.table, 1)
|
||||
|
||||
# Action buttons
|
||||
buttons = QWidget()
|
||||
btn_layout = QHBoxLayout(buttons)
|
||||
btn_layout.setContentsMargins(0, 0, 0, 0)
|
||||
btn_layout.setSpacing(12)
|
||||
|
||||
self.btn_edit = QPushButton("Edit")
|
||||
self.btn_edit.setObjectName("secondary")
|
||||
self.btn_edit.clicked.connect(self._edit_provider)
|
||||
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_provider)
|
||||
self.btn_delete.setEnabled(False)
|
||||
btn_layout.addWidget(self.btn_delete)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
self.btn_test = QPushButton("Test")
|
||||
self.btn_test.clicked.connect(self._test_provider)
|
||||
self.btn_test.setEnabled(False)
|
||||
btn_layout.addWidget(self.btn_test)
|
||||
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the provider list."""
|
||||
providers = load_providers()
|
||||
|
||||
self.table.setRowCount(len(providers))
|
||||
for row, provider in enumerate(sorted(providers, key=lambda p: p.name)):
|
||||
name_item = QTableWidgetItem(provider.name)
|
||||
name_item.setData(Qt.UserRole, provider)
|
||||
self.table.setItem(row, 0, name_item)
|
||||
self.table.setItem(row, 1, QTableWidgetItem(provider.command))
|
||||
self.table.setItem(row, 2, QTableWidgetItem(provider.description or ""))
|
||||
|
||||
self._on_selection_changed()
|
||||
|
||||
def _on_selection_changed(self):
|
||||
"""Handle selection change."""
|
||||
has_selection = len(self.table.selectedItems()) > 0
|
||||
self.btn_edit.setEnabled(has_selection)
|
||||
self.btn_delete.setEnabled(has_selection)
|
||||
self.btn_test.setEnabled(has_selection)
|
||||
|
||||
def _get_selected_provider(self) -> tuple:
|
||||
"""Get selected provider name and object."""
|
||||
items = self.table.selectedItems()
|
||||
if not items:
|
||||
return None, None
|
||||
|
||||
row = items[0].row()
|
||||
name_item = self.table.item(row, 0)
|
||||
return name_item.text(), name_item.data(Qt.UserRole)
|
||||
|
||||
def _add_provider(self):
|
||||
"""Add a new provider."""
|
||||
from ..dialogs.provider_dialog import ProviderDialog
|
||||
dialog = ProviderDialog(self)
|
||||
if dialog.exec():
|
||||
self.refresh()
|
||||
self.main_window.show_status("Provider added")
|
||||
|
||||
def _edit_provider(self):
|
||||
"""Edit selected provider."""
|
||||
name, provider = self._get_selected_provider()
|
||||
if not name:
|
||||
return
|
||||
|
||||
from ..dialogs.provider_dialog import ProviderDialog
|
||||
dialog = ProviderDialog(self, name, provider)
|
||||
if dialog.exec():
|
||||
self.refresh()
|
||||
self.main_window.show_status("Provider updated")
|
||||
|
||||
def _delete_provider(self):
|
||||
"""Delete selected provider."""
|
||||
name, _ = self._get_selected_provider()
|
||||
if not name:
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Provider",
|
||||
f"Are you sure you want to delete provider '{name}'?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
try:
|
||||
delete_provider(name)
|
||||
self.refresh()
|
||||
self.main_window.show_status(f"Deleted provider '{name}'")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to delete provider:\n{e}")
|
||||
|
||||
def _test_provider(self):
|
||||
"""Test selected provider."""
|
||||
name, provider = self._get_selected_provider()
|
||||
if not name or not provider:
|
||||
return
|
||||
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
# Check if command exists
|
||||
cmd_parts = provider.command.split()
|
||||
if not cmd_parts:
|
||||
QMessageBox.warning(self, "Invalid", "Provider has no command configured")
|
||||
return
|
||||
|
||||
exe = cmd_parts[0]
|
||||
if not shutil.which(exe):
|
||||
QMessageBox.warning(
|
||||
self, "Not Found",
|
||||
f"Command '{exe}' not found in PATH.\n\n"
|
||||
"Make sure the provider is installed and accessible."
|
||||
)
|
||||
return
|
||||
|
||||
# Try running with --help or similar
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd_parts + ["--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
QMessageBox.information(
|
||||
self, "Provider Test",
|
||||
f"Provider '{name}' is available.\n\n"
|
||||
f"Command: {provider.command}\n"
|
||||
f"Exit code: {result.returncode}"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
QMessageBox.information(
|
||||
self, "Provider Test",
|
||||
f"Provider '{name}' command exists but timed out.\n"
|
||||
"This may be normal for interactive commands."
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self, "Provider Test",
|
||||
f"Error testing provider:\n{e}"
|
||||
)
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Tool builder page - create and edit tools."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||
QLineEdit, QTextEdit, QComboBox, QPushButton,
|
||||
QGroupBox, QListWidget, QListWidgetItem, QLabel,
|
||||
QMessageBox, QSplitter, QFrame
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from ...tool import (
|
||||
Tool, ToolArgument, PromptStep, CodeStep,
|
||||
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
|
||||
)
|
||||
|
||||
|
||||
class ToolBuilderPage(QWidget):
|
||||
"""Tool builder/editor page."""
|
||||
|
||||
def __init__(self, main_window, tool_name: str = None):
|
||||
super().__init__()
|
||||
self.main_window = main_window
|
||||
self.editing = tool_name is not None
|
||||
self.original_name = tool_name
|
||||
self._tool = None
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
if tool_name:
|
||||
self._load_tool(tool_name)
|
||||
|
||||
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("Edit Tool" if self.editing else "Create Tool")
|
||||
title.setObjectName("heading")
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self._cancel)
|
||||
header_layout.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_save = QPushButton("Save")
|
||||
self.btn_save.clicked.connect(self._save)
|
||||
header_layout.addWidget(self.btn_save)
|
||||
|
||||
layout.addWidget(header)
|
||||
|
||||
# Main form splitter
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# Left: Basic info
|
||||
left = QWidget()
|
||||
left_layout = QVBoxLayout(left)
|
||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||
left_layout.setSpacing(16)
|
||||
|
||||
# Basic info group
|
||||
info_box = QGroupBox("Basic Information")
|
||||
info_layout = QFormLayout(info_box)
|
||||
info_layout.setSpacing(12)
|
||||
|
||||
self.name_input = QLineEdit()
|
||||
self.name_input.setPlaceholderText("my-tool")
|
||||
info_layout.addRow("Name:", self.name_input)
|
||||
|
||||
self.desc_input = QLineEdit()
|
||||
self.desc_input.setPlaceholderText("A brief description of what this tool does")
|
||||
info_layout.addRow("Description:", self.desc_input)
|
||||
|
||||
self.category_combo = QComboBox()
|
||||
self.category_combo.setEditable(True)
|
||||
for cat in DEFAULT_CATEGORIES:
|
||||
self.category_combo.addItem(cat)
|
||||
info_layout.addRow("Category:", self.category_combo)
|
||||
|
||||
left_layout.addWidget(info_box)
|
||||
|
||||
# Arguments group
|
||||
args_box = QGroupBox("Arguments")
|
||||
args_layout = QVBoxLayout(args_box)
|
||||
|
||||
self.args_list = QListWidget()
|
||||
self.args_list.itemDoubleClicked.connect(self._edit_argument)
|
||||
args_layout.addWidget(self.args_list)
|
||||
|
||||
args_btns = QHBoxLayout()
|
||||
self.btn_add_arg = QPushButton("Add")
|
||||
self.btn_add_arg.clicked.connect(self._add_argument)
|
||||
args_btns.addWidget(self.btn_add_arg)
|
||||
|
||||
self.btn_edit_arg = QPushButton("Edit")
|
||||
self.btn_edit_arg.setObjectName("secondary")
|
||||
self.btn_edit_arg.clicked.connect(self._edit_argument)
|
||||
args_btns.addWidget(self.btn_edit_arg)
|
||||
|
||||
self.btn_del_arg = QPushButton("Delete")
|
||||
self.btn_del_arg.setObjectName("danger")
|
||||
self.btn_del_arg.clicked.connect(self._delete_argument)
|
||||
args_btns.addWidget(self.btn_del_arg)
|
||||
|
||||
args_btns.addStretch()
|
||||
args_layout.addLayout(args_btns)
|
||||
|
||||
left_layout.addWidget(args_box)
|
||||
|
||||
splitter.addWidget(left)
|
||||
|
||||
# Right: Steps and output
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout(right)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.setSpacing(16)
|
||||
|
||||
# Steps group
|
||||
steps_box = QGroupBox("Steps")
|
||||
steps_layout = QVBoxLayout(steps_box)
|
||||
|
||||
self.steps_list = QListWidget()
|
||||
self.steps_list.itemDoubleClicked.connect(self._edit_step)
|
||||
steps_layout.addWidget(self.steps_list)
|
||||
|
||||
steps_btns = QHBoxLayout()
|
||||
self.btn_add_prompt = QPushButton("Add Prompt")
|
||||
self.btn_add_prompt.clicked.connect(self._add_prompt_step)
|
||||
steps_btns.addWidget(self.btn_add_prompt)
|
||||
|
||||
self.btn_add_code = QPushButton("Add Code")
|
||||
self.btn_add_code.clicked.connect(self._add_code_step)
|
||||
steps_btns.addWidget(self.btn_add_code)
|
||||
|
||||
self.btn_edit_step = QPushButton("Edit")
|
||||
self.btn_edit_step.setObjectName("secondary")
|
||||
self.btn_edit_step.clicked.connect(self._edit_step)
|
||||
steps_btns.addWidget(self.btn_edit_step)
|
||||
|
||||
self.btn_del_step = QPushButton("Delete")
|
||||
self.btn_del_step.setObjectName("danger")
|
||||
self.btn_del_step.clicked.connect(self._delete_step)
|
||||
steps_btns.addWidget(self.btn_del_step)
|
||||
|
||||
steps_btns.addStretch()
|
||||
steps_layout.addLayout(steps_btns)
|
||||
|
||||
right_layout.addWidget(steps_box)
|
||||
|
||||
# Output group
|
||||
output_box = QGroupBox("Output Template")
|
||||
output_layout = QVBoxLayout(output_box)
|
||||
|
||||
self.output_input = QTextEdit()
|
||||
self.output_input.setPlaceholderText("{response}\n\nUse {variable} to reference step outputs")
|
||||
self.output_input.setMaximumHeight(100)
|
||||
output_layout.addWidget(self.output_input)
|
||||
|
||||
right_layout.addWidget(output_box)
|
||||
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([400, 600])
|
||||
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
def _load_tool(self, name: str):
|
||||
"""Load an existing tool for editing."""
|
||||
tool = load_tool(name)
|
||||
if not tool:
|
||||
QMessageBox.critical(self, "Error", f"Tool '{name}' not found")
|
||||
self._cancel()
|
||||
return
|
||||
|
||||
self._tool = tool
|
||||
self.name_input.setText(tool.name)
|
||||
self.name_input.setEnabled(False) # Can't rename
|
||||
self.desc_input.setText(tool.description or "")
|
||||
|
||||
# Set category
|
||||
if tool.category:
|
||||
idx = self.category_combo.findText(tool.category)
|
||||
if idx >= 0:
|
||||
self.category_combo.setCurrentIndex(idx)
|
||||
else:
|
||||
self.category_combo.setCurrentText(tool.category)
|
||||
|
||||
# Load arguments
|
||||
self._refresh_arguments()
|
||||
|
||||
# Load steps
|
||||
self._refresh_steps()
|
||||
|
||||
# Set output
|
||||
self.output_input.setPlainText(tool.output or "{response}")
|
||||
|
||||
def _refresh_arguments(self):
|
||||
"""Refresh arguments list."""
|
||||
self.args_list.clear()
|
||||
if self._tool and self._tool.arguments:
|
||||
for arg in self._tool.arguments:
|
||||
item = QListWidgetItem(f"{arg.flag} → ${arg.variable}")
|
||||
item.setData(Qt.UserRole, arg)
|
||||
self.args_list.addItem(item)
|
||||
|
||||
def _refresh_steps(self):
|
||||
"""Refresh steps list."""
|
||||
self.steps_list.clear()
|
||||
if self._tool and self._tool.steps:
|
||||
for i, step in enumerate(self._tool.steps, 1):
|
||||
if isinstance(step, PromptStep):
|
||||
text = f"{i}. Prompt [{step.provider}] → ${step.output_var}"
|
||||
elif isinstance(step, CodeStep):
|
||||
text = f"{i}. Code [python] → ${step.output_var}"
|
||||
else:
|
||||
text = f"{i}. Unknown step"
|
||||
item = QListWidgetItem(text)
|
||||
item.setData(Qt.UserRole, step)
|
||||
self.steps_list.addItem(item)
|
||||
|
||||
def _add_argument(self):
|
||||
"""Add a new argument."""
|
||||
from ..dialogs.argument_dialog import ArgumentDialog
|
||||
dialog = ArgumentDialog(self)
|
||||
if dialog.exec():
|
||||
arg = dialog.get_argument()
|
||||
if not self._tool:
|
||||
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
|
||||
self._tool.arguments.append(arg)
|
||||
self._refresh_arguments()
|
||||
|
||||
def _edit_argument(self):
|
||||
"""Edit selected argument."""
|
||||
items = self.args_list.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
|
||||
arg = items[0].data(Qt.UserRole)
|
||||
idx = self.args_list.row(items[0])
|
||||
|
||||
from ..dialogs.argument_dialog import ArgumentDialog
|
||||
dialog = ArgumentDialog(self, arg)
|
||||
if dialog.exec():
|
||||
self._tool.arguments[idx] = dialog.get_argument()
|
||||
self._refresh_arguments()
|
||||
|
||||
def _delete_argument(self):
|
||||
"""Delete selected argument."""
|
||||
items = self.args_list.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
|
||||
idx = self.args_list.row(items[0])
|
||||
del self._tool.arguments[idx]
|
||||
self._refresh_arguments()
|
||||
|
||||
def _add_prompt_step(self):
|
||||
"""Add a prompt step."""
|
||||
from ..dialogs.step_dialog import PromptStepDialog
|
||||
dialog = PromptStepDialog(self)
|
||||
if dialog.exec():
|
||||
step = dialog.get_step()
|
||||
if not self._tool:
|
||||
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
|
||||
self._tool.steps.append(step)
|
||||
self._refresh_steps()
|
||||
|
||||
def _add_code_step(self):
|
||||
"""Add a code step."""
|
||||
from ..dialogs.step_dialog import CodeStepDialog
|
||||
dialog = CodeStepDialog(self)
|
||||
if dialog.exec():
|
||||
step = dialog.get_step()
|
||||
if not self._tool:
|
||||
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
|
||||
self._tool.steps.append(step)
|
||||
self._refresh_steps()
|
||||
|
||||
def _edit_step(self):
|
||||
"""Edit selected step."""
|
||||
items = self.steps_list.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
|
||||
step = items[0].data(Qt.UserRole)
|
||||
idx = self.steps_list.row(items[0])
|
||||
|
||||
if isinstance(step, PromptStep):
|
||||
from ..dialogs.step_dialog import PromptStepDialog
|
||||
dialog = PromptStepDialog(self, step)
|
||||
elif isinstance(step, CodeStep):
|
||||
from ..dialogs.step_dialog import CodeStepDialog
|
||||
dialog = CodeStepDialog(self, step)
|
||||
else:
|
||||
return
|
||||
|
||||
if dialog.exec():
|
||||
self._tool.steps[idx] = dialog.get_step()
|
||||
self._refresh_steps()
|
||||
|
||||
def _delete_step(self):
|
||||
"""Delete selected step."""
|
||||
items = self.steps_list.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
|
||||
idx = self.steps_list.row(items[0])
|
||||
del self._tool.steps[idx]
|
||||
self._refresh_steps()
|
||||
|
||||
def _save(self):
|
||||
"""Save the tool."""
|
||||
name = self.name_input.text().strip()
|
||||
if not name:
|
||||
QMessageBox.warning(self, "Validation", "Tool name is required")
|
||||
return
|
||||
|
||||
# Validate name
|
||||
error = validate_tool_name(name)
|
||||
if error:
|
||||
QMessageBox.warning(self, "Validation", error)
|
||||
return
|
||||
|
||||
description = self.desc_input.text().strip()
|
||||
category = self.category_combo.currentText()
|
||||
output = self.output_input.toPlainText().strip() or "{response}"
|
||||
|
||||
# Build tool object
|
||||
tool = Tool(
|
||||
name=name,
|
||||
description=description,
|
||||
category=category,
|
||||
arguments=self._tool.arguments if self._tool else [],
|
||||
steps=self._tool.steps if self._tool else [],
|
||||
output=output
|
||||
)
|
||||
|
||||
# Preserve source if editing
|
||||
if self._tool and self._tool.source:
|
||||
tool.source = self._tool.source
|
||||
|
||||
try:
|
||||
save_tool(tool)
|
||||
self.main_window.show_status(f"Saved tool '{name}'")
|
||||
self.main_window.close_tool_builder()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to save tool:\n{e}")
|
||||
|
||||
def _cancel(self):
|
||||
"""Cancel and return to tools page."""
|
||||
self.main_window.close_tool_builder()
|
||||
|
||||
def save_tool(self):
|
||||
"""Public method for keyboard shortcut to save the tool."""
|
||||
self._save()
|
||||
@@ -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("<", "<").replace(">", ">")
|
||||
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}'")
|
||||
@@ -0,0 +1,379 @@
|
||||
"""Modern stylesheet for CmdForge GUI."""
|
||||
|
||||
STYLESHEET = """
|
||||
/* Main Window */
|
||||
QMainWindow {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
background-color: #2d3748;
|
||||
border: none;
|
||||
min-width: 180px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
#sidebar::item {
|
||||
color: #e2e8f0;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
#sidebar::item:selected {
|
||||
background-color: #4a5568;
|
||||
border-left: 3px solid #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#sidebar::item:hover:!selected {
|
||||
background-color: #3d4a5c;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
QWidget#content_area {
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
QPushButton {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #5a67d8;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #4c51bf;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
background-color: #a0aec0;
|
||||
}
|
||||
|
||||
QPushButton#secondary {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
QPushButton#secondary:hover {
|
||||
background-color: #cbd5e0;
|
||||
}
|
||||
|
||||
QPushButton#danger {
|
||||
background-color: #e53e3e;
|
||||
}
|
||||
|
||||
QPushButton#danger:hover {
|
||||
background-color: #c53030;
|
||||
}
|
||||
|
||||
/* Input fields */
|
||||
QLineEdit, QTextEdit, QPlainTextEdit {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
selection-background-color: #667eea;
|
||||
}
|
||||
|
||||
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {
|
||||
border-color: #667eea;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Combo boxes */
|
||||
QComboBox {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
QComboBox:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
QComboBox::down-arrow {
|
||||
image: none;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid #718096;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
border: 1px solid #e2e8f0;
|
||||
background-color: white;
|
||||
selection-background-color: #667eea;
|
||||
}
|
||||
|
||||
/* Lists and Trees */
|
||||
QListWidget, QTreeWidget, QTableWidget {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background-color: white;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
QListWidget::item, QTreeWidget::item {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
QListWidget::item:selected, QTreeWidget::item:selected {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
QListWidget::item:hover:!selected, QTreeWidget::item:hover:!selected {
|
||||
background-color: #edf2f7;
|
||||
}
|
||||
|
||||
/* Table Widget */
|
||||
QTableWidget {
|
||||
gridline-color: #e2e8f0;
|
||||
}
|
||||
|
||||
QTableWidget::item {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
QTableWidget::item:selected {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #f7fafc;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
/* Group boxes */
|
||||
QGroupBox {
|
||||
font-weight: 600;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
left: 12px;
|
||||
padding: 0 8px;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
QLabel {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
QLabel#heading {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
QLabel#subheading {
|
||||
font-size: 14px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
QLabel#label {
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
QScrollBar:vertical {
|
||||
background-color: #f7fafc;
|
||||
width: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background-color: #cbd5e0;
|
||||
border-radius: 6px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background-color: #a0aec0;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
background-color: #f7fafc;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background-color: #cbd5e0;
|
||||
border-radius: 6px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background-color: #a0aec0;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
QStatusBar {
|
||||
background-color: #f7fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* Splitter */
|
||||
QSplitter::handle {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
QSplitter::handle:horizontal {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: #edf2f7;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-bottom: none;
|
||||
padding: 8px 16px;
|
||||
margin-right: 2px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
|
||||
QTabBar::tab:hover:!selected {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
QDialog {
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
|
||||
/* Message boxes */
|
||||
QMessageBox {
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
|
||||
QMessageBox QPushButton {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Tool tips */
|
||||
QToolTip {
|
||||
background-color: #2d3748;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
QProgressBar {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background-color: #edf2f7;
|
||||
text-align: center;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background-color: #667eea;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Spin boxes */
|
||||
QSpinBox, QDoubleSpinBox {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
QSpinBox:focus, QDoubleSpinBox:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
/* Check boxes and radio buttons */
|
||||
QCheckBox, QRadioButton {
|
||||
spacing: 8px;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
QCheckBox::indicator, QRadioButton::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #cbd5e0;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 4px;
|
||||
background-color: #667eea;
|
||||
}
|
||||
|
||||
QRadioButton::indicator:unchecked {
|
||||
border: 2px solid #cbd5e0;
|
||||
border-radius: 9px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
QRadioButton::indicator:checked {
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 9px;
|
||||
background-color: #667eea;
|
||||
}
|
||||
"""
|
||||
@@ -0,0 +1 @@
|
||||
"""Custom widgets for CmdForge GUI."""
|
||||
Reference in New Issue
Block a user