diff --git a/src/cmdforge/gui/pages/tool_builder_page.py b/src/cmdforge/gui/pages/tool_builder_page.py index da0c82f..c169aaa 100644 --- a/src/cmdforge/gui/pages/tool_builder_page.py +++ b/src/cmdforge/gui/pages/tool_builder_page.py @@ -13,6 +13,7 @@ from ...tool import ( Tool, ToolArgument, PromptStep, CodeStep, load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES ) +from ..widgets.icons import get_prompt_icon, get_code_icon class ToolBuilderPage(QWidget): @@ -242,6 +243,7 @@ class ToolBuilderPage(QWidget): 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) @@ -283,6 +285,17 @@ class ToolBuilderPage(QWidget): # 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. @@ -371,15 +384,20 @@ class ToolBuilderPage(QWidget): 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"{i}. {step_name} [{step.provider}] → ${step.output_var}" + 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"{i}. {step_name} [python] → ${step.output_var}" + text = f"{step_name} [python] → ${step.output_var}" + icon = get_code_icon(20) else: - text = f"{i}. Unknown step" + text = f"Unknown step" + icon = None item = QListWidgetItem(text) + if icon: + item.setIcon(icon) item.setData(Qt.UserRole, step) # Check for broken dependencies @@ -401,6 +419,59 @@ class ToolBuilderPage(QWidget): 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 + + # 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) + 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 diff --git a/src/cmdforge/gui/widgets/flow_graph.py b/src/cmdforge/gui/widgets/flow_graph.py index ed364b8..aa6efd9 100644 --- a/src/cmdforge/gui/widgets/flow_graph.py +++ b/src/cmdforge/gui/widgets/flow_graph.py @@ -9,6 +9,10 @@ from PySide6.QtGui import QKeyEvent, QAction from NodeGraphQt import NodeGraph, BaseNode from ...tool import Tool, PromptStep, CodeStep +from .icons import ( + get_prompt_icon_path, get_code_icon_path, + get_input_icon_path, get_output_icon_path +) # ============================================================================= @@ -35,6 +39,7 @@ class InputNode(CmdForgeBaseNode): def __init__(self): super().__init__() self.set_color(90, 90, 160) # Indigo + self.set_icon(get_input_icon_path(16)) # Default output for stdin self.add_output('input', color=(180, 180, 250)) @@ -56,7 +61,8 @@ class PromptNode(CmdForgeBaseNode): def __init__(self): super().__init__() self.set_color(102, 126, 234) # Indigo (matching CmdForge theme) - self.add_input('in', color=(180, 180, 250), multi_input=True) + self.set_icon(get_prompt_icon_path(16)) + self.add_input('in', color=(180, 180, 250)) self.add_output('out', color=(180, 250, 180)) # Add properties for display @@ -82,7 +88,8 @@ class CodeNode(CmdForgeBaseNode): def __init__(self): super().__init__() self.set_color(72, 187, 120) # Green - self.add_input('in', color=(180, 250, 180), multi_input=True) + self.set_icon(get_code_icon_path(16)) + self.add_input('in', color=(180, 250, 180)) self.add_output('out', color=(250, 220, 180)) # Add properties @@ -106,7 +113,8 @@ class OutputNode(CmdForgeBaseNode): def __init__(self): super().__init__() self.set_color(237, 137, 54) # Orange - self.add_input('in', color=(250, 220, 180), multi_input=True) + self.set_icon(get_output_icon_path(16)) + self.add_input('in', color=(250, 220, 180)) # ============================================================================= @@ -114,7 +122,12 @@ class OutputNode(CmdForgeBaseNode): # ============================================================================= class FlowGraphWidget(QWidget): - """Widget for visualizing tool flow as a node graph.""" + """Widget for visualizing tool flow as a node graph. + + Note: Currently the flow view is read-only for connections. Reordering + steps by manipulating connections is a future enhancement (TODO). + Use the list view for reordering steps via drag-drop. + """ # Emitted when a node is double-clicked (step_index, step_type) node_double_clicked = Signal(int, str) @@ -128,6 +141,9 @@ class FlowGraphWidget(QWidget): # Emitted when steps are reordered (new order as list of step indices) steps_reordered = Signal(list) + # Emitted when a step name is changed (step_index, new_name) + step_name_changed = Signal(int, str) + def __init__(self, parent=None): super().__init__(parent) self._tool: Optional[Tool] = None @@ -157,7 +173,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) - self._graph.port_disconnected.connect(self._on_port_disconnected) + self._graph.property_changed.connect(self._on_property_changed) # Add graph widget layout.addWidget(self._graph.widget, 1) @@ -172,7 +188,6 @@ class FlowGraphWidget(QWidget): "Zoom: Scroll | " "Select: Click/drag | " "Edit: Double-click | " - "Cut links: Alt+Shift+drag | " "Select All: A | " "Fit: F" ) @@ -449,59 +464,23 @@ class FlowGraphWidget(QWidget): self.steps_deleted.emit(deleted_indices) self.flow_changed.emit() - def _on_port_disconnected(self, input_port, output_port): - """Handle port disconnection - reorder steps. + def _on_property_changed(self, node, prop_name, value): + """Handle node property change. - When a connection is broken: - 1. The node that lost its input moves to the end - 2. The chain reconnects (previous node → next node) - 3. Disconnected node attaches at the end before Output + Filters for 'name' property to detect step name changes. """ # Ignore during rebuild if self._rebuilding: return - if not self._tool or not self._tool.steps: + # Only interested in name changes + if prop_name != 'name': return - # Get the nodes involved - # input_port belongs to the node that lost its input - # output_port belongs to the node that was providing the input - disconnected_node = input_port.node() - source_node = output_port.node() - # Only handle step nodes (not Input/Output) - if not hasattr(disconnected_node, '_step_index') or disconnected_node._step_index < 0: - return - - disconnected_idx = disconnected_node._step_index - - # Find the node that the disconnected node was connected to (its output target) - next_node = None - if disconnected_node.output_ports(): - out_port = disconnected_node.output_ports()[0] - connected_ports = out_port.connected_ports() - if connected_ports: - next_node = connected_ports[0].node() - - # Build new order: move disconnected step to end - old_order = list(range(len(self._tool.steps))) - old_order.remove(disconnected_idx) - old_order.append(disconnected_idx) - - # Reconnect the graph visually - # Connect source to next (if next is a step node) - if next_node and hasattr(next_node, '_step_index') and next_node._step_index >= 0: - if source_node.output_ports() and next_node.input_ports(): - source_node.output_ports()[0].connect_to(next_node.input_ports()[0]) - - # Connect the last step (before disconnected) to disconnected node - # and disconnected node to output - # This will be handled by the rebuild after reorder - - # Emit the new order - self.steps_reordered.emit(old_order) - self.flow_changed.emit() + if hasattr(node, '_step_index') and node._step_index >= 0: + self.step_name_changed.emit(node._step_index, value) + self.flow_changed.emit() def refresh(self): """Refresh the graph from current tool data.""" diff --git a/src/cmdforge/gui/widgets/icons.py b/src/cmdforge/gui/widgets/icons.py new file mode 100644 index 0000000..984533e --- /dev/null +++ b/src/cmdforge/gui/widgets/icons.py @@ -0,0 +1,275 @@ +"""Step type icons for CmdForge GUI.""" + +from PySide6.QtGui import QIcon, QPixmap, QPainter, QColor, QPen, QBrush, QPainterPath, QFont +from PySide6.QtCore import Qt, QRect, QRectF + + +def create_prompt_icon(size: int = 24, color: QColor = None) -> QIcon: + """Create a speech bubble icon for prompt steps. + + Args: + size: Icon size in pixels + color: Icon color (defaults to indigo #667eea) + """ + if color is None: + color = QColor(102, 126, 234) # Indigo + + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + + # Draw speech bubble + pen = QPen(color, 1.5) + painter.setPen(pen) + painter.setBrush(QBrush(color)) + + # Bubble body + margin = size * 0.1 + bubble_rect = QRectF(margin, margin, size - 2 * margin, size * 0.65) + painter.drawRoundedRect(bubble_rect, 4, 4) + + # Bubble tail (triangle pointing down-left) + path = QPainterPath() + tail_x = margin + size * 0.2 + tail_y = bubble_rect.bottom() + path.moveTo(tail_x, tail_y - 2) + path.lineTo(tail_x - size * 0.1, tail_y + size * 0.2) + path.lineTo(tail_x + size * 0.15, tail_y - 2) + path.closeSubpath() + painter.drawPath(path) + + painter.end() + return QIcon(pixmap) + + +def create_code_icon(size: int = 24, color: QColor = None) -> QIcon: + """Create a code brackets icon for code steps. + + Args: + size: Icon size in pixels + color: Icon color (defaults to green #48bb78) + """ + if color is None: + color = QColor(72, 187, 120) # Green + + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + + pen = QPen(color, 2.0) + pen.setCapStyle(Qt.RoundCap) + painter.setPen(pen) + + # Draw < > brackets + margin = size * 0.15 + mid_y = size / 2 + bracket_height = size * 0.5 + + # Left bracket < + left_x = margin + size * 0.1 + painter.drawLine( + int(left_x + size * 0.2), int(mid_y - bracket_height / 2), + int(left_x), int(mid_y) + ) + painter.drawLine( + int(left_x), int(mid_y), + int(left_x + size * 0.2), int(mid_y + bracket_height / 2) + ) + + # Right bracket > + right_x = size - margin - size * 0.1 + painter.drawLine( + int(right_x - size * 0.2), int(mid_y - bracket_height / 2), + int(right_x), int(mid_y) + ) + painter.drawLine( + int(right_x), int(mid_y), + int(right_x - size * 0.2), int(mid_y + bracket_height / 2) + ) + + # Slash in middle / + slash_width = size * 0.12 + painter.drawLine( + int(size / 2 + slash_width), int(mid_y - bracket_height / 2.5), + int(size / 2 - slash_width), int(mid_y + bracket_height / 2.5) + ) + + painter.end() + return QIcon(pixmap) + + +def create_input_icon(size: int = 24, color: QColor = None) -> QIcon: + """Create an input icon (arrow pointing right into box). + + Args: + size: Icon size in pixels + color: Icon color (defaults to indigo #5a5a9f) + """ + if color is None: + color = QColor(90, 90, 160) # Indigo + + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + + pen = QPen(color, 2.0) + pen.setCapStyle(Qt.RoundCap) + painter.setPen(pen) + + margin = size * 0.15 + mid_y = size / 2 + + # Arrow pointing right + arrow_start = margin + arrow_end = size - margin - size * 0.15 + + # Arrow shaft + painter.drawLine(int(arrow_start), int(mid_y), int(arrow_end), int(mid_y)) + + # Arrow head + head_size = size * 0.2 + painter.drawLine( + int(arrow_end), int(mid_y), + int(arrow_end - head_size), int(mid_y - head_size) + ) + painter.drawLine( + int(arrow_end), int(mid_y), + int(arrow_end - head_size), int(mid_y + head_size) + ) + + painter.end() + return QIcon(pixmap) + + +def create_output_icon(size: int = 24, color: QColor = None) -> QIcon: + """Create an output icon (arrow pointing right out of box). + + Args: + size: Icon size in pixels + color: Icon color (defaults to orange #ed8936) + """ + if color is None: + color = QColor(237, 137, 54) # Orange + + pixmap = QPixmap(size, size) + pixmap.fill(Qt.transparent) + + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.Antialiasing) + + pen = QPen(color, 2.0) + pen.setCapStyle(Qt.RoundCap) + painter.setPen(pen) + + margin = size * 0.15 + mid_y = size / 2 + + # Arrow pointing right (export style) + arrow_start = margin + size * 0.15 + arrow_end = size - margin + + # Arrow shaft + painter.drawLine(int(arrow_start), int(mid_y), int(arrow_end), int(mid_y)) + + # Arrow head + head_size = size * 0.2 + painter.drawLine( + int(arrow_end), int(mid_y), + int(arrow_end - head_size), int(mid_y - head_size) + ) + painter.drawLine( + int(arrow_end), int(mid_y), + int(arrow_end - head_size), int(mid_y + head_size) + ) + + painter.end() + return QIcon(pixmap) + + +# Cached icons for reuse +_icon_cache = {} + +# Cached icon file paths for NodeGraphQt (which requires file paths) +_icon_path_cache = {} + + +def _get_icon_path(icon_type: str, size: int, create_func) -> str: + """Get or create a cached icon file path. + + NodeGraphQt requires file paths for icons, so we save to temp files. + """ + import os + import tempfile + + key = (icon_type, size) + if key not in _icon_path_cache: + icon = create_func(size) + pixmap = icon.pixmap(size, size) + + # Save to temp directory with a stable name + temp_dir = os.path.join(tempfile.gettempdir(), 'cmdforge_icons') + os.makedirs(temp_dir, exist_ok=True) + + path = os.path.join(temp_dir, f'{icon_type}_{size}.png') + pixmap.save(path, 'PNG') + _icon_path_cache[key] = path + + return _icon_path_cache[key] + + +def get_prompt_icon_path(size: int = 16) -> str: + """Get file path to prompt icon (for NodeGraphQt).""" + return _get_icon_path('prompt', size, create_prompt_icon) + + +def get_code_icon_path(size: int = 16) -> str: + """Get file path to code icon (for NodeGraphQt).""" + return _get_icon_path('code', size, create_code_icon) + + +def get_input_icon_path(size: int = 16) -> str: + """Get file path to input icon (for NodeGraphQt).""" + return _get_icon_path('input', size, create_input_icon) + + +def get_output_icon_path(size: int = 16) -> str: + """Get file path to output icon (for NodeGraphQt).""" + return _get_icon_path('output', size, create_output_icon) + + +def get_prompt_icon(size: int = 24) -> QIcon: + """Get cached prompt icon.""" + key = ('prompt', size) + if key not in _icon_cache: + _icon_cache[key] = create_prompt_icon(size) + return _icon_cache[key] + + +def get_code_icon(size: int = 24) -> QIcon: + """Get cached code icon.""" + key = ('code', size) + if key not in _icon_cache: + _icon_cache[key] = create_code_icon(size) + return _icon_cache[key] + + +def get_input_icon(size: int = 24) -> QIcon: + """Get cached input icon.""" + key = ('input', size) + if key not in _icon_cache: + _icon_cache[key] = create_input_icon(size) + return _icon_cache[key] + + +def get_output_icon(size: int = 24) -> QIcon: + """Get cached output icon.""" + key = ('output', size) + if key not in _icon_cache: + _icon_cache[key] = create_output_icon(size) + return _icon_cache[key]