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 <noreply@anthropic.com>
This commit is contained in:
rob 2026-01-16 01:21:20 -04:00
parent bc970fb9f7
commit 8e6b03cade
3 changed files with 378 additions and 53 deletions

View File

@ -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

View File

@ -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."""

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]