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

{tool.get('owner', '')}/{tool.get('name', '')}

") + + if tool.get("description"): + lines.append(f"

{tool.get('description')}

") + + lines.append(f"

Version: {tool.get('version', '1.0.0')}

") + lines.append(f"

Downloads: {tool.get('downloads', 0)}

") + + if tool.get("category"): + lines.append(f"

Category: {tool.get('category')}

") + + if tool.get("tags"): + tags = ", ".join(tool.get("tags", [])) + lines.append(f"

Tags: {tags}

") + + 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}") diff --git a/src/cmdforge/gui/pages/tool_builder_page.py b/src/cmdforge/gui/pages/tool_builder_page.py new file mode 100644 index 0000000..bfed676 --- /dev/null +++ b/src/cmdforge/gui/pages/tool_builder_page.py @@ -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() diff --git a/src/cmdforge/gui/pages/tools_page.py b/src/cmdforge/gui/pages/tools_page.py new file mode 100644 index 0000000..4458399 --- /dev/null +++ b/src/cmdforge/gui/pages/tools_page.py @@ -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"

{tool.name}

") + if tool.description: + lines.append(f"

{tool.description}

") + + # Source info + if tool.source: + source_type = tool.source.type + if source_type == "imported": + source_url = tool.source.url or "registry" + lines.append(f"

Imported from {source_url}

") + elif source_type == "forked": + lines.append(f"

Forked from {tool.source.original_tool}

") + + # Arguments + if tool.arguments: + lines.append("

Arguments

") + lines.append("
    ") + for arg in tool.arguments: + default = f" (default: {arg.default})" if arg.default else "" + lines.append(f"
  • {arg.flag}${arg.variable}{default}
  • ") + lines.append("
") + + # Steps + if tool.steps: + lines.append("

Steps

") + lines.append("
    ") + for i, step in enumerate(tool.steps, 1): + if isinstance(step, PromptStep): + lines.append(f"
  1. Prompt using {step.provider}${step.output_var}
  2. ") + elif isinstance(step, CodeStep): + lines.append(f"
  3. Code (python) → ${step.output_var}
  4. ") + elif isinstance(step, ToolStep): + lines.append(f"
  5. Tool: {step.tool}${step.output_var}
  6. ") + lines.append("
") + + # Output + if tool.output: + lines.append("

Output Template

") + output_escaped = tool.output.replace("<", "<").replace(">", ">") + lines.append(f"
{output_escaped}
") + + # Category + if tool.category: + lines.append(f"

Category: {tool.category}

") + + 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}'") diff --git a/src/cmdforge/gui/styles.py b/src/cmdforge/gui/styles.py new file mode 100644 index 0000000..d009d9c --- /dev/null +++ b/src/cmdforge/gui/styles.py @@ -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; +} +""" diff --git a/src/cmdforge/gui/widgets/__init__.py b/src/cmdforge/gui/widgets/__init__.py new file mode 100644 index 0000000..0d84b9a --- /dev/null +++ b/src/cmdforge/gui/widgets/__init__.py @@ -0,0 +1 @@ +"""Custom widgets for CmdForge GUI.""" diff --git a/src/cmdforge/ui.py b/src/cmdforge/ui.py deleted file mode 100644 index df15fc2..0000000 --- a/src/cmdforge/ui.py +++ /dev/null @@ -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() diff --git a/src/cmdforge/ui_registry.py b/src/cmdforge/ui_registry.py deleted file mode 100644 index e6c14f5..0000000 --- a/src/cmdforge/ui_registry.py +++ /dev/null @@ -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 diff --git a/src/cmdforge/ui_snack.py b/src/cmdforge/ui_snack.py deleted file mode 100644 index 6a47cff..0000000 --- a/src/cmdforge/ui_snack.py +++ /dev/null @@ -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() diff --git a/src/cmdforge/ui_urwid.py b/src/cmdforge/ui_urwid.py deleted file mode 100644 index 2efe405..0000000 --- a/src/cmdforge/ui_urwid.py +++ /dev/null @@ -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() diff --git a/src/cmdforge/ui_urwid/__init__.py b/src/cmdforge/ui_urwid/__init__.py deleted file mode 100644 index 605039d..0000000 --- a/src/cmdforge/ui_urwid/__init__.py +++ /dev/null @@ -1,2214 +0,0 @@ -"""BIOS-style TUI for CmdForge using urwid (with mouse support).""" - -import urwid -from typing import Optional, Callable - -from ..tool import ( - Tool, ToolArgument, PromptStep, CodeStep, - list_tools, load_tool, save_tool, delete_tool, tool_exists, validate_tool_name, - get_tools_dir, DEFAULT_CATEGORIES -) -from ..providers import Provider, load_providers, add_provider, delete_provider, get_provider -from ..registry_client import RegistryClient, RegistryError - -from .palette import PALETTE -from .widgets import ( - SelectableText, Button3D, Button3DCompact, ClickableButton, - SelectableToolItem, ToolListBox, TabCyclePile, TabPassEdit, - UndoableEdit, DOSScrollBar, ToolBuilderLayout, Dialog -) - - -class CmdForgeUI: - """Urwid-based UI for CmdForge with mouse support.""" - - def __init__(self): - self.loop = None - self.main_widget = None - self.overlay_stack = [] - - def run(self): - """Run the UI.""" - self.show_main_menu() - self.loop = urwid.MainLoop( - self.main_widget, - palette=PALETTE, - unhandled_input=self.handle_input, - handle_mouse=True # Enable mouse support! - ) - self.loop.run() - - def handle_input(self, key): - """Handle global key input.""" - if key in ('q', 'Q', 'esc'): - if self.overlay_stack: - self.close_overlay() - else: - raise urwid.ExitMainLoop() - - def refresh(self): - """Refresh the display.""" - if self.loop: - self.loop.draw_screen() - - def set_main(self, widget): - """Set the main widget.""" - self.main_widget = urwid.AttrMap(widget, 'body') - if self.loop: - self.loop.widget = self.main_widget - - def show_overlay(self, dialog, width=60, height=20): - """Show a dialog overlay.""" - overlay = urwid.Overlay( - dialog, - self.main_widget, - align='center', width=width, - valign='middle', height=height, - ) - self.overlay_stack.append(self.main_widget) - self.main_widget = overlay - if self.loop: - self.loop.widget = self.main_widget - - def close_overlay(self): - """Close the current overlay.""" - if self.overlay_stack: - self.main_widget = self.overlay_stack.pop() - if self.loop: - self.loop.widget = self.main_widget - - def message_box(self, title: str, message: str, callback=None): - """Show a message box.""" - def on_ok(_): - self.close_overlay() - if callback: - callback() - - body = urwid.Text(message) - dialog = Dialog(title, body, [("OK", on_ok)], width=50) - self.show_overlay(dialog, width=52, height=min(10 + message.count('\n'), 20)) - - def yes_no(self, title: str, message: str, on_yes=None, on_no=None): - """Show a yes/no dialog.""" - def handle_yes(_): - self.close_overlay() - if on_yes: - on_yes() - - def handle_no(_): - self.close_overlay() - if on_no: - on_no() - - body = urwid.Text(message) - dialog = Dialog(title, body, [("Yes", handle_yes), ("No", handle_no)], width=50) - self.show_overlay(dialog, width=52, height=10) - - def input_dialog(self, title: str, prompt: str, initial: str, callback: Callable[[str], None]): - """Show an input dialog.""" - edit = urwid.Edit(('label', f"{prompt}: "), initial) - edit = urwid.AttrMap(edit, 'edit', 'edit_focus') - - def on_ok(_): - value = edit.base_widget.edit_text - self.close_overlay() - callback(value) - - def on_cancel(_): - self.close_overlay() - - body = urwid.Filler(edit, valign='top') - dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)], width=50) - self.show_overlay(dialog, width=52, height=8) - - # ==================== Main Menu ==================== - - def show_main_menu(self): - """Show the main menu with tool list and info panel.""" - self._selected_tool_name = None - self._refresh_main_menu() - - def _refresh_main_menu(self): - """Refresh the main menu display.""" - from collections import defaultdict - - tools = list_tools() - self._tools_list = tools - - # Group tools by category - tools_by_category = defaultdict(list) - for name in tools: - tool = load_tool(name) - category = tool.category if tool else "Other" - tools_by_category[category].append(name) - - # Build tool list with category headers - tool_items = [] - - # Show categories in defined order, then any custom ones - 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 header (non-selectable) - header = urwid.AttrMap( - urwid.Text(f"─── {category} ───"), - 'label' - ) - tool_items.append(header) - - # Tools in this category - for name in sorted(tools_by_category[category]): - item = SelectableToolItem(name, on_select=self._on_tool_select) - tool_items.append(item) - - if not tools: - tool_items.append(urwid.Text(('label', " (no tools - click Create to add one) "))) - - self._tool_walker = urwid.SimpleFocusListWalker(tool_items) - tool_listbox = ToolListBox(self._tool_walker, on_focus_change=self._on_tool_focus) - tool_box = urwid.LineBox(tool_listbox, title='Tools') - - # Check if connected to registry - from ..config import load_config - config = load_config() - is_connected = bool(config.registry.token) - - # Action buttons - Tab navigates here from tool list (3D style) - create_btn = Button3DCompact("Create", lambda _: self._create_tool_before_selected()) - edit_btn = Button3DCompact("Edit", lambda _: self._edit_selected_tool()) - delete_btn = Button3DCompact("Delete", lambda _: self._delete_selected_tool()) - test_btn = Button3DCompact("Test", lambda _: self._test_selected_tool()) - - # Show Connect or Publish based on connection status - if is_connected: - connect_publish_btn = Button3DCompact("Publish", lambda _: self._publish_selected_tool()) - else: - connect_publish_btn = Button3DCompact("Connect", lambda _: self._start_connect_flow()) - - registry_btn = Button3DCompact("Registry", lambda _: self.browse_registry()) - providers_btn = Button3DCompact("Providers", lambda _: self.manage_providers()) - - buttons_row = urwid.Columns([ - ('pack', create_btn), - ('pack', urwid.Text(" ")), - ('pack', edit_btn), - ('pack', urwid.Text(" ")), - ('pack', delete_btn), - ('pack', urwid.Text(" ")), - ('pack', test_btn), - ('pack', urwid.Text(" ")), - ('pack', connect_publish_btn), - ('pack', urwid.Text(" ")), - ('pack', registry_btn), - ('pack', urwid.Text(" ")), - ('pack', providers_btn), - ]) - buttons_padded = urwid.Padding(buttons_row, align='left', left=1) - - # Info panel - shows details of selected tool (not focusable) - self._info_name = urwid.Text("") - self._info_desc = urwid.Text("") - self._info_args = urwid.Text("") - self._info_steps = urwid.Text("") - self._info_output = urwid.Text("") - self._info_source = urwid.Text("") - - info_content = urwid.Pile([ - self._info_name, - self._info_desc, - urwid.Divider(), - self._info_args, - urwid.Divider(), - self._info_steps, - urwid.Divider(), - self._info_output, - urwid.Divider(), - self._info_source, - ]) - info_filler = urwid.Filler(info_content, valign='top') - info_box = urwid.LineBox(info_filler, title='Tool Info') - - # Exit button at bottom (3D style) - exit_btn = Button3DCompact("EXIT", lambda _: self.exit_app()) - exit_centered = urwid.Padding(exit_btn, align='center', width=12) - - # Use a custom Pile that handles Tab to cycle between tool list and buttons - self._main_pile = TabCyclePile([ - ('weight', 1, tool_box), - ('pack', buttons_padded), - ('pack', urwid.Divider('─')), - ('weight', 2, info_box), - ('pack', urwid.Divider()), - ('pack', exit_centered), - ], tab_positions=[0, 1, 5]) # Tool list, buttons row, exit button - - # Header - header = urwid.Text(('header', ' CmdForge Manager '), align='center') - - # Footer - footer = urwid.Text(('footer', ' Arrow:Navigate list | Tab:Jump to buttons | Enter/Click:Select | Q:Quit '), align='center') - - frame = urwid.Frame(self._main_pile, header=header, footer=footer) - self.set_main(frame) - - # Update info for first tool if any - if tools: - self._on_tool_focus(tools[0]) - - def _create_tool_before_selected(self): - """Create a new tool (will appear in list based on name sorting).""" - self.create_tool() - - def _on_tool_focus(self, name): - """Called when a tool is focused/highlighted.""" - self._selected_tool_name = name - - # Update selection state on all tool items - if hasattr(self, '_tool_walker'): - for item in self._tool_walker: - if isinstance(item, SelectableToolItem): - item.set_selected(item.name == name) - - tool = load_tool(name) - - if tool: - self._info_name.set_text(('label', f"Name: {tool.name}")) - self._info_desc.set_text(f"Description: {tool.description or '(none)'}") - - if tool.arguments: - args_text = "Arguments:\n" - for arg in tool.arguments: - default = f" = {arg.default}" if arg.default else "" - args_text += f" {arg.flag} -> {{{arg.variable}}}{default}\n" - else: - args_text = "Arguments: (none)" - self._info_args.set_text(args_text.rstrip()) - - if tool.steps: - steps_text = "Execution Steps:\n" - for i, step in enumerate(tool.steps): - if isinstance(step, PromptStep): - steps_text += f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}\n" - else: - steps_text += f" {i+1}. CODE -> {{{step.output_var}}}\n" - else: - steps_text = "Execution Steps: (none)" - self._info_steps.set_text(steps_text.rstrip()) - - self._info_output.set_text(f"Output: {tool.output}") - - # Display source attribution if present - if tool.source: - source_text = "Source:\n" - source_text += f" Type: {tool.source.type}\n" - if tool.source.author: - source_text += f" Author: {tool.source.author}\n" - if tool.source.license: - source_text += f" License: {tool.source.license}\n" - if tool.source.url: - source_text += f" URL: {tool.source.url}\n" - if tool.source.original_tool: - source_text += f" Original: {tool.source.original_tool}\n" - self._info_source.set_text(source_text.rstrip()) - else: - self._info_source.set_text("") - else: - self._info_name.set_text("") - self._info_desc.set_text("") - self._info_args.set_text("") - self._info_steps.set_text("") - self._info_output.set_text("") - self._info_source.set_text("") - - def _on_tool_select(self, name): - """Called when a tool is selected (Enter/double-click).""" - # Edit the tool on select - tool = load_tool(name) - if tool: - self.tool_builder(tool) - - def _edit_selected_tool(self): - """Edit the currently selected tool.""" - if self._selected_tool_name: - tool = load_tool(self._selected_tool_name) - if tool: - self.tool_builder(tool) - else: - self.message_box("Edit", "No tool selected.") - - def _delete_selected_tool(self): - """Delete the currently selected tool.""" - if self._selected_tool_name: - name = self._selected_tool_name - def do_delete(): - delete_tool(name) - self._selected_tool_name = None - self.message_box("Deleted", f"Tool '{name}' deleted.", self._refresh_main_menu) - self.yes_no("Confirm", f"Delete tool '{name}'?", on_yes=do_delete) - else: - self.message_box("Delete", "No tool selected.") - - def _test_selected_tool(self): - """Test the currently selected tool.""" - if self._selected_tool_name: - tool = load_tool(self._selected_tool_name) - if tool: - self._test_tool(tool) - else: - self.message_box("Test", "No tool selected.") - - def _publish_selected_tool(self): - """Publish the currently selected tool to the registry.""" - if not self._selected_tool_name: - self.message_box("Publish", "No tool selected.") - return - - tool = load_tool(self._selected_tool_name) - if not tool: - self.message_box("Publish", "Could not load tool.") - return - - # Check for version - tool_dir = get_tools_dir() / self._selected_tool_name - config_path = tool_dir / "config.yaml" - - if not config_path.exists(): - self.message_box("Publish", "Tool config not found.") - return - - import yaml - config_text = config_path.read_text() - config_data = yaml.safe_load(config_text) - version = config_data.get("version", "") - - if not version: - # Prompt for version - self._prompt_for_version_and_publish(tool, config_path, config_data, config_text) - else: - self._do_publish(tool, version) - - def _prompt_for_version_and_publish(self, tool, config_path, config_data, config_text): - """Prompt user for version and then publish.""" - def on_version(version): - version = version.strip() - if not version: - self.message_box("Publish", "Version is required for publishing.") - return - - # Add version to config - import yaml - config_data["version"] = version - config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False)) - - self._do_publish(tool, version) - - self.input_dialog( - "Version Required", - "Enter version (e.g., 1.0.0)", - "1.0.0", - on_version - ) - - def _prompt_for_token(self, tool, version): - """Prompt user to connect their account or enter a token manually.""" - from ..config import set_registry_token - - def on_connect(_): - self.close_overlay() - self._start_connect_flow(tool, version) - - def on_manual(_): - self.close_overlay() - def on_token(token): - if token and token.strip(): - token = token.strip() - try: - set_registry_token(token) - self.message_box( - "Token Saved", - "Token configured successfully!", - callback=lambda: self._do_publish(tool, version) - ) - except Exception as e: - self.message_box("Error", f"Failed to save token: {e}") - - self.input_dialog( - "Enter Token", - "Paste your API token:", - "", - on_token - ) - - def on_cancel(_): - self.close_overlay() - - body = urwid.Text( - "To publish tools, you need to connect your account.\n\n" - "Option 1: Connect (Recommended)\n" - " - Creates a secure link to your account\n" - " - No manual token handling\n\n" - "Option 2: Enter Token Manually\n" - " - For CI/CD or advanced setups\n" - " - Get token from cmdforge.brrd.tech/dashboard" - ) - - dialog = Dialog( - "Authentication Required", - body, - [("Connect", on_connect), ("Manual Token", on_manual), ("Cancel", on_cancel)] - ) - self.show_overlay(dialog, width=55, height=16) - - def _start_connect_flow(self, tool=None, version=None): - """Start the account connection flow.""" - from ..config import set_registry_token, get_registry_url - import socket - import time - import threading - - def on_username(username): - if not username or not username.strip(): - self.message_box("Error", "Username is required.") - return - - username = username.strip() - hostname = socket.gethostname() - registry_url = get_registry_url() - - # Remove trailing /api/v1 if present - 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/{username}" - - # Show waiting dialog - status_text = urwid.Text(('label', f"Connecting as @{username}...\nDevice: {hostname}\n\nWaiting for approval...")) - instructions = urwid.Text( - "\nIn your browser, go to:\n" - " https://cmdforge.brrd.tech/dashboard/connected-apps\n" - "and click 'Connect New App', then 'I've Run the Command'." - ) - countdown_text = urwid.Text(('label', "Expires in 5:00")) - - body = urwid.Pile([ - status_text, - urwid.Divider(), - instructions, - urwid.Divider(), - countdown_text, - ]) - - # Track polling state - polling_state = {"active": True, "start_time": time.time()} - - def on_cancel(_): - polling_state["active"] = False - self.close_overlay() - - dialog = Dialog("Connecting...", body, [("Cancel", on_cancel)]) - self.show_overlay(dialog, width=60, height=18) - - def poll_for_connection(): - """Background thread to poll for connection.""" - import requests - - max_time = 300 # 5 minutes - while polling_state["active"]: - elapsed = time.time() - polling_state["start_time"] - if elapsed > max_time: - if self.loop: - self.loop.set_alarm_in(0, lambda l, d: self._on_connect_timeout()) - return - - # Update countdown - capture values to avoid closure issues - remaining = int(max_time - elapsed) - mins = remaining // 60 - secs = remaining % 60 - - def update_countdown(loop, data, m=mins, s=secs): - countdown_text.set_text(('label', f"Expires in {m}:{s:02d}")) - loop.draw_screen() - if self.loop: - self.loop.set_alarm_in(0, update_countdown) - - try: - response = requests.get( - pairing_url, - params={"hostname": 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: - set_registry_token(token) - def on_success(loop, data): - self._on_connect_success(tool, version, username, hostname) - if self.loop: - self.loop.set_alarm_in(0, on_success) - return - - except Exception: - pass # Network errors, keep trying - - time.sleep(2) - - # Start polling in background thread - thread = threading.Thread(target=poll_for_connection, daemon=True) - thread.start() - - self.input_dialog( - "Connect to your CmdForge Account", - "Username (create account at cmdforge.brrd.tech)", - "", - on_username - ) - - def _on_connect_timeout(self): - """Called when connection times out.""" - self.close_overlay() - self.message_box( - "Timeout", - "Connection timed out.\n\n" - "Please initiate the connection from the web interface first,\n" - "then try again." - ) - - def _on_connect_success(self, tool, version, username, hostname): - """Called when connection succeeds.""" - self.close_overlay() - - def on_ok(): - if tool and version: - self._do_publish(tool, version) - - self.message_box( - "Connected!", - f"Successfully connected!\n\n" - f"Device: {hostname}\n" - f"Account: @{username}\n\n" - "You can now publish tools.", - callback=on_ok - ) - - def _do_publish(self, tool, version): - """Perform the actual publish.""" - from ..config import load_config, set_registry_token - - config = load_config() - if not config.registry.token: - self._prompt_for_token(tool, version) - return - - def do_publish(): - try: - client = RegistryClient() - tool_dir = get_tools_dir() / tool.name - config_yaml = (tool_dir / "config.yaml").read_text() - readme_path = tool_dir / "README.md" - readme = readme_path.read_text() if readme_path.exists() else "" - - result = client.publish_tool(config_yaml, readme) - - status = result.get("status", "") - pr_url = result.get("pr_url", "") - - if status == "published" or result.get("version"): - owner = result.get("owner", "unknown") - name = result.get("name", tool.name) - self.message_box("Success", f"Published {owner}/{name}@{version}") - elif pr_url: - self.message_box("Pending Review", f"PR created: {pr_url}\n\nYour tool is pending review.") - else: - self.message_box("Success", "Tool published successfully!") - - except RegistryError as e: - if e.code == "UNAUTHORIZED": - self.message_box("Error", "Authentication failed.\nYour token may have expired.") - elif e.code == "VERSION_EXISTS": - self.message_box("Error", f"Version {version} already exists.\nBump the version and try again.") - else: - self.message_box("Error", f"Publish failed: {e.message}") - except Exception as e: - self.message_box("Error", f"Publish failed: {e}") - - self.yes_no( - "Publish Tool", - f"Publish {tool.name}@{version} to registry?", - on_yes=do_publish - ) - - def exit_app(self): - """Exit the application.""" - raise urwid.ExitMainLoop() - - - # ==================== Tool Builder ==================== - - def create_tool(self): - """Create a new tool.""" - self.tool_builder(None) - - def tool_builder(self, existing: Optional[Tool]): - """Main tool builder interface.""" - 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}") - - # Store references for callbacks - self._current_tool = tool - self._is_edit = is_edit - self._selected_arg_idx = None - self._selected_step_idx = None - - self._show_tool_builder() - - def _save_tool_fields(self): - """Save current edit field values to the tool object.""" - if not hasattr(self, '_name_edit') or not hasattr(self, '_current_tool'): - return - - tool = self._current_tool - - # Save name (only if it's an edit widget, not a text label) - if not self._is_edit and hasattr(self._name_edit, 'base_widget'): - name_edit = self._name_edit.base_widget if hasattr(self._name_edit, 'base_widget') else self._name_edit - if hasattr(name_edit, 'edit_text'): - tool.name = name_edit.edit_text.strip() - - # Save description - if hasattr(self, '_desc_edit') and hasattr(self._desc_edit, 'base_widget'): - tool.description = self._desc_edit.base_widget.edit_text.strip() - - # Save output - if hasattr(self, '_output_edit') and hasattr(self._output_edit, 'base_widget'): - tool.output = self._output_edit.base_widget.edit_text.strip() - - def _show_tool_builder(self): - """Render the tool builder screen.""" - tool = self._current_tool - - # Create edit widgets - if self._is_edit: - name_widget = urwid.Text(('label', f"Name: {tool.name}")) - else: - name_widget = urwid.AttrMap(urwid.Edit(('label', "Name: "), tool.name), 'edit', 'edit_focus') - self._name_edit = name_widget - - self._desc_edit = urwid.AttrMap(urwid.Edit(('label', "Desc: "), tool.description), 'edit', 'edit_focus') - self._output_edit = urwid.AttrMap(urwid.Edit(('label', "Output: "), tool.output), 'edit', 'edit_focus') - - # Category selector - self._selected_category = [tool.category or "Other"] - category_btn_text = urwid.Text(self._selected_category[0]) - category_btn = urwid.AttrMap( - urwid.Padding(category_btn_text, left=1, right=1), - 'edit', 'edit_focus' - ) - - def show_category_dropdown(_): - """Show category selection popup.""" - def select_category(cat): - def callback(_): - self._selected_category[0] = cat - category_btn_text.set_text(cat) - tool.category = cat - self.close_overlay() - return callback - - items = [] - for cat in DEFAULT_CATEGORIES: - btn = urwid.Button(cat, on_press=select_category(cat)) - items.append(urwid.AttrMap(btn, 'button', 'button_focus')) - - listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) - popup = Dialog("Select Category", listbox, []) - self.show_overlay(popup, width=30, height=len(DEFAULT_CATEGORIES) + 4) - - category_select_btn = Button3DCompact("▼", on_press=show_category_dropdown) - - category_row = urwid.Columns([ - ('pack', urwid.Text(('label', "Category: "))), - ('weight', 1, category_btn), - ('pack', urwid.Text(" ")), - ('pack', category_select_btn), - ]) - - # Left column - fields - left_pile = urwid.Pile([ - ('pack', name_widget), - ('pack', urwid.Divider()), - ('pack', self._desc_edit), - ('pack', urwid.Divider()), - ('pack', category_row), - ('pack', urwid.Divider()), - ('pack', self._output_edit), - ]) - left_box = urwid.LineBox(urwid.Filler(left_pile, valign='top'), title='Tool Info') - - # Arguments list - arg_items = [] - for i, arg in enumerate(tool.arguments): - text = f"{arg.flag} -> {{{arg.variable}}}" - item = SelectableToolItem(text, on_select=lambda n, idx=i: self._on_arg_activate(idx)) - item.name = i # Store index - arg_items.append(item) - if not arg_items: - arg_items.append(urwid.Text(('label', " (none) "))) - - self._arg_walker = urwid.SimpleFocusListWalker(arg_items) - args_listbox = ToolListBox(self._arg_walker, on_focus_change=self._on_arg_focus) - args_box = urwid.LineBox(args_listbox, title='Arguments') - - # Argument buttons - arg_add_btn = ClickableButton("Add", lambda _: self._add_argument_dialog()) - arg_edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_arg()) - arg_del_btn = ClickableButton("Delete", lambda _: self._delete_selected_arg()) - arg_buttons = urwid.Columns([ - ('pack', arg_add_btn), - ('pack', urwid.Text(" ")), - ('pack', arg_edit_btn), - ('pack', urwid.Text(" ")), - ('pack', arg_del_btn), - ]) - arg_buttons_padded = urwid.Padding(arg_buttons, align='left', left=1) - - # Args section (list + buttons) - args_section = urwid.Pile([ - ('weight', 1, args_box), - ('pack', arg_buttons_padded), - ]) - - # Steps list - step_items = [] - for i, step in enumerate(tool.steps): - if isinstance(step, PromptStep): - text = f"P:{step.provider} -> {{{step.output_var}}}" - else: - text = f"C: -> {{{step.output_var}}}" - item = SelectableToolItem(text, on_select=lambda n, idx=i: self._on_step_activate(idx)) - item.name = i # Store index - step_items.append(item) - if not step_items: - step_items.append(urwid.Text(('label', " (none) "))) - - self._step_walker = urwid.SimpleFocusListWalker(step_items) - steps_listbox = ToolListBox(self._step_walker, on_focus_change=self._on_step_focus) - steps_box = urwid.LineBox(steps_listbox, title='Execution Steps') - - # Step buttons - step_add_btn = ClickableButton("Add", lambda _: self._add_step_choice()) - step_edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_step()) - step_del_btn = ClickableButton("Delete", lambda _: self._delete_selected_step()) - step_buttons = urwid.Columns([ - ('pack', step_add_btn), - ('pack', urwid.Text(" ")), - ('pack', step_edit_btn), - ('pack', urwid.Text(" ")), - ('pack', step_del_btn), - ]) - step_buttons_padded = urwid.Padding(step_buttons, align='left', left=1) - - # Steps section (list + buttons) - steps_section = urwid.Pile([ - ('weight', 1, steps_box), - ('pack', step_buttons_padded), - ]) - - # Save/Cancel buttons - save_btn = ClickableButton("Save", self._on_save_tool) - cancel_btn = ClickableButton("Cancel", self._on_cancel_tool) - bottom_buttons = urwid.Columns([ - ('pack', save_btn), - ('pack', urwid.Text(" ")), - ('pack', cancel_btn), - ], dividechars=1) - bottom_buttons_centered = urwid.Padding(bottom_buttons, align='center', width='pack') - - # Use ToolBuilderLayout for proper Tab cycling - # Pass LineBoxes for title highlighting and on_cancel for Escape key - body = ToolBuilderLayout( - left_box, args_box, steps_box, - args_section, steps_section, bottom_buttons_centered, - on_cancel=self._on_cancel_tool - ) - - # Frame - title = f"Edit Tool: {tool.name}" if self._is_edit and tool.name else "New Tool" - header = urwid.Text(('header', f' {title} '), align='center') - footer = urwid.Text(('footer', ' Arrow:Navigate | Tab:Next section | Enter/Click:Select | Esc:Cancel '), align='center') - - frame = urwid.Frame(body, header=header, footer=footer) - self.set_main(frame) - - # Set initial selection - if tool.arguments: - self._selected_arg_idx = 0 - self._on_arg_focus(0) - if tool.steps: - self._selected_step_idx = 0 - self._on_step_focus(0) - - def _on_arg_focus(self, idx): - """Called when an argument is focused.""" - if isinstance(idx, int): - self._selected_arg_idx = idx - # Update selection display - if hasattr(self, '_arg_walker'): - for i, item in enumerate(self._arg_walker): - if isinstance(item, SelectableToolItem): - item.set_selected(i == idx) - - def _on_arg_activate(self, idx): - """Called when an argument is activated (Enter/click).""" - self._selected_arg_idx = idx - self._edit_argument_at(idx) - - def _on_step_focus(self, idx): - """Called when a step is focused.""" - if isinstance(idx, int): - self._selected_step_idx = idx - # Update selection display - if hasattr(self, '_step_walker'): - for i, item in enumerate(self._step_walker): - if isinstance(item, SelectableToolItem): - item.set_selected(i == idx) - - def _on_step_activate(self, idx): - """Called when a step is activated (Enter/click).""" - self._selected_step_idx = idx - self._edit_step_at(idx) - - def _edit_selected_arg(self): - """Edit the currently selected argument.""" - if self._selected_arg_idx is not None and self._selected_arg_idx < len(self._current_tool.arguments): - self._edit_argument_at(self._selected_arg_idx) - else: - self.message_box("Edit", "No argument selected.") - - def _delete_selected_arg(self): - """Delete the currently selected argument.""" - if self._selected_arg_idx is not None and self._selected_arg_idx < len(self._current_tool.arguments): - idx = self._selected_arg_idx - arg = self._current_tool.arguments[idx] - def do_delete(): - self._save_tool_fields() - self._current_tool.arguments.pop(idx) - self._selected_arg_idx = None - self._show_tool_builder() - self.yes_no("Delete", f"Delete argument {arg.flag}?", on_yes=do_delete) - else: - self.message_box("Delete", "No argument selected.") - - def _edit_selected_step(self): - """Edit the currently selected step.""" - if self._selected_step_idx is not None and self._selected_step_idx < len(self._current_tool.steps): - self._edit_step_at(self._selected_step_idx) - else: - self.message_box("Edit", "No step selected.") - - def _delete_selected_step(self): - """Delete the currently selected step.""" - if self._selected_step_idx is not None and self._selected_step_idx < len(self._current_tool.steps): - idx = self._selected_step_idx - def do_delete(): - self._save_tool_fields() - self._current_tool.steps.pop(idx) - self._selected_step_idx = None - self._show_tool_builder() - self.yes_no("Delete", f"Delete step {idx + 1}?", on_yes=do_delete) - else: - self.message_box("Delete", "No step selected.") - - def _edit_argument_at(self, idx): - """Edit argument at index.""" - self._do_edit_argument(idx) - - def _edit_step_at(self, idx): - """Edit step at index - opens the appropriate dialog based on step type.""" - # Save current field values before showing dialog - self._save_tool_fields() - - step = self._current_tool.steps[idx] - if isinstance(step, PromptStep): - self._add_prompt_dialog(step, idx) - else: - self._add_code_dialog(step, idx) - - def _add_argument_dialog(self): - """Show add argument dialog.""" - # Save current field values before showing dialog - self._save_tool_fields() - - flag_edit = urwid.Edit(('label', "Flag: "), "--") - var_edit = urwid.Edit(('label', "Variable: "), "") - default_edit = urwid.Edit(('label', "Default: "), "") - - def on_ok(_): - flag = flag_edit.edit_text.strip() - var = var_edit.edit_text.strip() - default = default_edit.edit_text.strip() or None - - if not flag: - return - if not var: - var = flag.lstrip("-").replace("-", "_") - - self._current_tool.arguments.append(ToolArgument( - flag=flag, variable=var, default=default, description="" - )) - self.close_overlay() - self._show_tool_builder() - - def on_cancel(_): - self.close_overlay() - - body = urwid.Pile([ - urwid.AttrMap(flag_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(var_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(default_edit, 'edit', 'edit_focus'), - ]) - - dialog = Dialog("Add Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=50, height=14) - - def _do_edit_argument(self, idx): - """Edit an existing argument.""" - # Save current field values before showing dialog - self._save_tool_fields() - - arg = self._current_tool.arguments[idx] - - flag_edit = urwid.Edit(('label', "Flag: "), arg.flag) - var_edit = urwid.Edit(('label', "Variable: "), arg.variable) - default_edit = urwid.Edit(('label', "Default: "), arg.default or "") - - def on_ok(_): - arg.flag = flag_edit.edit_text.strip() - arg.variable = var_edit.edit_text.strip() - arg.default = default_edit.edit_text.strip() or None - self.close_overlay() - self._show_tool_builder() - - def on_cancel(_): - self.close_overlay() - - body = urwid.Pile([ - urwid.AttrMap(flag_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(var_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(default_edit, 'edit', 'edit_focus'), - ]) - - dialog = Dialog("Edit Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=50, height=14) - - def _add_step_choice(self): - """Choose step type to add.""" - # Save current field values before showing dialog - self._save_tool_fields() - - def on_prompt(_): - self.close_overlay() - # Defer dialog opening to avoid overlay rendering issues - if self.loop: - self.loop.set_alarm_in(0, lambda loop, data: self._add_prompt_dialog()) - else: - self._add_prompt_dialog() - - def on_code(_): - self.close_overlay() - # Defer dialog opening to avoid overlay rendering issues - if self.loop: - self.loop.set_alarm_in(0, lambda loop, data: self._add_code_dialog()) - else: - self._add_code_dialog() - - def on_cancel(_): - self.close_overlay() - - body = urwid.Text("Choose step type:") - dialog = Dialog("Add Step", body, [("Prompt", on_prompt), ("Code", on_code), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=45, height=9) - - def _get_available_vars(self, up_to=-1): - """Get available variables.""" - tool = self._current_tool - variables = ["input"] - for arg in tool.arguments: - variables.append(arg.variable) - if up_to == -1: - up_to = len(tool.steps) - for i, step in enumerate(tool.steps): - if i >= up_to: - break - variables.append(step.output_var) - return variables - - def _add_prompt_dialog(self, existing=None, idx=-1): - """Add/edit prompt step with provider dropdown and multiline prompt.""" - providers = load_providers() - provider_names = [p.name for p in providers] - if not provider_names: - provider_names = ["mock"] - current_provider = existing.provider if existing else provider_names[0] - - # Provider selector state - selected_provider = [current_provider] # Use list to allow mutation in closures - - # Provider dropdown button - provider_btn_text = urwid.Text(current_provider) - provider_btn = urwid.AttrMap( - urwid.Padding(provider_btn_text, left=1, right=1), - 'edit', 'edit_focus' - ) - - def show_provider_dropdown(_): - """Show provider selection popup with descriptions.""" - # Build provider lookup for descriptions - provider_lookup = {p.name: p.description for p in providers} - - # Description display (updates on focus change) - desc_text = urwid.Text("") - desc_box = urwid.AttrMap( - urwid.Padding(desc_text, left=1, right=1), - 'label' - ) - - def update_description(name): - """Update the description text for the focused provider.""" - desc = provider_lookup.get(name, "") - desc_text.set_text(('label', desc if desc else "No description")) - - def select_provider(name): - def callback(_): - selected_provider[0] = name - provider_btn_text.set_text(name) - self.close_overlay() - return callback - - # Create focusable buttons that update description on focus - class DescriptiveButton(urwid.Button): - def __init__(self, name, desc_callback): - super().__init__(name, on_press=select_provider(name)) - self._name = name - self._desc_callback = desc_callback - - def render(self, size, focus=False): - if focus: - self._desc_callback(self._name) - return super().render(size, focus) - - items = [] - for name in provider_names: - # Show short hint inline: "name | short_desc" - short_desc = provider_lookup.get(name, "") - # Extract just the key info (after the timing) - if "|" in short_desc: - short_desc = short_desc.split("|", 1)[1].strip()[:20] - else: - short_desc = short_desc[:20] - - label = f"{name:<18} {short_desc}" - btn = DescriptiveButton(name, update_description) - btn.set_label(label) - items.append(urwid.AttrMap(btn, 'button', 'button_focus')) - - # Set initial description - update_description(provider_names[0]) - - listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) - - # Combine listbox with description footer - body = urwid.Pile([ - ('weight', 1, listbox), - ('pack', urwid.Divider('─')), - ('pack', desc_box), - ]) - - popup = Dialog("Select Provider", body, []) - self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) - - provider_select_btn = Button3DCompact("▼", on_press=show_provider_dropdown) - - # File input for external prompt - default_file = existing.prompt_file if existing and existing.prompt_file else "prompt.txt" - file_edit = urwid.Edit(('label', "File: "), default_file) - - # Multiline prompt editor - use TabPassEdit so Tab passes through for navigation - prompt_edit = TabPassEdit( - edit_text=existing.prompt if existing else "{input}", - multiline=True - ) - - output_edit = urwid.Edit(('label', "Output var: "), existing.output_var if existing else "response") - - vars_available = self._get_available_vars(idx) - vars_text = urwid.Text(('label', f"Variables: {', '.join('{'+v+'}' for v in vars_available)}")) - - status_text = urwid.Text("") - - def do_load(): - """Actually load prompt from file.""" - filename = file_edit.edit_text.strip() - tool_dir = get_tools_dir() / self._current_tool.name - prompt_path = tool_dir / filename - - try: - prompt_edit.set_edit_text(prompt_path.read_text()) - status_text.set_text(('success', f"Loaded from {filename}")) - except Exception as e: - status_text.set_text(('error', f"Load error: {e}")) - - def on_load(_): - """Load prompt from file with confirmation.""" - filename = file_edit.edit_text.strip() - if not filename: - status_text.set_text(('error', "Enter a filename first")) - return - - tool_dir = get_tools_dir() / self._current_tool.name - prompt_path = tool_dir / filename - - if not prompt_path.exists(): - status_text.set_text(('error', f"File not found: {filename}")) - return - - # Show confirmation dialog - def on_yes(_): - self.close_overlay() - do_load() - - def on_no(_): - self.close_overlay() - - confirm_body = urwid.Text(f"Load from '{filename}'?\nThis will replace the current prompt.") - confirm_dialog = Dialog("Confirm Load", confirm_body, [("Yes", on_yes), ("No", on_no)]) - self.show_overlay(confirm_dialog, width=50, height=8) - - def on_ok(_): - provider = selected_provider[0] - prompt = prompt_edit.edit_text.strip() - output_var = output_edit.edit_text.strip() or "response" - prompt_file = file_edit.edit_text.strip() or None - - # Auto-save to file if filename is set - if prompt_file: - tool_dir = get_tools_dir() / self._current_tool.name - tool_dir.mkdir(parents=True, exist_ok=True) - prompt_path = tool_dir / prompt_file - try: - prompt_path.write_text(prompt) - except Exception as e: - status_text.set_text(('error', f"Save error: {e}")) - return - - step = PromptStep(prompt=prompt, provider=provider, output_var=output_var, prompt_file=prompt_file) - - if existing and idx >= 0: - self._current_tool.steps[idx] = step - else: - self._current_tool.steps.append(step) - - self.close_overlay() - self._show_tool_builder() - - def on_cancel(_): - self.close_overlay() - - def on_external_edit(_): - """Open prompt in external editor ($EDITOR).""" - import os - import subprocess - import tempfile - - current_prompt = prompt_edit.edit_text - - # Stop the urwid loop temporarily - if self.loop: - self.loop.stop() - - try: - # Create temp file with current prompt - with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: - f.write(current_prompt) - temp_path = f.name - - # Get editor from environment - editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'nano')) - - # Run editor - subprocess.run([editor, temp_path], check=True) - - # Read back the edited prompt - with open(temp_path, 'r') as f: - new_prompt = f.read() - - # Update the prompt editor - prompt_edit.set_edit_text(new_prompt) - status_text.set_text(('success', f"Prompt updated from {editor}")) - - # Clean up temp file - os.unlink(temp_path) - - except subprocess.CalledProcessError: - status_text.set_text(('error', "Editor exited with error")) - except FileNotFoundError: - status_text.set_text(('error', f"Editor '{editor}' not found")) - except Exception as e: - status_text.set_text(('error', f"Edit error: {e}")) - finally: - # Restart the urwid loop - if self.loop: - self.loop.start() - - load_btn = Button3DCompact("Load", on_load) - edit_btn = Button3DCompact("$EDITOR", on_external_edit) - - # Prompt editor in a box - use ListBox for proper focus handling and scrolling - # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons - prompt_edit_styled = urwid.AttrMap(prompt_edit, 'edit', 'edit_focus') - prompt_walker = urwid.SimpleFocusListWalker([prompt_edit_styled]) - prompt_listbox = urwid.ListBox(prompt_walker) - prompt_scrollbar = DOSScrollBar(prompt_listbox) - prompt_box = urwid.LineBox(prompt_scrollbar, title="Prompt") - - # Use TabCyclePile so Tab cycles between sections - # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when - # the Pile contains weighted items (urwid 3.x requirement) - body = TabCyclePile([ - ('pack', vars_text), - ('pack', urwid.Divider()), - ('pack', urwid.Columns([ - ('pack', urwid.Text(('label', "Provider: "))), - ('weight', 1, provider_btn), - ('pack', urwid.Text(" ")), - ('pack', provider_select_btn), - ])), - ('pack', urwid.Divider()), - ('pack', urwid.Columns([ - ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), - ('pack', urwid.Text(" ")), - ('pack', load_btn), - ('pack', urwid.Text(" ")), - ('pack', edit_btn), - ])), - ('pack', status_text), - ('weight', 1, prompt_box), - ('pack', urwid.Divider()), - ('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), - ], tab_positions=[2, 4, 6, 8]) - - title = "Edit Prompt Step" if existing else "Add Prompt Step" - dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=75, height=22) - - def _add_code_dialog(self, existing=None, idx=-1): - """Add/edit code step with multiline editor, file support, and AI auto-adjust.""" - from ..providers import call_provider - - # File name input (default based on output_var) - default_output_var = existing.output_var if existing else "processed" - default_file = existing.code_file if existing and existing.code_file else f"{default_output_var}.py" - file_edit = urwid.Edit(('label', "File: "), default_file) - - # Multiline code editor with undo/redo (Alt+U / Alt+R) - default_code = existing.code if existing else f"{default_output_var} = input.upper()" - code_edit = UndoableEdit( - edit_text=default_code, - multiline=True - ) - - output_edit = urwid.Edit(('label', "Output var: "), existing.output_var if existing else "processed") - - vars_available = self._get_available_vars(idx) - vars_text = urwid.Text(('label', f"Variables: {', '.join(vars_available)}")) - - status_text = urwid.Text("") - - # --- Auto-adjust AI feature --- - providers = load_providers() - provider_names = [p.name for p in providers] - if not provider_names: - provider_names = ["mock"] - selected_ai_provider = [provider_names[0]] - - ai_provider_btn_text = urwid.Text(provider_names[0]) - ai_provider_btn = urwid.AttrMap( - urwid.Padding(ai_provider_btn_text, left=1, right=1), - 'edit', 'edit_focus' - ) - - def show_ai_provider_dropdown(_): - provider_lookup = {p.name: p.description for p in providers} - desc_text = urwid.Text("") - desc_box = urwid.AttrMap(urwid.Padding(desc_text, left=1, right=1), 'label') - - def update_description(name): - desc = provider_lookup.get(name, "") - desc_text.set_text(('label', desc if desc else "No description")) - - def select_provider(name): - def callback(_): - selected_ai_provider[0] = name - ai_provider_btn_text.set_text(name) - self.close_overlay() - return callback - - class DescriptiveButton(urwid.Button): - def __init__(btn_self, name, desc_callback): - super().__init__(name, on_press=select_provider(name)) - btn_self._name = name - btn_self._desc_callback = desc_callback - - def render(btn_self, size, focus=False): - if focus: - btn_self._desc_callback(btn_self._name) - return super().render(size, focus) - - items = [] - for name in provider_names: - short_desc = provider_lookup.get(name, "") - if "|" in short_desc: - short_desc = short_desc.split("|", 1)[1].strip()[:20] - else: - short_desc = short_desc[:20] - label = f"{name:<18} {short_desc}" - btn = DescriptiveButton(name, update_description) - btn.set_label(label) - items.append(urwid.AttrMap(btn, 'button', 'button_focus')) - - update_description(provider_names[0]) - listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) - popup_body = urwid.Pile([ - ('weight', 1, listbox), - ('pack', urwid.Divider('─')), - ('pack', desc_box), - ]) - popup = Dialog("Select Provider", popup_body, []) - self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) - - ai_provider_select_btn = Button3DCompact("▼", on_press=show_ai_provider_dropdown) - - # Default prompt template for AI code generation/adjustment - # Show variables in triple-quote format so the AI follows the pattern - vars_formatted = ', '.join(f'\"\"\"{{{v}}}\"\"\"' for v in vars_available) - default_ai_prompt = f"""Write inline Python code (NOT a function definition) according to my instruction. - -The code runs directly with variable substitution. Assign any "Available Variables" used to a new standard variable first, then use that variable in the code. Use triple quotes and curly braces since the substituted content may contain quotes/newlines. - -Example: -my_var = \"\"\"{{variable}}\"\"\" - -INSTRUCTION: [Describe what you want] - -CURRENT CODE: -```python -{{code}} -``` - -AVAILABLE VARIABLES: {vars_formatted} - -IMPORTANT: Return ONLY executable inline code. Do NOT wrap in a function. -No explanations, no markdown fencing, just the code.""" - - # Multiline editable prompt for AI with DOS-style scrollbar - ai_prompt_edit = TabPassEdit(edit_text=default_ai_prompt, multiline=True) - ai_prompt_styled = urwid.AttrMap(ai_prompt_edit, 'edit', 'edit_focus') - ai_prompt_walker = urwid.SimpleFocusListWalker([ai_prompt_styled]) - ai_prompt_listbox = urwid.ListBox(ai_prompt_walker) - ai_prompt_scrollbar = DOSScrollBar(ai_prompt_listbox) - ai_prompt_box = urwid.LineBox(ai_prompt_scrollbar, title="Prompt") - - # Output/feedback area for AI responses - ai_output_text = urwid.Text("") - ai_output_walker = urwid.SimpleFocusListWalker([ai_output_text]) - ai_output_listbox = urwid.ListBox(ai_output_walker) - ai_output_box = urwid.LineBox(ai_output_listbox, title="Output & Feedback") - - def on_auto_adjust(_): - prompt_template = ai_prompt_edit.edit_text.strip() - if not prompt_template: - ai_output_text.set_text(('error', "Enter a prompt for the AI")) - return - - current_code = code_edit.edit_text.strip() - - # Replace {code} placeholder with actual code - prompt = prompt_template.replace("{code}", current_code) - - provider_name = selected_ai_provider[0] - ai_output_text.set_text(('label', f"Calling {provider_name}...\nPlease wait...")) - self.refresh() - - result = call_provider(provider_name, prompt) - - if result.success: - new_code = result.text.strip() - # Strip markdown code fences if present - if new_code.startswith("```python"): - new_code = new_code[9:] - if new_code.startswith("```"): - new_code = new_code[3:] - if new_code.endswith("```"): - new_code = new_code[:-3] - new_code = new_code.strip() - - code_edit.set_edit_text(new_code) - ai_output_text.set_text(('success', f"✓ Code updated successfully!\n\nProvider: {provider_name}\nResponse length: {len(result.text)} chars")) - else: - error_msg = result.error or "Unknown error" - ai_output_text.set_text(('error', f"✗ Error from {provider_name}:\n\n{error_msg}")) - - auto_adjust_btn = Button3DCompact("Auto-adjust", on_auto_adjust) - - # Build the AI assist box with provider selector, prompt editor, output area, and button - ai_provider_row = urwid.Columns([ - ('pack', urwid.Text(('label', "Provider: "))), - ('pack', ai_provider_btn), - ('pack', ai_provider_select_btn), - ]) - - ai_assist_content = urwid.Pile([ - ('pack', ai_provider_row), - ('pack', urwid.Divider()), - ('weight', 2, ai_prompt_box), - ('weight', 1, ai_output_box), - ('pack', urwid.Padding(auto_adjust_btn, align='center', width=16)), - ]) - ai_assist_box = urwid.LineBox(ai_assist_content, title="AI Assisted Auto-adjust") - # --- End Auto-adjust feature --- - - def do_load(): - """Actually load code from file.""" - filename = file_edit.edit_text.strip() - tool_dir = get_tools_dir() / self._current_tool.name - code_path = tool_dir / filename - - try: - code_edit.set_edit_text(code_path.read_text()) - status_text.set_text(('success', f"Loaded from {filename}")) - except Exception as e: - status_text.set_text(('error', f"Load error: {e}")) - - def on_load(_): - """Load code from file with confirmation.""" - filename = file_edit.edit_text.strip() - if not filename: - status_text.set_text(('error', "Enter a filename first")) - return - - tool_dir = get_tools_dir() / self._current_tool.name - code_path = tool_dir / filename - - if not code_path.exists(): - status_text.set_text(('error', f"File not found: {filename}")) - return - - def on_yes(_): - self.close_overlay() - do_load() - - def on_no(_): - self.close_overlay() - - confirm_body = urwid.Text(f"Load from '{filename}'?\nThis will replace the current code.") - confirm_dialog = Dialog("Confirm Load", confirm_body, [("Yes", on_yes), ("No", on_no)]) - self.show_overlay(confirm_dialog, width=50, height=8) - - def on_ok(_): - import ast - - code = code_edit.edit_text.strip() - output_var = output_edit.edit_text.strip() or "processed" - code_file = file_edit.edit_text.strip() or None - - if code: - try: - ast.parse(code) - except SyntaxError as e: - line_info = f" (line {e.lineno})" if e.lineno else "" - status_text.set_text(('error', f"Syntax error{line_info}: {e.msg}")) - return - - if code_file: - tool_dir = get_tools_dir() / self._current_tool.name - tool_dir.mkdir(parents=True, exist_ok=True) - code_path = tool_dir / code_file - try: - code_path.write_text(code) - except Exception as e: - status_text.set_text(('error', f"Save error: {e}")) - return - - from ..tool import CodeStep - step = CodeStep(code=code, output_var=output_var, code_file=code_file) - - if existing and idx >= 0: - self._current_tool.steps[idx] = step - else: - self._current_tool.steps.append(step) - - self.close_overlay() - self._show_tool_builder() - - def on_cancel(_): - self.close_overlay() - - def on_external_edit(_): - """Open code in external editor ($EDITOR).""" - import os - import subprocess - import tempfile - - current_code = code_edit.edit_text - - # Stop the urwid loop temporarily - if self.loop: - self.loop.stop() - - try: - # Create temp file with current code - with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: - f.write(current_code) - temp_path = f.name - - # Get editor from environment - editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'nano')) - - # Run editor - subprocess.run([editor, temp_path], check=True) - - # Read back the edited code - with open(temp_path, 'r') as f: - new_code = f.read() - - # Update the code editor - code_edit.set_edit_text(new_code) - status_text.set_text(('success', f"Code updated from {editor}")) - - # Clean up temp file - os.unlink(temp_path) - - except subprocess.CalledProcessError: - status_text.set_text(('error', "Editor exited with error")) - except FileNotFoundError: - status_text.set_text(('error', f"Editor '{editor}' not found")) - except Exception as e: - status_text.set_text(('error', f"Edit error: {e}")) - finally: - # Restart the urwid loop - if self.loop: - self.loop.start() - - load_btn = Button3DCompact("Load", on_load) - edit_btn = Button3DCompact("$EDITOR", on_external_edit) - - # Code editor in a box - use ListBox for proper focus handling and scrolling - # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons - code_edit_styled = urwid.AttrMap(code_edit, 'edit', 'edit_focus') - code_walker = urwid.SimpleFocusListWalker([code_edit_styled]) - code_listbox = urwid.ListBox(code_walker) - code_scrollbar = DOSScrollBar(code_listbox) - code_box = urwid.LineBox(code_scrollbar, title="Code") - - # Layout: Code editor on left, AI assist box on right - main_columns = urwid.Columns([ - ('weight', 1, code_box), - ('weight', 1, ai_assist_box), - ], dividechars=1) - - # Use TabCyclePile so Tab cycles between sections - # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when - # the Pile contains weighted items (urwid 3.x requirement) - body = TabCyclePile([ - ('pack', vars_text), - ('pack', urwid.Divider()), - ('pack', urwid.Columns([ - ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), - ('pack', urwid.Text(" ")), - ('pack', load_btn), - ('pack', urwid.Text(" ")), - ('pack', edit_btn), - ])), - ('pack', status_text), - ('weight', 1, main_columns), - ('pack', urwid.Divider()), - ('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), - ], tab_positions=[2, 4, 6]) - - title = "Edit Code Step" if existing else "Add Code Step" - dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=90, height=30) - - def _on_save_tool(self, _): - """Save the tool.""" - tool = self._current_tool - - # Update from edits - widgets are wrapped in AttrMap, access base_widget - if not self._is_edit: - # Name edit is an AttrMap wrapping an Edit - name_edit = self._name_edit.base_widget if hasattr(self._name_edit, 'base_widget') else self._name_edit - if hasattr(name_edit, 'edit_text'): - tool.name = name_edit.edit_text.strip() - tool.description = self._desc_edit.base_widget.edit_text.strip() - tool.output = self._output_edit.base_widget.edit_text.strip() - - if not tool.name: - self.message_box("Error", "Tool name is required.") - return - - # Validate tool name - is_valid, error_msg = validate_tool_name(tool.name) - if not is_valid: - self.message_box("Error", error_msg) - return - - def do_save(): - save_tool(tool) - self._offer_sync_after_save(tool) - - if not self._is_edit and tool_exists(tool.name): - self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", on_yes=do_save) - else: - do_save() - - def _offer_sync_after_save(self, tool): - """After saving, offer to sync to registry if connected.""" - from ..config import load_config - import yaml - - config = load_config() - - # Not connected - just show success and go to main menu - if not config.registry.token: - self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) - return - - # Connected - offer to sync privately - tool_dir = get_tools_dir() / tool.name - config_path = tool_dir / "config.yaml" - - if not config_path.exists(): - self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) - return - - try: - config_data = yaml.safe_load(config_path.read_text()) - version = config_data.get("version", "") - except Exception: - version = "" - - if not version: - # No version - need to add one before syncing - def on_yes(): - self._prompt_for_version_and_sync(tool, config_path) - - def on_no(): - self.show_main_menu() - - self.yes_no( - "Sync to Registry?", - f"Tool '{tool.name}' saved locally.\n\n" - "Sync as a private tool to your account?\n" - "(Accessible only to you across devices)", - on_yes=on_yes, - on_no=on_no - ) - else: - # Already has version - offer sync - def on_yes(): - self._sync_tool_privately(tool, version) - - def on_no(): - self.show_main_menu() - - self.yes_no( - "Sync to Registry?", - f"Tool '{tool.name}' v{version} saved locally.\n\n" - "Sync as a private tool to your account?\n" - "(Accessible only to you across devices)", - on_yes=on_yes, - on_no=on_no - ) - - def _prompt_for_version_and_sync(self, tool, config_path): - """Prompt for version then sync privately.""" - import yaml - - def on_version(version): - version = version.strip() - if not version: - self.message_box("Cancelled", "Version is required for sync.", self.show_main_menu) - return - - # Update config with version - try: - config_data = yaml.safe_load(config_path.read_text()) or {} - config_data["version"] = version - config_path.write_text(yaml.dump(config_data, default_flow_style=False, sort_keys=False)) - except Exception as e: - self.message_box("Error", f"Failed to save version: {e}", self.show_main_menu) - return - - self._sync_tool_privately(tool, version) - - self.input_dialog( - "Version Required", - "Enter version for sync (e.g., 1.0.0)", - "1.0.0", - on_version - ) - - def _sync_tool_privately(self, tool, version): - """Sync tool to registry as private.""" - tool_dir = get_tools_dir() / tool.name - config_path = tool_dir / "config.yaml" - readme_path = tool_dir / "README.md" - - try: - config_yaml = config_path.read_text() - readme = readme_path.read_text() if readme_path.exists() else "" - - client = RegistryClient() - result = client.publish_tool(config_yaml, readme, visibility="private") - - owner = result.get("owner", "you") - self.message_box( - "Synced", - f"Tool synced privately!\n\n" - f"{owner}/{tool.name}@{version}\n\n" - "Only visible to you. Use 'Publish' to make public.", - self.show_main_menu - ) - - except RegistryError as e: - if e.code == "VERSION_EXISTS": - self.message_box( - "Already Synced", - f"Version {version} is already synced.\n" - "Bump the version to sync changes.", - self.show_main_menu - ) - else: - self.message_box("Sync Failed", f"Error: {e.message}", self.show_main_menu) - except Exception as e: - self.message_box("Sync Failed", f"Error: {e}", self.show_main_menu) - - def _on_cancel_tool(self, _): - """Cancel tool editing.""" - self.show_main_menu() - - def _test_tool(self, tool): - """Test a tool with mock input.""" - def on_input(text): - from ..runner import run_tool - output, code = run_tool( - tool=tool, - input_text=text, - custom_args={}, - provider_override="mock", - dry_run=False, - show_prompt=False, - verbose=False - ) - result = f"Exit code: {code}\n\nOutput:\n{output[:300]}" - self.message_box("Test Result", result) - - self.input_dialog("Test Input", "Enter test input", "Hello world", on_input) - - - # ==================== Registry Browser ==================== - - def browse_registry(self): - """Browse and install tools from the registry.""" - self._registry_search_query = "" - self._registry_tools = [] - self._selected_registry_tool = None - self._show_registry_browser() - - def _show_registry_browser(self, search_query: str = ""): - """Show the registry browser screen.""" - # Search input - search_edit = urwid.Edit(('label', "Search: "), search_query) - search_edit = urwid.AttrMap(search_edit, 'edit', 'edit_focus') - - # Status/loading text - status_text = urwid.Text(('label', "Loading...")) - - # Tool list (will be populated) - self._registry_walker = urwid.SimpleFocusListWalker([]) - tool_listbox = ToolListBox(self._registry_walker, on_focus_change=self._on_registry_tool_focus) - tool_box = urwid.LineBox(tool_listbox, title='Registry Tools') - - # Info panel - self._reg_info_name = urwid.Text("") - self._reg_info_desc = urwid.Text("") - self._reg_info_owner = urwid.Text("") - self._reg_info_version = urwid.Text("") - self._reg_info_downloads = urwid.Text("") - self._reg_info_tags = urwid.Text("") - - info_content = urwid.Pile([ - self._reg_info_name, - self._reg_info_owner, - self._reg_info_version, - urwid.Divider(), - self._reg_info_desc, - urwid.Divider(), - self._reg_info_downloads, - self._reg_info_tags, - ]) - info_filler = urwid.Filler(info_content, valign='top') - info_box = urwid.LineBox(info_filler, title='Tool Info') - - def do_search(_=None): - query = search_edit.base_widget.edit_text.strip() - self._registry_search_query = query - if query: - status_text.set_text(('label', f"Searching for '{query}'...")) - else: - status_text.set_text(('label', "Loading all tools...")) - self.refresh() - - try: - client = RegistryClient() - # Use list_tools for browsing, search_tools only when there's a query - if query: - result = client.search_tools(query=query, per_page=50) - else: - result = client.list_tools(per_page=50) - self._registry_tools = result.data - - # Update the list - self._registry_walker.clear() - if self._registry_tools: - for tool in self._registry_tools: - display = f"{tool['owner']}/{tool['name']}" - item = SelectableToolItem(display, on_select=lambda n: self._install_registry_tool()) - item.tool_data = tool - self._registry_walker.append(item) - - # Select first item - self._selected_registry_tool = self._registry_tools[0] - self._on_registry_tool_focus(f"{self._registry_tools[0]['owner']}/{self._registry_tools[0]['name']}") - status_text.set_text(('label', f"Found {len(self._registry_tools)} tools")) - else: - self._registry_walker.append(urwid.Text(('label', " (no results) "))) - status_text.set_text(('label', "No tools found")) - - except RegistryError as e: - status_text.set_text(('error', f"Error: {e}")) - self._registry_walker.clear() - self._registry_walker.append(urwid.Text(('error', f" Error: {e} "))) - except Exception as e: - status_text.set_text(('error', f"Error: {e}")) - self._registry_walker.clear() - self._registry_walker.append(urwid.Text(('error', f" Error: {e} "))) - - self.refresh() - - def on_install(_): - self._install_registry_tool() - - def on_close(_): - self.close_overlay() - self._refresh_main_menu() - - # Buttons - search_btn = ClickableButton("Search", do_search) - install_btn = ClickableButton("Install", on_install) - close_btn = ClickableButton("Close", on_close) - - buttons = urwid.Columns([ - ('pack', search_btn), - ('pack', urwid.Text(" ")), - ('pack', install_btn), - ('pack', urwid.Text(" ")), - ('pack', close_btn), - ]) - buttons_centered = urwid.Padding(buttons, align='center', width='pack') - - # Search row - search_row = urwid.Columns([ - ('weight', 1, search_edit), - ('pack', urwid.Text(" ")), - ('pack', search_btn), - ]) - - # Main layout with columns for list and info - list_and_info = urwid.Columns([ - ('weight', 1, tool_box), - ('weight', 1, info_box), - ]) - - body = urwid.Pile([ - ('pack', search_row), - ('pack', status_text), - ('pack', urwid.Divider()), - ('weight', 1, list_and_info), - ('pack', urwid.Divider()), - ('pack', buttons_centered), - ]) - - # Wrap in frame - header = urwid.Text(('header', ' Browse Registry '), align='center') - footer = urwid.Text(('footer', ' Enter: Install | Tab: Navigate | Esc: Close '), align='center') - frame = urwid.Frame(body, header=header, footer=footer) - frame = urwid.LineBox(frame) - frame = urwid.AttrMap(frame, 'dialog') - - self.show_overlay(frame, width=80, height=24) - - # Do initial search - do_search() - - def _on_registry_tool_focus(self, name): - """Called when a registry tool is focused.""" - # Find the tool data - for tool in self._registry_tools: - if f"{tool['owner']}/{tool['name']}" == name: - self._selected_registry_tool = tool - break - - if self._selected_registry_tool: - tool = self._selected_registry_tool - self._reg_info_name.set_text(('label', f"Name: {tool['name']}")) - self._reg_info_owner.set_text(f"Publisher: {tool['owner']}") - self._reg_info_version.set_text(f"Version: {tool.get('version', 'unknown')}") - self._reg_info_desc.set_text(f"Description: {tool.get('description', '(none)')}") - self._reg_info_downloads.set_text(f"Downloads: {tool.get('downloads', 0)}") - - tags = tool.get('tags', []) - if tags: - self._reg_info_tags.set_text(f"Tags: {', '.join(tags)}") - else: - self._reg_info_tags.set_text("Tags: (none)") - - # Update selection state - if hasattr(self, '_registry_walker'): - for item in self._registry_walker: - if isinstance(item, SelectableToolItem): - item.set_selected(item.name == name) - - def _install_registry_tool(self): - """Install the selected registry tool.""" - if not self._selected_registry_tool: - self.message_box("Install", "No tool selected.") - return - - tool = self._selected_registry_tool - tool_name = f"{tool['owner']}/{tool['name']}" - - def do_install(): - try: - client = RegistryClient() - client.install_tool(tool_name) - self.message_box("Success", f"Installed {tool_name}\n\nRun 'cmdforge refresh' to create wrapper script.") - except RegistryError as e: - self.message_box("Error", f"Failed to install: {e}") - except Exception as e: - self.message_box("Error", f"Failed to install: {e}") - - self.yes_no( - "Install Tool", - f"Install {tool_name}?", - on_yes=do_install - ) - - # ==================== Provider Management ==================== - - def manage_providers(self): - """Manage providers.""" - self._show_providers_menu() - - def _show_providers_menu(self): - """Show providers management menu.""" - providers = load_providers() - self._selected_provider_name = None - self._provider_walker = None - - def on_provider_focus(name): - """Called when a provider is focused.""" - self._selected_provider_name = name - # Update selection state on all items - if self._provider_walker: - for item in self._provider_walker: - if isinstance(item, SelectableToolItem): - item.set_selected(item.name == name) - - def on_provider_activate(name): - """Called when Enter is pressed on a provider.""" - self.close_overlay() - self._edit_provider_menu(name) - - def on_add(_): - self.close_overlay() - self._add_provider_dialog() - - def on_edit(_): - if self._selected_provider_name: - self.close_overlay() - self._edit_provider_menu(self._selected_provider_name) - else: - self.message_box("Edit", "No provider selected.") - - def on_cancel(_): - self.close_overlay() - - # Build provider list - items = [] - for p in providers: - item = SelectableToolItem(f"{p.name}: {p.command}", on_select=on_provider_activate) - item.name = p.name # Store the actual provider name - items.append(item) - - if not items: - items.append(urwid.Text(('label', " (no providers) "))) - - self._provider_walker = urwid.SimpleFocusListWalker(items) - listbox = ToolListBox(self._provider_walker, on_focus_change=on_provider_focus) - listbox_box = urwid.LineBox(listbox, title='Providers') - - # Buttons row - add_btn = ClickableButton("Add", on_add) - edit_btn = ClickableButton("Edit", on_edit) - cancel_btn = ClickableButton("Cancel", on_cancel) - buttons = urwid.Columns([ - ('pack', add_btn), - ('pack', urwid.Text(" ")), - ('pack', edit_btn), - ('pack', urwid.Text(" ")), - ('pack', cancel_btn), - ]) - buttons_centered = urwid.Padding(buttons, align='center', width='pack') - - # Layout - body = urwid.Pile([ - ('weight', 1, listbox_box), - ('pack', urwid.Divider()), - ('pack', buttons_centered), - ]) - - # Wrap in a frame with title - header = urwid.Text(('header', ' Manage Providers '), align='center') - frame = urwid.Frame(body, header=header) - frame = urwid.LineBox(frame) - frame = urwid.AttrMap(frame, 'dialog') - - height = min(len(providers) + 10, 18) - self.show_overlay(frame, width=55, height=height) - - # Set initial selection - if providers: - self._selected_provider_name = providers[0].name - on_provider_focus(providers[0].name) - - def _add_provider_dialog(self): - """Add a new provider.""" - name_edit = urwid.Edit(('label', "Name: "), "") - cmd_edit = urwid.Edit(('label', "Command: "), "") - desc_edit = urwid.Edit(('label', "Description: "), "") - - def on_ok(_): - name = name_edit.edit_text.strip() - cmd = cmd_edit.edit_text.strip() - desc = desc_edit.edit_text.strip() - - if name and cmd: - add_provider(Provider(name=name, command=cmd, description=desc)) - self.close_overlay() - self.message_box("Success", f"Provider '{name}' added.", self._show_providers_menu) - else: - self.message_box("Error", "Name and command are required.") - - def on_cancel(_): - self.close_overlay() - self._show_providers_menu() - - body = urwid.Pile([ - urwid.AttrMap(name_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(cmd_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(desc_edit, 'edit', 'edit_focus'), - ]) - - dialog = Dialog("Add Provider", body, [("OK", on_ok), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=55, height=14) - - def _edit_provider_menu(self, name): - """Edit a provider.""" - provider = get_provider(name) - if not provider: - return - - name_edit = urwid.Edit(('label', "Name: "), provider.name) - cmd_edit = urwid.Edit(('label', "Command: "), provider.command) - desc_edit = urwid.Edit(('label', "Description: "), provider.description or "") - - def on_save(_): - new_name = name_edit.edit_text.strip() - cmd = cmd_edit.edit_text.strip() - desc = desc_edit.edit_text.strip() - - if new_name and cmd: - # Delete old provider if name changed - if new_name != name: - delete_provider(name) - # Save with new/same name - add_provider(Provider(name=new_name, command=cmd, description=desc)) - self.close_overlay() - self.message_box("Success", f"Provider '{new_name}' saved.", self._show_providers_menu) - else: - self.message_box("Error", "Name and command are required.") - - def on_delete(_): - self.close_overlay() - def do_delete(): - delete_provider(name) - self.message_box("Deleted", f"Provider '{name}' deleted.", self._show_providers_menu) - self.yes_no("Confirm", f"Delete provider '{name}'?", on_yes=do_delete, on_no=self._show_providers_menu) - - def on_cancel(_): - self.close_overlay() - self._show_providers_menu() - - body = urwid.Pile([ - urwid.AttrMap(name_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(cmd_edit, 'edit', 'edit_focus'), - urwid.Divider(), - urwid.AttrMap(desc_edit, 'edit', 'edit_focus'), - ]) - - dialog = Dialog("Edit Provider", body, [("Save", on_save), ("Delete", on_delete), ("Cancel", on_cancel)]) - self.show_overlay(dialog, width=55, height=16) - - -def run_ui(): - """Entry point for the urwid UI.""" - ui = CmdForgeUI() - ui.run() diff --git a/src/cmdforge/ui_urwid/__main__.py b/src/cmdforge/ui_urwid/__main__.py deleted file mode 100644 index 71b1bd2..0000000 --- a/src/cmdforge/ui_urwid/__main__.py +++ /dev/null @@ -1,6 +0,0 @@ -"""Allow running the UI as a module.""" - -from . import run_ui - -if __name__ == "__main__": - run_ui() diff --git a/src/cmdforge/ui_urwid/palette.py b/src/cmdforge/ui_urwid/palette.py deleted file mode 100644 index b65cff9..0000000 --- a/src/cmdforge/ui_urwid/palette.py +++ /dev/null @@ -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'), -] diff --git a/src/cmdforge/ui_urwid/widgets.py b/src/cmdforge/ui_urwid/widgets.py deleted file mode 100644 index f08313d..0000000 --- a/src/cmdforge/ui_urwid/widgets.py +++ /dev/null @@ -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) diff --git a/wiki/Home.md b/wiki/Home.md index a61fa2d..2032099 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -151,15 +151,15 @@ These tools combine AI prompts with code for validation: ## Creating Your Own Tools -### Using the TUI +### Using the GUI The easiest way to create tools: ```bash -cmdforge ui +cmdforge ``` -Navigate with arrow keys, create tools visually, edit prompts, test with mock provider. +Opens the graphical interface where you can create tools visually, edit prompts, manage providers, and browse the registry. ### Tool Anatomy (YAML) @@ -245,10 +245,10 @@ cat important.txt | summarize --provider claude-opus ### Change Tool Default -Edit the tool's config or use the TUI: +Edit the tool's config or use the GUI: ```bash -cmdforge ui +cmdforge # Select tool → Edit → Change provider in step ```