Add step type icons and inline name editing

- Add icons module with programmatically drawn icons:
  - Speech bubble for prompt steps
  - Code brackets </> for code steps
  - Arrow icons for input/output nodes
- Display icons in list view next to step names
- Display icons in flow view node headers
- Add inline name editing in flow view via property_changed signal
- Sync name changes from flow view to tool model and list view

Co-Authored-By: Claude Opus 4.5 <[email protected]>
This commit is contained in:
rob
2026-01-16 01:21:20 -04:00
co-authored by Claude Opus 4.5
parent bc970fb9f7
commit 8e6b03cade
3 changed files with 378 additions and 53 deletions
+74 -3
View File
@@ -13,6 +13,7 @@ from ...tool import (
Tool, ToolArgument, PromptStep, CodeStep, Tool, ToolArgument, PromptStep, CodeStep,
load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES load_tool, save_tool, validate_tool_name, DEFAULT_CATEGORIES
) )
from ..widgets.icons import get_prompt_icon, get_code_icon
class ToolBuilderPage(QWidget): 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.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_deleted.connect(self._on_flow_steps_deleted)
self._flow_widget.steps_reordered.connect(self._on_flow_steps_reordered) 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 # Replace placeholder
old_widget = self.steps_stack.widget(1) 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) # Refresh the list view (flow view will be refreshed by set_tool)
self._refresh_steps() 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): def _on_flow_steps_reordered(self, new_order: list):
"""Handle step reordering from flow view. """Handle step reordering from flow view.
@@ -371,15 +384,20 @@ class ToolBuilderPage(QWidget):
prompt_count += 1 prompt_count += 1
# Use custom name if set, otherwise default per-type naming # Use custom name if set, otherwise default per-type naming
step_name = step.name if step.name else f"Prompt {prompt_count}" 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): elif isinstance(step, CodeStep):
code_count += 1 code_count += 1
step_name = step.name if step.name else f"Code {code_count}" 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: else:
text = f"{i}. Unknown step" text = f"Unknown step"
icon = None
item = QListWidgetItem(text) item = QListWidgetItem(text)
if icon:
item.setIcon(icon)
item.setData(Qt.UserRole, step) item.setData(Qt.UserRole, step)
# Check for broken dependencies # Check for broken dependencies
@@ -401,6 +419,59 @@ class ToolBuilderPage(QWidget):
if self._flow_widget: if self._flow_widget:
self._flow_widget.set_tool(self._tool) 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): def _add_argument(self):
"""Add a new argument.""" """Add a new argument."""
from ..dialogs.argument_dialog import ArgumentDialog from ..dialogs.argument_dialog import ArgumentDialog
+28 -49
View File
@@ -9,6 +9,10 @@ from PySide6.QtGui import QKeyEvent, QAction
from NodeGraphQt import NodeGraph, BaseNode from NodeGraphQt import NodeGraph, BaseNode
from ...tool import Tool, PromptStep, CodeStep 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): def __init__(self):
super().__init__() super().__init__()
self.set_color(90, 90, 160) # Indigo self.set_color(90, 90, 160) # Indigo
self.set_icon(get_input_icon_path(16))
# Default output for stdin # Default output for stdin
self.add_output('input', color=(180, 180, 250)) self.add_output('input', color=(180, 180, 250))
@@ -56,7 +61,8 @@ class PromptNode(CmdForgeBaseNode):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.set_color(102, 126, 234) # Indigo (matching CmdForge theme) 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)) self.add_output('out', color=(180, 250, 180))
# Add properties for display # Add properties for display
@@ -82,7 +88,8 @@ class CodeNode(CmdForgeBaseNode):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.set_color(72, 187, 120) # Green 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)) self.add_output('out', color=(250, 220, 180))
# Add properties # Add properties
@@ -106,7 +113,8 @@ class OutputNode(CmdForgeBaseNode):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self.set_color(237, 137, 54) # Orange 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): 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) # Emitted when a node is double-clicked (step_index, step_type)
node_double_clicked = Signal(int, str) 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) # Emitted when steps are reordered (new order as list of step indices)
steps_reordered = Signal(list) 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): def __init__(self, parent=None):
super().__init__(parent) super().__init__(parent)
self._tool: Optional[Tool] = None self._tool: Optional[Tool] = None
@@ -157,7 +173,7 @@ class FlowGraphWidget(QWidget):
# Connect signals # Connect signals
self._graph.node_double_clicked.connect(self._on_node_double_clicked) self._graph.node_double_clicked.connect(self._on_node_double_clicked)
self._graph.nodes_deleted.connect(self._on_nodes_deleted) 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 # Add graph widget
layout.addWidget(self._graph.widget, 1) layout.addWidget(self._graph.widget, 1)
@@ -172,7 +188,6 @@ class FlowGraphWidget(QWidget):
"Zoom: Scroll | " "Zoom: Scroll | "
"Select: Click/drag | " "Select: Click/drag | "
"Edit: Double-click | " "Edit: Double-click | "
"Cut links: Alt+Shift+drag | "
"Select All: A | " "Select All: A | "
"Fit: F" "Fit: F"
) )
@@ -449,58 +464,22 @@ class FlowGraphWidget(QWidget):
self.steps_deleted.emit(deleted_indices) self.steps_deleted.emit(deleted_indices)
self.flow_changed.emit() self.flow_changed.emit()
def _on_port_disconnected(self, input_port, output_port): def _on_property_changed(self, node, prop_name, value):
"""Handle port disconnection - reorder steps. """Handle node property change.
When a connection is broken: Filters for 'name' property to detect step name changes.
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
""" """
# Ignore during rebuild # Ignore during rebuild
if self._rebuilding: if self._rebuilding:
return return
if not self._tool or not self._tool.steps: # Only interested in name changes
if prop_name != 'name':
return 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) # Only handle step nodes (not Input/Output)
if not hasattr(disconnected_node, '_step_index') or disconnected_node._step_index < 0: if hasattr(node, '_step_index') and node._step_index >= 0:
return self.step_name_changed.emit(node._step_index, value)
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() self.flow_changed.emit()
def refresh(self): def refresh(self):
+275
View File
@@ -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]