Convert TUI to PySide6 desktop GUI

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

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

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

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

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

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-14 04:38:35 -04:00
co-authored by Claude Opus 4.5
parent 604b473806
commit 4fe2d26244
30 changed files with 2726 additions and 5102 deletions
+1
View File
@@ -0,0 +1 @@
"""GUI dialogs."""
+104
View File
@@ -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 ""
)
+234
View File
@@ -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)
+101
View File
@@ -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}")
+182
View File
@@ -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;")
+211
View File
@@ -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()
)