1140 lines
43 KiB
Python
1140 lines
43 KiB
Python
"""Tool builder page - create and edit tools."""
|
|
|
|
import yaml
|
|
|
|
from PySide6.QtWidgets import (
|
|
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
|
|
QLineEdit, QTextEdit, QPlainTextEdit, QComboBox, QPushButton,
|
|
QGroupBox, QListWidget, QListWidgetItem, QLabel,
|
|
QMessageBox, QSplitter, QFrame, QStackedWidget,
|
|
QButtonGroup
|
|
)
|
|
from PySide6.QtCore import Qt
|
|
|
|
from ...tool import (
|
|
Tool, ToolArgument, PromptStep, CodeStep, ToolStep,
|
|
load_tool, save_tool, validate_tool_name, get_all_categories,
|
|
ensure_settings
|
|
)
|
|
from ..widgets.icons import get_prompt_icon, get_code_icon, get_tool_icon
|
|
|
|
|
|
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._flow_widget = None # Lazy-loaded
|
|
|
|
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.setToolTip("Cancel and return to tools page (Escape)")
|
|
self.btn_cancel.clicked.connect(self._cancel)
|
|
header_layout.addWidget(self.btn_cancel)
|
|
|
|
self.btn_save = QPushButton("Save")
|
|
self.btn_save.setToolTip("Save this tool (Ctrl+S)")
|
|
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")
|
|
self.name_input.setToolTip("Unique tool name using lowercase letters, numbers, and hyphens")
|
|
info_layout.addRow("Name:", self.name_input)
|
|
|
|
self.desc_input = QLineEdit()
|
|
self.desc_input.setPlaceholderText("A brief description of what this tool does")
|
|
self.desc_input.setToolTip("Brief description shown in tool listings and help text")
|
|
info_layout.addRow("Description:", self.desc_input)
|
|
|
|
self.category_combo = QComboBox()
|
|
self.category_combo.setEditable(True)
|
|
self.category_combo.setToolTip("Category for organizing tools (select or type custom)")
|
|
for cat in get_all_categories():
|
|
self.category_combo.addItem(cat)
|
|
info_layout.addRow("Category:", self.category_combo)
|
|
|
|
left_layout.addWidget(info_box)
|
|
|
|
# Arguments group
|
|
args_box = QGroupBox()
|
|
args_layout = QVBoxLayout(args_box)
|
|
|
|
args_label = QLabel("Arguments")
|
|
args_label.setObjectName("sectionHeading")
|
|
args_label.setToolTip(
|
|
"<p>Arguments are command-line flags users can pass to your tool.</p>"
|
|
"<p>Example: Adding --max with variable 'max' lets users run:<br>"
|
|
"<code>my-tool --max 100 < input.txt</code></p>"
|
|
"<p>Use {max} in your prompts to reference the value.</p>"
|
|
)
|
|
args_layout.addWidget(args_label)
|
|
|
|
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)
|
|
|
|
# Dependencies group (compact - doesn't stretch)
|
|
deps_box = QGroupBox()
|
|
deps_layout = QVBoxLayout(deps_box)
|
|
deps_layout.setContentsMargins(9, 9, 9, 9)
|
|
deps_layout.setSpacing(8)
|
|
|
|
deps_label = QLabel("Dependencies")
|
|
deps_label.setObjectName("sectionHeading")
|
|
deps_label.setToolTip(
|
|
"Declare tools called via subprocess in code steps. "
|
|
"Tool steps are automatically included when saved."
|
|
)
|
|
deps_layout.addWidget(deps_label)
|
|
|
|
self.deps_list = QListWidget()
|
|
self.deps_list.setMaximumHeight(80)
|
|
self.deps_list.setMinimumHeight(60)
|
|
deps_layout.addWidget(self.deps_list)
|
|
|
|
# Dependency add row: combo + add button
|
|
deps_add_row = QHBoxLayout()
|
|
deps_add_row.setSpacing(6)
|
|
self.deps_combo = QComboBox()
|
|
self.deps_combo.setEditable(True)
|
|
self.deps_combo.setPlaceholderText("Select or type tool name...")
|
|
self._populate_deps_combo()
|
|
deps_add_row.addWidget(self.deps_combo, 1)
|
|
|
|
self.btn_add_dep = QPushButton("Add")
|
|
self.btn_add_dep.clicked.connect(self._add_dependency)
|
|
deps_add_row.addWidget(self.btn_add_dep)
|
|
|
|
self.btn_del_dep = QPushButton("Remove")
|
|
self.btn_del_dep.setObjectName("danger")
|
|
self.btn_del_dep.clicked.connect(self._delete_dependency)
|
|
deps_add_row.addWidget(self.btn_del_dep)
|
|
|
|
deps_layout.addLayout(deps_add_row)
|
|
|
|
left_layout.addWidget(deps_box)
|
|
|
|
# System Dependencies group (compact)
|
|
sys_deps_box = QGroupBox()
|
|
sys_deps_layout = QVBoxLayout(sys_deps_box)
|
|
sys_deps_layout.setContentsMargins(9, 9, 9, 9)
|
|
sys_deps_layout.setSpacing(8)
|
|
|
|
sys_deps_label = QLabel("System Dependencies")
|
|
sys_deps_label.setObjectName("sectionHeading")
|
|
sys_deps_label.setToolTip(
|
|
"System packages (apt, brew, pacman, dnf) required by your tool.\n"
|
|
"Users will be prompted to install missing packages."
|
|
)
|
|
sys_deps_layout.addWidget(sys_deps_label)
|
|
|
|
self.sys_deps_list = QListWidget()
|
|
self.sys_deps_list.setMaximumHeight(80)
|
|
self.sys_deps_list.setMinimumHeight(60)
|
|
self.sys_deps_list.itemDoubleClicked.connect(self._edit_sys_dep)
|
|
sys_deps_layout.addWidget(self.sys_deps_list)
|
|
|
|
# System dependency buttons
|
|
sys_deps_btns = QHBoxLayout()
|
|
sys_deps_btns.setSpacing(6)
|
|
|
|
self.btn_add_sys_dep = QPushButton("Add")
|
|
self.btn_add_sys_dep.clicked.connect(self._add_sys_dep)
|
|
sys_deps_btns.addWidget(self.btn_add_sys_dep)
|
|
|
|
self.btn_edit_sys_dep = QPushButton("Edit")
|
|
self.btn_edit_sys_dep.setObjectName("secondary")
|
|
self.btn_edit_sys_dep.clicked.connect(self._edit_sys_dep)
|
|
sys_deps_btns.addWidget(self.btn_edit_sys_dep)
|
|
|
|
self.btn_del_sys_dep = QPushButton("Remove")
|
|
self.btn_del_sys_dep.setObjectName("danger")
|
|
self.btn_del_sys_dep.clicked.connect(self._delete_sys_dep)
|
|
sys_deps_btns.addWidget(self.btn_del_sys_dep)
|
|
|
|
sys_deps_btns.addStretch()
|
|
sys_deps_layout.addLayout(sys_deps_btns)
|
|
|
|
left_layout.addWidget(sys_deps_box)
|
|
|
|
# Defaults group (collapsible)
|
|
self.defaults_group = QGroupBox("Defaults (Optional)")
|
|
self.defaults_group.setCheckable(True)
|
|
self.defaults_group.setChecked(False) # Collapsed by default
|
|
|
|
defaults_layout = QVBoxLayout(self.defaults_group)
|
|
defaults_layout.setContentsMargins(9, 9, 9, 9)
|
|
defaults_layout.setSpacing(8)
|
|
|
|
defaults_help = QLabel(
|
|
"Define configurable settings users can customize.\n"
|
|
"Access in templates as {settings.key} (scalars only)\n"
|
|
"Access in code as settings['key'] (any type)"
|
|
)
|
|
defaults_help.setWordWrap(True)
|
|
defaults_help.setStyleSheet("color: #718096; font-size: 11px;")
|
|
defaults_layout.addWidget(defaults_help)
|
|
|
|
self.defaults_editor = QPlainTextEdit()
|
|
self.defaults_editor.setPlaceholderText(
|
|
"# Example defaults.yaml\n"
|
|
"backend: piper\n"
|
|
"endpoint: http://localhost:5001\n"
|
|
"api_key: '' # User fills in\n"
|
|
)
|
|
self.defaults_editor.setMaximumHeight(150)
|
|
defaults_layout.addWidget(self.defaults_editor)
|
|
|
|
left_layout.addWidget(self.defaults_group)
|
|
left_layout.addStretch() # Push everything up, groups won't stretch
|
|
|
|
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 with view toggle
|
|
steps_box = QGroupBox()
|
|
steps_layout = QVBoxLayout(steps_box)
|
|
|
|
# Steps header with label and view toggle
|
|
steps_header = QHBoxLayout()
|
|
steps_label = QLabel("Steps")
|
|
steps_label.setObjectName("sectionHeading")
|
|
steps_label.setToolTip(
|
|
"<p>Steps define what your tool does, executed in order.</p>"
|
|
"<p><b>Step types:</b><br>"
|
|
"- Prompt: Call an AI provider with a template<br>"
|
|
"- Code: Run Python code to process data<br>"
|
|
"- Tool: Call another CmdForge tool</p>"
|
|
"<p>Use {variable} syntax to pass data between steps.<br>"
|
|
"Built-in: {input} contains stdin content.</p>"
|
|
)
|
|
steps_header.addWidget(steps_label)
|
|
steps_header.addStretch()
|
|
|
|
# View toggle buttons
|
|
self.btn_list_view = QPushButton("List")
|
|
self.btn_list_view.setCheckable(True)
|
|
self.btn_list_view.setChecked(True)
|
|
self.btn_list_view.setObjectName("viewToggle")
|
|
self.btn_list_view.setToolTip("View steps as a list (drag to reorder)")
|
|
self.btn_list_view.clicked.connect(lambda: self._set_view_mode(0))
|
|
|
|
self.btn_flow_view = QPushButton("Flow")
|
|
self.btn_flow_view.setCheckable(True)
|
|
self.btn_flow_view.setObjectName("viewToggle")
|
|
self.btn_flow_view.setToolTip("View steps as a visual flow graph")
|
|
self.btn_flow_view.clicked.connect(lambda: self._set_view_mode(1))
|
|
|
|
# Button group for mutual exclusivity
|
|
self.view_group = QButtonGroup(self)
|
|
self.view_group.addButton(self.btn_list_view, 0)
|
|
self.view_group.addButton(self.btn_flow_view, 1)
|
|
|
|
steps_header.addWidget(self.btn_list_view)
|
|
steps_header.addWidget(self.btn_flow_view)
|
|
steps_layout.addLayout(steps_header)
|
|
|
|
# Stacked widget for list/flow views
|
|
self.steps_stack = QStackedWidget()
|
|
|
|
# List view (index 0)
|
|
list_container = QWidget()
|
|
list_layout = QVBoxLayout(list_container)
|
|
list_layout.setContentsMargins(0, 0, 0, 0)
|
|
|
|
self.steps_list = QListWidget()
|
|
self.steps_list.itemDoubleClicked.connect(self._edit_step)
|
|
# Enable drag-drop reordering
|
|
self.steps_list.setDragDropMode(QListWidget.InternalMove)
|
|
self.steps_list.setDefaultDropAction(Qt.MoveAction)
|
|
self.steps_list.model().rowsMoved.connect(self._on_steps_reordered)
|
|
list_layout.addWidget(self.steps_list)
|
|
|
|
self.steps_stack.addWidget(list_container)
|
|
|
|
# Flow view placeholder (index 1) - lazy loaded
|
|
flow_placeholder = QLabel("Loading flow visualization...")
|
|
flow_placeholder.setAlignment(Qt.AlignCenter)
|
|
flow_placeholder.setStyleSheet("color: #718096; padding: 40px;")
|
|
self.steps_stack.addWidget(flow_placeholder)
|
|
|
|
steps_layout.addWidget(self.steps_stack)
|
|
|
|
# Step action buttons
|
|
steps_btns = QHBoxLayout()
|
|
self.btn_add_prompt = QPushButton("Add Prompt")
|
|
self.btn_add_prompt.setToolTip("Add an AI prompt step - calls your provider with a template")
|
|
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.setToolTip("Add a Python code step - process or transform data")
|
|
self.btn_add_code.clicked.connect(self._add_code_step)
|
|
steps_btns.addWidget(self.btn_add_code)
|
|
|
|
self.btn_add_tool = QPushButton("Add Tool")
|
|
self.btn_add_tool.setToolTip("Add a tool step - chain another CmdForge tool")
|
|
self.btn_add_tool.clicked.connect(self._add_tool_step)
|
|
steps_btns.addWidget(self.btn_add_tool)
|
|
|
|
self.btn_edit_step = QPushButton("Edit")
|
|
self.btn_edit_step.setObjectName("secondary")
|
|
self.btn_edit_step.setToolTip("Edit the selected step (or double-click)")
|
|
self.btn_edit_step.clicked.connect(self._edit_step)
|
|
steps_btns.addWidget(self.btn_edit_step)
|
|
|
|
self.btn_test_step = QPushButton("Test")
|
|
self.btn_test_step.setObjectName("secondary")
|
|
self.btn_test_step.setToolTip("Test the selected step with custom input")
|
|
self.btn_test_step.clicked.connect(self._test_step)
|
|
steps_btns.addWidget(self.btn_test_step)
|
|
|
|
self.btn_del_step = QPushButton("Delete")
|
|
self.btn_del_step.setObjectName("danger")
|
|
self.btn_del_step.setToolTip("Delete the selected step")
|
|
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_layout = QVBoxLayout(output_box)
|
|
|
|
output_label = QLabel("Output Template")
|
|
output_label.setObjectName("sectionHeading")
|
|
output_label.setToolTip(
|
|
"<p>The output template defines what your tool prints.</p>"
|
|
"<p><b>Use {variable} to include step outputs:</b><br>"
|
|
"- {response} - typical AI response variable<br>"
|
|
"- {result} - common code step output<br>"
|
|
"- Any output_var you defined in steps</p>"
|
|
"<p>Example: <code>{response}</code> prints just the AI response.</p>"
|
|
)
|
|
output_layout.addWidget(output_label)
|
|
|
|
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 _set_view_mode(self, mode: int):
|
|
"""Switch between list (0) and flow (1) views."""
|
|
if mode == 1 and self._flow_widget is None:
|
|
# Lazy-load flow widget
|
|
self._init_flow_widget()
|
|
|
|
self.steps_stack.setCurrentIndex(mode)
|
|
|
|
# Update flow widget if visible
|
|
if mode == 1 and self._flow_widget:
|
|
self._flow_widget.set_tool(self._tool)
|
|
|
|
def _init_flow_widget(self):
|
|
"""Initialize the flow visualization widget."""
|
|
try:
|
|
from ..widgets.flow_graph import FlowGraphWidget
|
|
self._flow_widget = FlowGraphWidget()
|
|
self._flow_widget.node_double_clicked.connect(self._on_flow_node_double_clicked)
|
|
self._flow_widget.steps_deleted.connect(self._on_flow_steps_deleted)
|
|
self._flow_widget.steps_reordered.connect(self._on_flow_steps_reordered)
|
|
self._flow_widget.step_name_changed.connect(self._on_flow_step_name_changed)
|
|
|
|
# Replace placeholder
|
|
old_widget = self.steps_stack.widget(1)
|
|
self.steps_stack.removeWidget(old_widget)
|
|
old_widget.deleteLater()
|
|
self.steps_stack.insertWidget(1, self._flow_widget)
|
|
|
|
# Set current tool
|
|
if self._tool:
|
|
self._flow_widget.set_tool(self._tool)
|
|
except ImportError as e:
|
|
# NodeGraphQt not available
|
|
error_label = QLabel(f"Flow visualization unavailable:\n{e}")
|
|
error_label.setAlignment(Qt.AlignCenter)
|
|
error_label.setStyleSheet("color: #e53e3e; padding: 40px;")
|
|
old_widget = self.steps_stack.widget(1)
|
|
self.steps_stack.removeWidget(old_widget)
|
|
old_widget.deleteLater()
|
|
self.steps_stack.insertWidget(1, error_label)
|
|
|
|
def _on_flow_node_double_clicked(self, step_index: int, step_type: str):
|
|
"""Handle double-click on a flow node."""
|
|
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
|
|
return
|
|
|
|
step = self._tool.steps[step_index]
|
|
self._edit_step_at_index(step_index, step)
|
|
|
|
def _on_flow_steps_deleted(self, indices: list):
|
|
"""Handle step deletion from flow view."""
|
|
if not self._tool:
|
|
return
|
|
|
|
# Delete steps in reverse order (indices are already sorted reverse)
|
|
for idx in indices:
|
|
if 0 <= idx < len(self._tool.steps):
|
|
del self._tool.steps[idx]
|
|
|
|
# Refresh the list view (flow view will be refreshed by set_tool)
|
|
self._refresh_steps()
|
|
|
|
def _on_flow_step_name_changed(self, step_index: int, new_name: str):
|
|
"""Handle step name change from flow view inline editing."""
|
|
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
|
|
return
|
|
|
|
step = self._tool.steps[step_index]
|
|
step.name = new_name
|
|
|
|
# Refresh list view to show updated name
|
|
self._refresh_steps_list_only()
|
|
|
|
def _on_flow_steps_reordered(self, new_order: list):
|
|
"""Handle step reordering from flow view.
|
|
|
|
Args:
|
|
new_order: List of old indices in new order.
|
|
e.g., [0, 2, 1] means step 0 stays first,
|
|
step 2 moves to second, step 1 moves to third.
|
|
"""
|
|
if not self._tool or not self._tool.steps:
|
|
return
|
|
|
|
# Reorder steps according to new_order
|
|
old_steps = self._tool.steps[:]
|
|
new_steps = [old_steps[i] for i in new_order if i < len(old_steps)]
|
|
|
|
# Validate the new order
|
|
warnings = self._validate_step_order(new_steps)
|
|
|
|
if warnings:
|
|
# Show warning but allow the reorder
|
|
warning_msg = "Variable dependency warnings:\n\n" + "\n".join(warnings)
|
|
warning_msg += "\n\nThe reorder has been applied. You may need to fix these issues."
|
|
QMessageBox.warning(self, "Dependency Warning", warning_msg)
|
|
|
|
self._tool.steps = new_steps
|
|
|
|
# Refresh both views
|
|
self._refresh_steps()
|
|
|
|
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 dependencies
|
|
self._refresh_dependencies()
|
|
|
|
# Load system dependencies
|
|
self._refresh_sys_deps()
|
|
|
|
# Load steps
|
|
self._refresh_steps()
|
|
|
|
# Set output
|
|
self.output_input.setPlainText(tool.output or "{response}")
|
|
|
|
# Load defaults if exists
|
|
if tool.path:
|
|
defaults_path = tool.path.parent / "defaults.yaml"
|
|
if defaults_path.exists():
|
|
self.defaults_editor.setPlainText(defaults_path.read_text())
|
|
self.defaults_group.setChecked(True)
|
|
else:
|
|
self.defaults_editor.clear()
|
|
self.defaults_group.setChecked(False)
|
|
|
|
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 and flow view."""
|
|
self.steps_list.clear()
|
|
if self._tool and self._tool.steps:
|
|
# Count steps by type for default naming
|
|
prompt_count = 0
|
|
code_count = 0
|
|
|
|
# Track available variables for dependency checking
|
|
available_vars = {"input"}
|
|
if self._tool.arguments:
|
|
for arg in self._tool.arguments:
|
|
if arg.variable:
|
|
available_vars.add(arg.variable)
|
|
|
|
# Count for default naming
|
|
tool_count = 0
|
|
|
|
for i, step in enumerate(self._tool.steps, 1):
|
|
if isinstance(step, PromptStep):
|
|
prompt_count += 1
|
|
# Use custom name if set, otherwise default per-type naming
|
|
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
|
text = f"{step_name} [{step.provider}] → ${step.output_var}"
|
|
icon = get_prompt_icon(20)
|
|
elif isinstance(step, CodeStep):
|
|
code_count += 1
|
|
step_name = step.name if step.name else f"Code {code_count}"
|
|
text = f"{step_name} [python] → ${step.output_var}"
|
|
icon = get_code_icon(20)
|
|
elif isinstance(step, ToolStep):
|
|
tool_count += 1
|
|
step_name = step.name if step.name else f"Tool {tool_count}"
|
|
text = f"{step_name} [{step.tool}] → ${step.output_var}"
|
|
icon = get_tool_icon(20)
|
|
else:
|
|
text = f"Unknown step"
|
|
icon = None
|
|
|
|
item = QListWidgetItem(text)
|
|
if icon:
|
|
item.setIcon(icon)
|
|
item.setData(Qt.UserRole, step)
|
|
|
|
# Check for broken dependencies
|
|
refs = self._get_step_variable_refs(step)
|
|
broken_refs = [r for r in refs if r not in available_vars]
|
|
if broken_refs:
|
|
# Mark item with warning color and tooltip
|
|
item.setForeground(Qt.red)
|
|
item.setToolTip(f"Missing variables: {', '.join(broken_refs)}")
|
|
|
|
self.steps_list.addItem(item)
|
|
|
|
# Add this step's output to available vars for next iteration
|
|
if hasattr(step, 'output_var') and step.output_var:
|
|
for var in step.output_var.split(','):
|
|
available_vars.add(var.strip())
|
|
|
|
# Update flow widget if initialized
|
|
if self._flow_widget:
|
|
self._flow_widget.set_tool(self._tool)
|
|
|
|
def _refresh_steps_list_only(self):
|
|
"""Refresh only the steps list (not flow view).
|
|
|
|
Used when a change originated from the flow view to avoid
|
|
rebuilding the graph unnecessarily.
|
|
"""
|
|
self.steps_list.clear()
|
|
if self._tool and self._tool.steps:
|
|
# Count steps by type for default naming
|
|
prompt_count = 0
|
|
code_count = 0
|
|
tool_count = 0
|
|
|
|
# Track available variables for dependency checking
|
|
available_vars = {"input"}
|
|
if self._tool.arguments:
|
|
for arg in self._tool.arguments:
|
|
if arg.variable:
|
|
available_vars.add(arg.variable)
|
|
|
|
for i, step in enumerate(self._tool.steps, 1):
|
|
if isinstance(step, PromptStep):
|
|
prompt_count += 1
|
|
step_name = step.name if step.name else f"Prompt {prompt_count}"
|
|
text = f"{step_name} [{step.provider}] → ${step.output_var}"
|
|
icon = get_prompt_icon(20)
|
|
elif isinstance(step, CodeStep):
|
|
code_count += 1
|
|
step_name = step.name if step.name else f"Code {code_count}"
|
|
text = f"{step_name} [python] → ${step.output_var}"
|
|
icon = get_code_icon(20)
|
|
elif isinstance(step, ToolStep):
|
|
tool_count += 1
|
|
step_name = step.name if step.name else f"Tool {tool_count}"
|
|
text = f"{step_name} [{step.tool}] → ${step.output_var}"
|
|
icon = get_tool_icon(20)
|
|
else:
|
|
text = f"Unknown step"
|
|
icon = None
|
|
|
|
item = QListWidgetItem(text)
|
|
if icon:
|
|
item.setIcon(icon)
|
|
item.setData(Qt.UserRole, step)
|
|
|
|
# Check for broken dependencies
|
|
refs = self._get_step_variable_refs(step)
|
|
broken_refs = [r for r in refs if r not in available_vars]
|
|
if broken_refs:
|
|
item.setForeground(Qt.red)
|
|
item.setToolTip(f"Missing variables: {', '.join(broken_refs)}")
|
|
|
|
self.steps_list.addItem(item)
|
|
|
|
# Add this step's output to available vars for next iteration
|
|
if hasattr(step, 'output_var') and step.output_var:
|
|
for var in step.output_var.split(','):
|
|
available_vars.add(var.strip())
|
|
|
|
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 _populate_deps_combo(self):
|
|
"""Populate dependencies combo with installed tools."""
|
|
from ...tool import TOOLS_DIR
|
|
self.deps_combo.clear()
|
|
|
|
# Get all installed tools
|
|
tools = []
|
|
if TOOLS_DIR.exists():
|
|
for item in TOOLS_DIR.iterdir():
|
|
if item.is_dir():
|
|
config = item / "config.yaml"
|
|
if config.exists():
|
|
tools.append(item.name)
|
|
|
|
# Sort and add to combo
|
|
for tool in sorted(tools):
|
|
self.deps_combo.addItem(tool)
|
|
|
|
def _add_dependency(self):
|
|
"""Add selected tool to dependencies."""
|
|
tool_ref = self.deps_combo.currentText().strip()
|
|
if not tool_ref:
|
|
return
|
|
|
|
if not self._tool:
|
|
# Create a minimal tool object if none exists
|
|
self._tool = Tool(name="", description="")
|
|
|
|
# Initialize dependencies list if needed
|
|
if not hasattr(self._tool, 'dependencies') or self._tool.dependencies is None:
|
|
self._tool.dependencies = []
|
|
|
|
# Check if already in list
|
|
if tool_ref in self._tool.dependencies:
|
|
self.main_window.show_status(f"'{tool_ref}' is already a dependency")
|
|
return
|
|
|
|
self._tool.dependencies.append(tool_ref)
|
|
self._refresh_dependencies()
|
|
self.deps_combo.setCurrentText("")
|
|
self.main_window.show_status(f"Added dependency: {tool_ref}")
|
|
|
|
def _delete_dependency(self):
|
|
"""Remove selected dependency."""
|
|
items = self.deps_list.selectedItems()
|
|
if not items:
|
|
return
|
|
|
|
dep = items[0].text()
|
|
if self._tool and self._tool.dependencies and dep in self._tool.dependencies:
|
|
self._tool.dependencies.remove(dep)
|
|
self._refresh_dependencies()
|
|
|
|
def _refresh_dependencies(self):
|
|
"""Refresh dependencies list widget."""
|
|
self.deps_list.clear()
|
|
if self._tool and self._tool.dependencies:
|
|
for dep in self._tool.dependencies:
|
|
self.deps_list.addItem(dep)
|
|
|
|
def _add_sys_dep(self):
|
|
"""Add a new system dependency."""
|
|
from ..dialogs.system_dep_dialog import SystemDependencyDialog
|
|
dialog = SystemDependencyDialog(self)
|
|
if dialog.exec():
|
|
dep = dialog.get_dependency()
|
|
if not self._tool:
|
|
from ...tool import Tool
|
|
self._tool = Tool(name="", description="")
|
|
if not hasattr(self._tool, 'system_dependencies') or self._tool.system_dependencies is None:
|
|
self._tool.system_dependencies = []
|
|
|
|
# Check if already exists
|
|
existing_names = [d.name for d in self._tool.system_dependencies]
|
|
if dep.name in existing_names:
|
|
self.main_window.show_status(f"'{dep.name}' is already a system dependency")
|
|
return
|
|
|
|
self._tool.system_dependencies.append(dep)
|
|
self._refresh_sys_deps()
|
|
self.main_window.show_status(f"Added system dependency: {dep.name}")
|
|
|
|
def _edit_sys_dep(self):
|
|
"""Edit selected system dependency."""
|
|
items = self.sys_deps_list.selectedItems()
|
|
if not items:
|
|
return
|
|
|
|
idx = self.sys_deps_list.row(items[0])
|
|
if not self._tool or idx >= len(self._tool.system_dependencies):
|
|
return
|
|
|
|
dep = self._tool.system_dependencies[idx]
|
|
|
|
from ..dialogs.system_dep_dialog import SystemDependencyDialog
|
|
dialog = SystemDependencyDialog(self, dep)
|
|
if dialog.exec():
|
|
self._tool.system_dependencies[idx] = dialog.get_dependency()
|
|
self._refresh_sys_deps()
|
|
|
|
def _delete_sys_dep(self):
|
|
"""Remove selected system dependency."""
|
|
items = self.sys_deps_list.selectedItems()
|
|
if not items:
|
|
return
|
|
|
|
idx = self.sys_deps_list.row(items[0])
|
|
if self._tool and self._tool.system_dependencies and idx < len(self._tool.system_dependencies):
|
|
del self._tool.system_dependencies[idx]
|
|
self._refresh_sys_deps()
|
|
|
|
def _refresh_sys_deps(self):
|
|
"""Refresh system dependencies list widget."""
|
|
self.sys_deps_list.clear()
|
|
if self._tool and self._tool.system_dependencies:
|
|
for dep in self._tool.system_dependencies:
|
|
# Show name and description if available
|
|
text = dep.name
|
|
if dep.description:
|
|
text += f" - {dep.description}"
|
|
self.sys_deps_list.addItem(text)
|
|
|
|
def _add_tool_dependency(self, tool_ref: str):
|
|
"""Add a tool reference to the dependencies list if not already present.
|
|
|
|
Args:
|
|
tool_ref: Tool reference (e.g., 'my-tool' or 'owner/tool-name')
|
|
"""
|
|
if not self._tool or not tool_ref:
|
|
return
|
|
|
|
# Initialize dependencies list if needed
|
|
if not hasattr(self._tool, 'dependencies') or self._tool.dependencies is None:
|
|
self._tool.dependencies = []
|
|
|
|
# Add if not already present
|
|
if tool_ref not in self._tool.dependencies:
|
|
self._tool.dependencies.append(tool_ref)
|
|
|
|
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 _add_tool_step(self):
|
|
"""Add a tool step (call another tool)."""
|
|
from ..dialogs.step_dialog import ToolStepDialog
|
|
available_vars = self._get_available_vars()
|
|
current_name = self._tool.name if self._tool else None
|
|
dialog = ToolStepDialog(self, available_vars=available_vars, current_tool_name=current_name)
|
|
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)
|
|
# Auto-add to dependencies if not already present
|
|
self._add_tool_dependency(step.tool)
|
|
self._refresh_steps()
|
|
|
|
def _edit_step(self):
|
|
"""Edit selected step from list view."""
|
|
items = self.steps_list.selectedItems()
|
|
if not items:
|
|
return
|
|
|
|
step = items[0].data(Qt.UserRole)
|
|
idx = self.steps_list.row(items[0])
|
|
self._edit_step_at_index(idx, step)
|
|
|
|
def _edit_step_at_index(self, idx: int, step):
|
|
"""Edit a step at a specific index."""
|
|
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)
|
|
elif isinstance(step, ToolStep):
|
|
from ..dialogs.step_dialog import ToolStepDialog
|
|
available_vars = self._get_available_vars(up_to_step=idx)
|
|
current_name = self._tool.name if self._tool else None
|
|
dialog = ToolStepDialog(self, step, available_vars=available_vars, current_tool_name=current_name)
|
|
else:
|
|
return
|
|
|
|
if dialog.exec():
|
|
new_step = dialog.get_step()
|
|
self._tool.steps[idx] = new_step
|
|
# Auto-add to dependencies if it's a ToolStep
|
|
if isinstance(new_step, ToolStep):
|
|
self._add_tool_dependency(new_step.tool)
|
|
self._refresh_steps()
|
|
|
|
def _test_step(self):
|
|
"""Test the selected step with custom input."""
|
|
items = self.steps_list.selectedItems()
|
|
if not items:
|
|
QMessageBox.information(self, "Test Step", "Please select a step to test")
|
|
return
|
|
|
|
step = items[0].data(Qt.UserRole)
|
|
idx = self.steps_list.row(items[0])
|
|
available_vars = self._get_available_vars(up_to_step=idx)
|
|
|
|
from ..dialogs.test_step_dialog import TestStepDialog
|
|
dialog = TestStepDialog(self, step, available_vars=available_vars)
|
|
dialog.exec()
|
|
|
|
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 _on_steps_reordered(self, parent, start, end, dest, row):
|
|
"""Handle step reordering via drag-drop."""
|
|
if not self._tool or not self._tool.steps:
|
|
return
|
|
|
|
# Rebuild steps list from current list widget order
|
|
new_steps = []
|
|
for i in range(self.steps_list.count()):
|
|
item = self.steps_list.item(i)
|
|
step = item.data(Qt.UserRole)
|
|
if step:
|
|
new_steps.append(step)
|
|
|
|
# Validate the new order
|
|
warnings = self._validate_step_order(new_steps)
|
|
|
|
if warnings:
|
|
# Show warning but allow the reorder
|
|
warning_msg = "Variable dependency warnings:\n\n" + "\n".join(warnings)
|
|
warning_msg += "\n\nThe reorder has been applied. You may need to fix these issues."
|
|
QMessageBox.warning(self, "Dependency Warning", warning_msg)
|
|
|
|
# Apply the new order
|
|
self._tool.steps = new_steps
|
|
|
|
# Refresh to update numbering and flow view
|
|
self._refresh_steps()
|
|
|
|
def _validate_step_order(self, steps: list) -> list:
|
|
"""Validate step order for variable dependencies.
|
|
|
|
Returns a list of warning messages for broken dependencies.
|
|
"""
|
|
warnings = []
|
|
|
|
# Track available variables at each position
|
|
available = {"input"}
|
|
|
|
# Add argument variables
|
|
if self._tool and self._tool.arguments:
|
|
for arg in self._tool.arguments:
|
|
if arg.variable:
|
|
available.add(arg.variable)
|
|
|
|
for i, step in enumerate(steps):
|
|
# Get variables this step references
|
|
refs = self._get_step_variable_refs(step)
|
|
|
|
# Check for undefined references
|
|
for ref in refs:
|
|
if ref not in available:
|
|
step_name = step.name if step.name else f"Step {i+1}"
|
|
warnings.append(f"{step_name}: references '{{{ref}}}' which is not defined yet")
|
|
|
|
# Add this step's output to available vars
|
|
if hasattr(step, 'output_var') and step.output_var:
|
|
for var in step.output_var.split(','):
|
|
available.add(var.strip())
|
|
|
|
return warnings
|
|
|
|
def _get_step_variable_refs(self, step) -> set:
|
|
"""Extract variable references from a step.
|
|
|
|
Parses {variable} patterns from prompt templates.
|
|
Note: CodeSteps are NOT parsed because variables are available directly
|
|
as Python variables, and {var} in code is typically Python f-string
|
|
or .format() syntax, not CmdForge substitution.
|
|
"""
|
|
import re
|
|
refs = set()
|
|
|
|
# Pattern to match {variable} but not {{escaped}}
|
|
pattern = r'\{(\w+)\}'
|
|
|
|
if isinstance(step, PromptStep):
|
|
# Parse prompt template - these ARE CmdForge substitutions
|
|
matches = re.findall(pattern, step.prompt or "")
|
|
refs.update(matches)
|
|
# CodeStep: Don't parse - variables are available as Python vars directly
|
|
# and {var} syntax is typically Python string formatting
|
|
|
|
return refs
|
|
|
|
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
|
|
is_valid, error = validate_tool_name(name)
|
|
if not is_valid:
|
|
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,
|
|
dependencies=self._tool.dependencies if self._tool else [],
|
|
system_dependencies=self._tool.system_dependencies if self._tool else []
|
|
)
|
|
|
|
# Preserve source if editing
|
|
if self._tool and self._tool.source:
|
|
tool.source = self._tool.source
|
|
|
|
# Preserve version if editing
|
|
if self._tool and self._tool.version:
|
|
tool.version = self._tool.version
|
|
|
|
try:
|
|
config_path = save_tool(tool)
|
|
tool_dir = config_path.parent
|
|
|
|
# Save defaults if provided
|
|
defaults_content = self.defaults_editor.toPlainText().strip()
|
|
defaults_path = tool_dir / "defaults.yaml"
|
|
|
|
if defaults_content and self.defaults_group.isChecked():
|
|
# Validate YAML
|
|
try:
|
|
yaml.safe_load(defaults_content)
|
|
except yaml.YAMLError as e:
|
|
QMessageBox.warning(self, "Invalid YAML", f"Defaults YAML is invalid:\n{e}")
|
|
return
|
|
|
|
defaults_path.write_text(defaults_content)
|
|
# Ensure settings.yaml exists
|
|
ensure_settings(tool_dir)
|
|
elif defaults_path.exists() and not self.defaults_group.isChecked():
|
|
# Remove defaults if group is unchecked and was previously present
|
|
defaults_path.unlink()
|
|
settings_path = tool_dir / "settings.yaml"
|
|
if settings_path.exists():
|
|
settings_path.unlink()
|
|
|
|
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()
|