diff --git a/src/cmdforge/gui/pages/tool_builder_page.py b/src/cmdforge/gui/pages/tool_builder_page.py index 188374c..0155480 100644 --- a/src/cmdforge/gui/pages/tool_builder_page.py +++ b/src/cmdforge/gui/pages/tool_builder_page.py @@ -163,6 +163,10 @@ class ToolBuilderPage(QWidget): 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) @@ -236,6 +240,7 @@ class ToolBuilderPage(QWidget): 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) # Replace placeholder old_widget = self.steps_stack.widget(1) @@ -264,6 +269,19 @@ class ToolBuilderPage(QWidget): 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 _load_tool(self, name: str): """Load an existing tool for editing.""" tool = load_tool(name) @@ -311,6 +329,13 @@ class ToolBuilderPage(QWidget): 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) + for i, step in enumerate(self._tool.steps, 1): if isinstance(step, PromptStep): prompt_count += 1 @@ -323,10 +348,25 @@ class ToolBuilderPage(QWidget): text = f"{i}. {step_name} [python] → ${step.output_var}" else: text = f"{i}. Unknown step" + item = QListWidgetItem(text) 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) @@ -454,6 +494,91 @@ class ToolBuilderPage(QWidget): 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 and code. + """ + import re + refs = set() + + # Pattern to match {variable} but not {{escaped}} + pattern = r'\{(\w+)\}' + + if isinstance(step, PromptStep): + # Parse prompt template + matches = re.findall(pattern, step.prompt or "") + refs.update(matches) + elif isinstance(step, CodeStep): + # Parse code for variable references + # In code, variables are accessed directly, but we also check for {var} patterns + # since the code might use string formatting + matches = re.findall(pattern, step.code or "") + refs.update(matches) + + return refs + def _save(self): """Save the tool.""" name = self.name_input.text().strip() diff --git a/src/cmdforge/gui/widgets/flow_graph.py b/src/cmdforge/gui/widgets/flow_graph.py index f0ef37b..de2bf1d 100644 --- a/src/cmdforge/gui/widgets/flow_graph.py +++ b/src/cmdforge/gui/widgets/flow_graph.py @@ -122,6 +122,9 @@ class FlowGraphWidget(QWidget): # Emitted when the flow structure changes flow_changed = Signal() + # Emitted when steps are deleted from flow view (list of step indices) + steps_deleted = Signal(list) + def __init__(self, parent=None): super().__init__(parent) self._tool: Optional[Tool] = None @@ -149,6 +152,7 @@ class FlowGraphWidget(QWidget): # Connect signals self._graph.node_double_clicked.connect(self._on_node_double_clicked) + self._graph.nodes_deleted.connect(self._on_nodes_deleted) # Add graph widget layout.addWidget(self._graph.widget, 1) @@ -409,6 +413,20 @@ class FlowGraphWidget(QWidget): step_type = 'prompt' if isinstance(node, PromptNode) else 'code' self.node_double_clicked.emit(node._step_index, step_type) + def _on_nodes_deleted(self, nodes): + """Handle node deletion from the graph.""" + # Collect step indices of deleted step nodes (not Input/Output nodes) + deleted_indices = [] + for node in nodes: + if hasattr(node, '_step_index') and node._step_index >= 0: + deleted_indices.append(node._step_index) + + if deleted_indices: + # Sort in reverse order so we delete from end first + deleted_indices.sort(reverse=True) + self.steps_deleted.emit(deleted_indices) + self.flow_changed.emit() + def refresh(self): """Refresh the graph from current tool data.""" self._rebuild_graph()