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:
parent
bc970fb9f7
commit
8e6b03cade
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
Loading…
Reference in New Issue