CmdForge/src/cmdforge/gui/pages/tool_builder_page.py

395 lines
13 KiB
Python

"""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, 1) # Steps box gets stretch priority
# Output group
output_box = QGroupBox("Output Template")
output_layout = QVBoxLayout(output_box)
self.output_input = QTextEdit()
self.output_input.setPlaceholderText("Use {variable} to reference step outputs, e.g. {response}")
self.output_input.setPlainText("{response}") # Default value
self.output_input.setMinimumHeight(80)
self.output_input.setMaximumHeight(120)
output_layout.addWidget(self.output_input)
right_layout.addWidget(output_box, 0) # Output box stays fixed size
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 _get_available_vars(self, up_to_step: int = -1) -> list:
"""Get available variables for a step.
Args:
up_to_step: Include variables from steps before this index.
-1 means include all existing steps (for new steps).
"""
vars_list = ["input"]
# Add argument variables
if self._tool and self._tool.arguments:
for arg in self._tool.arguments:
if arg.variable and arg.variable not in vars_list:
vars_list.append(arg.variable)
# Add output variables from previous steps
if self._tool and self._tool.steps:
steps_to_check = self._tool.steps if up_to_step < 0 else self._tool.steps[:up_to_step]
for step in steps_to_check:
if hasattr(step, 'output_var') and step.output_var:
# Handle comma-separated output vars
for var in step.output_var.split(','):
var = var.strip()
if var and var not in vars_list:
vars_list.append(var)
return vars_list
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
available_vars = self._get_available_vars()
dialog = CodeStepDialog(self, available_vars=available_vars)
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
available_vars = self._get_available_vars(up_to_step=idx)
dialog = CodeStepDialog(self, step, available_vars=available_vars)
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()