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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-14 04:38:35 -04:00
parent 604b473806
commit 4fe2d26244
30 changed files with 2726 additions and 5102 deletions

View File

@ -8,18 +8,18 @@
- `wiki/` contains additional reference material. - `wiki/` contains additional reference material.
## Architecture Overview ## Architecture Overview
- `cli.py` routes subcommands like `list`, `create`, `run`, `test`, `ui`, and `refresh`. - `cli/` directory routes subcommands like `list`, `create`, `run`, `test`, and `refresh`.
- `tool.py` defines tool/step models and handles YAML config loading and wrapper generation. - `tool.py` defines tool/step models and handles YAML config loading and wrapper generation.
- `runner.py` executes steps and performs `{input}`/argument variable substitution. - `runner.py` executes steps and performs `{input}`/argument variable substitution.
- `providers.py` shells out to configured AI provider CLIs (or the `mock` provider). - `providers.py` shells out to configured AI provider CLIs (or the `mock` provider).
- `ui_urwid.py` and `ui_snack.py` provide the TUI implementations, selected by `ui.py`. - `gui/` provides the PySide6 desktop GUI with pages for tools, registry, and providers.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `pip install -e ".[dev]"` installs CmdForge in editable mode with dev dependencies. - `pip install -e ".[dev]"` installs CmdForge in editable mode with dev dependencies.
- `pytest` runs the full test suite. - `pytest` runs the full test suite.
- `pytest tests/test.py::test_name` runs a focused test. - `pytest tests/test.py::test_name` runs a focused test.
- `python -m cmdforge.cli` runs the CLI module directly. - `python -m cmdforge.cli` runs the CLI module directly.
- `cmdforge ui` launches the TUI (requires `urwid` or `python-newt`). - `cmdforge` launches the desktop GUI (requires PySide6).
- `docker-compose build` builds the dev container image. - `docker-compose build` builds the dev container image.
- `docker-compose run --rm test` runs tests inside Docker. - `docker-compose run --rm test` runs tests inside Docker.

View File

@ -21,21 +21,22 @@ pytest tests/test.py::test_name
# Run the CLI # Run the CLI
python -m cmdforge.cli python -m cmdforge.cli
# Launch the UI # Launch the GUI
cmdforge ui cmdforge
``` ```
## Architecture ## Architecture
### Core Modules (`src/cmdforge/`) ### Core Modules (`src/cmdforge/`)
- **cli.py**: Entry point (`cmdforge` command). Routes subcommands: list, create, edit, delete, test, run, ui, refresh - **cli/**: CLI commands entry points (`cmdforge` command). Routes subcommands: list, create, edit, delete, test, run, refresh
- **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`), YAML config loading/saving, wrapper script generation - **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`), YAML config loading/saving, wrapper script generation
- **runner.py**: Execution engine. Runs tool steps sequentially, handles variable substitution (`{input}`, `{varname}`), executes Python code steps via `exec()` - **runner.py**: Execution engine. Runs tool steps sequentially, handles variable substitution (`{input}`, `{varname}`), executes Python code steps via `exec()`
- **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.cmdforge/providers.yaml` - **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.cmdforge/providers.yaml`
- **ui.py**: UI dispatcher - selects between urwid and snack implementations - **gui/**: PySide6 desktop GUI
- **ui_urwid.py**: Full TUI implementation using urwid library - **main_window.py**: Main application window with sidebar navigation
- **ui_snack.py**: Fallback TUI using python-newt/snack - **pages/**: Tools page, Tool Builder, Registry browser, Providers management
- **dialogs/**: Step editors, Argument editor, Provider dialog, Connect/Publish dialogs
### Key Paths ### Key Paths

130
README.md
View File

@ -66,10 +66,10 @@ export PATH="$HOME/.local/bin:$PATH"
# Install an AI provider (interactive guide) # Install an AI provider (interactive guide)
cmdforge providers install cmdforge providers install
# Launch the UI # Launch the GUI
cmdforge ui cmdforge
# Or create your first tool # Or create your first tool via CLI
cmdforge create summarize cmdforge create summarize
``` ```
@ -95,7 +95,7 @@ pip install -e ".[dev]"
- Python 3.10+ - Python 3.10+
- At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md)) - At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md))
- Optional: `urwid` for the TUI (`pip install urwid`) - PySide6 (included automatically - requires display server on Linux)
### Post-Install ### Post-Install
@ -113,13 +113,16 @@ cmdforge refresh
## Usage ## Usage
### UI Mode (Recommended for Beginners) ### GUI Mode (Recommended for Beginners)
```bash ```bash
cmdforge ui cmdforge
``` ```
Navigate with arrow keys, Tab, Enter, and mouse. Create tools visually with the built-in prompt editor. Opens the graphical interface where you can create and manage tools visually. Features include:
- **My Tools** - Browse, create, edit, and delete tools organized by category
- **Registry** - Search and install community tools from the CmdForge registry
- **Providers** - Manage AI provider configurations
### CLI Mode ### CLI Mode
@ -410,55 +413,82 @@ vnoremap <leader>fg :!fix-grammar<CR>
vnoremap <leader>ec :!explain-code<CR> vnoremap <leader>ec :!explain-code<CR>
``` ```
## UI Navigation ## GUI Features
| Action | Keys | The graphical interface provides a modern desktop experience:
|--------|------|
| Cycle sections | `Tab` |
| Go back | `Escape` |
| Select | `Enter` or click |
| Navigate | Arrow keys |
| **Scroll content** | Mouse wheel |
| **Scroll up** | Click top of scrollbar |
| **Scroll down** | Click bottom of scrollbar |
| **Page up/down** | Click middle of scrollbar |
| **Select text** | `Shift` + mouse drag |
| **Copy** | Terminal native (with Shift) |
| **Paste** | `Ctrl+Shift+V` |
| **Undo** (code editor) | `Alt+U` |
| **Redo** (code editor) | `Alt+R` |
**Tips:** ### My Tools Page
- Hold `Shift` while using mouse for terminal-native text selection. - View all your tools organized by category (Text, Developer, Data, Other)
- Code/Prompt editors have DOS-style scrollbars with `▲` and `▼` arrow buttons. - Double-click a tool to edit it
- In step dialogs, use `Tab` to cycle between File, Editor, and Output fields. - Create new tools with the built-in Tool Builder
- The code editor supports undo/redo (up to 50 states) with `Alt+U` and `Alt+R`. - Connect to the registry to publish your tools
- Use the `$EDITOR` button to open code or prompts in your external editor.
## AI-Assisted Code Generation ### Tool Builder
- Visual form for creating and editing tools
- Add arguments with flags and default values
- Add prompt steps (AI calls) or code steps (Python)
- Preview the generated YAML configuration
- Test tools before saving
When adding or editing a **Code Step**, the dialog includes an AI assist panel: ### Registry Browser
- Search community tools by name or keyword
- View tool details, downloads, and ratings
- One-click install to your local machine
``` ### Provider Management
┌─ Code ─────────────┐ ┌─ AI Assisted Auto-adjust ─────────────┐ - Add and configure AI providers
│ result = input... │ │ Provider: [opencode-deepseek] [▼] │ - Test provider connectivity
│ │ │ ┌─ Prompt ──────────────────────────┐ │ - Set default providers for new tools
│ │ │ │ Modify this code to... │ │
│ │ │ │ {code} │ │ ### Keyboard Shortcuts
│ │ │ └──────────────────────────────────┘ │
│ │ │ ┌─ Output & Feedback ───────────────┐ │ | Shortcut | Action |
│ │ │ │ ✓ Code updated successfully! │ │ |----------|--------|
│ │ │ └──────────────────────────────────┘ │ | `Ctrl+N` | Create new tool |
│ │ │ < Auto-adjust > | `Ctrl+S` | Save tool (in builder) |
└────────────────────┘ └───────────────────────────────────────┘ | `Ctrl+R` | Refresh current page |
| `Ctrl+1` | Go to My Tools |
| `Ctrl+2` | Go to Registry |
| `Ctrl+3` | Go to Providers |
| `Escape` | Close tool builder |
| `Ctrl+Q` | Quit application |
## Multi-Step Tool Patterns
CmdForge supports powerful multi-step workflows combining AI and code:
### Code → AI
Extract or transform data with Python, then pass to AI:
```yaml
steps:
- type: code
code: |
# Extract just the error lines
errors = [l for l in input_text.split('\n') if 'error' in l.lower()]
result = '\n'.join(errors[-10:]) # Last 10 errors
output_var: errors
- type: prompt
prompt: "Explain these errors:\n{errors}"
output_var: explanation
``` ```
- **Provider**: Select any configured AI provider ### AI → Code
- **Prompt**: Fully editable template - use `{code}` placeholder for current code Generate content with AI, then validate/process with Python:
- **Output**: Shows status, success/error messages, and provider feedback ```yaml
- **Auto-adjust**: Sends prompt to AI and replaces code with response steps:
- type: prompt
This lets you generate or modify Python code using AI directly within the tool builder. prompt: "Generate Python code to: {input}"
output_var: generated
- type: code
code: |
import ast
try:
ast.parse(generated) # Syntax check
result = generated
except SyntaxError as e:
result = f"# Syntax Error: {e}\n{generated}"
output_var: validated
```
## Philosophy ## Philosophy
@ -515,7 +545,7 @@ docker run -it --rm -v cmdforge-data:/home/user/.cmdforge cmdforge-ready
Inside the container, CmdForge is ready to use: Inside the container, CmdForge is ready to use:
```bash ```bash
cmdforge list # See 27 pre-installed tools cmdforge list # See 27 pre-installed tools
cmdforge ui # Launch the TUI cmdforge # Launch the GUI (requires display)
cmdforge run summarize # Run a tool cmdforge run summarize # Run a tool
``` ```

View File

@ -32,16 +32,13 @@ classifiers = [
dependencies = [ dependencies = [
"PyYAML>=6.0", "PyYAML>=6.0",
"requests>=2.28", "requests>=2.28",
"PySide6>=6.5",
] ]
[project.optional-dependencies] [project.optional-dependencies]
tui = [
"urwid>=2.1.0",
]
dev = [ dev = [
"pytest>=7.0", "pytest>=7.0",
"pytest-cov>=4.0", "pytest-cov>=4.0",
"urwid>=2.1.0",
] ]
registry = [ registry = [
"Flask>=2.3", "Flask>=2.3",
@ -50,7 +47,6 @@ registry = [
"gunicorn>=21.0", "gunicorn>=21.0",
] ]
all = [ all = [
"urwid>=2.1.0",
"Flask>=2.3", "Flask>=2.3",
"argon2-cffi>=21.0", "argon2-cffi>=21.0",
"sentry-sdk[flask]>=1.0", "sentry-sdk[flask]>=1.0",

View File

@ -501,14 +501,7 @@ def _cmd_registry_my_tools(args):
def _cmd_registry_browse(args): def _cmd_registry_browse(args):
"""Browse tools (TUI).""" """Browse tools (GUI)."""
try: from ..gui import run_gui
from ..ui_registry import run_registry_browser # Launch GUI - it will open to Registry page
return run_registry_browser() return run_gui()
except ImportError:
print("TUI browser requires urwid. Install with:", file=sys.stderr)
print(" pip install 'cmdforge[tui]'", file=sys.stderr)
print()
print("Or search from command line:", file=sys.stderr)
print(" cmdforge registry search <query>", file=sys.stderr)
return 1

View File

@ -7,7 +7,7 @@ from ..tool import (
list_tools, load_tool, save_tool, delete_tool, get_tools_dir, list_tools, load_tool, save_tool, delete_tool, get_tools_dir,
Tool, ToolArgument, PromptStep, CodeStep, ToolStep Tool, ToolArgument, PromptStep, CodeStep, ToolStep
) )
from ..ui import run_ui from ..gui import run_gui
def cmd_list(args): def cmd_list(args):
@ -248,9 +248,8 @@ def cmd_run(args):
def cmd_ui(args): def cmd_ui(args):
"""Launch the interactive UI.""" """Launch the interactive GUI."""
run_ui() return run_gui()
return 0
def cmd_refresh(args): def cmd_refresh(args):

View File

@ -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()

View File

@ -0,0 +1 @@
"""GUI dialogs."""

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 ""
)

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)

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}")

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;")

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()
)

View File

@ -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)

View File

@ -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"]

View File

@ -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}"
)

View File

@ -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}")

View File

@ -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()

View File

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

379
src/cmdforge/gui/styles.py Normal file
View File

@ -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;
}
"""

View File

@ -0,0 +1 @@
"""Custom widgets for CmdForge GUI."""

View File

@ -1,828 +0,0 @@
"""Dialog-based UI for managing CmdForge."""
import subprocess
import sys
import tempfile
from typing import Optional, Tuple, List
from .tool import (
Tool, ToolArgument, PromptStep, CodeStep, Step,
list_tools, load_tool, save_tool, delete_tool, tool_exists
)
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
def _check_urwid() -> bool:
"""Check if urwid is available (preferred - has mouse support)."""
try:
import urwid
return True
except ImportError:
return False
def _check_snack() -> bool:
"""Check if snack (python3-newt) is available."""
try:
if '/usr/lib/python3/dist-packages' not in sys.path:
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import snack
return True
except ImportError:
return False
def check_dialog() -> str:
"""Check for available dialog program. Returns 'dialog', 'whiptail', or None."""
for prog in ["dialog", "whiptail"]:
try:
subprocess.run([prog, "--version"], capture_output=True, check=False)
return prog
except FileNotFoundError:
continue
return None
def run_dialog(args: list[str], dialog_prog: str = "dialog") -> Tuple[int, str]:
"""Run a dialog command and return (exit_code, output)."""
try:
if dialog_prog == "whiptail":
result = subprocess.run(
[dialog_prog] + args,
stderr=subprocess.PIPE,
text=True
)
return result.returncode, result.stderr.strip()
else:
cmd_with_stdout = [dialog_prog, "--stdout"] + args
result = subprocess.run(
cmd_with_stdout,
stdout=subprocess.PIPE,
text=True
)
return result.returncode, result.stdout.strip()
except Exception as e:
return 1, ""
def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> Optional[str]:
"""Show a menu and return the selected item."""
args = ["--title", title, "--menu", "Choose an option:", "20", "75", str(len(choices))]
for tag, desc in choices:
args.extend([tag, desc])
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
def show_input(title: str, prompt: str, initial: str = "", dialog_prog: str = "dialog") -> Optional[str]:
"""Show an input box and return the entered text."""
args = ["--title", title, "--inputbox", prompt, "10", "60", initial]
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
def show_textbox(title: str, text: str, dialog_prog: str = "dialog") -> Optional[str]:
"""Show a text editor for multi-line input."""
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(text)
temp_path = f.name
try:
args = ["--title", title, "--editbox", temp_path, "20", "75"]
code, output = run_dialog(args, dialog_prog)
return output if code == 0 else None
finally:
import os
os.unlink(temp_path)
def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool:
"""Show a yes/no dialog."""
args = ["--title", title, "--yesno", text, "10", "60"]
code, _ = run_dialog(args, dialog_prog)
return code == 0
def show_message(title: str, text: str, dialog_prog: str = "dialog"):
"""Show a message box."""
args = ["--title", title, "--msgbox", text, "15", "70"]
run_dialog(args, dialog_prog)
def show_mixed_form(title: str, fields: dict, dialog_prog: str, height: int = 20) -> Optional[dict]:
"""Show a form with multiple fields using --mixedform."""
args = ["--title", title, "--mixedform",
"Tab: next field | Enter: submit | Esc: cancel",
str(height), "75", "0"]
field_names = list(fields.keys())
y = 1
for name in field_names:
label, initial, field_type = fields[name]
args.extend([
label, str(y), "1",
initial, str(y), "18",
"52", "256", str(field_type)
])
y += 1
code, output = run_dialog(args, dialog_prog)
if code != 0:
return None
values = output.split('\n')
result = {}
for i, name in enumerate(field_names):
result[name] = values[i] if i < len(values) else ""
return result
# ============ Provider Management ============
def select_provider(dialog_prog: str) -> Optional[str]:
"""Show provider selection menu with option to create new."""
providers = load_providers()
choices = [(p.name, f"{p.description} ({p.command})") for p in providers]
choices.append(("__new__", "[ + Add New Provider ]"))
selected = show_menu("Select Provider", choices, dialog_prog)
if selected == "__new__":
provider = create_provider_form(dialog_prog)
if provider:
add_provider(provider)
return provider.name
return None
return selected
def create_provider_form(dialog_prog: str, existing: Optional[Provider] = None) -> Optional[Provider]:
"""Show form for creating/editing a provider."""
title = f"Edit Provider: {existing.name}" if existing else "Add New Provider"
fields = {
"name": (
"Name:",
existing.name if existing else "",
2 if existing else 0 # readonly if editing
),
"command": (
"Command:",
existing.command if existing else "",
0
),
"description": (
"Description:",
existing.description if existing else "",
0
),
}
result = show_mixed_form(title, fields, dialog_prog, height=12)
if not result:
return None
name = result["name"].strip()
command = result["command"].strip()
if not name:
show_message("Error", "Provider name is required.", dialog_prog)
return None
if not command:
show_message("Error", "Command is required.", dialog_prog)
return None
return Provider(
name=name,
command=command,
description=result["description"].strip()
)
def ui_manage_providers(dialog_prog: str):
"""Manage providers menu."""
while True:
providers = load_providers()
choices = [(p.name, f"{p.command}") for p in providers]
choices.append(("__add__", "[ + Add New Provider ]"))
choices.append(("__back__", "[ <- Back to Main Menu ]"))
selected = show_menu("Manage Providers", choices, dialog_prog)
if selected is None or selected == "__back__":
break
elif selected == "__add__":
provider = create_provider_form(dialog_prog)
if provider:
add_provider(provider)
show_message("Success", f"Provider '{provider.name}' added.", dialog_prog)
else:
# Edit or delete existing provider
provider = get_provider(selected)
if provider:
action = show_menu(
f"Provider: {selected}",
[
("edit", "Edit provider"),
("delete", "Delete provider"),
("back", "Back"),
],
dialog_prog
)
if action == "edit":
updated = create_provider_form(dialog_prog, provider)
if updated:
add_provider(updated)
show_message("Success", f"Provider '{updated.name}' updated.", dialog_prog)
elif action == "delete":
if show_yesno("Confirm", f"Delete provider '{selected}'?", dialog_prog):
delete_provider(selected)
show_message("Deleted", f"Provider '{selected}' deleted.", dialog_prog)
# ============ Tool Builder UI ============
def format_tool_summary(tool: Tool) -> str:
"""Format a summary of the tool's components."""
lines = []
lines.append(f"Name: {tool.name}")
lines.append(f"Description: {tool.description or '(none)'}")
lines.append("")
if tool.arguments:
lines.append("Arguments:")
for arg in tool.arguments:
default = f" = {arg.default}" if arg.default else ""
lines.append(f" {arg.flag} -> {{{arg.variable}}}{default}")
lines.append("")
if tool.steps:
lines.append("Steps:")
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
preview = step.prompt[:40].replace('\n', ' ') + "..."
lines.append(f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}")
lines.append(f" {preview}")
elif isinstance(step, CodeStep):
preview = step.code[:40].replace('\n', ' ') + "..."
lines.append(f" {i+1}. CODE -> {{{step.output_var}}}")
lines.append(f" {preview}")
lines.append("")
lines.append(f"Output: {tool.output}")
return "\n".join(lines)
def get_available_variables(tool: Tool, up_to_step: int = -1) -> List[str]:
"""Get list of available variables at a given point in the tool."""
variables = ["input"]
for arg in tool.arguments:
variables.append(arg.variable)
if up_to_step == -1:
up_to_step = len(tool.steps)
for i, step in enumerate(tool.steps):
if i >= up_to_step:
break
variables.append(step.output_var)
return variables
def edit_argument(dialog_prog: str, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
"""Edit or create an argument."""
title = f"Edit Argument: {existing.flag}" if existing else "Add Argument"
fields = {
"flag": ("Flag:", existing.flag if existing else "--", 0),
"variable": ("Variable:", existing.variable if existing else "", 0),
"default": ("Default:", existing.default or "" if existing else "", 0),
"description": ("Description:", existing.description if existing else "", 0),
}
result = show_mixed_form(title, fields, dialog_prog, height=14)
if not result:
return None
flag = result["flag"].strip()
variable = result["variable"].strip()
if not flag:
show_message("Error", "Flag is required (e.g., --max-size).", dialog_prog)
return None
if not variable:
# Auto-generate variable name from flag
variable = flag.lstrip("-").replace("-", "_")
return ToolArgument(
flag=flag,
variable=variable,
default=result["default"].strip() or None,
description=result["description"].strip()
)
def edit_prompt_step(dialog_prog: str, existing: Optional[PromptStep] = None,
available_vars: List[str] = None) -> Optional[PromptStep]:
"""Edit or create a prompt step."""
title = "Edit Prompt Step" if existing else "Add Prompt Step"
# First, select provider
provider = select_provider(dialog_prog)
if not provider:
provider = existing.provider if existing else "mock"
# Show variable help
var_help = "Available: " + ", ".join(f"{{{v}}}" for v in (available_vars or ["input"]))
# Edit prompt text
default_prompt = existing.prompt if existing else f"Process this input:\n\n{{input}}"
prompt = show_textbox(f"Prompt Template\n{var_help}", default_prompt, dialog_prog)
if prompt is None:
return None
# Get output variable
output_var = show_input(
"Output Variable",
"Variable name to store the result:",
existing.output_var if existing else "result",
dialog_prog
)
if not output_var:
return None
return PromptStep(
prompt=prompt,
provider=provider,
output_var=output_var.strip()
)
def edit_code_step(dialog_prog: str, existing: Optional[CodeStep] = None,
available_vars: List[str] = None) -> Optional[CodeStep]:
"""Edit or create a code step."""
title = "Edit Code Step" if existing else "Add Code Step"
# Show variable help
var_help = "Variables: " + ", ".join(available_vars or ["input"])
var_help += "\nSet 'result' variable for output"
# Edit code
default_code = existing.code if existing else "# Available variables: " + ", ".join(available_vars or ["input"]) + "\n# Set 'result' for output\nresult = input.upper()"
code = show_textbox(f"Python Code\n{var_help}", default_code, dialog_prog)
if code is None:
return None
# Get output variable
output_var = show_input(
"Output Variable",
"Variable name to store the result:",
existing.output_var if existing else "processed",
dialog_prog
)
if not output_var:
return None
return CodeStep(
code=code,
output_var=output_var.strip()
)
def edit_tool_info(tool: Tool, is_edit: bool, dialog_prog: str) -> None:
"""Edit basic tool info (name, description, output)."""
while True:
# Build info section menu
output_preview = tool.output[:35] + "..." if len(tool.output) > 35 else tool.output
args_count = len(tool.arguments)
args_summary = f"({args_count} defined)" if args_count else "(none)"
choices = [
("name", f"Name: {tool.name or '(not set)'}"),
("desc", f"Description: {tool.description[:35] + '...' if len(tool.description) > 35 else tool.description or '(none)'}"),
("---1", "" * 40),
]
# Show arguments
if tool.arguments:
for i, arg in enumerate(tool.arguments):
default = f" = {arg.default}" if arg.default else ""
choices.append((f"arg_{i}", f" {arg.flag} -> {{{arg.variable}}}{default}"))
choices.append(("add_arg", " [ + Add Argument ]"))
choices.append(("---2", "" * 40))
choices.append(("output", f"Output Template: {output_preview}"))
choices.append(("---3", "" * 40))
choices.append(("back", "<- Back to Tool Builder"))
selected = show_menu("Tool Info & Arguments", choices, dialog_prog)
if selected is None or selected == "back":
break
elif selected == "name":
if is_edit:
show_message("Info", "Cannot change tool name after creation.", dialog_prog)
else:
new_name = show_input("Tool Name", "Enter tool name:", tool.name, dialog_prog)
if new_name:
tool.name = new_name.strip()
elif selected == "desc":
new_desc = show_input("Description", "Enter tool description:", tool.description, dialog_prog)
if new_desc is not None:
tool.description = new_desc.strip()
elif selected == "add_arg":
arg = edit_argument(dialog_prog)
if arg:
tool.arguments.append(arg)
elif selected.startswith("arg_"):
idx = int(selected[4:])
arg = tool.arguments[idx]
action = show_menu(
f"Argument: {arg.flag}",
[("edit", "Edit"), ("delete", "Delete"), ("back", "Back")],
dialog_prog
)
if action == "edit":
updated = edit_argument(dialog_prog, arg)
if updated:
tool.arguments[idx] = updated
elif action == "delete":
if show_yesno("Delete", f"Delete argument {arg.flag}?", dialog_prog):
tool.arguments.pop(idx)
elif selected == "output":
available = get_available_variables(tool)
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
new_output = show_textbox(f"Output Template\n{var_help}", tool.output, dialog_prog)
if new_output is not None:
tool.output = new_output
def edit_tool_steps(tool: Tool, dialog_prog: str) -> None:
"""Edit tool processing steps."""
while True:
# Build steps section menu
choices = []
if tool.steps:
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
choices.append((f"step_{i}", f"{i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}"))
elif isinstance(step, CodeStep):
choices.append((f"step_{i}", f"{i+1}. CODE -> {{{step.output_var}}}"))
else:
choices.append(("none", "(no steps defined)"))
choices.append(("---1", "" * 40))
choices.append(("add_prompt", "[ + Add Prompt Step ]"))
choices.append(("add_code", "[ + Add Code Step ]"))
choices.append(("---2", "" * 40))
choices.append(("back", "<- Back to Tool Builder"))
selected = show_menu("Processing Steps", choices, dialog_prog)
if selected is None or selected == "back" or selected == "none":
if selected == "none":
continue
break
elif selected == "add_prompt":
available = get_available_variables(tool)
step = edit_prompt_step(dialog_prog, available_vars=available)
if step:
tool.steps.append(step)
elif selected == "add_code":
available = get_available_variables(tool)
step = edit_code_step(dialog_prog, available_vars=available)
if step:
tool.steps.append(step)
elif selected.startswith("step_"):
idx = int(selected[5:])
step = tool.steps[idx]
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
move_choices = [("edit", "Edit"), ("delete", "Delete")]
if idx > 0:
move_choices.insert(1, ("move_up", "Move Up"))
if idx < len(tool.steps) - 1:
move_choices.insert(2 if idx > 0 else 1, ("move_down", "Move Down"))
move_choices.append(("back", "Back"))
action = show_menu(f"Step {idx+1}: {step_type}", move_choices, dialog_prog)
if action == "edit":
available = get_available_variables(tool, idx)
if isinstance(step, PromptStep):
updated = edit_prompt_step(dialog_prog, step, available)
else:
updated = edit_code_step(dialog_prog, step, available)
if updated:
tool.steps[idx] = updated
elif action == "move_up" and idx > 0:
tool.steps[idx], tool.steps[idx-1] = tool.steps[idx-1], tool.steps[idx]
elif action == "move_down" and idx < len(tool.steps) - 1:
tool.steps[idx], tool.steps[idx+1] = tool.steps[idx+1], tool.steps[idx]
elif action == "delete":
if show_yesno("Delete", f"Delete step {idx+1}?", dialog_prog):
tool.steps.pop(idx)
def tool_builder(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]:
"""Main tool builder interface with tabbed sections."""
is_edit = existing is not None
# Initialize tool
if existing:
tool = Tool(
name=existing.name,
description=existing.description,
arguments=list(existing.arguments),
steps=list(existing.steps),
output=existing.output
)
else:
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
while True:
# Build main menu with section summaries
args_count = len(tool.arguments)
steps_count = len(tool.steps)
# Info summary
name_display = tool.name or "(not set)"
info_summary = f"{name_display}"
if tool.arguments:
info_summary += f" | {args_count} arg{'s' if args_count != 1 else ''}"
# Steps summary
if tool.steps:
step_types = []
for s in tool.steps:
if isinstance(s, PromptStep):
step_types.append(f"P:{s.provider}")
else:
step_types.append("C")
steps_summary = " -> ".join(step_types)
else:
steps_summary = "(none)"
choices = [
("info", f"[1] Info & Args : {info_summary}"),
("steps", f"[2] Steps : {steps_summary}"),
("---", "" * 50),
("preview", "Preview Full Summary"),
("save", "Save Tool"),
("cancel", "Cancel"),
]
title = f"Tool Builder: {tool.name}" if tool.name else "Tool Builder: New Tool"
selected = show_menu(title, choices, dialog_prog)
if selected is None or selected == "cancel":
if show_yesno("Cancel", "Discard changes?", dialog_prog):
return None
continue
elif selected == "info":
edit_tool_info(tool, is_edit, dialog_prog)
elif selected == "steps":
edit_tool_steps(tool, dialog_prog)
elif selected == "preview":
summary = format_tool_summary(tool)
show_message("Tool Summary", summary, dialog_prog)
elif selected == "save":
if not tool.name:
show_message("Error", "Tool name is required. Go to Info & Args to set it.", dialog_prog)
continue
if not is_edit and tool_exists(tool.name):
if not show_yesno("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", dialog_prog):
continue
return tool
# ============ Main Menu Functions ============
def ui_list_tools(dialog_prog: str):
"""Show list of tools."""
tools = list_tools()
if not tools:
show_message("Tools", "No tools found.\n\nCreate your first tool from the main menu.", dialog_prog)
return
text = "Available tools:\n\n"
for name in tools:
tool = load_tool(name)
if tool:
text += f" {name}: {tool.description or 'No description'}\n"
if tool.arguments:
args = ", ".join(arg.flag for arg in tool.arguments)
text += f" Arguments: {args}\n"
if tool.steps:
step_info = []
for step in tool.steps:
if isinstance(step, PromptStep):
step_info.append(f"PROMPT[{step.provider}]")
else:
step_info.append("CODE")
text += f" Steps: {' -> '.join(step_info)}\n"
text += "\n"
show_message("Tools", text, dialog_prog)
def ui_create_tool(dialog_prog: str):
"""Create a new tool."""
tool = tool_builder(dialog_prog)
if tool:
path = save_tool(tool)
# Build usage example
usage = f"{tool.name}"
for arg in tool.arguments:
if arg.default:
usage += f" [{arg.flag} <{arg.variable}>]"
else:
usage += f" {arg.flag} <{arg.variable}>"
usage += " < input.txt"
show_message("Success",
f"Tool '{tool.name}' created!\n\n"
f"Config: {path}\n\n"
f"Usage: {usage}",
dialog_prog)
def ui_edit_tool(dialog_prog: str):
"""Edit an existing tool."""
tools = list_tools()
if not tools:
show_message("Edit Tool", "No tools found.", dialog_prog)
return
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Edit", choices, dialog_prog)
if selected:
existing = load_tool(selected)
if existing:
tool = tool_builder(dialog_prog, existing)
if tool:
save_tool(tool)
show_message("Success", f"Tool '{tool.name}' updated!", dialog_prog)
def ui_delete_tool(dialog_prog: str):
"""Delete a tool."""
tools = list_tools()
if not tools:
show_message("Delete Tool", "No tools found.", dialog_prog)
return
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Delete", choices, dialog_prog)
if selected:
if show_yesno("Confirm Delete", f"Delete tool '{selected}'?\n\nThis cannot be undone.", dialog_prog):
if delete_tool(selected):
show_message("Deleted", f"Tool '{selected}' deleted.", dialog_prog)
else:
show_message("Error", f"Failed to delete '{selected}'.", dialog_prog)
def ui_test_tool(dialog_prog: str):
"""Test a tool with mock provider."""
tools = list_tools()
if not tools:
show_message("Test Tool", "No tools found.", dialog_prog)
return
choices = []
for name in tools:
tool = load_tool(name)
desc = tool.description if tool else "No description"
choices.append((name, desc))
selected = show_menu("Select Tool to Test", choices, dialog_prog)
if selected:
tool = load_tool(selected)
if tool:
test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog)
if test_input:
from .runner import run_tool
output, code = run_tool(
tool=tool,
input_text=test_input,
custom_args={},
provider_override="mock",
dry_run=False,
show_prompt=False,
verbose=False
)
result_text = f"Exit code: {code}\n\n--- Output ---\n{output[:1000]}"
if len(output) > 1000:
result_text += "\n... (truncated)"
show_message("Test Result", result_text, dialog_prog)
def main_menu(dialog_prog: str):
"""Show the main menu."""
while True:
choice = show_menu(
"CmdForge Manager",
[
("list", "List all tools"),
("create", "Create new tool"),
("edit", "Edit existing tool"),
("delete", "Delete tool"),
("test", "Test tool (mock provider)"),
("providers", "Manage providers"),
("exit", "Exit"),
],
dialog_prog
)
if choice is None or choice == "exit":
break
elif choice == "list":
ui_list_tools(dialog_prog)
elif choice == "create":
ui_create_tool(dialog_prog)
elif choice == "edit":
ui_edit_tool(dialog_prog)
elif choice == "delete":
ui_delete_tool(dialog_prog)
elif choice == "test":
ui_test_tool(dialog_prog)
elif choice == "providers":
ui_manage_providers(dialog_prog)
def run_ui():
"""Entry point for the UI."""
# Prefer urwid (has mouse support)
if _check_urwid():
from .ui_urwid import run_ui as run_urwid_ui
run_urwid_ui()
return
# Fallback to snack (BIOS-style)
if _check_snack():
from .ui_snack import run_ui as run_snack_ui
run_snack_ui()
return
# Fallback to dialog/whiptail
dialog_prog = check_dialog()
if not dialog_prog:
print("Error: No TUI library found.", file=sys.stderr)
print("Install one of:", file=sys.stderr)
print(" pip install urwid (recommended - has mouse support)", file=sys.stderr)
print(" sudo apt install python3-newt", file=sys.stderr)
print(" sudo apt install dialog", file=sys.stderr)
sys.exit(1)
try:
main_menu(dialog_prog)
except KeyboardInterrupt:
pass
finally:
subprocess.run(["clear"], check=False)
if __name__ == "__main__":
run_ui()

View File

@ -1,496 +0,0 @@
"""TUI for browsing the CmdForge Registry using urwid.
Uses threading for non-blocking network operations.
"""
import os
import threading
from concurrent.futures import ThreadPoolExecutor
from typing import Optional, List, Dict, Any, Callable
import urwid
from .registry_client import (
RegistryClient, RegistryError, ToolInfo,
get_client, PaginatedResponse
)
from .resolver import install_from_registry
# Color palette - matching the main UI style
PALETTE = [
('body', 'white', 'dark blue'),
('header', 'white', 'dark red', 'bold'),
('footer', 'black', 'light gray'),
('button', 'black', 'light gray'),
('button_focus', 'white', 'dark red', 'bold'),
('edit', 'black', 'light gray'),
('edit_focus', 'black', 'yellow'),
('listbox', 'black', 'light gray'),
('listbox_focus', 'white', 'dark red'),
('dialog', 'black', 'light gray'),
('label', 'yellow', 'dark blue', 'bold'),
('error', 'white', 'dark red', 'bold'),
('success', 'light green', 'dark blue', 'bold'),
('info', 'light cyan', 'dark blue'),
('downloads', 'light green', 'light gray'),
('category', 'dark cyan', 'light gray'),
('version', 'brown', 'light gray'),
('loading', 'yellow', 'dark blue'),
]
class ToolListItem(urwid.WidgetWrap):
"""A selectable tool item in the browse list."""
def __init__(self, tool_data: Dict[str, Any], on_select=None, on_install=None):
self.tool_data = tool_data
self.on_select = on_select
self.on_install = on_install
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
version = tool_data.get("version", "")
description = tool_data.get("description", "")[:50]
downloads = tool_data.get("downloads", 0)
category = tool_data.get("category", "")
# Format: owner/name v1.0.0 [category] ↓123
main_line = urwid.Text([
('listbox', f" {owner}/"),
('listbox', f"{name} "),
('version', f"v{version}"),
])
desc_line = urwid.Text([
('listbox', f" {description}{'...' if len(tool_data.get('description', '')) > 50 else ''}"),
])
meta_line = urwid.Text([
('category', f" [{category}]" if category else ""),
('downloads', f"{downloads}"),
])
pile = urwid.Pile([main_line, desc_line, meta_line])
self.attr_map = urwid.AttrMap(pile, 'listbox', 'listbox_focus')
super().__init__(self.attr_map)
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter' and self.on_select:
self.on_select(self.tool_data)
return None
if key == 'i' and self.on_install:
self.on_install(self.tool_data)
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if event == 'mouse press' and button == 1 and self.on_select:
self.on_select(self.tool_data)
return True
return False
class SearchEdit(urwid.Edit):
"""Search box that triggers callback on enter."""
def __init__(self, on_search=None):
self.on_search = on_search
super().__init__(caption="Search: ", edit_text="")
def keypress(self, size, key):
if key == 'enter' and self.on_search:
self.on_search(self.edit_text)
return None
return super().keypress(size, key)
class AsyncOperation:
"""Manages async operations with UI callbacks."""
def __init__(self, executor: ThreadPoolExecutor):
self.executor = executor
self._write_fd: Optional[int] = None
self._read_fd: Optional[int] = None
self._pending_callbacks: List[Callable] = []
self._lock = threading.Lock()
def setup_pipe(self, loop: urwid.MainLoop):
"""Setup a pipe for thread-safe UI updates."""
self._read_fd, self._write_fd = os.pipe()
loop.watch_file(self._read_fd, self._handle_callback)
def cleanup(self):
"""Cleanup pipe file descriptors."""
if self._read_fd is not None:
os.close(self._read_fd)
if self._write_fd is not None:
os.close(self._write_fd)
def _handle_callback(self):
"""Handle pending callbacks from worker threads."""
# Read and discard the notification byte
os.read(self._read_fd, 1)
# Process pending callbacks
with self._lock:
callbacks = self._pending_callbacks[:]
self._pending_callbacks.clear()
for callback in callbacks:
callback()
def _schedule_callback(self, callback: Callable):
"""Schedule a callback to run on the main thread."""
with self._lock:
self._pending_callbacks.append(callback)
# Wake up the main loop
if self._write_fd is not None:
os.write(self._write_fd, b'x')
def run_async(
self,
operation: Callable,
on_success: Callable[[Any], None],
on_error: Callable[[Exception], None]
):
"""Run an operation asynchronously."""
def worker():
try:
result = operation()
self._schedule_callback(lambda: on_success(result))
except Exception as e:
self._schedule_callback(lambda: on_error(e))
self.executor.submit(worker)
class RegistryBrowser:
"""TUI browser for the CmdForge Registry."""
def __init__(self):
self.client = get_client()
self.tools: List[Dict] = []
self.categories: List[Dict] = []
self.current_category: Optional[str] = None
self.current_query: str = ""
self.current_page: int = 1
self.total_pages: int = 1
self.status_message: str = ""
self.loop: Optional[urwid.MainLoop] = None
self.loading: bool = False
# Thread pool for async operations
self.executor = ThreadPoolExecutor(max_workers=2)
self.async_ops = AsyncOperation(self.executor)
# Build UI
self._build_ui()
def _build_ui(self):
"""Build the main UI layout."""
# Header
self.header = urwid.AttrMap(
urwid.Text(" CmdForge Registry Browser ", align='center'),
'header'
)
# Search box
self.search_edit = SearchEdit(on_search=self._do_search)
search_widget = urwid.AttrMap(self.search_edit, 'edit', 'edit_focus')
# Category selector
self.category_text = urwid.Text("Category: All")
category_widget = urwid.AttrMap(self.category_text, 'info')
# Top bar with search and category
top_bar = urwid.Columns([
('weight', 2, search_widget),
('weight', 1, category_widget),
], dividechars=2)
# Tools list
self.list_walker = urwid.SimpleFocusListWalker([])
self.listbox = urwid.ListBox(self.list_walker)
list_frame = urwid.LineBox(self.listbox, title="Tools")
# Detail panel (right side)
self.detail_text = urwid.Text("Select a tool to view details\n\nPress 'i' to install")
self.detail_box = urwid.LineBox(
urwid.Filler(self.detail_text, valign='top'),
title="Details"
)
# Main content area with list and details
self.main_columns = urwid.Columns([
('weight', 2, list_frame),
('weight', 1, self.detail_box),
], dividechars=1)
# Status bar
self.status_text = urwid.Text(" Loading...")
self.footer = urwid.AttrMap(
urwid.Columns([
self.status_text,
urwid.Text("↑↓:Navigate Enter:Details i:Install /:Search c:Category q:Quit", align='right'),
]),
'footer'
)
# Main frame
body = urwid.Pile([
('pack', urwid.AttrMap(top_bar, 'body')),
('pack', urwid.Divider()),
self.main_columns,
])
self.frame = urwid.Frame(
urwid.AttrMap(body, 'body'),
header=self.header,
footer=self.footer
)
def _load_tools(self, query: str = "", category: str = None, page: int = 1):
"""Load tools from the registry asynchronously."""
if self.loading:
return
self.loading = True
self._set_status("Loading...", loading=True)
def fetch():
if query:
return self.client.search_tools(
query=query,
category=category,
page=page,
per_page=20
)
else:
return self.client.list_tools(
category=category,
page=page,
per_page=20
)
def on_success(result: PaginatedResponse):
self.loading = False
self.tools = result.data
self.current_page = result.page
self.total_pages = result.total_pages
self._update_list()
self._set_status(f"Found {result.total} tools (page {result.page}/{result.total_pages})")
def on_error(e: Exception):
self.loading = False
if isinstance(e, RegistryError):
self._set_status(f"Error: {e.message}")
else:
self._set_status(f"Error: {e}")
self.async_ops.run_async(fetch, on_success, on_error)
def _load_categories(self):
"""Load categories from the registry asynchronously."""
def fetch():
return self.client.get_categories()
def on_success(categories):
self.categories = categories
def on_error(e):
self.categories = []
self.async_ops.run_async(fetch, on_success, on_error)
def _update_list(self):
"""Update the tool list display."""
self.list_walker.clear()
if not self.tools:
self.list_walker.append(urwid.Text(" No tools found"))
return
for tool in self.tools:
item = ToolListItem(
tool,
on_select=self._show_detail,
on_install=self._install_tool
)
self.list_walker.append(item)
self.list_walker.append(urwid.Divider(''))
def _show_detail(self, tool_data: Dict):
"""Show tool details in the detail panel."""
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
version = tool_data.get("version", "")
description = tool_data.get("description", "No description")
category = tool_data.get("category", "")
tags = tool_data.get("tags", [])
downloads = tool_data.get("downloads", 0)
detail = f"""{owner}/{name}
Version: {version}
Category: {category}
Downloads: {downloads}
{description}
Tags: {', '.join(tags) if tags else 'None'}
Install command:
cmdforge registry install {owner}/{name}
Press 'i' to install this tool
"""
self.detail_text.set_text(detail)
def _install_tool(self, tool_data: Dict):
"""Install the selected tool asynchronously."""
if self.loading:
return
owner = tool_data.get("owner", "")
name = tool_data.get("name", "")
self.loading = True
self._set_status(f"Installing {owner}/{name}...", loading=True)
def install():
return install_from_registry(f"{owner}/{name}")
def on_success(resolved):
self.loading = False
self._set_status(f"Installed: {resolved.full_name}@{resolved.version}")
def on_error(e):
self.loading = False
self._set_status(f"Install failed: {e}")
self.async_ops.run_async(install, on_success, on_error)
def _do_search(self, query: str):
"""Perform search."""
self.current_query = query
self.current_page = 1
self._load_tools(query=query, category=self.current_category)
def _cycle_category(self):
"""Cycle through categories."""
if not self.categories:
self._load_categories()
# Schedule the cycle after categories load
return
if not self.categories:
return
cat_names = [None] + [c.get("name") for c in self.categories]
try:
idx = cat_names.index(self.current_category)
idx = (idx + 1) % len(cat_names)
except ValueError:
idx = 0
self.current_category = cat_names[idx]
cat_display = self.current_category or "All"
self.category_text.set_text(f"Category: {cat_display}")
self._load_tools(query=self.current_query, category=self.current_category)
def _next_page(self):
"""Go to next page."""
if self.current_page < self.total_pages:
self.current_page += 1
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
def _prev_page(self):
"""Go to previous page."""
if self.current_page > 1:
self.current_page -= 1
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
def _set_status(self, message: str, loading: bool = False):
"""Update status bar message."""
if loading:
self.status_text.set_text(('loading', f"{message}"))
else:
self.status_text.set_text(f" {message}")
def _handle_input(self, key):
"""Handle global key input."""
if key in ('q', 'Q'):
raise urwid.ExitMainLoop()
elif key == '/':
# Focus search box
self.frame.body.base_widget.set_focus(0)
return None
elif key == 'c':
self._cycle_category()
return None
elif key == 'n':
self._next_page()
return None
elif key == 'p':
self._prev_page()
return None
elif key == 'r':
# Refresh current view
self._load_tools(
query=self.current_query,
category=self.current_category,
page=self.current_page
)
return None
return key
def run(self):
"""Run the TUI browser."""
# Create main loop
self.loop = urwid.MainLoop(
self.frame,
palette=PALETTE,
unhandled_input=self._handle_input,
handle_mouse=True
)
# Setup async pipe for thread-safe callbacks
self.async_ops.setup_pipe(self.loop)
try:
# Initial load (async)
self._load_categories()
self._load_tools()
# Run main loop
self.loop.run()
finally:
# Cleanup
self.async_ops.cleanup()
self.executor.shutdown(wait=False)
def run_registry_browser():
"""Entry point for the registry browser TUI."""
try:
browser = RegistryBrowser()
browser.run()
except RegistryError as e:
print(f"Error connecting to registry: {e.message}")
return 1
except Exception as e:
print(f"Error: {e}")
return 1
return 0

View File

@ -1,706 +0,0 @@
"""BIOS-style TUI for CmdForge using snack (python3-newt)."""
import sys
# Ensure system packages are accessible
if '/usr/lib/python3/dist-packages' not in sys.path:
sys.path.insert(0, '/usr/lib/python3/dist-packages')
import snack
from typing import Optional, List, Tuple
from .tool import (
Tool, ToolArgument, PromptStep, CodeStep, Step,
list_tools, load_tool, save_tool, delete_tool, tool_exists
)
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
class CmdForgeUI:
"""BIOS-style UI for CmdForge."""
def __init__(self):
self.screen = None
def run(self):
"""Run the UI."""
self.screen = snack.SnackScreen()
# Enable mouse support
self.screen.pushHelpLine(" Tab/Arrow:Navigate | Enter:Select | Mouse:Click | Esc:Back ")
try:
# Enable mouse - newt supports GPM and xterm mouse
import os
os.environ.setdefault('NEWT_MONO', '0')
self.main_menu()
finally:
self.screen.finish()
def main_menu(self):
"""Show the main menu."""
while True:
items = [
("Create New Tool", "create"),
("Edit Tool", "edit"),
("Delete Tool", "delete"),
("List Tools", "list"),
("Test Tool", "test"),
("Manage Providers", "providers"),
("Exit", "exit"),
]
listbox = snack.Listbox(height=7, width=30, returnExit=1)
for label, value in items:
listbox.append(label, value)
grid = snack.GridForm(self.screen, "CmdForge Manager", 1, 1)
grid.add(listbox, 0, 0)
result = grid.runOnce()
selected = listbox.current()
if selected == "exit" or result == "ESC":
break
elif selected == "create":
self.tool_builder(None)
elif selected == "edit":
self.select_and_edit_tool()
elif selected == "delete":
self.select_and_delete_tool()
elif selected == "list":
self.show_tools_list()
elif selected == "test":
self.select_and_test_tool()
elif selected == "providers":
self.manage_providers()
def message_box(self, title: str, message: str):
"""Show a message box."""
snack.ButtonChoiceWindow(self.screen, title, message, ["OK"])
def yes_no(self, title: str, message: str) -> bool:
"""Show a yes/no dialog."""
result = snack.ButtonChoiceWindow(self.screen, title, message, ["Yes", "No"])
return result == "yes"
def input_box(self, title: str, prompt: str, initial: str = "", width: int = 40) -> Optional[str]:
"""Show an input dialog."""
entry = snack.Entry(width, initial)
grid = snack.GridForm(self.screen, title, 1, 3)
grid.add(snack.Label(prompt), 0, 0)
grid.add(entry, 0, 1, padding=(0, 1, 0, 1))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 2)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
return entry.value()
return None
def text_edit(self, title: str, initial: str = "", width: int = 60, height: int = 10) -> Optional[str]:
"""Show a multi-line text editor."""
text = snack.Textbox(width, height, initial, scroll=1, wrap=1)
# snack doesn't have a true multi-line editor, so we use Entry for now
# For multi-line, we'll use a workaround with a simple entry
entry = snack.Entry(width, initial, scroll=1)
grid = snack.GridForm(self.screen, title, 1, 2)
grid.add(entry, 0, 0, padding=(0, 0, 0, 1))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
return entry.value()
return None
def select_provider(self) -> Optional[str]:
"""Show provider selection dialog."""
providers = load_providers()
listbox = snack.Listbox(height=min(len(providers) + 1, 8), width=50, returnExit=1)
for p in providers:
listbox.append(f"{p.name}: {p.command}", p.name)
listbox.append("[ + Add New Provider ]", "__new__")
grid = snack.GridForm(self.screen, "Select Provider", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
return None
selected = listbox.current()
if selected == "__new__":
provider = self.add_provider_dialog()
if provider:
add_provider(provider)
return provider.name
return None
return selected
def add_provider_dialog(self) -> Optional[Provider]:
"""Dialog to add a new provider."""
name_entry = snack.Entry(30, "")
cmd_entry = snack.Entry(40, "")
desc_entry = snack.Entry(40, "")
grid = snack.GridForm(self.screen, "Add Provider", 2, 4)
grid.add(snack.Label("Name:"), 0, 0, anchorLeft=1)
grid.add(name_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(snack.Label("Command:"), 0, 1, anchorLeft=1)
grid.add(cmd_entry, 1, 1, padding=(1, 0, 0, 0))
grid.add(snack.Label("Description:"), 0, 2, anchorLeft=1)
grid.add(desc_entry, 1, 2, padding=(1, 0, 0, 0))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 3, growx=1, growy=1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
name = name_entry.value().strip()
cmd = cmd_entry.value().strip()
if name and cmd:
return Provider(name=name, command=cmd, description=desc_entry.value().strip())
self.message_box("Error", "Name and command are required.")
return None
def add_argument_dialog(self, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]:
"""Dialog to add/edit an argument."""
flag_entry = snack.Entry(20, existing.flag if existing else "--")
var_entry = snack.Entry(20, existing.variable if existing else "")
default_entry = snack.Entry(20, existing.default or "" if existing else "")
desc_entry = snack.Entry(40, existing.description if existing else "")
title = "Edit Argument" if existing else "Add Argument"
grid = snack.GridForm(self.screen, title, 2, 5)
grid.add(snack.Label("Flag:"), 0, 0, anchorLeft=1)
grid.add(flag_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(snack.Label("Variable:"), 0, 1, anchorLeft=1)
grid.add(var_entry, 1, 1, padding=(1, 0, 0, 0))
grid.add(snack.Label("Default:"), 0, 2, anchorLeft=1)
grid.add(default_entry, 1, 2, padding=(1, 0, 0, 0))
grid.add(snack.Label("Description:"), 0, 3, anchorLeft=1)
grid.add(desc_entry, 1, 3, padding=(1, 0, 0, 0))
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 4, growx=1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
flag = flag_entry.value().strip()
var = var_entry.value().strip()
if not flag:
self.message_box("Error", "Flag is required.")
return None
if not var:
var = flag.lstrip("-").replace("-", "_")
return ToolArgument(
flag=flag,
variable=var,
default=default_entry.value().strip() or None,
description=desc_entry.value().strip()
)
return None
def add_step_dialog(self, tool: Tool, existing_step: Optional[Step] = None, step_idx: int = -1) -> Optional[Step]:
"""Dialog to choose and add a step."""
if existing_step:
# Edit existing step
if isinstance(existing_step, PromptStep):
return self.add_prompt_dialog(tool, existing_step, step_idx)
else:
return self.add_code_dialog(tool, existing_step, step_idx)
# Choose step type
listbox = snack.Listbox(height=2, width=30, returnExit=1)
listbox.append("Prompt (AI call)", "prompt")
listbox.append("Code (Python)", "code")
grid = snack.GridForm(self.screen, "Add Step", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "cancel" or result == "ESC":
return None
step_type = listbox.current()
if step_type == "prompt":
return self.add_prompt_dialog(tool, None, step_idx)
else:
return self.add_code_dialog(tool, None, step_idx)
def get_available_variables(self, tool: Tool, up_to_step: int = -1) -> List[str]:
"""Get available variables at a point in the tool."""
variables = ["input"]
for arg in tool.arguments:
variables.append(arg.variable)
if up_to_step == -1:
up_to_step = len(tool.steps)
for i, step in enumerate(tool.steps):
if i >= up_to_step:
break
variables.append(step.output_var)
return variables
def add_prompt_dialog(self, tool: Tool, existing: Optional[PromptStep] = None, step_idx: int = -1) -> Optional[PromptStep]:
"""Dialog to add/edit a prompt step."""
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available)
# Provider selection first
provider = self.select_provider()
if not provider:
provider = existing.provider if existing else "mock"
prompt_entry = snack.Entry(60, existing.prompt if existing else f"Process this:\n\n{{input}}", scroll=1)
output_entry = snack.Entry(20, existing.output_var if existing else "result")
title = "Edit Prompt Step" if existing else "Add Prompt Step"
grid = snack.GridForm(self.screen, title, 2, 4)
grid.add(snack.Label(f"Provider: {provider}"), 0, 0, anchorLeft=1, growx=1)
grid.add(snack.Label(""), 1, 0)
grid.add(snack.Label(f"Prompt ({var_help}):"), 0, 1, anchorLeft=1, growx=1)
grid.add(snack.Label(""), 1, 1)
grid.add(prompt_entry, 0, 2, growx=1)
grid.add(snack.Label(""), 1, 2)
sub_grid = snack.Grid(2, 1)
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(sub_grid, 0, 3, anchorLeft=1)
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 1, 3)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
prompt = prompt_entry.value().strip()
output_var = output_entry.value().strip()
if not prompt:
self.message_box("Error", "Prompt is required.")
return None
if not output_var:
output_var = "result"
return PromptStep(prompt=prompt, provider=provider, output_var=output_var)
return None
def add_code_dialog(self, tool: Tool, existing: Optional[CodeStep] = None, step_idx: int = -1) -> Optional[CodeStep]:
"""Dialog to add/edit a code step."""
available = self.get_available_variables(tool, step_idx if step_idx >= 0 else -1)
var_help = "Variables: " + ", ".join(available)
default_code = existing.code if existing else "# Set 'result' for output\nresult = input.upper()"
code_entry = snack.Entry(60, default_code, scroll=1)
output_entry = snack.Entry(20, existing.output_var if existing else "processed")
title = "Edit Code Step" if existing else "Add Code Step"
grid = snack.GridForm(self.screen, title, 2, 4)
grid.add(snack.Label(f"Python Code ({var_help}):"), 0, 0, anchorLeft=1, growx=1)
grid.add(snack.Label("Set 'result' variable for output"), 1, 0)
grid.add(code_entry, 0, 1, growx=1)
grid.add(snack.Label(""), 1, 1)
sub_grid = snack.Grid(2, 1)
sub_grid.setField(snack.Label("Output var:"), 0, 0, anchorLeft=1)
sub_grid.setField(output_entry, 1, 0, padding=(1, 0, 0, 0))
grid.add(sub_grid, 0, 2, anchorLeft=1)
buttons = snack.ButtonBar(self.screen, [("OK", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 1, 2)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
code = code_entry.value().strip()
output_var = output_entry.value().strip()
if not code:
self.message_box("Error", "Code is required.")
return None
if not output_var:
output_var = "processed"
return CodeStep(code=code, output_var=output_var)
return None
def tool_builder(self, existing: Optional[Tool] = None) -> Optional[Tool]:
"""Main tool builder - BIOS-style unified form."""
is_edit = existing is not None
# Initialize tool
if existing:
tool = Tool(
name=existing.name,
description=existing.description,
arguments=list(existing.arguments),
steps=list(existing.steps),
output=existing.output
)
else:
tool = Tool(name="", description="", arguments=[], steps=[], output="{input}")
while True:
# Create form elements
name_entry = snack.Entry(25, tool.name, scroll=0)
desc_entry = snack.Entry(25, tool.description, scroll=1)
output_entry = snack.Entry(25, tool.output, scroll=1)
# Arguments listbox
args_listbox = snack.Listbox(height=4, width=35, returnExit=0, scroll=1)
for i, arg in enumerate(tool.arguments):
args_listbox.append(f"{arg.flag} -> {{{arg.variable}}}", i)
args_listbox.append("[ + Add ]", "add")
# Steps listbox
steps_listbox = snack.Listbox(height=5, width=35, returnExit=0, scroll=1)
for i, step in enumerate(tool.steps):
if isinstance(step, PromptStep):
steps_listbox.append(f"P:{step.provider} -> {{{step.output_var}}}", i)
else:
steps_listbox.append(f"C: -> {{{step.output_var}}}", i)
steps_listbox.append("[ + Add ]", "add")
# Build the grid layout using nested grids for better control
title = f"Edit Tool: {tool.name}" if is_edit and tool.name else "New Tool"
# Left column grid: Name, Description, Output
left_grid = snack.Grid(1, 6)
left_grid.setField(snack.Label("Name:"), 0, 0, anchorLeft=1)
left_grid.setField(name_entry, 0, 1, anchorLeft=1)
left_grid.setField(snack.Label("Description:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
left_grid.setField(desc_entry, 0, 3, anchorLeft=1)
left_grid.setField(snack.Label("Output:"), 0, 4, anchorLeft=1, padding=(0, 1, 0, 0))
left_grid.setField(output_entry, 0, 5, anchorLeft=1)
# Right column grid: Arguments and Steps
right_grid = snack.Grid(1, 4)
right_grid.setField(snack.Label("Arguments:"), 0, 0, anchorLeft=1)
right_grid.setField(args_listbox, 0, 1, anchorLeft=1)
right_grid.setField(snack.Label("Execution Steps:"), 0, 2, anchorLeft=1, padding=(0, 1, 0, 0))
right_grid.setField(steps_listbox, 0, 3, anchorLeft=1)
# Main grid
grid = snack.GridForm(self.screen, title, 2, 2)
grid.add(left_grid, 0, 0, anchorTop=1, padding=(0, 0, 2, 0))
grid.add(right_grid, 1, 0, anchorTop=1)
# Buttons at bottom
buttons = snack.ButtonBar(self.screen, [("Save", "save"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1, growx=1)
# Handle hotkeys for listbox interaction
form = grid.form
while True:
result = form.run()
# Update tool from entries
if not is_edit:
tool.name = name_entry.value().strip()
tool.description = desc_entry.value().strip()
tool.output = output_entry.value().strip()
# Check what was activated
if result == args_listbox:
selected = args_listbox.current()
if selected == "add":
new_arg = self.add_argument_dialog()
if new_arg:
tool.arguments.append(new_arg)
break # Refresh form
elif isinstance(selected, int):
# Edit/delete existing argument
action = self.arg_action_menu(tool.arguments[selected])
if action == "edit":
updated = self.add_argument_dialog(tool.arguments[selected])
if updated:
tool.arguments[selected] = updated
elif action == "delete":
tool.arguments.pop(selected)
break # Refresh form
elif result == steps_listbox:
selected = steps_listbox.current()
if selected == "add":
new_step = self.add_step_dialog(tool)
if new_step:
tool.steps.append(new_step)
break # Refresh form
elif isinstance(selected, int):
# Edit/delete existing step
action = self.step_action_menu(tool.steps[selected], selected, len(tool.steps))
if action == "edit":
updated = self.add_step_dialog(tool, tool.steps[selected], selected)
if updated:
tool.steps[selected] = updated
elif action == "delete":
tool.steps.pop(selected)
elif action == "move_up" and selected > 0:
tool.steps[selected], tool.steps[selected-1] = tool.steps[selected-1], tool.steps[selected]
elif action == "move_down" and selected < len(tool.steps) - 1:
tool.steps[selected], tool.steps[selected+1] = tool.steps[selected+1], tool.steps[selected]
break # Refresh form
elif buttons.buttonPressed(result) == "save":
if not tool.name:
self.message_box("Error", "Tool name is required.")
break
if not is_edit and tool_exists(tool.name):
if not self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?"):
break
self.screen.popWindow()
save_tool(tool)
self.message_box("Success", f"Tool '{tool.name}' saved!")
return tool
elif buttons.buttonPressed(result) == "cancel" or result == "ESC":
self.screen.popWindow()
return None
else:
# Tab between fields, continue
continue
self.screen.popWindow()
def arg_action_menu(self, arg: ToolArgument) -> Optional[str]:
"""Show action menu for an argument."""
listbox = snack.Listbox(height=3, width=20, returnExit=1)
listbox.append("Edit", "edit")
listbox.append("Delete", "delete")
listbox.append("Cancel", "cancel")
grid = snack.GridForm(self.screen, f"Argument: {arg.flag}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def step_action_menu(self, step: Step, idx: int, total: int) -> Optional[str]:
"""Show action menu for a step."""
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
items = [("Edit", "edit")]
if idx > 0:
items.append(("Move Up", "move_up"))
if idx < total - 1:
items.append(("Move Down", "move_down"))
items.append(("Delete", "delete"))
items.append(("Cancel", "cancel"))
listbox = snack.Listbox(height=len(items), width=20, returnExit=1)
for label, value in items:
listbox.append(label, value)
grid = snack.GridForm(self.screen, f"Step {idx+1}: {step_type}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def select_and_edit_tool(self):
"""Select a tool to edit."""
tools = list_tools()
if not tools:
self.message_box("Edit Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
tool = load_tool(name)
desc = tool.description[:30] if tool and tool.description else "No description"
listbox.append(f"{name}: {desc}", name)
grid = snack.GridForm(self.screen, "Select Tool to Edit", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Select", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
tool = load_tool(selected)
if tool:
self.tool_builder(tool)
def select_and_delete_tool(self):
"""Select a tool to delete."""
tools = list_tools()
if not tools:
self.message_box("Delete Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
listbox.append(name, name)
grid = snack.GridForm(self.screen, "Select Tool to Delete", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Delete", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
if self.yes_no("Confirm", f"Delete tool '{selected}'?"):
if delete_tool(selected):
self.message_box("Deleted", f"Tool '{selected}' deleted.")
def show_tools_list(self):
"""Show list of all tools."""
tools = list_tools()
if not tools:
self.message_box("Tools", "No tools found.\n\nCreate one from the main menu.")
return
text = ""
for name in tools:
tool = load_tool(name)
if tool:
text += f"{name}\n"
text += f" {tool.description or 'No description'}\n"
if tool.arguments:
args = ", ".join(a.flag for a in tool.arguments)
text += f" Args: {args}\n"
if tool.steps:
steps = []
for s in tool.steps:
if isinstance(s, PromptStep):
steps.append(f"P:{s.provider}")
else:
steps.append("C")
text += f" Steps: {' -> '.join(steps)}\n"
text += "\n"
snack.ButtonChoiceWindow(self.screen, "Available Tools", text.strip(), ["OK"])
def select_and_test_tool(self):
"""Select a tool to test."""
tools = list_tools()
if not tools:
self.message_box("Test Tool", "No tools found.")
return
listbox = snack.Listbox(height=min(len(tools), 10), width=40, returnExit=1, scroll=1)
for name in tools:
listbox.append(name, name)
grid = snack.GridForm(self.screen, "Select Tool to Test", 1, 2)
grid.add(listbox, 0, 0)
buttons = snack.ButtonBar(self.screen, [("Test", "ok"), ("Cancel", "cancel")])
grid.add(buttons, 0, 1)
result = grid.runOnce()
if buttons.buttonPressed(result) == "ok":
selected = listbox.current()
tool = load_tool(selected)
if tool:
test_input = self.input_box("Test Input", "Enter test input:", "Hello world")
if test_input:
from .runner import run_tool
output, code = run_tool(
tool=tool,
input_text=test_input,
custom_args={},
provider_override="mock",
dry_run=False,
show_prompt=False,
verbose=False
)
result_text = f"Exit code: {code}\n\nOutput:\n{output[:500]}"
self.message_box("Test Result", result_text)
def manage_providers(self):
"""Manage providers menu."""
while True:
providers = load_providers()
listbox = snack.Listbox(height=min(len(providers) + 2, 10), width=50, returnExit=1, scroll=1)
for p in providers:
listbox.append(f"{p.name}: {p.command}", p.name)
listbox.append("[ + Add Provider ]", "__add__")
listbox.append("[ <- Back ]", "__back__")
grid = snack.GridForm(self.screen, "Manage Providers", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
selected = listbox.current()
if selected == "__back__":
break
elif selected == "__add__":
provider = self.add_provider_dialog()
if provider:
add_provider(provider)
self.message_box("Success", f"Provider '{provider.name}' added.")
else:
# Edit or delete
provider = get_provider(selected)
if provider:
action = self.provider_action_menu(provider)
if action == "edit":
updated = self.add_provider_dialog() # TODO: pass existing
if updated:
add_provider(updated)
elif action == "delete":
if self.yes_no("Confirm", f"Delete provider '{selected}'?"):
delete_provider(selected)
def provider_action_menu(self, provider: Provider) -> Optional[str]:
"""Show action menu for a provider."""
listbox = snack.Listbox(height=3, width=20, returnExit=1)
listbox.append("Edit", "edit")
listbox.append("Delete", "delete")
listbox.append("Cancel", "cancel")
grid = snack.GridForm(self.screen, f"Provider: {provider.name}", 1, 1)
grid.add(listbox, 0, 0)
grid.runOnce()
return listbox.current() if listbox.current() != "cancel" else None
def run_ui():
"""Entry point for the snack UI."""
ui = CmdForgeUI()
ui.run()
if __name__ == "__main__":
run_ui()

View File

@ -1,23 +0,0 @@
"""BIOS-style TUI for CmdForge using urwid.
This module is a thin wrapper for backwards compatibility.
The actual implementation is in the ui_urwid/ package.
"""
from .ui_urwid import run_ui, CmdForgeUI
from .ui_urwid.palette import PALETTE
from .ui_urwid.widgets import (
SelectableText, Button3D, Button3DCompact, ClickableButton,
SelectableToolItem, ToolListBox, TabCyclePile, TabPassEdit,
UndoableEdit, DOSScrollBar, ToolBuilderLayout, Dialog
)
__all__ = [
'run_ui', 'CmdForgeUI', 'PALETTE',
'SelectableText', 'Button3D', 'Button3DCompact', 'ClickableButton',
'SelectableToolItem', 'ToolListBox', 'TabCyclePile', 'TabPassEdit',
'UndoableEdit', 'DOSScrollBar', 'ToolBuilderLayout', 'Dialog'
]
if __name__ == "__main__":
run_ui()

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +0,0 @@
"""Allow running the UI as a module."""
from . import run_ui
if __name__ == "__main__":
run_ui()

View File

@ -1,30 +0,0 @@
"""Color palette for the BIOS-style TUI."""
# Color palette - BIOS style with 3D button effects
PALETTE = [
('body', 'white', 'dark blue'),
('header', 'white', 'dark red', 'bold'),
('footer', 'black', 'light gray'),
# Button colors - raised 3D effect
('button', 'black', 'light gray'),
('button_focus', 'white', 'dark red', 'bold'),
('button_highlight', 'white', 'light gray'), # Top/left edge (light)
('button_shadow', 'dark gray', 'light gray'), # Bottom/right edge (dark)
('button_pressed', 'black', 'dark gray'), # Pressed state
# Edit fields
('edit', 'black', 'light gray'),
('edit_focus', 'black', 'yellow'),
# List items
('listbox', 'black', 'light gray'),
('listbox_focus', 'white', 'dark red'),
# Dialog
('dialog', 'black', 'light gray'),
('dialog_border', 'white', 'dark blue'),
# Text styles
('label', 'yellow', 'dark blue', 'bold'),
('error', 'white', 'dark red', 'bold'),
('success', 'light green', 'dark blue', 'bold'),
# 3D shadow elements
('shadow', 'black', 'black'),
('shadow_edge', 'dark gray', 'dark blue'),
]

View File

@ -1,715 +0,0 @@
"""Custom widgets for the BIOS-style TUI."""
import urwid
class SelectableText(urwid.WidgetWrap):
"""A selectable text widget for list items."""
def __init__(self, text, value=None, on_select=None):
self.value = value
self.on_select = on_select
self.text_widget = urwid.Text(text)
display = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus')
super().__init__(display)
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter' and self.on_select:
self.on_select(self.value)
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if event == 'mouse press' and button == 1 and self.on_select:
self.on_select(self.value)
return True
return False
class Button3D(urwid.WidgetWrap):
"""A 3D-style button using box-drawing characters for depth.
Creates a raised button effect like DOS/BIOS interfaces:
Label
When focused, colors change to show selection.
"""
signals = ['click']
def __init__(self, label, on_press=None, user_data=None):
self.label = label
self.on_press = on_press
self.user_data = user_data
self._pressed = False
# Build the 3D button structure
self._build_widget()
super().__init__(self._widget)
def _build_widget(self):
"""Build the 3D button widget structure."""
label = self.label
width = len(label) + 4 # Padding inside button
# Button face with border
# Top edge: ┌────┐
top = '' + '' * (width - 2) + ''
# Middle: │ Label │ with shadow
middle_text = '' + label + ''
# Bottom edge: └────┘ with shadow
bottom = '' + '' * (width - 2) + ''
# Shadow characters (right and bottom)
shadow_right = ''
shadow_bottom = ''
# Create the rows
top_row = urwid.Text(top + ' ') # Space for shadow alignment
middle_row = urwid.Columns([
('pack', urwid.Text(middle_text)),
('pack', urwid.Text(('shadow_edge', shadow_right))),
])
bottom_row = urwid.Columns([
('pack', urwid.Text(bottom)),
('pack', urwid.Text(('shadow_edge', shadow_right))),
])
shadow_row = urwid.Text(('shadow_edge', ' ' + shadow_bottom * (width - 1)))
# Stack them
pile = urwid.Pile([
top_row,
middle_row,
bottom_row,
shadow_row,
])
self._widget = urwid.AttrMap(pile, 'button', 'button_focus')
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter':
self._activate()
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if button == 1:
if event == 'mouse press':
self._pressed = True
return True
elif event == 'mouse release' and self._pressed:
self._pressed = False
self._activate()
return True
return False
def _activate(self):
"""Trigger the button callback."""
if self.on_press:
self.on_press(self.user_data)
self._emit('click')
class Button3DCompact(urwid.WidgetWrap):
"""A compact 3D button that fits on a single line with shadow effect.
Creates a subtle 3D effect: [ Label ]
Better for inline use where vertical space is limited.
"""
signals = ['click']
def __init__(self, label, on_press=None, user_data=None):
self.label = label
self.on_press = on_press
self.user_data = user_data
# Build compact button: ▐ Label ▌ with shadow
# Using block characters for edges
button_text = urwid.Text([
('button_highlight', ''),
('button', f' {label} '),
('button_shadow', ''),
('shadow_edge', ''),
])
self._widget = urwid.AttrMap(button_text, None, {
'button': 'button_focus',
'button_highlight': 'button_focus',
'button_shadow': 'button_focus',
})
super().__init__(self._widget)
def selectable(self):
return True
def keypress(self, size, key):
if key == 'enter':
self._activate()
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if button == 1 and event == 'mouse release':
self._activate()
return True
return False
def _activate(self):
if self.on_press:
self.on_press(self.user_data)
self._emit('click')
class ClickableButton(urwid.WidgetWrap):
"""A button that responds to mouse clicks (legacy wrapper)."""
def __init__(self, label, on_press=None, user_data=None):
self.on_press = on_press
self.user_data = user_data
button = urwid.Button(label)
if on_press:
urwid.connect_signal(button, 'click', self._handle_click)
display = urwid.AttrMap(button, 'button', 'button_focus')
super().__init__(display)
def _handle_click(self, button):
if self.on_press:
self.on_press(self.user_data)
class SelectableToolItem(urwid.WidgetWrap):
"""A selectable tool item that maintains selection state."""
def __init__(self, name, on_select=None):
self.name = name
self.on_select = on_select
self._selected = False
self.text_widget = urwid.Text(f" {name} ")
self.attr_map = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus')
super().__init__(self.attr_map)
def selectable(self):
return True
def set_selected(self, selected):
"""Set whether this item is the selected tool."""
self._selected = selected
if self._selected:
self.attr_map.set_attr_map({None: 'listbox_focus'})
else:
self.attr_map.set_attr_map({None: 'listbox'})
def keypress(self, size, key):
if key == 'enter' and self.on_select:
self.on_select(self.name)
return None
return key
def mouse_event(self, size, event, button, col, row, focus):
if event == 'mouse press' and button == 1:
# Single click just selects/focuses - don't call on_select
# on_select is only called on Enter key (to edit)
return True
return False
class ToolListBox(urwid.ListBox):
"""A ListBox that keeps arrow keys internal and passes Tab out."""
def __init__(self, body, on_focus_change=None):
super().__init__(body)
self.on_focus_change = on_focus_change
self._last_focus = None
def keypress(self, size, key):
if key in ('up', 'down'):
# Handle arrow keys internally - navigate within list
result = super().keypress(size, key)
# Check if focus changed
self._check_focus_change()
return result
elif key == 'tab':
# Pass tab out to parent for focus cycling
return key
elif key == 'shift tab':
return key
else:
return super().keypress(size, key)
def _check_focus_change(self):
"""Check if focus changed and notify callback."""
try:
current = self.focus
if current is not self._last_focus:
self._last_focus = current
if self.on_focus_change and isinstance(current, SelectableToolItem):
self.on_focus_change(current.name)
except (IndexError, TypeError):
pass
def render(self, size, focus=False):
# Check focus on render too (for initial display)
if focus:
self._check_focus_change()
return super().render(size, focus)
class TabCyclePile(urwid.Pile):
"""A Pile that uses Tab/Shift-Tab to cycle between specific positions.
Args:
widget_list: List of widgets (same as urwid.Pile)
tab_positions: List of indices in the pile that Tab should cycle between.
Default is [0] (only first position).
"""
def __init__(self, widget_list, tab_positions=None):
super().__init__(widget_list)
# Positions in the pile that Tab should cycle between
self.tab_positions = tab_positions or [0]
self._current_tab_idx = 0
def keypress(self, size, key):
if key == 'tab':
# Move to next tab position
self._current_tab_idx = (self._current_tab_idx + 1) % len(self.tab_positions)
self.focus_position = self.tab_positions[self._current_tab_idx]
return None
elif key == 'shift tab':
# Move to previous tab position
self._current_tab_idx = (self._current_tab_idx - 1) % len(self.tab_positions)
self.focus_position = self.tab_positions[self._current_tab_idx]
return None
else:
return super().keypress(size, key)
class TabPassEdit(urwid.Edit):
"""A multiline Edit that passes Tab through for focus cycling instead of inserting tabs."""
def keypress(self, size, key):
if key in ('tab', 'shift tab'):
# Pass Tab through to parent for focus cycling
return key
return super().keypress(size, key)
class UndoableEdit(urwid.Edit):
"""A multiline Edit with undo/redo support.
Features:
- Undo with Alt+U (up to 50 states)
- Redo with Alt+R
- Tab passes through for focus cycling
"""
MAX_UNDO = 50 # Maximum undo history size
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._undo_stack = [] # List of (text, cursor_pos) tuples
self._redo_stack = []
def keypress(self, size, key):
if key in ('tab', 'shift tab'):
return key
# Handle undo (Alt+U or meta u)
if key in ('meta u', 'alt u'):
self._undo()
return None
# Handle redo (Alt+R or meta r)
if key in ('meta r', 'alt r'):
self._redo()
return None
# Save current state BEFORE the edit for undo
old_text = self.edit_text
old_pos = self.edit_pos
# Let the parent handle the keypress
result = super().keypress(size, key)
# If text changed, save the old state to undo stack
if self.edit_text != old_text:
self._save_undo_state(old_text, old_pos)
self._redo_stack.clear() # Clear redo on new edit
return result
def _save_undo_state(self, text, pos):
"""Save state to undo stack."""
# Don't save duplicate states
if self._undo_stack and self._undo_stack[-1][0] == text:
return
if len(self._undo_stack) >= self.MAX_UNDO:
self._undo_stack.pop(0)
self._undo_stack.append((text, pos))
def _undo(self):
"""Restore previous state from undo stack."""
if not self._undo_stack:
return
# Save current state to redo stack
self._redo_stack.append((self.edit_text, self.edit_pos))
# Restore previous state
text, pos = self._undo_stack.pop()
self.set_edit_text(text)
self.set_edit_pos(min(pos, len(text)))
def _redo(self):
"""Restore state from redo stack."""
if not self._redo_stack:
return
# Save current state to undo stack
self._undo_stack.append((self.edit_text, self.edit_pos))
# Restore redo state
text, pos = self._redo_stack.pop()
self.set_edit_text(text)
self.set_edit_pos(min(pos, len(text)))
class DOSScrollBar(urwid.WidgetWrap):
"""A DOS-style scrollbar with arrow buttons at top and bottom.
Renders a scrollbar on the right side of the wrapped widget with:
- arrow at top (click to scroll up)
- track with thumb showing scroll position
- arrow at bottom (click to scroll down)
Click zones (expanded to last 2 columns for easier clicking):
- Top 25%: scroll up 3 lines
- Bottom 25%: scroll down 3 lines
- Middle: page up/down based on which half clicked
"""
def __init__(self, widget):
self._wrapped = widget
# Create a columns layout: content on left, scrollbar on right
super().__init__(widget)
def render(self, size, focus=False):
maxcol, maxrow = size
# Render the wrapped widget with one less column for scrollbar
content_size = (maxcol - 1, maxrow)
content_canvas = self._wrapped.render(content_size, focus)
# Build the scrollbar column
scrollbar_chars = []
# Up arrow at top
scrollbar_chars.append('')
# Calculate thumb position
if maxrow > 2:
track_height = maxrow - 2 # Minus the two arrow buttons
# Get scroll position info from wrapped widget
try:
if hasattr(self._wrapped, 'rows_max'):
rows_max = self._wrapped.rows_max(content_size)
scroll_pos = self._wrapped.get_scrollpos(content_size)
else:
rows_max = maxrow
scroll_pos = 0
if rows_max > maxrow:
# Calculate thumb position within track
thumb_pos = int((scroll_pos / (rows_max - maxrow)) * (track_height - 1))
thumb_pos = max(0, min(thumb_pos, track_height - 1))
else:
thumb_pos = 0
except (AttributeError, TypeError, ZeroDivisionError):
thumb_pos = 0
# Build track with thumb
for i in range(track_height):
if i == thumb_pos:
scrollbar_chars.append('') # Thumb
else:
scrollbar_chars.append('') # Track
# Down arrow at bottom
scrollbar_chars.append('')
# Create scrollbar canvas
scrollbar_text = '\n'.join(scrollbar_chars[:maxrow])
scrollbar_canvas = urwid.Text(scrollbar_text).render((1,))
# Combine canvases
combined = urwid.CanvasJoin([
(content_canvas, None, focus, content_size[0]),
(scrollbar_canvas, None, False, 1),
])
return combined
def keypress(self, size, key):
maxcol, maxrow = size
content_size = (maxcol - 1, maxrow)
return self._wrapped.keypress(content_size, key)
def mouse_event(self, size, event, button, col, row, focus):
maxcol, maxrow = size
content_size = (maxcol - 1, maxrow)
# Expand clickable area - last 2 columns count as scrollbar
if col >= maxcol - 2:
if button == 1 and event == 'mouse press':
# Top 25% of scrollbar = scroll up
if row < maxrow // 4:
for _ in range(3):
self._wrapped.keypress(content_size, 'up')
self._invalidate()
return True
# Bottom 25% of scrollbar = scroll down
elif row >= maxrow - (maxrow // 4):
for _ in range(3):
self._wrapped.keypress(content_size, 'down')
self._invalidate()
return True
# Middle = page up/down based on which half
elif row < maxrow // 2:
for _ in range(maxrow // 2):
self._wrapped.keypress(content_size, 'up')
self._invalidate()
return True
else:
for _ in range(maxrow // 2):
self._wrapped.keypress(content_size, 'down')
self._invalidate()
return True
# Handle mouse wheel on scrollbar
if button == 4: # Scroll up
for _ in range(3):
self._wrapped.keypress(content_size, 'up')
self._invalidate()
return True
elif button == 5: # Scroll down
for _ in range(3):
self._wrapped.keypress(content_size, 'down')
self._invalidate()
return True
return True # Consume other scrollbar clicks
# Pass to wrapped widget
return self._wrapped.mouse_event(content_size, event, button, col, row, focus)
def selectable(self):
return self._wrapped.selectable()
def sizing(self):
return frozenset([urwid.Sizing.BOX])
class ToolBuilderLayout(urwid.WidgetWrap):
"""Custom layout for tool builder that handles Tab cycling across all sections."""
def __init__(self, left_box, args_box, steps_box, args_section, steps_section, bottom_buttons, on_cancel=None):
self._current_section = 0
self.on_cancel = on_cancel
# Store references to LineBoxes for title highlighting
self.left_box = left_box
self.args_box = args_box
self.steps_box = steps_box
# Build visual layout: left column and right column side by side
right_pile = urwid.Pile([
('weight', 1, args_section),
('pack', urwid.Divider()),
('weight', 1, steps_section),
])
columns = urwid.Columns([
('weight', 1, left_box),
('weight', 1, right_pile),
], dividechars=1)
main_pile = urwid.Pile([
('weight', 1, columns),
('pack', urwid.Divider()),
('pack', bottom_buttons),
])
super().__init__(main_pile)
# Set initial highlight
self._update_section_titles()
def keypress(self, size, key):
if key == 'tab':
self._current_section = (self._current_section + 1) % 4
self._focus_section(self._current_section)
self._update_section_titles()
return None
elif key == 'shift tab':
self._current_section = (self._current_section - 1) % 4
self._focus_section(self._current_section)
self._update_section_titles()
return None
elif key == 'esc':
# Go back to main menu instead of exiting
if self.on_cancel:
self.on_cancel(None)
return None
else:
return super().keypress(size, key)
def mouse_event(self, size, event, button, col, row, focus):
# Let the parent handle the mouse event first
result = super().mouse_event(size, event, button, col, row, focus)
# After mouse click, detect which section has focus and update titles
if event == 'mouse press':
self._detect_current_section()
self._update_section_titles()
return result
def _detect_current_section(self):
"""Detect which section currently has focus based on widget hierarchy."""
main_pile = self._w
# Check if bottom buttons have focus (position 2)
if main_pile.focus_position == 2:
self._current_section = 3
return
# Focus is on columns (position 0)
columns = main_pile.contents[0][0]
if columns.focus_position == 0:
# Left box (Tool Info)
self._current_section = 0
else:
# Right pile
right_pile = columns.contents[1][0]
if right_pile.focus_position == 0:
# Args section
self._current_section = 1
else:
# Steps section
self._current_section = 2
def _update_section_titles(self):
"""Update section titles to highlight the current one with markers."""
# Section 0 = Tool Info, Section 1 = Arguments, Section 2 = Steps, Section 3 = buttons
if self._current_section == 0:
self.left_box.set_title('[ Tool Info ]')
self.args_box.set_title('Arguments')
self.steps_box.set_title('Execution Steps')
elif self._current_section == 1:
self.left_box.set_title('Tool Info')
self.args_box.set_title('[ Arguments ]')
self.steps_box.set_title('Execution Steps')
elif self._current_section == 2:
self.left_box.set_title('Tool Info')
self.args_box.set_title('Arguments')
self.steps_box.set_title('[ Execution Steps ]')
else:
# Buttons focused - no section highlighted
self.left_box.set_title('Tool Info')
self.args_box.set_title('Arguments')
self.steps_box.set_title('Execution Steps')
def _focus_section(self, section_idx):
"""Set focus to the specified section."""
# Get the main pile
main_pile = self._w
if section_idx == 0:
# Tool Info (left box) - focus columns, then left
main_pile.focus_position = 0 # columns
columns = main_pile.contents[0][0]
columns.focus_position = 0 # left box
elif section_idx == 1:
# Arguments section - focus columns, then right, then args
main_pile.focus_position = 0 # columns
columns = main_pile.contents[0][0]
columns.focus_position = 1 # right pile
right_pile = columns.contents[1][0]
right_pile.focus_position = 0 # args section
elif section_idx == 2:
# Steps section - focus columns, then right, then steps
main_pile.focus_position = 0 # columns
columns = main_pile.contents[0][0]
columns.focus_position = 1 # right pile
right_pile = columns.contents[1][0]
right_pile.focus_position = 2 # steps section (after divider)
elif section_idx == 3:
# Save/Cancel buttons
main_pile.focus_position = 2 # bottom buttons (after divider)
class Dialog(urwid.WidgetWrap):
"""A dialog box overlay with 3D-style buttons."""
def __init__(self, title, body, buttons, width=60, height=None):
# Title
title_widget = urwid.Text(('header', f' {title} '), align='center')
# Buttons row - use 3D compact buttons for dialog actions
button_widgets = []
for label, callback in buttons:
btn = Button3DCompact(label, callback)
button_widgets.append(btn)
buttons_row = urwid.Columns([('pack', b) for b in button_widgets], dividechars=2)
buttons_centered = urwid.Padding(buttons_row, align='center', width='pack')
# Check if body is a box widget
# ListBox is always a box widget. For Piles with weighted items,
# check if it ONLY supports BOX sizing (not FLOW).
is_box_widget = isinstance(body, (urwid.ListBox, urwid.Scrollable, urwid.ScrollBar))
if not is_box_widget:
try:
sizing = body.sizing()
# Box widget if it ONLY supports BOX sizing
is_box_widget = sizing == frozenset({urwid.Sizing.BOX})
except (AttributeError, TypeError):
pass
if is_box_widget:
# Box widget - use directly with weight
pile = urwid.Pile([
('pack', title_widget),
('pack', urwid.Divider('')),
('weight', 1, body),
('pack', urwid.Divider('')),
('pack', buttons_centered),
])
else:
# Flow widget - wrap in Filler
body_padded = urwid.Padding(body, left=1, right=1)
body_filled = urwid.Filler(body_padded, valign='top')
pile = urwid.Pile([
('pack', title_widget),
('pack', urwid.Divider('')),
body_filled,
('pack', urwid.Divider('')),
('pack', buttons_centered),
])
# Box it
box = urwid.LineBox(pile, title='', title_align='center')
box = urwid.AttrMap(box, 'dialog')
super().__init__(box)

View File

@ -151,15 +151,15 @@ These tools combine AI prompts with code for validation:
## Creating Your Own Tools ## Creating Your Own Tools
### Using the TUI ### Using the GUI
The easiest way to create tools: The easiest way to create tools:
```bash ```bash
cmdforge ui cmdforge
``` ```
Navigate with arrow keys, create tools visually, edit prompts, test with mock provider. Opens the graphical interface where you can create tools visually, edit prompts, manage providers, and browse the registry.
### Tool Anatomy (YAML) ### Tool Anatomy (YAML)
@ -245,10 +245,10 @@ cat important.txt | summarize --provider claude-opus
### Change Tool Default ### Change Tool Default
Edit the tool's config or use the TUI: Edit the tool's config or use the GUI:
```bash ```bash
cmdforge ui cmdforge
# Select tool → Edit → Change provider in step # Select tool → Edit → Change provider in step
``` ```