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.
|
- `wiki/` contains additional reference material.
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture Overview
|
||||||
- `cli.py` routes subcommands like `list`, `create`, `run`, `test`, `ui`, and `refresh`.
|
- `cli/` directory routes subcommands like `list`, `create`, `run`, `test`, and `refresh`.
|
||||||
- `tool.py` defines tool/step models and handles YAML config loading and wrapper generation.
|
- `tool.py` defines tool/step models and handles YAML config loading and wrapper generation.
|
||||||
- `runner.py` executes steps and performs `{input}`/argument variable substitution.
|
- `runner.py` executes steps and performs `{input}`/argument variable substitution.
|
||||||
- `providers.py` shells out to configured AI provider CLIs (or the `mock` provider).
|
- `providers.py` shells out to configured AI provider CLIs (or the `mock` provider).
|
||||||
- `ui_urwid.py` and `ui_snack.py` provide the TUI implementations, selected by `ui.py`.
|
- `gui/` provides the PySide6 desktop GUI with pages for tools, registry, and providers.
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
## Build, Test, and Development Commands
|
||||||
- `pip install -e ".[dev]"` installs CmdForge in editable mode with dev dependencies.
|
- `pip install -e ".[dev]"` installs CmdForge in editable mode with dev dependencies.
|
||||||
- `pytest` runs the full test suite.
|
- `pytest` runs the full test suite.
|
||||||
- `pytest tests/test.py::test_name` runs a focused test.
|
- `pytest tests/test.py::test_name` runs a focused test.
|
||||||
- `python -m cmdforge.cli` runs the CLI module directly.
|
- `python -m cmdforge.cli` runs the CLI module directly.
|
||||||
- `cmdforge ui` launches the TUI (requires `urwid` or `python-newt`).
|
- `cmdforge` launches the desktop GUI (requires PySide6).
|
||||||
- `docker-compose build` builds the dev container image.
|
- `docker-compose build` builds the dev container image.
|
||||||
- `docker-compose run --rm test` runs tests inside Docker.
|
- `docker-compose run --rm test` runs tests inside Docker.
|
||||||
|
|
||||||
|
|
|
||||||
13
CLAUDE.md
13
CLAUDE.md
|
|
@ -21,21 +21,22 @@ pytest tests/test.py::test_name
|
||||||
# Run the CLI
|
# Run the CLI
|
||||||
python -m cmdforge.cli
|
python -m cmdforge.cli
|
||||||
|
|
||||||
# Launch the UI
|
# Launch the GUI
|
||||||
cmdforge ui
|
cmdforge
|
||||||
```
|
```
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
### Core Modules (`src/cmdforge/`)
|
### Core Modules (`src/cmdforge/`)
|
||||||
|
|
||||||
- **cli.py**: Entry point (`cmdforge` command). Routes subcommands: list, create, edit, delete, test, run, ui, refresh
|
- **cli/**: CLI commands entry points (`cmdforge` command). Routes subcommands: list, create, edit, delete, test, run, refresh
|
||||||
- **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`), YAML config loading/saving, wrapper script generation
|
- **tool.py**: Tool definition dataclasses (`Tool`, `ToolArgument`, `PromptStep`, `CodeStep`), YAML config loading/saving, wrapper script generation
|
||||||
- **runner.py**: Execution engine. Runs tool steps sequentially, handles variable substitution (`{input}`, `{varname}`), executes Python code steps via `exec()`
|
- **runner.py**: Execution engine. Runs tool steps sequentially, handles variable substitution (`{input}`, `{varname}`), executes Python code steps via `exec()`
|
||||||
- **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.cmdforge/providers.yaml`
|
- **providers.py**: Provider abstraction. Calls AI CLI tools via subprocess, reads provider configs from `~/.cmdforge/providers.yaml`
|
||||||
- **ui.py**: UI dispatcher - selects between urwid and snack implementations
|
- **gui/**: PySide6 desktop GUI
|
||||||
- **ui_urwid.py**: Full TUI implementation using urwid library
|
- **main_window.py**: Main application window with sidebar navigation
|
||||||
- **ui_snack.py**: Fallback TUI using python-newt/snack
|
- **pages/**: Tools page, Tool Builder, Registry browser, Providers management
|
||||||
|
- **dialogs/**: Step editors, Argument editor, Provider dialog, Connect/Publish dialogs
|
||||||
|
|
||||||
### Key Paths
|
### Key Paths
|
||||||
|
|
||||||
|
|
|
||||||
130
README.md
130
README.md
|
|
@ -66,10 +66,10 @@ export PATH="$HOME/.local/bin:$PATH"
|
||||||
# Install an AI provider (interactive guide)
|
# Install an AI provider (interactive guide)
|
||||||
cmdforge providers install
|
cmdforge providers install
|
||||||
|
|
||||||
# Launch the UI
|
# Launch the GUI
|
||||||
cmdforge ui
|
cmdforge
|
||||||
|
|
||||||
# Or create your first tool
|
# Or create your first tool via CLI
|
||||||
cmdforge create summarize
|
cmdforge create summarize
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -95,7 +95,7 @@ pip install -e ".[dev]"
|
||||||
|
|
||||||
- Python 3.10+
|
- Python 3.10+
|
||||||
- At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md))
|
- At least one AI CLI tool installed (see [Provider Setup](docs/PROVIDERS.md))
|
||||||
- Optional: `urwid` for the TUI (`pip install urwid`)
|
- PySide6 (included automatically - requires display server on Linux)
|
||||||
|
|
||||||
### Post-Install
|
### Post-Install
|
||||||
|
|
||||||
|
|
@ -113,13 +113,16 @@ cmdforge refresh
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### UI Mode (Recommended for Beginners)
|
### GUI Mode (Recommended for Beginners)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cmdforge ui
|
cmdforge
|
||||||
```
|
```
|
||||||
|
|
||||||
Navigate with arrow keys, Tab, Enter, and mouse. Create tools visually with the built-in prompt editor.
|
Opens the graphical interface where you can create and manage tools visually. Features include:
|
||||||
|
- **My Tools** - Browse, create, edit, and delete tools organized by category
|
||||||
|
- **Registry** - Search and install community tools from the CmdForge registry
|
||||||
|
- **Providers** - Manage AI provider configurations
|
||||||
|
|
||||||
### CLI Mode
|
### CLI Mode
|
||||||
|
|
||||||
|
|
@ -410,55 +413,82 @@ vnoremap <leader>fg :!fix-grammar<CR>
|
||||||
vnoremap <leader>ec :!explain-code<CR>
|
vnoremap <leader>ec :!explain-code<CR>
|
||||||
```
|
```
|
||||||
|
|
||||||
## UI Navigation
|
## GUI Features
|
||||||
|
|
||||||
| Action | Keys |
|
The graphical interface provides a modern desktop experience:
|
||||||
|--------|------|
|
|
||||||
| Cycle sections | `Tab` |
|
|
||||||
| Go back | `Escape` |
|
|
||||||
| Select | `Enter` or click |
|
|
||||||
| Navigate | Arrow keys |
|
|
||||||
| **Scroll content** | Mouse wheel |
|
|
||||||
| **Scroll up** | Click top of scrollbar |
|
|
||||||
| **Scroll down** | Click bottom of scrollbar |
|
|
||||||
| **Page up/down** | Click middle of scrollbar |
|
|
||||||
| **Select text** | `Shift` + mouse drag |
|
|
||||||
| **Copy** | Terminal native (with Shift) |
|
|
||||||
| **Paste** | `Ctrl+Shift+V` |
|
|
||||||
| **Undo** (code editor) | `Alt+U` |
|
|
||||||
| **Redo** (code editor) | `Alt+R` |
|
|
||||||
|
|
||||||
**Tips:**
|
### My Tools Page
|
||||||
- Hold `Shift` while using mouse for terminal-native text selection.
|
- View all your tools organized by category (Text, Developer, Data, Other)
|
||||||
- Code/Prompt editors have DOS-style scrollbars with `▲` and `▼` arrow buttons.
|
- Double-click a tool to edit it
|
||||||
- In step dialogs, use `Tab` to cycle between File, Editor, and Output fields.
|
- Create new tools with the built-in Tool Builder
|
||||||
- The code editor supports undo/redo (up to 50 states) with `Alt+U` and `Alt+R`.
|
- Connect to the registry to publish your tools
|
||||||
- Use the `$EDITOR` button to open code or prompts in your external editor.
|
|
||||||
|
|
||||||
## AI-Assisted Code Generation
|
### Tool Builder
|
||||||
|
- Visual form for creating and editing tools
|
||||||
|
- Add arguments with flags and default values
|
||||||
|
- Add prompt steps (AI calls) or code steps (Python)
|
||||||
|
- Preview the generated YAML configuration
|
||||||
|
- Test tools before saving
|
||||||
|
|
||||||
When adding or editing a **Code Step**, the dialog includes an AI assist panel:
|
### Registry Browser
|
||||||
|
- Search community tools by name or keyword
|
||||||
|
- View tool details, downloads, and ratings
|
||||||
|
- One-click install to your local machine
|
||||||
|
|
||||||
```
|
### Provider Management
|
||||||
┌─ Code ─────────────┐ ┌─ AI Assisted Auto-adjust ─────────────┐
|
- Add and configure AI providers
|
||||||
│ result = input... │ │ Provider: [opencode-deepseek] [▼] │
|
- Test provider connectivity
|
||||||
│ │ │ ┌─ Prompt ──────────────────────────┐ │
|
- Set default providers for new tools
|
||||||
│ │ │ │ Modify this code to... │ │
|
|
||||||
│ │ │ │ {code} │ │
|
### Keyboard Shortcuts
|
||||||
│ │ │ └──────────────────────────────────┘ │
|
|
||||||
│ │ │ ┌─ Output & Feedback ───────────────┐ │
|
| Shortcut | Action |
|
||||||
│ │ │ │ ✓ Code updated successfully! │ │
|
|----------|--------|
|
||||||
│ │ │ └──────────────────────────────────┘ │
|
| `Ctrl+N` | Create new tool |
|
||||||
│ │ │ < Auto-adjust > │
|
| `Ctrl+S` | Save tool (in builder) |
|
||||||
└────────────────────┘ └───────────────────────────────────────┘
|
| `Ctrl+R` | Refresh current page |
|
||||||
|
| `Ctrl+1` | Go to My Tools |
|
||||||
|
| `Ctrl+2` | Go to Registry |
|
||||||
|
| `Ctrl+3` | Go to Providers |
|
||||||
|
| `Escape` | Close tool builder |
|
||||||
|
| `Ctrl+Q` | Quit application |
|
||||||
|
|
||||||
|
## Multi-Step Tool Patterns
|
||||||
|
|
||||||
|
CmdForge supports powerful multi-step workflows combining AI and code:
|
||||||
|
|
||||||
|
### Code → AI
|
||||||
|
Extract or transform data with Python, then pass to AI:
|
||||||
|
```yaml
|
||||||
|
steps:
|
||||||
|
- type: code
|
||||||
|
code: |
|
||||||
|
# Extract just the error lines
|
||||||
|
errors = [l for l in input_text.split('\n') if 'error' in l.lower()]
|
||||||
|
result = '\n'.join(errors[-10:]) # Last 10 errors
|
||||||
|
output_var: errors
|
||||||
|
- type: prompt
|
||||||
|
prompt: "Explain these errors:\n{errors}"
|
||||||
|
output_var: explanation
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Provider**: Select any configured AI provider
|
### AI → Code
|
||||||
- **Prompt**: Fully editable template - use `{code}` placeholder for current code
|
Generate content with AI, then validate/process with Python:
|
||||||
- **Output**: Shows status, success/error messages, and provider feedback
|
```yaml
|
||||||
- **Auto-adjust**: Sends prompt to AI and replaces code with response
|
steps:
|
||||||
|
- type: prompt
|
||||||
This lets you generate or modify Python code using AI directly within the tool builder.
|
prompt: "Generate Python code to: {input}"
|
||||||
|
output_var: generated
|
||||||
|
- type: code
|
||||||
|
code: |
|
||||||
|
import ast
|
||||||
|
try:
|
||||||
|
ast.parse(generated) # Syntax check
|
||||||
|
result = generated
|
||||||
|
except SyntaxError as e:
|
||||||
|
result = f"# Syntax Error: {e}\n{generated}"
|
||||||
|
output_var: validated
|
||||||
|
```
|
||||||
|
|
||||||
## Philosophy
|
## Philosophy
|
||||||
|
|
||||||
|
|
@ -515,7 +545,7 @@ docker run -it --rm -v cmdforge-data:/home/user/.cmdforge cmdforge-ready
|
||||||
Inside the container, CmdForge is ready to use:
|
Inside the container, CmdForge is ready to use:
|
||||||
```bash
|
```bash
|
||||||
cmdforge list # See 27 pre-installed tools
|
cmdforge list # See 27 pre-installed tools
|
||||||
cmdforge ui # Launch the TUI
|
cmdforge # Launch the GUI (requires display)
|
||||||
cmdforge run summarize # Run a tool
|
cmdforge run summarize # Run a tool
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -32,16 +32,13 @@ classifiers = [
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"PyYAML>=6.0",
|
"PyYAML>=6.0",
|
||||||
"requests>=2.28",
|
"requests>=2.28",
|
||||||
|
"PySide6>=6.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
tui = [
|
|
||||||
"urwid>=2.1.0",
|
|
||||||
]
|
|
||||||
dev = [
|
dev = [
|
||||||
"pytest>=7.0",
|
"pytest>=7.0",
|
||||||
"pytest-cov>=4.0",
|
"pytest-cov>=4.0",
|
||||||
"urwid>=2.1.0",
|
|
||||||
]
|
]
|
||||||
registry = [
|
registry = [
|
||||||
"Flask>=2.3",
|
"Flask>=2.3",
|
||||||
|
|
@ -50,7 +47,6 @@ registry = [
|
||||||
"gunicorn>=21.0",
|
"gunicorn>=21.0",
|
||||||
]
|
]
|
||||||
all = [
|
all = [
|
||||||
"urwid>=2.1.0",
|
|
||||||
"Flask>=2.3",
|
"Flask>=2.3",
|
||||||
"argon2-cffi>=21.0",
|
"argon2-cffi>=21.0",
|
||||||
"sentry-sdk[flask]>=1.0",
|
"sentry-sdk[flask]>=1.0",
|
||||||
|
|
|
||||||
|
|
@ -501,14 +501,7 @@ def _cmd_registry_my_tools(args):
|
||||||
|
|
||||||
|
|
||||||
def _cmd_registry_browse(args):
|
def _cmd_registry_browse(args):
|
||||||
"""Browse tools (TUI)."""
|
"""Browse tools (GUI)."""
|
||||||
try:
|
from ..gui import run_gui
|
||||||
from ..ui_registry import run_registry_browser
|
# Launch GUI - it will open to Registry page
|
||||||
return run_registry_browser()
|
return run_gui()
|
||||||
except ImportError:
|
|
||||||
print("TUI browser requires urwid. Install with:", file=sys.stderr)
|
|
||||||
print(" pip install 'cmdforge[tui]'", file=sys.stderr)
|
|
||||||
print()
|
|
||||||
print("Or search from command line:", file=sys.stderr)
|
|
||||||
print(" cmdforge registry search <query>", file=sys.stderr)
|
|
||||||
return 1
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ from ..tool import (
|
||||||
list_tools, load_tool, save_tool, delete_tool, get_tools_dir,
|
list_tools, load_tool, save_tool, delete_tool, get_tools_dir,
|
||||||
Tool, ToolArgument, PromptStep, CodeStep, ToolStep
|
Tool, ToolArgument, PromptStep, CodeStep, ToolStep
|
||||||
)
|
)
|
||||||
from ..ui import run_ui
|
from ..gui import run_gui
|
||||||
|
|
||||||
|
|
||||||
def cmd_list(args):
|
def cmd_list(args):
|
||||||
|
|
@ -248,9 +248,8 @@ def cmd_run(args):
|
||||||
|
|
||||||
|
|
||||||
def cmd_ui(args):
|
def cmd_ui(args):
|
||||||
"""Launch the interactive UI."""
|
"""Launch the interactive GUI."""
|
||||||
run_ui()
|
return run_gui()
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_refresh(args):
|
def cmd_refresh(args):
|
||||||
|
|
|
||||||
|
|
@ -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
|
## Creating Your Own Tools
|
||||||
|
|
||||||
### Using the TUI
|
### Using the GUI
|
||||||
|
|
||||||
The easiest way to create tools:
|
The easiest way to create tools:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cmdforge ui
|
cmdforge
|
||||||
```
|
```
|
||||||
|
|
||||||
Navigate with arrow keys, create tools visually, edit prompts, test with mock provider.
|
Opens the graphical interface where you can create tools visually, edit prompts, manage providers, and browse the registry.
|
||||||
|
|
||||||
### Tool Anatomy (YAML)
|
### Tool Anatomy (YAML)
|
||||||
|
|
||||||
|
|
@ -245,10 +245,10 @@ cat important.txt | summarize --provider claude-opus
|
||||||
|
|
||||||
### Change Tool Default
|
### Change Tool Default
|
||||||
|
|
||||||
Edit the tool's config or use the TUI:
|
Edit the tool's config or use the GUI:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cmdforge ui
|
cmdforge
|
||||||
# Select tool → Edit → Change provider in step
|
# Select tool → Edit → Change provider in step
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue