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:
parent
604b473806
commit
4fe2d26244
|
|
@ -8,18 +8,18 @@
|
|||
- `wiki/` contains additional reference material.
|
||||
|
||||
## 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.
|
||||
- `runner.py` executes steps and performs `{input}`/argument variable substitution.
|
||||
- `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
|
||||
- `pip install -e ".[dev]"` installs CmdForge in editable mode with dev dependencies.
|
||||
- `pytest` runs the full test suite.
|
||||
- `pytest tests/test.py::test_name` runs a focused test.
|
||||
- `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 run --rm test` runs tests inside Docker.
|
||||
|
||||
|
|
|
|||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -21,21 +21,22 @@ pytest tests/test.py::test_name
|
|||
# Run the CLI
|
||||
python -m cmdforge.cli
|
||||
|
||||
# Launch the UI
|
||||
cmdforge ui
|
||||
# Launch the GUI
|
||||
cmdforge
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### 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
|
||||
- **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`
|
||||
- **ui.py**: UI dispatcher - selects between urwid and snack implementations
|
||||
- **ui_urwid.py**: Full TUI implementation using urwid library
|
||||
- **ui_snack.py**: Fallback TUI using python-newt/snack
|
||||
- **gui/**: PySide6 desktop GUI
|
||||
- **main_window.py**: Main application window with sidebar navigation
|
||||
- **pages/**: Tools page, Tool Builder, Registry browser, Providers management
|
||||
- **dialogs/**: Step editors, Argument editor, Provider dialog, Connect/Publish dialogs
|
||||
|
||||
### Key Paths
|
||||
|
||||
|
|
|
|||
130
README.md
130
README.md
|
|
@ -66,10 +66,10 @@ export PATH="$HOME/.local/bin:$PATH"
|
|||
# Install an AI provider (interactive guide)
|
||||
cmdforge providers install
|
||||
|
||||
# Launch the UI
|
||||
cmdforge ui
|
||||
# Launch the GUI
|
||||
cmdforge
|
||||
|
||||
# Or create your first tool
|
||||
# Or create your first tool via CLI
|
||||
cmdforge create summarize
|
||||
```
|
||||
|
||||
|
|
@ -95,7 +95,7 @@ pip install -e ".[dev]"
|
|||
|
||||
- Python 3.10+
|
||||
- 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
|
||||
|
||||
|
|
@ -113,13 +113,16 @@ cmdforge refresh
|
|||
|
||||
## Usage
|
||||
|
||||
### UI Mode (Recommended for Beginners)
|
||||
### GUI Mode (Recommended for Beginners)
|
||||
|
||||
```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
|
||||
|
||||
|
|
@ -410,55 +413,82 @@ vnoremap <leader>fg :!fix-grammar<CR>
|
|||
vnoremap <leader>ec :!explain-code<CR>
|
||||
```
|
||||
|
||||
## UI Navigation
|
||||
## GUI Features
|
||||
|
||||
| Action | Keys |
|
||||
|--------|------|
|
||||
| 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` |
|
||||
The graphical interface provides a modern desktop experience:
|
||||
|
||||
**Tips:**
|
||||
- Hold `Shift` while using mouse for terminal-native text selection.
|
||||
- Code/Prompt editors have DOS-style scrollbars with `▲` and `▼` arrow buttons.
|
||||
- In step dialogs, use `Tab` to cycle between File, Editor, and Output fields.
|
||||
- The code editor supports undo/redo (up to 50 states) with `Alt+U` and `Alt+R`.
|
||||
- Use the `$EDITOR` button to open code or prompts in your external editor.
|
||||
### My Tools Page
|
||||
- View all your tools organized by category (Text, Developer, Data, Other)
|
||||
- Double-click a tool to edit it
|
||||
- Create new tools with the built-in Tool Builder
|
||||
- Connect to the registry to publish your tools
|
||||
|
||||
## 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
|
||||
|
||||
```
|
||||
┌─ Code ─────────────┐ ┌─ AI Assisted Auto-adjust ─────────────┐
|
||||
│ result = input... │ │ Provider: [opencode-deepseek] [▼] │
|
||||
│ │ │ ┌─ Prompt ──────────────────────────┐ │
|
||||
│ │ │ │ Modify this code to... │ │
|
||||
│ │ │ │ {code} │ │
|
||||
│ │ │ └──────────────────────────────────┘ │
|
||||
│ │ │ ┌─ Output & Feedback ───────────────┐ │
|
||||
│ │ │ │ ✓ Code updated successfully! │ │
|
||||
│ │ │ └──────────────────────────────────┘ │
|
||||
│ │ │ < Auto-adjust > │
|
||||
└────────────────────┘ └───────────────────────────────────────┘
|
||||
### Provider Management
|
||||
- Add and configure AI providers
|
||||
- Test provider connectivity
|
||||
- Set default providers for new tools
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| `Ctrl+N` | Create new tool |
|
||||
| `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
|
||||
- **Prompt**: Fully editable template - use `{code}` placeholder for current code
|
||||
- **Output**: Shows status, success/error messages, and provider feedback
|
||||
- **Auto-adjust**: Sends prompt to AI and replaces code with response
|
||||
|
||||
This lets you generate or modify Python code using AI directly within the tool builder.
|
||||
### AI → Code
|
||||
Generate content with AI, then validate/process with Python:
|
||||
```yaml
|
||||
steps:
|
||||
- type: prompt
|
||||
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
|
||||
|
||||
|
|
@ -515,7 +545,7 @@ docker run -it --rm -v cmdforge-data:/home/user/.cmdforge cmdforge-ready
|
|||
Inside the container, CmdForge is ready to use:
|
||||
```bash
|
||||
cmdforge list # See 27 pre-installed tools
|
||||
cmdforge ui # Launch the TUI
|
||||
cmdforge # Launch the GUI (requires display)
|
||||
cmdforge run summarize # Run a tool
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -32,16 +32,13 @@ classifiers = [
|
|||
dependencies = [
|
||||
"PyYAML>=6.0",
|
||||
"requests>=2.28",
|
||||
"PySide6>=6.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
tui = [
|
||||
"urwid>=2.1.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=7.0",
|
||||
"pytest-cov>=4.0",
|
||||
"urwid>=2.1.0",
|
||||
]
|
||||
registry = [
|
||||
"Flask>=2.3",
|
||||
|
|
@ -50,7 +47,6 @@ registry = [
|
|||
"gunicorn>=21.0",
|
||||
]
|
||||
all = [
|
||||
"urwid>=2.1.0",
|
||||
"Flask>=2.3",
|
||||
"argon2-cffi>=21.0",
|
||||
"sentry-sdk[flask]>=1.0",
|
||||
|
|
|
|||
|
|
@ -501,14 +501,7 @@ def _cmd_registry_my_tools(args):
|
|||
|
||||
|
||||
def _cmd_registry_browse(args):
|
||||
"""Browse tools (TUI)."""
|
||||
try:
|
||||
from ..ui_registry import run_registry_browser
|
||||
return run_registry_browser()
|
||||
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
|
||||
"""Browse tools (GUI)."""
|
||||
from ..gui import run_gui
|
||||
# Launch GUI - it will open to Registry page
|
||||
return run_gui()
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from ..tool import (
|
|||
list_tools, load_tool, save_tool, delete_tool, get_tools_dir,
|
||||
Tool, ToolArgument, PromptStep, CodeStep, ToolStep
|
||||
)
|
||||
from ..ui import run_ui
|
||||
from ..gui import run_gui
|
||||
|
||||
|
||||
def cmd_list(args):
|
||||
|
|
@ -248,9 +248,8 @@ def cmd_run(args):
|
|||
|
||||
|
||||
def cmd_ui(args):
|
||||
"""Launch the interactive UI."""
|
||||
run_ui()
|
||||
return 0
|
||||
"""Launch the interactive GUI."""
|
||||
return run_gui()
|
||||
|
||||
|
||||
def cmd_refresh(args):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
"""PySide6 GUI for CmdForge."""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def run_gui():
|
||||
"""Launch the CmdForge GUI application."""
|
||||
from PySide6.QtWidgets import QApplication
|
||||
from .main_window import MainWindow
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
app.setApplicationName("CmdForge")
|
||||
app.setOrganizationName("CmdForge")
|
||||
|
||||
window = MainWindow()
|
||||
window.show()
|
||||
|
||||
return app.exec()
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""GUI dialogs."""
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
"""Argument editor dialog."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
||||
QCheckBox, QPushButton, QHBoxLayout, QLabel
|
||||
)
|
||||
|
||||
from ...tool import ToolArgument
|
||||
|
||||
|
||||
class ArgumentDialog(QDialog):
|
||||
"""Dialog for editing tool arguments."""
|
||||
|
||||
def __init__(self, parent, argument: ToolArgument = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit Argument" if argument else "Add Argument")
|
||||
self.setMinimumWidth(400)
|
||||
self._argument = argument
|
||||
self._setup_ui()
|
||||
|
||||
if argument:
|
||||
self._load_argument(argument)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
self.flag_input = QLineEdit()
|
||||
self.flag_input.setPlaceholderText("--input, -i")
|
||||
form.addRow("Flag:", self.flag_input)
|
||||
|
||||
self.var_input = QLineEdit()
|
||||
self.var_input.setPlaceholderText("input_text")
|
||||
form.addRow("Variable:", self.var_input)
|
||||
|
||||
self.default_input = QLineEdit()
|
||||
self.default_input.setPlaceholderText("(optional)")
|
||||
form.addRow("Default:", self.default_input)
|
||||
|
||||
self.desc_input = QLineEdit()
|
||||
self.desc_input.setPlaceholderText("(optional)")
|
||||
form.addRow("Description:", self.desc_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(
|
||||
"The flag is how users specify this argument (e.g. --input).\n"
|
||||
"The variable name is used in prompts and templates as ${variable}."
|
||||
)
|
||||
help_text.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
help_text.setWordWrap(True)
|
||||
layout.addWidget(help_text)
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_ok = QPushButton("OK")
|
||||
self.btn_ok.clicked.connect(self._validate_and_accept)
|
||||
buttons.addWidget(self.btn_ok)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _load_argument(self, arg: ToolArgument):
|
||||
"""Load argument data into form."""
|
||||
self.flag_input.setText(arg.flag)
|
||||
self.var_input.setText(arg.variable)
|
||||
self.default_input.setText(arg.default or "")
|
||||
self.desc_input.setText(arg.description or "")
|
||||
|
||||
def _validate_and_accept(self):
|
||||
"""Validate input and accept."""
|
||||
flag = self.flag_input.text().strip()
|
||||
variable = self.var_input.text().strip()
|
||||
|
||||
if not flag:
|
||||
self.flag_input.setFocus()
|
||||
return
|
||||
|
||||
if not variable:
|
||||
self.var_input.setFocus()
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_argument(self) -> ToolArgument:
|
||||
"""Get the argument from form data."""
|
||||
return ToolArgument(
|
||||
flag=self.flag_input.text().strip(),
|
||||
variable=self.var_input.text().strip(),
|
||||
default=self.default_input.text().strip() or None,
|
||||
description=self.desc_input.text().strip() or ""
|
||||
)
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
"""Connect to registry dialog."""
|
||||
|
||||
import socket
|
||||
import time
|
||||
import requests
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QLabel, QPushButton,
|
||||
QHBoxLayout, QLineEdit, QProgressBar, QTextEdit
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal, QTimer
|
||||
|
||||
from ...config import load_config, save_config, get_registry_url, set_registry_token
|
||||
|
||||
|
||||
class PairingWorker(QThread):
|
||||
"""Background worker for pairing flow."""
|
||||
success = Signal(str) # token
|
||||
error = Signal(str)
|
||||
status = Signal(str)
|
||||
|
||||
def __init__(self, username: str):
|
||||
super().__init__()
|
||||
self.username = username
|
||||
self.hostname = socket.gethostname()
|
||||
self._stop = False
|
||||
|
||||
def stop(self):
|
||||
self._stop = True
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
registry_url = get_registry_url()
|
||||
|
||||
# Remove trailing /api/v1 if present to get base URL
|
||||
base_url = registry_url.rstrip("/")
|
||||
if base_url.endswith("/api/v1"):
|
||||
base_url = base_url[:-7]
|
||||
|
||||
pairing_url = f"{base_url}/api/v1/pairing/check/{self.username}"
|
||||
|
||||
self.status.emit(f"Connecting to {base_url}...")
|
||||
|
||||
# Poll for pairing status
|
||||
max_attempts = 150 # 5 minutes at 2-second intervals
|
||||
attempt = 0
|
||||
|
||||
while attempt < max_attempts:
|
||||
if self._stop:
|
||||
return
|
||||
|
||||
attempt += 1
|
||||
try:
|
||||
response = requests.get(
|
||||
pairing_url,
|
||||
params={"hostname": self.hostname},
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
status = data.get("data", {}).get("status")
|
||||
|
||||
if status == "connected":
|
||||
token = data.get("data", {}).get("token")
|
||||
if token:
|
||||
self.success.emit(token)
|
||||
return
|
||||
else:
|
||||
self.error.emit("Pairing completed but no token received")
|
||||
return
|
||||
elif status == "expired":
|
||||
self.error.emit("Pairing request expired. Please try again.")
|
||||
return
|
||||
elif status == "pending":
|
||||
self.status.emit(f"Pairing pending, approve in web UI... ({attempt})")
|
||||
elif status == "not_found":
|
||||
self.status.emit(f"Waiting for web UI pairing... ({attempt})")
|
||||
else:
|
||||
self.status.emit(f"Status: {status} ({attempt})")
|
||||
elif response.status_code == 404:
|
||||
self.status.emit(f"Waiting for web approval... ({attempt})")
|
||||
else:
|
||||
self.status.emit(f"Server returned {response.status_code}")
|
||||
|
||||
except requests.exceptions.RequestException:
|
||||
self.status.emit(f"Network error, retrying... ({attempt})")
|
||||
|
||||
time.sleep(2)
|
||||
|
||||
self.error.emit("Timed out waiting for approval")
|
||||
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class ConnectDialog(QDialog):
|
||||
"""Dialog for connecting to registry."""
|
||||
|
||||
def __init__(self, parent):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Connect to Registry")
|
||||
self.setMinimumWidth(450)
|
||||
self._worker = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Title
|
||||
title = QLabel("Connect Your Account")
|
||||
title.setStyleSheet("font-size: 16px; font-weight: 600;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Instructions
|
||||
self.instructions = QLabel(
|
||||
"To publish tools, connect your CmdForge account.\n\n"
|
||||
"1. Enter your CmdForge username below\n"
|
||||
"2. Click Connect\n"
|
||||
"3. Go to cmdforge.brrd.tech/dashboard/connected-apps\n"
|
||||
"4. Click 'Connect New App' and approve this device"
|
||||
)
|
||||
self.instructions.setWordWrap(True)
|
||||
layout.addWidget(self.instructions)
|
||||
|
||||
# Username input
|
||||
username_layout = QHBoxLayout()
|
||||
username_label = QLabel("Username:")
|
||||
self.username_input = QLineEdit()
|
||||
self.username_input.setPlaceholderText("your-username")
|
||||
self.username_input.returnPressed.connect(self._start_pairing)
|
||||
username_layout.addWidget(username_label)
|
||||
username_layout.addWidget(self.username_input, 1)
|
||||
layout.addLayout(username_layout)
|
||||
|
||||
# Device info
|
||||
hostname = socket.gethostname()
|
||||
device_label = QLabel(f"Device: {hostname}")
|
||||
device_label.setStyleSheet("color: #718096; font-size: 12px;")
|
||||
layout.addWidget(device_label)
|
||||
|
||||
# Status
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #718096;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Log area for debugging
|
||||
self.log_area = QTextEdit()
|
||||
self.log_area.setReadOnly(True)
|
||||
self.log_area.setMaximumHeight(120)
|
||||
self.log_area.setStyleSheet("font-family: monospace; font-size: 11px; background: #f7fafc;")
|
||||
self.log_area.hide()
|
||||
layout.addWidget(self.log_area)
|
||||
|
||||
# Progress
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 0) # Indeterminate
|
||||
self.progress.hide()
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self._cancel)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_connect = QPushButton("Connect")
|
||||
self.btn_connect.clicked.connect(self._start_pairing)
|
||||
buttons.addWidget(self.btn_connect)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _start_pairing(self):
|
||||
"""Start the pairing flow."""
|
||||
username = self.username_input.text().strip()
|
||||
if not username:
|
||||
self.status_label.setText("Please enter your username")
|
||||
self.status_label.setStyleSheet("color: #e53e3e;")
|
||||
return
|
||||
|
||||
self.btn_connect.setEnabled(False)
|
||||
self.username_input.setEnabled(False)
|
||||
self.progress.show()
|
||||
self.log_area.clear()
|
||||
self.log_area.show()
|
||||
self.status_label.setStyleSheet("color: #718096;")
|
||||
|
||||
self._worker = PairingWorker(username)
|
||||
self._worker.success.connect(self._on_success)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.status.connect(self._on_status)
|
||||
self._worker.start()
|
||||
|
||||
def _on_success(self, token: str):
|
||||
"""Handle successful pairing."""
|
||||
set_registry_token(token)
|
||||
|
||||
self.progress.hide()
|
||||
self.status_label.setText("Connected successfully!")
|
||||
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
|
||||
|
||||
QTimer.singleShot(1000, self.accept)
|
||||
|
||||
def _on_error(self, error: str):
|
||||
"""Handle error."""
|
||||
self.progress.hide()
|
||||
self.btn_connect.setEnabled(True)
|
||||
self.username_input.setEnabled(True)
|
||||
self.status_label.setText(f"Error: {error}")
|
||||
self.status_label.setStyleSheet("color: #e53e3e;")
|
||||
|
||||
def _on_status(self, status: str):
|
||||
"""Handle status update."""
|
||||
self.status_label.setText(status)
|
||||
# Also append to log for debugging
|
||||
self.log_area.append(status)
|
||||
|
||||
def _cancel(self):
|
||||
"""Cancel pairing."""
|
||||
if self._worker:
|
||||
self._worker.stop()
|
||||
self._worker.wait(1000)
|
||||
self.reject()
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle close."""
|
||||
if self._worker:
|
||||
self._worker.stop()
|
||||
self._worker.wait(1000)
|
||||
super().closeEvent(event)
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
"""Provider editor dialog."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
||||
QPushButton, QHBoxLayout, QLabel
|
||||
)
|
||||
|
||||
from ...providers import Provider, add_provider
|
||||
|
||||
|
||||
class ProviderDialog(QDialog):
|
||||
"""Dialog for adding/editing providers."""
|
||||
|
||||
def __init__(self, parent, name: str = None, provider: Provider = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit Provider" if provider else "Add Provider")
|
||||
self.setMinimumWidth(450)
|
||||
self._editing = provider is not None
|
||||
self._original_name = name
|
||||
self._setup_ui()
|
||||
|
||||
if provider:
|
||||
self._load_provider(name, provider)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
self.name_input = QLineEdit()
|
||||
self.name_input.setPlaceholderText("claude")
|
||||
form.addRow("Name:", self.name_input)
|
||||
|
||||
self.cmd_input = QLineEdit()
|
||||
self.cmd_input.setPlaceholderText("claude-cli")
|
||||
form.addRow("Command:", self.cmd_input)
|
||||
|
||||
self.desc_input = QLineEdit()
|
||||
self.desc_input.setPlaceholderText("Claude AI via claude-cli")
|
||||
form.addRow("Description:", self.desc_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(
|
||||
"The command should accept input on stdin and output to stdout.\n"
|
||||
"Example commands: claude-cli, sgpt, llm, mods"
|
||||
)
|
||||
help_text.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
help_text.setWordWrap(True)
|
||||
layout.addWidget(help_text)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_ok = QPushButton("Save")
|
||||
self.btn_ok.clicked.connect(self._save)
|
||||
buttons.addWidget(self.btn_ok)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _load_provider(self, name: str, provider: Provider):
|
||||
"""Load provider data into form."""
|
||||
self.name_input.setText(name)
|
||||
self.name_input.setEnabled(False) # Can't rename
|
||||
self.cmd_input.setText(provider.command)
|
||||
self.desc_input.setText(provider.description or "")
|
||||
|
||||
def _save(self):
|
||||
"""Save the provider."""
|
||||
name = self.name_input.text().strip()
|
||||
command = self.cmd_input.text().strip()
|
||||
|
||||
if not name:
|
||||
self.name_input.setFocus()
|
||||
return
|
||||
|
||||
if not command:
|
||||
self.cmd_input.setFocus()
|
||||
return
|
||||
|
||||
description = self.desc_input.text().strip() or None
|
||||
|
||||
try:
|
||||
add_provider(name, command, description)
|
||||
self.accept()
|
||||
except Exception as e:
|
||||
from PySide6.QtWidgets import QMessageBox
|
||||
QMessageBox.critical(self, "Error", f"Failed to save provider:\n{e}")
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
"""Publish tool dialog."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QLabel, QPushButton,
|
||||
QHBoxLayout, QLineEdit, QTextEdit, QFormLayout,
|
||||
QProgressBar, QMessageBox, QComboBox
|
||||
)
|
||||
from PySide6.QtCore import QThread, Signal
|
||||
|
||||
from ...tool import Tool
|
||||
from ...registry_client import RegistryClient, RegistryError
|
||||
from ...config import load_config
|
||||
|
||||
|
||||
class PublishWorker(QThread):
|
||||
"""Background worker for publishing."""
|
||||
success = Signal(dict)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, tool_data: dict):
|
||||
super().__init__()
|
||||
self.tool_data = tool_data
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
config = load_config()
|
||||
client = RegistryClient()
|
||||
client.token = config.registry.token
|
||||
|
||||
result = client.publish_tool(self.tool_data)
|
||||
self.success.emit(result)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class PublishDialog(QDialog):
|
||||
"""Dialog for publishing a tool."""
|
||||
|
||||
def __init__(self, parent, tool: Tool):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Publish Tool")
|
||||
self.setMinimumSize(500, 400)
|
||||
self._tool = tool
|
||||
self._worker = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Title
|
||||
title = QLabel(f"Publish '{self._tool.name}'")
|
||||
title.setStyleSheet("font-size: 16px; font-weight: 600;")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
# Version
|
||||
self.version_input = QLineEdit()
|
||||
self.version_input.setText("1.0.0")
|
||||
self.version_input.setPlaceholderText("1.0.0")
|
||||
form.addRow("Version:", self.version_input)
|
||||
|
||||
# Category
|
||||
self.category_combo = QComboBox()
|
||||
self.category_combo.setEditable(True)
|
||||
categories = [
|
||||
"Text Processing", "Code", "Data", "System",
|
||||
"Writing", "Analysis", "Productivity", "Other"
|
||||
]
|
||||
self.category_combo.addItems(categories)
|
||||
if self._tool.category:
|
||||
idx = self.category_combo.findText(self._tool.category)
|
||||
if idx >= 0:
|
||||
self.category_combo.setCurrentIndex(idx)
|
||||
else:
|
||||
self.category_combo.setCurrentText(self._tool.category)
|
||||
form.addRow("Category:", self.category_combo)
|
||||
|
||||
# Tags
|
||||
self.tags_input = QLineEdit()
|
||||
self.tags_input.setPlaceholderText("ai, text, productivity (comma separated)")
|
||||
form.addRow("Tags:", self.tags_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Description
|
||||
desc_label = QLabel("Description:")
|
||||
layout.addWidget(desc_label)
|
||||
|
||||
self.desc_input = QTextEdit()
|
||||
self.desc_input.setPlaceholderText("Describe what your tool does...")
|
||||
self.desc_input.setPlainText(self._tool.description or "")
|
||||
self.desc_input.setMaximumHeight(100)
|
||||
layout.addWidget(self.desc_input)
|
||||
|
||||
# Status
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #718096;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
# Progress
|
||||
self.progress = QProgressBar()
|
||||
self.progress.setRange(0, 0)
|
||||
self.progress.hide()
|
||||
layout.addWidget(self.progress)
|
||||
|
||||
layout.addStretch()
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_publish = QPushButton("Publish")
|
||||
self.btn_publish.clicked.connect(self._publish)
|
||||
buttons.addWidget(self.btn_publish)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _publish(self):
|
||||
"""Publish the tool."""
|
||||
version = self.version_input.text().strip()
|
||||
if not version:
|
||||
QMessageBox.warning(self, "Validation", "Version is required")
|
||||
return
|
||||
|
||||
description = self.desc_input.toPlainText().strip()
|
||||
if not description:
|
||||
QMessageBox.warning(self, "Validation", "Description is required")
|
||||
return
|
||||
|
||||
category = self.category_combo.currentText()
|
||||
tags = [t.strip() for t in self.tags_input.text().split(",") if t.strip()]
|
||||
|
||||
# Build tool data for publishing
|
||||
tool_data = {
|
||||
"name": self._tool.name,
|
||||
"version": version,
|
||||
"description": description,
|
||||
"category": category,
|
||||
"tags": tags,
|
||||
"definition": self._tool.to_dict() if hasattr(self._tool, 'to_dict') else {}
|
||||
}
|
||||
|
||||
self.btn_publish.setEnabled(False)
|
||||
self.btn_cancel.setEnabled(False)
|
||||
self.progress.show()
|
||||
self.status_label.setText("Publishing...")
|
||||
|
||||
self._worker = PublishWorker(tool_data)
|
||||
self._worker.success.connect(self._on_success)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_success(self, result: dict):
|
||||
"""Handle publish success."""
|
||||
self.progress.hide()
|
||||
self.status_label.setText("Published successfully!")
|
||||
self.status_label.setStyleSheet("color: #38a169; font-weight: 600;")
|
||||
|
||||
QMessageBox.information(
|
||||
self, "Success",
|
||||
f"Tool '{self._tool.name}' has been published.\n\n"
|
||||
f"It will be available after review."
|
||||
)
|
||||
self.accept()
|
||||
|
||||
def _on_error(self, error: str):
|
||||
"""Handle publish error."""
|
||||
self.progress.hide()
|
||||
self.btn_publish.setEnabled(True)
|
||||
self.btn_cancel.setEnabled(True)
|
||||
self.status_label.setText(f"Error: {error}")
|
||||
self.status_label.setStyleSheet("color: #e53e3e;")
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
"""Step editor dialogs."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
||||
QComboBox, QPushButton, QHBoxLayout, QLabel,
|
||||
QPlainTextEdit
|
||||
)
|
||||
|
||||
from ...tool import PromptStep, CodeStep
|
||||
from ...providers import load_providers
|
||||
|
||||
|
||||
class PromptStepDialog(QDialog):
|
||||
"""Dialog for editing prompt steps."""
|
||||
|
||||
def __init__(self, parent, step: PromptStep = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit Prompt Step" if step else "Add Prompt Step")
|
||||
self.setMinimumSize(500, 400)
|
||||
self._step = step
|
||||
self._setup_ui()
|
||||
|
||||
if step:
|
||||
self._load_step(step)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
# Provider selection
|
||||
self.provider_combo = QComboBox()
|
||||
providers = load_providers()
|
||||
for provider in sorted(providers, key=lambda p: p.name):
|
||||
self.provider_combo.addItem(provider.name)
|
||||
# Add common defaults if not present
|
||||
for default in ["claude", "gpt", "mock"]:
|
||||
if self.provider_combo.findText(default) < 0:
|
||||
self.provider_combo.addItem(default)
|
||||
form.addRow("Provider:", self.provider_combo)
|
||||
|
||||
# Output variable
|
||||
self.output_input = QLineEdit()
|
||||
self.output_input.setPlaceholderText("response")
|
||||
self.output_input.setText("response")
|
||||
form.addRow("Output variable:", self.output_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Prompt text
|
||||
prompt_label = QLabel("Prompt template:")
|
||||
layout.addWidget(prompt_label)
|
||||
|
||||
self.prompt_input = QPlainTextEdit()
|
||||
self.prompt_input.setPlaceholderText(
|
||||
"Enter your prompt here...\n\n"
|
||||
"Use {input} for stdin input.\n"
|
||||
"Use {variable} for argument values.\n"
|
||||
"Use {prev_output} for previous step output."
|
||||
)
|
||||
layout.addWidget(self.prompt_input, 1)
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_ok = QPushButton("OK")
|
||||
self.btn_ok.clicked.connect(self._validate_and_accept)
|
||||
buttons.addWidget(self.btn_ok)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _load_step(self, step: PromptStep):
|
||||
"""Load step data into form."""
|
||||
idx = self.provider_combo.findText(step.provider)
|
||||
if idx >= 0:
|
||||
self.provider_combo.setCurrentIndex(idx)
|
||||
else:
|
||||
self.provider_combo.setCurrentText(step.provider)
|
||||
|
||||
self.output_input.setText(step.output_var)
|
||||
self.prompt_input.setPlainText(step.prompt)
|
||||
|
||||
def _validate_and_accept(self):
|
||||
"""Validate and accept."""
|
||||
prompt = self.prompt_input.toPlainText().strip()
|
||||
if not prompt:
|
||||
self.prompt_input.setFocus()
|
||||
return
|
||||
|
||||
output = self.output_input.text().strip()
|
||||
if not output:
|
||||
self.output_input.setFocus()
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_step(self) -> PromptStep:
|
||||
"""Get the step from form data."""
|
||||
return PromptStep(
|
||||
prompt=self.prompt_input.toPlainText(),
|
||||
provider=self.provider_combo.currentText(),
|
||||
output_var=self.output_input.text().strip()
|
||||
)
|
||||
|
||||
|
||||
class CodeStepDialog(QDialog):
|
||||
"""Dialog for editing code steps."""
|
||||
|
||||
def __init__(self, parent, step: CodeStep = None):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Edit Code Step" if step else "Add Code Step")
|
||||
self.setMinimumSize(600, 500)
|
||||
self._step = step
|
||||
self._setup_ui()
|
||||
|
||||
if step:
|
||||
self._load_step(step)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Form
|
||||
form = QFormLayout()
|
||||
form.setSpacing(12)
|
||||
|
||||
# Output variable
|
||||
self.output_input = QLineEdit()
|
||||
self.output_input.setPlaceholderText("result")
|
||||
self.output_input.setText("result")
|
||||
form.addRow("Output variable:", self.output_input)
|
||||
|
||||
layout.addLayout(form)
|
||||
|
||||
# Code editor
|
||||
code_label = QLabel("Code:")
|
||||
layout.addWidget(code_label)
|
||||
|
||||
self.code_input = QPlainTextEdit()
|
||||
self.code_input.setPlaceholderText(
|
||||
"# Python code here\n"
|
||||
"# Access input with: input_text\n"
|
||||
"# Access args with their variable names\n"
|
||||
"# Access previous step outputs with their variable names\n\n"
|
||||
"result = input_text.upper()"
|
||||
)
|
||||
font = self.code_input.font()
|
||||
font.setFamily("Courier New, Consolas, monospace")
|
||||
self.code_input.setFont(font)
|
||||
layout.addWidget(self.code_input, 1)
|
||||
|
||||
# Help text
|
||||
help_text = QLabel(
|
||||
"The code will be executed with variables in scope. "
|
||||
"The last expression or the output variable will be captured."
|
||||
)
|
||||
help_text.setStyleSheet("color: #718096; font-size: 11px;")
|
||||
help_text.setWordWrap(True)
|
||||
layout.addWidget(help_text)
|
||||
|
||||
# Buttons
|
||||
buttons = QHBoxLayout()
|
||||
buttons.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self.reject)
|
||||
buttons.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_ok = QPushButton("OK")
|
||||
self.btn_ok.clicked.connect(self._validate_and_accept)
|
||||
buttons.addWidget(self.btn_ok)
|
||||
|
||||
layout.addLayout(buttons)
|
||||
|
||||
def _load_step(self, step: CodeStep):
|
||||
"""Load step data into form."""
|
||||
self.output_input.setText(step.output_var)
|
||||
self.code_input.setPlainText(step.code)
|
||||
|
||||
def _validate_and_accept(self):
|
||||
"""Validate and accept."""
|
||||
code = self.code_input.toPlainText().strip()
|
||||
if not code:
|
||||
self.code_input.setFocus()
|
||||
return
|
||||
|
||||
output = self.output_input.text().strip()
|
||||
if not output:
|
||||
self.output_input.setFocus()
|
||||
return
|
||||
|
||||
self.accept()
|
||||
|
||||
def get_step(self) -> CodeStep:
|
||||
"""Get the step from form data."""
|
||||
return CodeStep(
|
||||
code=self.code_input.toPlainText(),
|
||||
output_var=self.output_input.text().strip()
|
||||
)
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
"""Main window for CmdForge GUI."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
|
||||
QListWidget, QListWidgetItem, QStackedWidget,
|
||||
QStatusBar, QLabel, QSplitter
|
||||
)
|
||||
from PySide6.QtCore import Qt, QSize, QSettings
|
||||
from PySide6.QtGui import QIcon, QFont, QShortcut, QKeySequence
|
||||
|
||||
from .styles import STYLESHEET
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""Main application window with sidebar navigation."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setWindowTitle("CmdForge")
|
||||
self.setMinimumSize(1000, 700)
|
||||
self.resize(1200, 800)
|
||||
|
||||
# Apply stylesheet
|
||||
self.setStyleSheet(STYLESHEET)
|
||||
|
||||
# Settings for persistence
|
||||
self._settings = QSettings("CmdForge", "CmdForge")
|
||||
|
||||
# Central widget
|
||||
central = QWidget()
|
||||
self.setCentralWidget(central)
|
||||
|
||||
# Main layout
|
||||
layout = QHBoxLayout(central)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
# Sidebar
|
||||
self.sidebar = QListWidget()
|
||||
self.sidebar.setObjectName("sidebar")
|
||||
self.sidebar.setFixedWidth(180)
|
||||
self.sidebar.setFocusPolicy(Qt.NoFocus)
|
||||
self._setup_sidebar()
|
||||
|
||||
# Stacked pages
|
||||
self.pages = QStackedWidget()
|
||||
self.pages.setObjectName("content_area")
|
||||
|
||||
# Add pages (lazy load to avoid import issues)
|
||||
self._setup_pages()
|
||||
|
||||
# Add to layout
|
||||
layout.addWidget(self.sidebar)
|
||||
layout.addWidget(self.pages, 1)
|
||||
|
||||
# Status bar
|
||||
self.status_bar = QStatusBar()
|
||||
self.setStatusBar(self.status_bar)
|
||||
self.status_bar.showMessage("Ready")
|
||||
|
||||
# Connect sidebar
|
||||
self.sidebar.currentRowChanged.connect(self._on_page_changed)
|
||||
self.sidebar.setCurrentRow(0)
|
||||
|
||||
# Setup keyboard shortcuts
|
||||
self._setup_shortcuts()
|
||||
|
||||
# Restore window geometry
|
||||
self._restore_geometry()
|
||||
|
||||
def _setup_sidebar(self):
|
||||
"""Set up sidebar navigation items."""
|
||||
items = [
|
||||
("Tools", "Manage your tools"),
|
||||
("Registry", "Browse and install tools"),
|
||||
("Providers", "Configure AI providers"),
|
||||
]
|
||||
|
||||
font = QFont()
|
||||
font.setPointSize(11)
|
||||
|
||||
for name, tooltip in items:
|
||||
item = QListWidgetItem(name)
|
||||
item.setFont(font)
|
||||
item.setToolTip(tooltip)
|
||||
item.setSizeHint(QSize(180, 48))
|
||||
self.sidebar.addItem(item)
|
||||
|
||||
def _setup_pages(self):
|
||||
"""Set up content pages."""
|
||||
# Import pages here to avoid circular imports
|
||||
from .pages.tools_page import ToolsPage
|
||||
from .pages.registry_page import RegistryPage
|
||||
from .pages.providers_page import ProvidersPage
|
||||
|
||||
self.tools_page = ToolsPage(self)
|
||||
self.registry_page = RegistryPage(self)
|
||||
self.providers_page = ProvidersPage(self)
|
||||
|
||||
self.pages.addWidget(self.tools_page)
|
||||
self.pages.addWidget(self.registry_page)
|
||||
self.pages.addWidget(self.providers_page)
|
||||
|
||||
def _on_page_changed(self, index: int):
|
||||
"""Handle page changes."""
|
||||
self.pages.setCurrentIndex(index)
|
||||
|
||||
# Refresh page if it has a refresh method
|
||||
current_page = self.pages.currentWidget()
|
||||
if hasattr(current_page, 'refresh'):
|
||||
current_page.refresh()
|
||||
|
||||
def show_status(self, message: str, timeout: int = 5000):
|
||||
"""Show a status bar message."""
|
||||
self.status_bar.showMessage(message, timeout)
|
||||
|
||||
def navigate_to(self, page_name: str):
|
||||
"""Navigate to a specific page by name."""
|
||||
page_map = {
|
||||
"tools": 0,
|
||||
"registry": 1,
|
||||
"providers": 2,
|
||||
}
|
||||
if page_name.lower() in page_map:
|
||||
self.sidebar.setCurrentRow(page_map[page_name.lower()])
|
||||
|
||||
def open_tool_builder(self, tool_name: str = None):
|
||||
"""Open the tool builder page for creating or editing a tool."""
|
||||
from .pages.tool_builder_page import ToolBuilderPage
|
||||
|
||||
# Create builder page
|
||||
builder = ToolBuilderPage(self, tool_name)
|
||||
|
||||
# Add to stack and switch to it
|
||||
index = self.pages.addWidget(builder)
|
||||
self.pages.setCurrentIndex(index)
|
||||
|
||||
# Hide sidebar selection
|
||||
self.sidebar.clearSelection()
|
||||
|
||||
def close_tool_builder(self):
|
||||
"""Close the tool builder and return to tools page."""
|
||||
# Remove builder page from stack
|
||||
current = self.pages.currentWidget()
|
||||
if hasattr(current, '__class__') and current.__class__.__name__ == 'ToolBuilderPage':
|
||||
self.pages.removeWidget(current)
|
||||
current.deleteLater()
|
||||
|
||||
# Return to tools page
|
||||
self.sidebar.setCurrentRow(0)
|
||||
self.tools_page.refresh()
|
||||
|
||||
def _setup_shortcuts(self):
|
||||
"""Set up keyboard shortcuts."""
|
||||
# Ctrl+N: New tool
|
||||
shortcut_new = QShortcut(QKeySequence("Ctrl+N"), self)
|
||||
shortcut_new.activated.connect(self._shortcut_new_tool)
|
||||
|
||||
# Ctrl+S: Save (when in tool builder)
|
||||
shortcut_save = QShortcut(QKeySequence("Ctrl+S"), self)
|
||||
shortcut_save.activated.connect(self._shortcut_save)
|
||||
|
||||
# Escape: Go back
|
||||
shortcut_escape = QShortcut(QKeySequence("Escape"), self)
|
||||
shortcut_escape.activated.connect(self._shortcut_escape)
|
||||
|
||||
# Ctrl+1/2/3: Navigate pages
|
||||
shortcut_page1 = QShortcut(QKeySequence("Ctrl+1"), self)
|
||||
shortcut_page1.activated.connect(lambda: self.sidebar.setCurrentRow(0))
|
||||
|
||||
shortcut_page2 = QShortcut(QKeySequence("Ctrl+2"), self)
|
||||
shortcut_page2.activated.connect(lambda: self.sidebar.setCurrentRow(1))
|
||||
|
||||
shortcut_page3 = QShortcut(QKeySequence("Ctrl+3"), self)
|
||||
shortcut_page3.activated.connect(lambda: self.sidebar.setCurrentRow(2))
|
||||
|
||||
# Ctrl+R: Refresh current page
|
||||
shortcut_refresh = QShortcut(QKeySequence("Ctrl+R"), self)
|
||||
shortcut_refresh.activated.connect(self._shortcut_refresh)
|
||||
|
||||
# Ctrl+Q: Quit
|
||||
shortcut_quit = QShortcut(QKeySequence("Ctrl+Q"), self)
|
||||
shortcut_quit.activated.connect(self.close)
|
||||
|
||||
def _shortcut_new_tool(self):
|
||||
"""Handle Ctrl+N: Create new tool."""
|
||||
self.open_tool_builder()
|
||||
self.show_status("Creating new tool... (Ctrl+S to save)")
|
||||
|
||||
def _shortcut_save(self):
|
||||
"""Handle Ctrl+S: Save current tool."""
|
||||
current = self.pages.currentWidget()
|
||||
if hasattr(current, 'save_tool'):
|
||||
current.save_tool()
|
||||
|
||||
def _shortcut_escape(self):
|
||||
"""Handle Escape: Go back or close builder."""
|
||||
current = self.pages.currentWidget()
|
||||
if hasattr(current, '__class__') and current.__class__.__name__ == 'ToolBuilderPage':
|
||||
self.close_tool_builder()
|
||||
else:
|
||||
# Could close dialogs if any are open
|
||||
pass
|
||||
|
||||
def _shortcut_refresh(self):
|
||||
"""Handle Ctrl+R: Refresh current page."""
|
||||
current = self.pages.currentWidget()
|
||||
if hasattr(current, 'refresh'):
|
||||
current.refresh()
|
||||
self.show_status("Refreshed")
|
||||
|
||||
def _restore_geometry(self):
|
||||
"""Restore window geometry from settings."""
|
||||
geometry = self._settings.value("geometry")
|
||||
if geometry:
|
||||
self.restoreGeometry(geometry)
|
||||
|
||||
state = self._settings.value("windowState")
|
||||
if state:
|
||||
self.restoreState(state)
|
||||
|
||||
def _save_geometry(self):
|
||||
"""Save window geometry to settings."""
|
||||
self._settings.setValue("geometry", self.saveGeometry())
|
||||
self._settings.setValue("windowState", self.saveState())
|
||||
|
||||
def closeEvent(self, event):
|
||||
"""Handle window close - save geometry."""
|
||||
self._save_geometry()
|
||||
super().closeEvent(event)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
"""GUI pages."""
|
||||
|
||||
from .tools_page import ToolsPage
|
||||
from .tool_builder_page import ToolBuilderPage
|
||||
from .registry_page import RegistryPage
|
||||
from .providers_page import ProvidersPage
|
||||
|
||||
__all__ = ["ToolsPage", "ToolBuilderPage", "RegistryPage", "ProvidersPage"]
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
"""Providers page - manage AI providers."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QTableWidget,
|
||||
QTableWidgetItem, QHeaderView, QPushButton, QLabel,
|
||||
QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from ...providers import Provider, load_providers, delete_provider
|
||||
|
||||
|
||||
class ProvidersPage(QWidget):
|
||||
"""Provider management page."""
|
||||
|
||||
def __init__(self, main_window):
|
||||
super().__init__()
|
||||
self.main_window = main_window
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Header
|
||||
header = QWidget()
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
title = QLabel("AI Providers")
|
||||
title.setObjectName("heading")
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
self.btn_add = QPushButton("Add Provider")
|
||||
self.btn_add.clicked.connect(self._add_provider)
|
||||
header_layout.addWidget(self.btn_add)
|
||||
|
||||
layout.addWidget(header)
|
||||
|
||||
# Description
|
||||
desc = QLabel(
|
||||
"Providers are external AI commands that CmdForge tools can use. "
|
||||
"Each provider wraps a CLI tool that accepts input on stdin and outputs to stdout."
|
||||
)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet("color: #718096;")
|
||||
layout.addWidget(desc)
|
||||
|
||||
# Providers table
|
||||
self.table = QTableWidget()
|
||||
self.table.setColumnCount(3)
|
||||
self.table.setHorizontalHeaderLabels(["Name", "Command", "Description"])
|
||||
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Stretch)
|
||||
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.table.setSelectionMode(QTableWidget.SingleSelection)
|
||||
self.table.verticalHeader().setVisible(False)
|
||||
self.table.itemSelectionChanged.connect(self._on_selection_changed)
|
||||
layout.addWidget(self.table, 1)
|
||||
|
||||
# Action buttons
|
||||
buttons = QWidget()
|
||||
btn_layout = QHBoxLayout(buttons)
|
||||
btn_layout.setContentsMargins(0, 0, 0, 0)
|
||||
btn_layout.setSpacing(12)
|
||||
|
||||
self.btn_edit = QPushButton("Edit")
|
||||
self.btn_edit.setObjectName("secondary")
|
||||
self.btn_edit.clicked.connect(self._edit_provider)
|
||||
self.btn_edit.setEnabled(False)
|
||||
btn_layout.addWidget(self.btn_edit)
|
||||
|
||||
self.btn_delete = QPushButton("Delete")
|
||||
self.btn_delete.setObjectName("danger")
|
||||
self.btn_delete.clicked.connect(self._delete_provider)
|
||||
self.btn_delete.setEnabled(False)
|
||||
btn_layout.addWidget(self.btn_delete)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
self.btn_test = QPushButton("Test")
|
||||
self.btn_test.clicked.connect(self._test_provider)
|
||||
self.btn_test.setEnabled(False)
|
||||
btn_layout.addWidget(self.btn_test)
|
||||
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the provider list."""
|
||||
providers = load_providers()
|
||||
|
||||
self.table.setRowCount(len(providers))
|
||||
for row, provider in enumerate(sorted(providers, key=lambda p: p.name)):
|
||||
name_item = QTableWidgetItem(provider.name)
|
||||
name_item.setData(Qt.UserRole, provider)
|
||||
self.table.setItem(row, 0, name_item)
|
||||
self.table.setItem(row, 1, QTableWidgetItem(provider.command))
|
||||
self.table.setItem(row, 2, QTableWidgetItem(provider.description or ""))
|
||||
|
||||
self._on_selection_changed()
|
||||
|
||||
def _on_selection_changed(self):
|
||||
"""Handle selection change."""
|
||||
has_selection = len(self.table.selectedItems()) > 0
|
||||
self.btn_edit.setEnabled(has_selection)
|
||||
self.btn_delete.setEnabled(has_selection)
|
||||
self.btn_test.setEnabled(has_selection)
|
||||
|
||||
def _get_selected_provider(self) -> tuple:
|
||||
"""Get selected provider name and object."""
|
||||
items = self.table.selectedItems()
|
||||
if not items:
|
||||
return None, None
|
||||
|
||||
row = items[0].row()
|
||||
name_item = self.table.item(row, 0)
|
||||
return name_item.text(), name_item.data(Qt.UserRole)
|
||||
|
||||
def _add_provider(self):
|
||||
"""Add a new provider."""
|
||||
from ..dialogs.provider_dialog import ProviderDialog
|
||||
dialog = ProviderDialog(self)
|
||||
if dialog.exec():
|
||||
self.refresh()
|
||||
self.main_window.show_status("Provider added")
|
||||
|
||||
def _edit_provider(self):
|
||||
"""Edit selected provider."""
|
||||
name, provider = self._get_selected_provider()
|
||||
if not name:
|
||||
return
|
||||
|
||||
from ..dialogs.provider_dialog import ProviderDialog
|
||||
dialog = ProviderDialog(self, name, provider)
|
||||
if dialog.exec():
|
||||
self.refresh()
|
||||
self.main_window.show_status("Provider updated")
|
||||
|
||||
def _delete_provider(self):
|
||||
"""Delete selected provider."""
|
||||
name, _ = self._get_selected_provider()
|
||||
if not name:
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Provider",
|
||||
f"Are you sure you want to delete provider '{name}'?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
try:
|
||||
delete_provider(name)
|
||||
self.refresh()
|
||||
self.main_window.show_status(f"Deleted provider '{name}'")
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to delete provider:\n{e}")
|
||||
|
||||
def _test_provider(self):
|
||||
"""Test selected provider."""
|
||||
name, provider = self._get_selected_provider()
|
||||
if not name or not provider:
|
||||
return
|
||||
|
||||
import subprocess
|
||||
import shutil
|
||||
|
||||
# Check if command exists
|
||||
cmd_parts = provider.command.split()
|
||||
if not cmd_parts:
|
||||
QMessageBox.warning(self, "Invalid", "Provider has no command configured")
|
||||
return
|
||||
|
||||
exe = cmd_parts[0]
|
||||
if not shutil.which(exe):
|
||||
QMessageBox.warning(
|
||||
self, "Not Found",
|
||||
f"Command '{exe}' not found in PATH.\n\n"
|
||||
"Make sure the provider is installed and accessible."
|
||||
)
|
||||
return
|
||||
|
||||
# Try running with --help or similar
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd_parts + ["--help"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
QMessageBox.information(
|
||||
self, "Provider Test",
|
||||
f"Provider '{name}' is available.\n\n"
|
||||
f"Command: {provider.command}\n"
|
||||
f"Exit code: {result.returncode}"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
QMessageBox.information(
|
||||
self, "Provider Test",
|
||||
f"Provider '{name}' command exists but timed out.\n"
|
||||
"This may be normal for interactive commands."
|
||||
)
|
||||
except Exception as e:
|
||||
QMessageBox.warning(
|
||||
self, "Provider Test",
|
||||
f"Error testing provider:\n{e}"
|
||||
)
|
||||
|
|
@ -0,0 +1,247 @@
|
|||
"""Registry page - browse and install tools from registry."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QLineEdit,
|
||||
QPushButton, QTableWidget, QTableWidgetItem, QLabel,
|
||||
QHeaderView, QGroupBox, QTextEdit, QSplitter, QMessageBox
|
||||
)
|
||||
from PySide6.QtCore import Qt, QThread, Signal
|
||||
|
||||
from ...registry_client import RegistryClient, RegistryError
|
||||
from ...config import load_config
|
||||
|
||||
|
||||
class SearchWorker(QThread):
|
||||
"""Background worker for registry search."""
|
||||
finished = Signal(list)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, query: str, page: int = 1):
|
||||
super().__init__()
|
||||
self.query = query
|
||||
self.page = page
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
client = RegistryClient()
|
||||
result = client.search_tools(self.query, page=self.page, per_page=20)
|
||||
# result is a PaginatedResponse with data attribute
|
||||
self.finished.emit(result.data)
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class InstallWorker(QThread):
|
||||
"""Background worker for tool installation."""
|
||||
finished = Signal(str)
|
||||
error = Signal(str)
|
||||
|
||||
def __init__(self, owner: str, name: str):
|
||||
super().__init__()
|
||||
self.owner = owner
|
||||
self.name = name
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
client = RegistryClient()
|
||||
client.install_tool(self.owner, self.name)
|
||||
self.finished.emit(f"{self.owner}/{self.name}")
|
||||
except Exception as e:
|
||||
self.error.emit(str(e))
|
||||
|
||||
|
||||
class RegistryPage(QWidget):
|
||||
"""Registry browser page."""
|
||||
|
||||
def __init__(self, main_window):
|
||||
super().__init__()
|
||||
self.main_window = main_window
|
||||
self._search_worker = None
|
||||
self._install_worker = None
|
||||
self._selected_tool = None
|
||||
self._setup_ui()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Header
|
||||
title = QLabel("Tool Registry")
|
||||
title.setObjectName("heading")
|
||||
layout.addWidget(title)
|
||||
|
||||
# Search bar
|
||||
search_box = QWidget()
|
||||
search_layout = QHBoxLayout(search_box)
|
||||
search_layout.setContentsMargins(0, 0, 0, 0)
|
||||
search_layout.setSpacing(8)
|
||||
|
||||
self.search_input = QLineEdit()
|
||||
self.search_input.setPlaceholderText("Search tools...")
|
||||
self.search_input.returnPressed.connect(self._do_search)
|
||||
search_layout.addWidget(self.search_input, 1)
|
||||
|
||||
self.btn_search = QPushButton("Search")
|
||||
self.btn_search.clicked.connect(self._do_search)
|
||||
search_layout.addWidget(self.btn_search)
|
||||
|
||||
layout.addWidget(search_box)
|
||||
|
||||
# Content splitter
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# Left: Results table
|
||||
left = QWidget()
|
||||
left_layout = QVBoxLayout(left)
|
||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
self.results_table = QTableWidget()
|
||||
self.results_table.setColumnCount(4)
|
||||
self.results_table.setHorizontalHeaderLabels(["Name", "Owner", "Downloads", "Version"])
|
||||
self.results_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||||
self.results_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
self.results_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
self.results_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
self.results_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.results_table.setSelectionMode(QTableWidget.SingleSelection)
|
||||
self.results_table.verticalHeader().setVisible(False)
|
||||
self.results_table.itemSelectionChanged.connect(self._on_selection_changed)
|
||||
left_layout.addWidget(self.results_table)
|
||||
|
||||
splitter.addWidget(left)
|
||||
|
||||
# Right: Tool details
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout(right)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
details_box = QGroupBox("Tool Details")
|
||||
details_layout = QVBoxLayout(details_box)
|
||||
|
||||
self.details_text = QTextEdit()
|
||||
self.details_text.setReadOnly(True)
|
||||
self.details_text.setPlaceholderText("Select a tool to view details")
|
||||
details_layout.addWidget(self.details_text)
|
||||
|
||||
right_layout.addWidget(details_box, 1)
|
||||
|
||||
# Install button
|
||||
self.btn_install = QPushButton("Install")
|
||||
self.btn_install.clicked.connect(self._install_tool)
|
||||
self.btn_install.setEnabled(False)
|
||||
right_layout.addWidget(self.btn_install)
|
||||
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([500, 500])
|
||||
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
# Status label
|
||||
self.status_label = QLabel("")
|
||||
self.status_label.setStyleSheet("color: #718096;")
|
||||
layout.addWidget(self.status_label)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh on page enter."""
|
||||
pass # Could auto-search popular tools
|
||||
|
||||
def _do_search(self):
|
||||
"""Perform search."""
|
||||
query = self.search_input.text().strip()
|
||||
if not query:
|
||||
return
|
||||
|
||||
self.btn_search.setEnabled(False)
|
||||
self.status_label.setText("Searching...")
|
||||
self.results_table.setRowCount(0)
|
||||
|
||||
self._search_worker = SearchWorker(query)
|
||||
self._search_worker.finished.connect(self._on_search_complete)
|
||||
self._search_worker.error.connect(self._on_search_error)
|
||||
self._search_worker.start()
|
||||
|
||||
def _on_search_complete(self, tools: list):
|
||||
"""Handle search results."""
|
||||
self.btn_search.setEnabled(True)
|
||||
self.status_label.setText(f"Found {len(tools)} tools")
|
||||
|
||||
self.results_table.setRowCount(len(tools))
|
||||
for row, tool in enumerate(tools):
|
||||
name_item = QTableWidgetItem(tool.get("name", ""))
|
||||
name_item.setData(Qt.UserRole, tool)
|
||||
self.results_table.setItem(row, 0, name_item)
|
||||
self.results_table.setItem(row, 1, QTableWidgetItem(tool.get("owner", "")))
|
||||
self.results_table.setItem(row, 2, QTableWidgetItem(str(tool.get("downloads", 0))))
|
||||
self.results_table.setItem(row, 3, QTableWidgetItem(tool.get("version", "1.0.0")))
|
||||
|
||||
def _on_search_error(self, error: str):
|
||||
"""Handle search error."""
|
||||
self.btn_search.setEnabled(True)
|
||||
self.status_label.setText(f"Error: {error}")
|
||||
|
||||
def _on_selection_changed(self):
|
||||
"""Handle selection change."""
|
||||
items = self.results_table.selectedItems()
|
||||
if not items:
|
||||
self._selected_tool = None
|
||||
self.details_text.clear()
|
||||
self.btn_install.setEnabled(False)
|
||||
return
|
||||
|
||||
row = items[0].row()
|
||||
name_item = self.results_table.item(row, 0)
|
||||
tool = name_item.data(Qt.UserRole)
|
||||
self._selected_tool = tool
|
||||
self._show_tool_details(tool)
|
||||
self.btn_install.setEnabled(True)
|
||||
|
||||
def _show_tool_details(self, tool: dict):
|
||||
"""Show tool details."""
|
||||
lines = []
|
||||
lines.append(f"<h2>{tool.get('owner', '')}/{tool.get('name', '')}</h2>")
|
||||
|
||||
if tool.get("description"):
|
||||
lines.append(f"<p>{tool.get('description')}</p>")
|
||||
|
||||
lines.append(f"<p><strong>Version:</strong> {tool.get('version', '1.0.0')}</p>")
|
||||
lines.append(f"<p><strong>Downloads:</strong> {tool.get('downloads', 0)}</p>")
|
||||
|
||||
if tool.get("category"):
|
||||
lines.append(f"<p><strong>Category:</strong> {tool.get('category')}</p>")
|
||||
|
||||
if tool.get("tags"):
|
||||
tags = ", ".join(tool.get("tags", []))
|
||||
lines.append(f"<p><strong>Tags:</strong> {tags}</p>")
|
||||
|
||||
self.details_text.setHtml("\n".join(lines))
|
||||
|
||||
def _install_tool(self):
|
||||
"""Install selected tool."""
|
||||
if not self._selected_tool:
|
||||
return
|
||||
|
||||
owner = self._selected_tool.get("owner", "")
|
||||
name = self._selected_tool.get("name", "")
|
||||
|
||||
self.btn_install.setEnabled(False)
|
||||
self.status_label.setText(f"Installing {owner}/{name}...")
|
||||
|
||||
self._install_worker = InstallWorker(owner, name)
|
||||
self._install_worker.finished.connect(self._on_install_complete)
|
||||
self._install_worker.error.connect(self._on_install_error)
|
||||
self._install_worker.start()
|
||||
|
||||
def _on_install_complete(self, tool_id: str):
|
||||
"""Handle install complete."""
|
||||
self.btn_install.setEnabled(True)
|
||||
self.status_label.setText(f"Installed {tool_id}")
|
||||
self.main_window.show_status(f"Installed {tool_id}")
|
||||
QMessageBox.information(self, "Success", f"Successfully installed {tool_id}")
|
||||
|
||||
def _on_install_error(self, error: str):
|
||||
"""Handle install error."""
|
||||
self.btn_install.setEnabled(True)
|
||||
self.status_label.setText(f"Error: {error}")
|
||||
QMessageBox.critical(self, "Install Error", f"Failed to install tool:\n{error}")
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
"""Tool builder page - create and edit tools."""
|
||||
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
|
||||
QLineEdit, QTextEdit, QComboBox, QPushButton,
|
||||
QGroupBox, QListWidget, QListWidgetItem, QLabel,
|
||||
QMessageBox, QSplitter, QFrame
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
|
||||
from ...tool import (
|
||||
Tool, ToolArgument, PromptStep, CodeStep,
|
||||
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
|
||||
)
|
||||
|
||||
|
||||
class ToolBuilderPage(QWidget):
|
||||
"""Tool builder/editor page."""
|
||||
|
||||
def __init__(self, main_window, tool_name: str = None):
|
||||
super().__init__()
|
||||
self.main_window = main_window
|
||||
self.editing = tool_name is not None
|
||||
self.original_name = tool_name
|
||||
self._tool = None
|
||||
|
||||
self._setup_ui()
|
||||
|
||||
if tool_name:
|
||||
self._load_tool(tool_name)
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Header
|
||||
header = QWidget()
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
title = QLabel("Edit Tool" if self.editing else "Create Tool")
|
||||
title.setObjectName("heading")
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
self.btn_cancel = QPushButton("Cancel")
|
||||
self.btn_cancel.setObjectName("secondary")
|
||||
self.btn_cancel.clicked.connect(self._cancel)
|
||||
header_layout.addWidget(self.btn_cancel)
|
||||
|
||||
self.btn_save = QPushButton("Save")
|
||||
self.btn_save.clicked.connect(self._save)
|
||||
header_layout.addWidget(self.btn_save)
|
||||
|
||||
layout.addWidget(header)
|
||||
|
||||
# Main form splitter
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# Left: Basic info
|
||||
left = QWidget()
|
||||
left_layout = QVBoxLayout(left)
|
||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||
left_layout.setSpacing(16)
|
||||
|
||||
# Basic info group
|
||||
info_box = QGroupBox("Basic Information")
|
||||
info_layout = QFormLayout(info_box)
|
||||
info_layout.setSpacing(12)
|
||||
|
||||
self.name_input = QLineEdit()
|
||||
self.name_input.setPlaceholderText("my-tool")
|
||||
info_layout.addRow("Name:", self.name_input)
|
||||
|
||||
self.desc_input = QLineEdit()
|
||||
self.desc_input.setPlaceholderText("A brief description of what this tool does")
|
||||
info_layout.addRow("Description:", self.desc_input)
|
||||
|
||||
self.category_combo = QComboBox()
|
||||
self.category_combo.setEditable(True)
|
||||
for cat in DEFAULT_CATEGORIES:
|
||||
self.category_combo.addItem(cat)
|
||||
info_layout.addRow("Category:", self.category_combo)
|
||||
|
||||
left_layout.addWidget(info_box)
|
||||
|
||||
# Arguments group
|
||||
args_box = QGroupBox("Arguments")
|
||||
args_layout = QVBoxLayout(args_box)
|
||||
|
||||
self.args_list = QListWidget()
|
||||
self.args_list.itemDoubleClicked.connect(self._edit_argument)
|
||||
args_layout.addWidget(self.args_list)
|
||||
|
||||
args_btns = QHBoxLayout()
|
||||
self.btn_add_arg = QPushButton("Add")
|
||||
self.btn_add_arg.clicked.connect(self._add_argument)
|
||||
args_btns.addWidget(self.btn_add_arg)
|
||||
|
||||
self.btn_edit_arg = QPushButton("Edit")
|
||||
self.btn_edit_arg.setObjectName("secondary")
|
||||
self.btn_edit_arg.clicked.connect(self._edit_argument)
|
||||
args_btns.addWidget(self.btn_edit_arg)
|
||||
|
||||
self.btn_del_arg = QPushButton("Delete")
|
||||
self.btn_del_arg.setObjectName("danger")
|
||||
self.btn_del_arg.clicked.connect(self._delete_argument)
|
||||
args_btns.addWidget(self.btn_del_arg)
|
||||
|
||||
args_btns.addStretch()
|
||||
args_layout.addLayout(args_btns)
|
||||
|
||||
left_layout.addWidget(args_box)
|
||||
|
||||
splitter.addWidget(left)
|
||||
|
||||
# Right: Steps and output
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout(right)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.setSpacing(16)
|
||||
|
||||
# Steps group
|
||||
steps_box = QGroupBox("Steps")
|
||||
steps_layout = QVBoxLayout(steps_box)
|
||||
|
||||
self.steps_list = QListWidget()
|
||||
self.steps_list.itemDoubleClicked.connect(self._edit_step)
|
||||
steps_layout.addWidget(self.steps_list)
|
||||
|
||||
steps_btns = QHBoxLayout()
|
||||
self.btn_add_prompt = QPushButton("Add Prompt")
|
||||
self.btn_add_prompt.clicked.connect(self._add_prompt_step)
|
||||
steps_btns.addWidget(self.btn_add_prompt)
|
||||
|
||||
self.btn_add_code = QPushButton("Add Code")
|
||||
self.btn_add_code.clicked.connect(self._add_code_step)
|
||||
steps_btns.addWidget(self.btn_add_code)
|
||||
|
||||
self.btn_edit_step = QPushButton("Edit")
|
||||
self.btn_edit_step.setObjectName("secondary")
|
||||
self.btn_edit_step.clicked.connect(self._edit_step)
|
||||
steps_btns.addWidget(self.btn_edit_step)
|
||||
|
||||
self.btn_del_step = QPushButton("Delete")
|
||||
self.btn_del_step.setObjectName("danger")
|
||||
self.btn_del_step.clicked.connect(self._delete_step)
|
||||
steps_btns.addWidget(self.btn_del_step)
|
||||
|
||||
steps_btns.addStretch()
|
||||
steps_layout.addLayout(steps_btns)
|
||||
|
||||
right_layout.addWidget(steps_box)
|
||||
|
||||
# Output group
|
||||
output_box = QGroupBox("Output Template")
|
||||
output_layout = QVBoxLayout(output_box)
|
||||
|
||||
self.output_input = QTextEdit()
|
||||
self.output_input.setPlaceholderText("{response}\n\nUse {variable} to reference step outputs")
|
||||
self.output_input.setMaximumHeight(100)
|
||||
output_layout.addWidget(self.output_input)
|
||||
|
||||
right_layout.addWidget(output_box)
|
||||
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([400, 600])
|
||||
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
def _load_tool(self, name: str):
|
||||
"""Load an existing tool for editing."""
|
||||
tool = load_tool(name)
|
||||
if not tool:
|
||||
QMessageBox.critical(self, "Error", f"Tool '{name}' not found")
|
||||
self._cancel()
|
||||
return
|
||||
|
||||
self._tool = tool
|
||||
self.name_input.setText(tool.name)
|
||||
self.name_input.setEnabled(False) # Can't rename
|
||||
self.desc_input.setText(tool.description or "")
|
||||
|
||||
# Set category
|
||||
if tool.category:
|
||||
idx = self.category_combo.findText(tool.category)
|
||||
if idx >= 0:
|
||||
self.category_combo.setCurrentIndex(idx)
|
||||
else:
|
||||
self.category_combo.setCurrentText(tool.category)
|
||||
|
||||
# Load arguments
|
||||
self._refresh_arguments()
|
||||
|
||||
# Load steps
|
||||
self._refresh_steps()
|
||||
|
||||
# Set output
|
||||
self.output_input.setPlainText(tool.output or "{response}")
|
||||
|
||||
def _refresh_arguments(self):
|
||||
"""Refresh arguments list."""
|
||||
self.args_list.clear()
|
||||
if self._tool and self._tool.arguments:
|
||||
for arg in self._tool.arguments:
|
||||
item = QListWidgetItem(f"{arg.flag} → ${arg.variable}")
|
||||
item.setData(Qt.UserRole, arg)
|
||||
self.args_list.addItem(item)
|
||||
|
||||
def _refresh_steps(self):
|
||||
"""Refresh steps list."""
|
||||
self.steps_list.clear()
|
||||
if self._tool and self._tool.steps:
|
||||
for i, step in enumerate(self._tool.steps, 1):
|
||||
if isinstance(step, PromptStep):
|
||||
text = f"{i}. Prompt [{step.provider}] → ${step.output_var}"
|
||||
elif isinstance(step, CodeStep):
|
||||
text = f"{i}. Code [python] → ${step.output_var}"
|
||||
else:
|
||||
text = f"{i}. Unknown step"
|
||||
item = QListWidgetItem(text)
|
||||
item.setData(Qt.UserRole, step)
|
||||
self.steps_list.addItem(item)
|
||||
|
||||
def _add_argument(self):
|
||||
"""Add a new argument."""
|
||||
from ..dialogs.argument_dialog import ArgumentDialog
|
||||
dialog = ArgumentDialog(self)
|
||||
if dialog.exec():
|
||||
arg = dialog.get_argument()
|
||||
if not self._tool:
|
||||
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
|
||||
self._tool.arguments.append(arg)
|
||||
self._refresh_arguments()
|
||||
|
||||
def _edit_argument(self):
|
||||
"""Edit selected argument."""
|
||||
items = self.args_list.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
|
||||
arg = items[0].data(Qt.UserRole)
|
||||
idx = self.args_list.row(items[0])
|
||||
|
||||
from ..dialogs.argument_dialog import ArgumentDialog
|
||||
dialog = ArgumentDialog(self, arg)
|
||||
if dialog.exec():
|
||||
self._tool.arguments[idx] = dialog.get_argument()
|
||||
self._refresh_arguments()
|
||||
|
||||
def _delete_argument(self):
|
||||
"""Delete selected argument."""
|
||||
items = self.args_list.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
|
||||
idx = self.args_list.row(items[0])
|
||||
del self._tool.arguments[idx]
|
||||
self._refresh_arguments()
|
||||
|
||||
def _add_prompt_step(self):
|
||||
"""Add a prompt step."""
|
||||
from ..dialogs.step_dialog import PromptStepDialog
|
||||
dialog = PromptStepDialog(self)
|
||||
if dialog.exec():
|
||||
step = dialog.get_step()
|
||||
if not self._tool:
|
||||
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
|
||||
self._tool.steps.append(step)
|
||||
self._refresh_steps()
|
||||
|
||||
def _add_code_step(self):
|
||||
"""Add a code step."""
|
||||
from ..dialogs.step_dialog import CodeStepDialog
|
||||
dialog = CodeStepDialog(self)
|
||||
if dialog.exec():
|
||||
step = dialog.get_step()
|
||||
if not self._tool:
|
||||
self._tool = Tool(name="", description="", arguments=[], steps=[], output="{response}")
|
||||
self._tool.steps.append(step)
|
||||
self._refresh_steps()
|
||||
|
||||
def _edit_step(self):
|
||||
"""Edit selected step."""
|
||||
items = self.steps_list.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
|
||||
step = items[0].data(Qt.UserRole)
|
||||
idx = self.steps_list.row(items[0])
|
||||
|
||||
if isinstance(step, PromptStep):
|
||||
from ..dialogs.step_dialog import PromptStepDialog
|
||||
dialog = PromptStepDialog(self, step)
|
||||
elif isinstance(step, CodeStep):
|
||||
from ..dialogs.step_dialog import CodeStepDialog
|
||||
dialog = CodeStepDialog(self, step)
|
||||
else:
|
||||
return
|
||||
|
||||
if dialog.exec():
|
||||
self._tool.steps[idx] = dialog.get_step()
|
||||
self._refresh_steps()
|
||||
|
||||
def _delete_step(self):
|
||||
"""Delete selected step."""
|
||||
items = self.steps_list.selectedItems()
|
||||
if not items:
|
||||
return
|
||||
|
||||
idx = self.steps_list.row(items[0])
|
||||
del self._tool.steps[idx]
|
||||
self._refresh_steps()
|
||||
|
||||
def _save(self):
|
||||
"""Save the tool."""
|
||||
name = self.name_input.text().strip()
|
||||
if not name:
|
||||
QMessageBox.warning(self, "Validation", "Tool name is required")
|
||||
return
|
||||
|
||||
# Validate name
|
||||
error = validate_tool_name(name)
|
||||
if error:
|
||||
QMessageBox.warning(self, "Validation", error)
|
||||
return
|
||||
|
||||
description = self.desc_input.text().strip()
|
||||
category = self.category_combo.currentText()
|
||||
output = self.output_input.toPlainText().strip() or "{response}"
|
||||
|
||||
# Build tool object
|
||||
tool = Tool(
|
||||
name=name,
|
||||
description=description,
|
||||
category=category,
|
||||
arguments=self._tool.arguments if self._tool else [],
|
||||
steps=self._tool.steps if self._tool else [],
|
||||
output=output
|
||||
)
|
||||
|
||||
# Preserve source if editing
|
||||
if self._tool and self._tool.source:
|
||||
tool.source = self._tool.source
|
||||
|
||||
try:
|
||||
save_tool(tool)
|
||||
self.main_window.show_status(f"Saved tool '{name}'")
|
||||
self.main_window.close_tool_builder()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to save tool:\n{e}")
|
||||
|
||||
def _cancel(self):
|
||||
"""Cancel and return to tools page."""
|
||||
self.main_window.close_tool_builder()
|
||||
|
||||
def save_tool(self):
|
||||
"""Public method for keyboard shortcut to save the tool."""
|
||||
self._save()
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
"""Tools page - main view for managing tools."""
|
||||
|
||||
from collections import defaultdict
|
||||
from PySide6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
|
||||
QTreeWidget, QTreeWidgetItem, QTextEdit, QLabel,
|
||||
QPushButton, QGroupBox, QMessageBox, QFrame
|
||||
)
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QFont
|
||||
|
||||
from ...tool import (
|
||||
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
|
||||
list_tools, load_tool, delete_tool, DEFAULT_CATEGORIES
|
||||
)
|
||||
from ...config import load_config
|
||||
|
||||
|
||||
class ToolsPage(QWidget):
|
||||
"""Main tools management page."""
|
||||
|
||||
def __init__(self, main_window):
|
||||
super().__init__()
|
||||
self.main_window = main_window
|
||||
self._current_tool = None
|
||||
self._setup_ui()
|
||||
self.refresh()
|
||||
|
||||
def _setup_ui(self):
|
||||
"""Set up the UI."""
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(24, 24, 24, 24)
|
||||
layout.setSpacing(16)
|
||||
|
||||
# Header
|
||||
header = QWidget()
|
||||
header_layout = QHBoxLayout(header)
|
||||
header_layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
title = QLabel("My Tools")
|
||||
title.setObjectName("heading")
|
||||
header_layout.addWidget(title)
|
||||
|
||||
header_layout.addStretch()
|
||||
|
||||
# Connection status
|
||||
config = load_config()
|
||||
if config.registry.token:
|
||||
status = QLabel("Connected to Registry")
|
||||
status.setStyleSheet("color: #38a169; font-weight: 500;")
|
||||
else:
|
||||
status = QLabel("Not connected")
|
||||
status.setStyleSheet("color: #718096;")
|
||||
header_layout.addWidget(status)
|
||||
|
||||
layout.addWidget(header)
|
||||
|
||||
# Main content splitter
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# Left side: Tool list
|
||||
left = QWidget()
|
||||
left_layout = QVBoxLayout(left)
|
||||
left_layout.setContentsMargins(0, 0, 0, 0)
|
||||
left_layout.setSpacing(8)
|
||||
|
||||
self.tool_tree = QTreeWidget()
|
||||
self.tool_tree.setHeaderHidden(True)
|
||||
self.tool_tree.setIndentation(16)
|
||||
self.tool_tree.setRootIsDecorated(True)
|
||||
self.tool_tree.itemSelectionChanged.connect(self._on_selection_changed)
|
||||
self.tool_tree.itemDoubleClicked.connect(self._on_double_click)
|
||||
left_layout.addWidget(self.tool_tree, 1)
|
||||
|
||||
splitter.addWidget(left)
|
||||
|
||||
# Right side: Info panel
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout(right)
|
||||
right_layout.setContentsMargins(0, 0, 0, 0)
|
||||
right_layout.setSpacing(16)
|
||||
|
||||
# Tool info
|
||||
info_box = QGroupBox("Tool Details")
|
||||
info_layout = QVBoxLayout(info_box)
|
||||
|
||||
self.info_text = QTextEdit()
|
||||
self.info_text.setReadOnly(True)
|
||||
self.info_text.setPlaceholderText("Select a tool to view details")
|
||||
info_layout.addWidget(self.info_text)
|
||||
|
||||
right_layout.addWidget(info_box, 1)
|
||||
|
||||
splitter.addWidget(right)
|
||||
|
||||
# Set splitter sizes
|
||||
splitter.setSizes([350, 650])
|
||||
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
# Action buttons
|
||||
buttons = QWidget()
|
||||
btn_layout = QHBoxLayout(buttons)
|
||||
btn_layout.setContentsMargins(0, 0, 0, 0)
|
||||
btn_layout.setSpacing(12)
|
||||
|
||||
self.btn_create = QPushButton("Create")
|
||||
self.btn_create.clicked.connect(self._create_tool)
|
||||
btn_layout.addWidget(self.btn_create)
|
||||
|
||||
self.btn_edit = QPushButton("Edit")
|
||||
self.btn_edit.setObjectName("secondary")
|
||||
self.btn_edit.clicked.connect(self._edit_tool)
|
||||
self.btn_edit.setEnabled(False)
|
||||
btn_layout.addWidget(self.btn_edit)
|
||||
|
||||
self.btn_delete = QPushButton("Delete")
|
||||
self.btn_delete.setObjectName("danger")
|
||||
self.btn_delete.clicked.connect(self._delete_tool)
|
||||
self.btn_delete.setEnabled(False)
|
||||
btn_layout.addWidget(self.btn_delete)
|
||||
|
||||
btn_layout.addStretch()
|
||||
|
||||
# Connect/Publish button
|
||||
config = load_config()
|
||||
if config.registry.token:
|
||||
self.btn_publish = QPushButton("Publish")
|
||||
self.btn_publish.clicked.connect(self._publish_tool)
|
||||
self.btn_publish.setEnabled(False)
|
||||
else:
|
||||
self.btn_publish = QPushButton("Connect")
|
||||
self.btn_publish.clicked.connect(self._connect_registry)
|
||||
btn_layout.addWidget(self.btn_publish)
|
||||
|
||||
layout.addWidget(buttons)
|
||||
|
||||
def refresh(self):
|
||||
"""Refresh the tool list."""
|
||||
self.tool_tree.clear()
|
||||
self._current_tool = None
|
||||
self.info_text.clear()
|
||||
|
||||
tools = list_tools()
|
||||
|
||||
# Group tools by category
|
||||
tools_by_category = defaultdict(list)
|
||||
for name in tools:
|
||||
tool = load_tool(name)
|
||||
if tool:
|
||||
category = tool.category if tool.category else "Other"
|
||||
tools_by_category[category].append((name, tool))
|
||||
|
||||
# Build tree with categories
|
||||
all_categories = list(DEFAULT_CATEGORIES)
|
||||
for cat in tools_by_category:
|
||||
if cat not in all_categories:
|
||||
all_categories.append(cat)
|
||||
|
||||
for category in all_categories:
|
||||
if category in tools_by_category and tools_by_category[category]:
|
||||
# Category item
|
||||
cat_item = QTreeWidgetItem([category])
|
||||
cat_item.setExpanded(True)
|
||||
font = cat_item.font(0)
|
||||
font.setBold(True)
|
||||
cat_item.setFont(0, font)
|
||||
cat_item.setFlags(cat_item.flags() & ~Qt.ItemIsSelectable)
|
||||
|
||||
# Tools in category
|
||||
for name, tool in sorted(tools_by_category[category], key=lambda x: x[0]):
|
||||
tool_item = QTreeWidgetItem([name])
|
||||
tool_item.setData(0, Qt.UserRole, name)
|
||||
if tool.source and tool.source.type == "imported":
|
||||
tool_item.setToolTip(0, f"Imported from {tool.source.url or 'registry'}")
|
||||
cat_item.addChild(tool_item)
|
||||
|
||||
self.tool_tree.addTopLevelItem(cat_item)
|
||||
|
||||
# Update button states
|
||||
self._update_buttons()
|
||||
|
||||
if not tools:
|
||||
self.info_text.setPlaceholderText(
|
||||
"No tools found.\n\nClick 'Create' to build your first tool, "
|
||||
"or browse the Registry to install community tools."
|
||||
)
|
||||
|
||||
def _on_selection_changed(self):
|
||||
"""Handle tool selection change."""
|
||||
items = self.tool_tree.selectedItems()
|
||||
if not items:
|
||||
self._current_tool = None
|
||||
self.info_text.clear()
|
||||
self._update_buttons()
|
||||
return
|
||||
|
||||
item = items[0]
|
||||
tool_name = item.data(0, Qt.UserRole)
|
||||
if not tool_name:
|
||||
self._current_tool = None
|
||||
self.info_text.clear()
|
||||
self._update_buttons()
|
||||
return
|
||||
|
||||
tool = load_tool(tool_name)
|
||||
if tool:
|
||||
self._current_tool = tool
|
||||
self._show_tool_info(tool)
|
||||
self._update_buttons()
|
||||
|
||||
def _on_double_click(self, item, column):
|
||||
"""Handle double-click on tool."""
|
||||
tool_name = item.data(0, Qt.UserRole)
|
||||
if tool_name:
|
||||
self._edit_tool()
|
||||
|
||||
def _show_tool_info(self, tool: Tool):
|
||||
"""Display tool information."""
|
||||
lines = []
|
||||
|
||||
# Name and description
|
||||
lines.append(f"<h2 style='margin: 0 0 8px 0; color: #2d3748;'>{tool.name}</h2>")
|
||||
if tool.description:
|
||||
lines.append(f"<p style='color: #4a5568; margin-bottom: 16px;'>{tool.description}</p>")
|
||||
|
||||
# Source info
|
||||
if tool.source:
|
||||
source_type = tool.source.type
|
||||
if source_type == "imported":
|
||||
source_url = tool.source.url or "registry"
|
||||
lines.append(f"<p style='color: #718096; font-size: 12px;'>Imported from {source_url}</p>")
|
||||
elif source_type == "forked":
|
||||
lines.append(f"<p style='color: #718096; font-size: 12px;'>Forked from {tool.source.original_tool}</p>")
|
||||
|
||||
# Arguments
|
||||
if tool.arguments:
|
||||
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Arguments</h3>")
|
||||
lines.append("<ul style='margin: 8px 0;'>")
|
||||
for arg in tool.arguments:
|
||||
default = f" (default: {arg.default})" if arg.default else ""
|
||||
lines.append(f"<li><code>{arg.flag}</code> → <code>${arg.variable}</code>{default}</li>")
|
||||
lines.append("</ul>")
|
||||
|
||||
# Steps
|
||||
if tool.steps:
|
||||
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Steps</h3>")
|
||||
lines.append("<ol style='margin: 8px 0;'>")
|
||||
for i, step in enumerate(tool.steps, 1):
|
||||
if isinstance(step, PromptStep):
|
||||
lines.append(f"<li><strong>Prompt</strong> using <code>{step.provider}</code> → <code>${step.output_var}</code></li>")
|
||||
elif isinstance(step, CodeStep):
|
||||
lines.append(f"<li><strong>Code</strong> (python) → <code>${step.output_var}</code></li>")
|
||||
elif isinstance(step, ToolStep):
|
||||
lines.append(f"<li><strong>Tool</strong>: <code>{step.tool}</code> → <code>${step.output_var}</code></li>")
|
||||
lines.append("</ol>")
|
||||
|
||||
# Output
|
||||
if tool.output:
|
||||
lines.append("<h3 style='color: #4a5568; margin-top: 16px;'>Output Template</h3>")
|
||||
output_escaped = tool.output.replace("<", "<").replace(">", ">")
|
||||
lines.append(f"<pre style='background: #edf2f7; padding: 8px; border-radius: 4px;'>{output_escaped}</pre>")
|
||||
|
||||
# Category
|
||||
if tool.category:
|
||||
lines.append(f"<p style='color: #718096; font-size: 12px; margin-top: 16px;'>Category: {tool.category}</p>")
|
||||
|
||||
self.info_text.setHtml("\n".join(lines))
|
||||
|
||||
def _update_buttons(self):
|
||||
"""Update button enabled states."""
|
||||
has_selection = self._current_tool is not None
|
||||
self.btn_edit.setEnabled(has_selection)
|
||||
self.btn_delete.setEnabled(has_selection)
|
||||
|
||||
config = load_config()
|
||||
if config.registry.token:
|
||||
self.btn_publish.setEnabled(has_selection)
|
||||
|
||||
def _create_tool(self):
|
||||
"""Create a new tool."""
|
||||
self.main_window.open_tool_builder()
|
||||
|
||||
def _edit_tool(self):
|
||||
"""Edit the selected tool."""
|
||||
if self._current_tool:
|
||||
self.main_window.open_tool_builder(self._current_tool.name)
|
||||
|
||||
def _delete_tool(self):
|
||||
"""Delete the selected tool."""
|
||||
if not self._current_tool:
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"Delete Tool",
|
||||
f"Are you sure you want to delete '{self._current_tool.name}'?\n\n"
|
||||
"This will remove the tool configuration and wrapper script.",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
QMessageBox.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
try:
|
||||
delete_tool(self._current_tool.name)
|
||||
self.main_window.show_status(f"Deleted tool '{self._current_tool.name}'")
|
||||
self.refresh()
|
||||
except Exception as e:
|
||||
QMessageBox.critical(self, "Error", f"Failed to delete tool:\n{e}")
|
||||
|
||||
def _connect_registry(self):
|
||||
"""Open connect dialog."""
|
||||
from ..dialogs.connect_dialog import ConnectDialog
|
||||
dialog = ConnectDialog(self)
|
||||
if dialog.exec():
|
||||
self.refresh()
|
||||
self.main_window.show_status("Connected to registry")
|
||||
# Recreate publish button
|
||||
self.btn_publish.setText("Publish")
|
||||
self.btn_publish.clicked.disconnect()
|
||||
self.btn_publish.clicked.connect(self._publish_tool)
|
||||
|
||||
def _publish_tool(self):
|
||||
"""Publish the selected tool."""
|
||||
if not self._current_tool:
|
||||
return
|
||||
|
||||
from ..dialogs.publish_dialog import PublishDialog
|
||||
dialog = PublishDialog(self, self._current_tool)
|
||||
if dialog.exec():
|
||||
self.main_window.show_status(f"Published '{self._current_tool.name}'")
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
"""Modern stylesheet for CmdForge GUI."""
|
||||
|
||||
STYLESHEET = """
|
||||
/* Main Window */
|
||||
QMainWindow {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
background-color: #2d3748;
|
||||
border: none;
|
||||
min-width: 180px;
|
||||
max-width: 180px;
|
||||
}
|
||||
|
||||
#sidebar::item {
|
||||
color: #e2e8f0;
|
||||
padding: 12px 16px;
|
||||
border: none;
|
||||
border-left: 3px solid transparent;
|
||||
}
|
||||
|
||||
#sidebar::item:selected {
|
||||
background-color: #4a5568;
|
||||
border-left: 3px solid #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#sidebar::item:hover:!selected {
|
||||
background-color: #3d4a5c;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
QWidget#content_area {
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
QPushButton {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 16px;
|
||||
font-weight: 500;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
QPushButton:hover {
|
||||
background-color: #5a67d8;
|
||||
}
|
||||
|
||||
QPushButton:pressed {
|
||||
background-color: #4c51bf;
|
||||
}
|
||||
|
||||
QPushButton:disabled {
|
||||
background-color: #a0aec0;
|
||||
}
|
||||
|
||||
QPushButton#secondary {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
QPushButton#secondary:hover {
|
||||
background-color: #cbd5e0;
|
||||
}
|
||||
|
||||
QPushButton#danger {
|
||||
background-color: #e53e3e;
|
||||
}
|
||||
|
||||
QPushButton#danger:hover {
|
||||
background-color: #c53030;
|
||||
}
|
||||
|
||||
/* Input fields */
|
||||
QLineEdit, QTextEdit, QPlainTextEdit {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
selection-background-color: #667eea;
|
||||
}
|
||||
|
||||
QLineEdit:focus, QTextEdit:focus, QPlainTextEdit:focus {
|
||||
border-color: #667eea;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Combo boxes */
|
||||
QComboBox {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
QComboBox:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 24px;
|
||||
}
|
||||
|
||||
QComboBox::down-arrow {
|
||||
image: none;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid #718096;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
QComboBox QAbstractItemView {
|
||||
border: 1px solid #e2e8f0;
|
||||
background-color: white;
|
||||
selection-background-color: #667eea;
|
||||
}
|
||||
|
||||
/* Lists and Trees */
|
||||
QListWidget, QTreeWidget, QTableWidget {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background-color: white;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
QListWidget::item, QTreeWidget::item {
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
QListWidget::item:selected, QTreeWidget::item:selected {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
QListWidget::item:hover:!selected, QTreeWidget::item:hover:!selected {
|
||||
background-color: #edf2f7;
|
||||
}
|
||||
|
||||
/* Table Widget */
|
||||
QTableWidget {
|
||||
gridline-color: #e2e8f0;
|
||||
}
|
||||
|
||||
QTableWidget::item {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
QTableWidget::item:selected {
|
||||
background-color: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
QHeaderView::section {
|
||||
background-color: #f7fafc;
|
||||
padding: 8px 12px;
|
||||
border: none;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
/* Group boxes */
|
||||
QGroupBox {
|
||||
font-weight: 600;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
left: 12px;
|
||||
padding: 0 8px;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
/* Labels */
|
||||
QLabel {
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
QLabel#heading {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
QLabel#subheading {
|
||||
font-size: 14px;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
QLabel#label {
|
||||
font-weight: 500;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
/* Scrollbars */
|
||||
QScrollBar:vertical {
|
||||
background-color: #f7fafc;
|
||||
width: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical {
|
||||
background-color: #cbd5e0;
|
||||
border-radius: 6px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background-color: #a0aec0;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {
|
||||
height: 0;
|
||||
}
|
||||
|
||||
QScrollBar:horizontal {
|
||||
background-color: #f7fafc;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal {
|
||||
background-color: #cbd5e0;
|
||||
border-radius: 6px;
|
||||
min-width: 30px;
|
||||
}
|
||||
|
||||
QScrollBar::handle:horizontal:hover {
|
||||
background-color: #a0aec0;
|
||||
}
|
||||
|
||||
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
/* Status bar */
|
||||
QStatusBar {
|
||||
background-color: #f7fafc;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* Splitter */
|
||||
QSplitter::handle {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
QSplitter::handle:horizontal {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
QSplitter::handle:vertical {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
QTabBar::tab {
|
||||
background-color: #edf2f7;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-bottom: none;
|
||||
padding: 8px 16px;
|
||||
margin-right: 2px;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
}
|
||||
|
||||
QTabBar::tab:selected {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid white;
|
||||
}
|
||||
|
||||
QTabBar::tab:hover:!selected {
|
||||
background-color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Dialogs */
|
||||
QDialog {
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
|
||||
/* Message boxes */
|
||||
QMessageBox {
|
||||
background-color: #f7fafc;
|
||||
}
|
||||
|
||||
QMessageBox QPushButton {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
/* Tool tips */
|
||||
QToolTip {
|
||||
background-color: #2d3748;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 6px 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
QProgressBar {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
background-color: #edf2f7;
|
||||
text-align: center;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
QProgressBar::chunk {
|
||||
background-color: #667eea;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* Spin boxes */
|
||||
QSpinBox, QDoubleSpinBox {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
QSpinBox:focus, QDoubleSpinBox:focus {
|
||||
border-color: #667eea;
|
||||
}
|
||||
|
||||
/* Check boxes and radio buttons */
|
||||
QCheckBox, QRadioButton {
|
||||
spacing: 8px;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
QCheckBox::indicator, QRadioButton::indicator {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:unchecked {
|
||||
border: 2px solid #cbd5e0;
|
||||
border-radius: 4px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
QCheckBox::indicator:checked {
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 4px;
|
||||
background-color: #667eea;
|
||||
}
|
||||
|
||||
QRadioButton::indicator:unchecked {
|
||||
border: 2px solid #cbd5e0;
|
||||
border-radius: 9px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
QRadioButton::indicator:checked {
|
||||
border: 2px solid #667eea;
|
||||
border-radius: 9px;
|
||||
background-color: #667eea;
|
||||
}
|
||||
"""
|
||||
|
|
@ -0,0 +1 @@
|
|||
"""Custom widgets for CmdForge GUI."""
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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
|
|
@ -1,6 +0,0 @@
|
|||
"""Allow running the UI as a module."""
|
||||
|
||||
from . import run_ui
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_ui()
|
||||
|
|
@ -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'),
|
||||
]
|
||||
|
|
@ -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)
|
||||
10
wiki/Home.md
10
wiki/Home.md
|
|
@ -151,15 +151,15 @@ These tools combine AI prompts with code for validation:
|
|||
|
||||
## Creating Your Own Tools
|
||||
|
||||
### Using the TUI
|
||||
### Using the GUI
|
||||
|
||||
The easiest way to create tools:
|
||||
|
||||
```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)
|
||||
|
||||
|
|
@ -245,10 +245,10 @@ cat important.txt | summarize --provider claude-opus
|
|||
|
||||
### Change Tool Default
|
||||
|
||||
Edit the tool's config or use the TUI:
|
||||
Edit the tool's config or use the GUI:
|
||||
|
||||
```bash
|
||||
cmdforge ui
|
||||
cmdforge
|
||||
# Select tool → Edit → Change provider in step
|
||||
```
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue