Compare commits

...

14 Commits

Author SHA1 Message Date
rob 8e6b03cade 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>
2026-01-16 01:21:20 -04:00
rob bc970fb9f7 Update help banner with link cutting shortcut
Added "Cut links: Alt+Shift+drag" to help banner.
Shortened other labels to fit.

In NodeGraphQt, connections are cut by:
- Alt+Shift+Left-drag to slice through connections
- Dragging a connection endpoint to empty space

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:59:35 -04:00
rob 683a8a6d8f Fix signal handling during graph rebuild
- Add _rebuilding flag to ignore signals during rebuild
- Prevents cascade of port_disconnected signals when clearing graph
- Fixes KeyError in NodeGraphQt undo stack
- Fixes false dependency warnings on view switch

The port_disconnected and nodes_deleted signals were firing during
clear_session(), causing recursive rebuilds and errors.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:54:48 -04:00
rob d1f0c2f893 Add flow view reordering via connection breaking
When a connection is broken in the flow view:
1. The node that lost its input moves to the end of the chain
2. The remaining nodes reconnect automatically
3. Tool model updates to reflect new order
4. Dependency validation warns about broken references

Flow: Input → A → B → C → Output
Break B's input: Input → A → C → B → Output

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:50:18 -04:00
rob 260ebf1b2f Fix false positives in variable dependency validation
Only parse {variable} patterns in PromptSteps, not CodeSteps.

In CodeSteps, variables are available directly as Python variables
(e.g., use `input` not `{input}`). The {var} syntax in code is
typically Python f-string or .format() syntax, not CmdForge
template substitution.

This fixes false positives like artifact-ai where Python f-strings
like f"{format_guide}" were incorrectly flagged as missing CmdForge
variable references.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:29:13 -04:00
rob b8760eb208 Add drag-reorder, validation, and flow-to-tool sync
List View:
- Enable drag-drop reordering of steps
- Show broken dependency warnings when reordering
- Steps with missing variable refs shown in red with tooltip

Validation:
- Parse {variable} references from prompts and code
- Track available variables at each step position
- Warn when steps reference undefined variables

Flow View Sync:
- Deleting nodes in flow view updates the tool model
- steps_deleted signal propagates changes to builder
- Both views stay in sync

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 23:13:44 -04:00
rob 8ab0fba67c Change Fit All to Fit Selection with Select All shortcut
- F key now fits to selected nodes (or all if none selected)
- A key selects all nodes
- Workflow: drag-select nodes then F to zoom, or A then F for all
- Updated help banner and context menu
- Context menu now has Select All (A) and Fit Selection (F)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:53:47 -04:00
rob 827f0f9eef Fix help banner visibility
- Show banner on showEvent (when flow view becomes visible)
- Show banner on mouse enter event
- Raise banner to front to ensure visibility
- Recalculate size before positioning

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:41:03 -04:00
rob fb879d09ea Improve flow view UX and fix step naming
- Remove Fit All button, F key shortcut is sufficient
- Add right-click context menu with "Fit All (F)" option
- Add floating help banner that appears on focus and auto-hides
- Banner stays visible when mouse hovers over it
- Fix default step naming to increment per type (Code 1, Prompt 1, Code 2)
  instead of globally (Code 1, Prompt 2, Code 3)
- List view still shows overall step number (1, 2, 3) with per-type names

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:36:13 -04:00
rob 4d9a0e5943 Add CmdForge welcome page to sidebar
- Create welcome_page.py with branded landing page
- Add quick action cards for common tasks (Create Tool, Registry, etc.)
- Add "CmdForge" entry at top of sidebar navigation
- Update page indices for navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:18:00 -04:00
rob 88c9a8a1e7 Add custom step names and fix F key shortcut
- Add name field to PromptStep, CodeStep, and ToolStep dataclasses
- Update step dialogs to allow editing step names
- Display custom step names in both list and flow views
- Fix F key shortcut by installing event filter on graph widget
- Steps without custom names show default "Prompt N" / "Code N"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 22:16:15 -04:00
rob 3c39cc9cda Improve flow visualization UX
- Add navigation help bar at top of flow view with instructions:
  Pan, Zoom, Select, Edit step shortcuts
- Fix zoom to fit all nodes on initial load (was only centering last node)
- Use QTimer to ensure fit happens after widget is fully rendered
- Clear selection after fitting so nodes don't appear selected

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:52:36 -04:00
rob ad9a59283c Add patch for NodeGraphQt PySide6 6.7+ compatibility
The setSelectionArea() API changed in PySide6 6.7 - it now requires
an ItemSelectionOperation parameter before ItemSelectionMode.

Files added:
- patches/nodegraphqt_pyside6_compat.patch: Git-style patch file
- patches/UPSTREAM_ISSUE.md: Issue report for upstream project
- scripts/patch_nodegraphqt.py: Script to auto-apply the patch

The patch fixes rubber band selection errors when dragging in the
node graph viewer. Run after pip install:

    python scripts/patch_nodegraphqt.py

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 19:10:10 -04:00
rob 042be8b49b Add flow visualization for tool builder
Implements node-based flow visualization using NodeGraphQt-QuiltiX-fork:

New files:
- src/cmdforge/gui/widgets/flow_graph.py: FlowGraphWidget with custom node types
  - InputNode: Shows tool inputs ($input + argument variables)
  - PromptNode: AI prompt steps with provider display
  - CodeNode: Python code steps
  - OutputNode: Final output
- scripts/test_nodegraph.py: Standalone prototype for testing

Modified files:
- tool_builder_page.py: Added List/Flow view toggle with QStackedWidget
  - Lazy-loads flow widget on first use
  - Syncs between list and flow views
  - Double-click nodes to edit steps
- styles.py: Added viewToggle button styling
- widgets/__init__.py: Export FlowGraphWidget
- pyproject.toml: Added 'flow' optional dependency group

Features:
- Toggle between List and Flow views in Steps panel
- Nodes auto-connected based on step order
- Auto-layout and fit-to-view on load
- Double-click nodes opens step edit dialog
- Graceful fallback if NodeGraphQt not installed

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 17:07:35 -04:00
15 changed files with 1693 additions and 20 deletions

80
patches/UPSTREAM_ISSUE.md Normal file
View File

@ -0,0 +1,80 @@
# PySide6 6.7+ Compatibility Issue: setSelectionArea API Change
## Issue
When using NodeGraphQt-QuiltiX-fork with PySide6 6.7+, dragging a rubber band selection in the node graph causes repeated TypeError exceptions:
```
TypeError: 'PySide6.QtWidgets.QGraphicsScene.setSelectionArea' called with wrong argument types:
PySide6.QtWidgets.QGraphicsScene.setSelectionArea(QPainterPath, ItemSelectionMode)
Supported signatures:
PySide6.QtWidgets.QGraphicsScene.setSelectionArea(PySide6.QtGui.QPainterPath, PySide6.QtGui.QTransform)
PySide6.QtWidgets.QGraphicsScene.setSelectionArea(PySide6.QtGui.QPainterPath, PySide6.QtCore.Qt.ItemSelectionOperation = Instance(Qt.ReplaceSelection), PySide6.QtCore.Qt.ItemSelectionMode = Instance(Qt.IntersectsItemShape), PySide6.QtGui.QTransform = Default(QTransform))
```
## Root Cause
The `QGraphicsScene.setSelectionArea()` method signature changed in PySide6 6.7. The old signature allowed:
```python
setSelectionArea(path, mode) # mode = Qt.ItemSelectionMode
```
The new signature requires:
```python
setSelectionArea(path, operation, mode) # operation = Qt.ItemSelectionOperation
```
## Affected File
`NodeGraphQt/widgets/viewer.py` line 524-526
## Current Code
```python
self.scene().setSelectionArea(
path, QtCore.Qt.IntersectsItemShape
)
```
## Fix
```python
self.scene().setSelectionArea(
path, QtCore.Qt.ReplaceSelection,
QtCore.Qt.IntersectsItemShape
)
```
## Environment
- Python: 3.12
- PySide6: 6.6.3.1 (installed by NodeGraphQt-QuiltiX-fork, but issue affects 6.7+)
- NodeGraphQt-QuiltiX-fork: 0.7.0
## Workaround
Apply this patch after installation:
```python
# In viewer.py, change line 524-526 from:
self.scene().setSelectionArea(
path, QtCore.Qt.IntersectsItemShape
)
# To:
self.scene().setSelectionArea(
path, QtCore.Qt.ReplaceSelection,
QtCore.Qt.IntersectsItemShape
)
```
## Related
This issue may also exist in:
- Original NodeGraphQt
- OdenGraphQt
- Other forks
The fix is backward compatible with older PySide6 versions that support the 3-argument signature.

View File

@ -0,0 +1,13 @@
--- a/NodeGraphQt/widgets/viewer.py
+++ b/NodeGraphQt/widgets/viewer.py
@@ -521,8 +521,10 @@ class NodeViewer(QtWidgets.QGraphicsView):
path = QtGui.QPainterPath()
path.addRect(map_rect)
self._rubber_band.setGeometry(rect)
+ # PySide6 6.7+ requires ItemSelectionOperation parameter
self.scene().setSelectionArea(
- path, QtCore.Qt.IntersectsItemShape
+ path, QtCore.Qt.ReplaceSelection,
+ QtCore.Qt.IntersectsItemShape
)
self.scene().update(map_rect)

View File

@ -46,11 +46,17 @@ registry = [
"sentry-sdk[flask]>=1.0",
"gunicorn>=21.0",
]
flow = [
"NodeGraphQt-QuiltiX-fork[pyside6]>=0.7.0",
"setuptools", # Required for distutils compatibility
]
all = [
"Flask>=2.3",
"argon2-cffi>=21.0",
"sentry-sdk[flask]>=1.0",
"gunicorn>=21.0",
"NodeGraphQt-QuiltiX-fork[pyside6]>=0.7.0",
"setuptools",
]
[project.scripts]

View File

@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Patch NodeGraphQt for PySide6 6.7+ compatibility.
The setSelectionArea() API changed in PySide6 6.7 to require an
ItemSelectionOperation parameter. This script patches the installed
NodeGraphQt package to work with newer PySide6 versions.
Issue: setSelectionArea(path, mode) -> setSelectionArea(path, operation, mode)
Usage:
python scripts/patch_nodegraphqt.py
This can be run after `pip install NodeGraphQt-QuiltiX-fork[pyside6]`
"""
import sys
import site
from pathlib import Path
def find_viewer_file():
"""Find the NodeGraphQt viewer.py file in site-packages."""
# Check common locations
for site_dir in site.getsitepackages() + [site.getusersitepackages()]:
viewer_path = Path(site_dir) / "NodeGraphQt" / "widgets" / "viewer.py"
if viewer_path.exists():
return viewer_path
# Check virtual environment
venv_path = Path(sys.prefix) / "lib"
for python_dir in venv_path.glob("python*"):
viewer_path = python_dir / "site-packages" / "NodeGraphQt" / "widgets" / "viewer.py"
if viewer_path.exists():
return viewer_path
return None
def patch_file(viewer_path: Path) -> bool:
"""Apply the PySide6 compatibility patch."""
content = viewer_path.read_text()
# Check if already patched
if "Qt.ReplaceSelection" in content:
print(f"Already patched: {viewer_path}")
return True
# The old code pattern
old_code = """self.scene().setSelectionArea(
path, QtCore.Qt.IntersectsItemShape
)"""
# The new code with ItemSelectionOperation
new_code = """self.scene().setSelectionArea(
path, QtCore.Qt.ReplaceSelection,
QtCore.Qt.IntersectsItemShape
)"""
if old_code not in content:
print(f"Warning: Could not find code to patch in {viewer_path}")
print("The file may have a different format or already be modified.")
return False
# Apply patch
patched_content = content.replace(old_code, new_code)
# Backup original
backup_path = viewer_path.with_suffix(".py.bak")
if not backup_path.exists():
viewer_path.rename(backup_path)
print(f"Backup created: {backup_path}")
# Write patched file
viewer_path.write_text(patched_content)
print(f"Patched successfully: {viewer_path}")
return True
def main():
print("NodeGraphQt PySide6 Compatibility Patch")
print("=" * 40)
viewer_path = find_viewer_file()
if not viewer_path:
print("Error: Could not find NodeGraphQt installation.")
print("Make sure NodeGraphQt-QuiltiX-fork is installed:")
print(" pip install 'NodeGraphQt-QuiltiX-fork[pyside6]'")
return 1
print(f"Found: {viewer_path}")
if patch_file(viewer_path):
print("\nPatch applied successfully!")
print("The rubber band selection should now work in PySide6 6.7+")
return 0
else:
print("\nPatch failed.")
return 1
if __name__ == "__main__":
sys.exit(main())

127
scripts/test_nodegraph.py Normal file
View File

@ -0,0 +1,127 @@
#!/usr/bin/env python3
"""Test script for NodeGraphQt integration."""
import sys
from PySide6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget
from NodeGraphQt import NodeGraph, BaseNode
class InputNode(BaseNode):
"""Node representing tool input."""
__identifier__ = 'cmdforge.nodes'
NODE_NAME = 'Input'
def __init__(self):
super().__init__()
self.set_color(100, 100, 180) # Blue-ish
self.add_output('input', color=(180, 180, 250))
self.add_output('file', color=(180, 180, 250))
class PromptNode(BaseNode):
"""Node representing a prompt step."""
__identifier__ = 'cmdforge.nodes'
NODE_NAME = 'Prompt'
def __init__(self):
super().__init__()
self.set_color(80, 120, 180) # Indigo-ish
self.add_input('in', color=(180, 180, 250))
self.add_output('response', color=(180, 250, 180))
# Add properties
self.add_text_input('provider', 'Provider', text='claude')
self.add_text_input('output_var', 'Output Var', text='response')
class CodeNode(BaseNode):
"""Node representing a code step."""
__identifier__ = 'cmdforge.nodes'
NODE_NAME = 'Code'
def __init__(self):
super().__init__()
self.set_color(80, 160, 100) # Green-ish
self.add_input('in', color=(180, 250, 180))
self.add_output('result', color=(250, 220, 180))
# Add properties
self.add_text_input('output_var', 'Output Var', text='result')
class OutputNode(BaseNode):
"""Node representing tool output."""
__identifier__ = 'cmdforge.nodes'
NODE_NAME = 'Output'
def __init__(self):
super().__init__()
self.set_color(180, 120, 80) # Orange-ish
self.add_input('in', color=(250, 220, 180))
def main():
app = QApplication(sys.argv)
# Create main window
window = QMainWindow()
window.setWindowTitle('CmdForge Flow Visualization - Prototype')
window.setGeometry(100, 100, 1000, 700)
# Create node graph
graph = NodeGraph()
# Register our custom nodes
graph.register_node(InputNode)
graph.register_node(PromptNode)
graph.register_node(CodeNode)
graph.register_node(OutputNode)
# Get the graph widget
graph_widget = graph.widget
# Set as central widget
window.setCentralWidget(graph_widget)
# Create a sample tool visualization
input_node = graph.create_node('cmdforge.nodes.InputNode', name='Input')
input_node.set_pos(-300, 0)
prompt_node = graph.create_node('cmdforge.nodes.PromptNode', name='Summarize')
prompt_node.set_pos(-50, 0)
prompt_node.set_property('provider', 'claude')
prompt_node.set_property('output_var', 'response')
code_node = graph.create_node('cmdforge.nodes.CodeNode', name='Format')
code_node.set_pos(200, 0)
code_node.set_property('output_var', 'formatted')
output_node = graph.create_node('cmdforge.nodes.OutputNode', name='Output')
output_node.set_pos(450, 0)
# Connect nodes
input_node.output(0).connect_to(prompt_node.input(0))
prompt_node.output(0).connect_to(code_node.input(0))
code_node.output(0).connect_to(output_node.input(0))
# Auto-layout
graph.auto_layout_nodes()
# Fit view
graph.fit_to_selection()
window.show()
print("Flow visualization prototype running...")
print("Nodes created: Input -> Prompt -> Code -> Output")
print("Close the window to exit.")
sys.exit(app.exec())
if __name__ == '__main__':
main()

View File

@ -36,6 +36,11 @@ class PromptStepDialog(QDialog):
form = QFormLayout()
form.setSpacing(12)
# Step name (optional)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Optional display name")
form.addRow("Step name:", self.name_input)
# Provider selection
self.provider_combo = QComboBox()
providers = load_providers()
@ -95,6 +100,10 @@ class PromptStepDialog(QDialog):
def _load_step(self, step: PromptStep):
"""Load step data into form."""
# Load name
if step.name:
self.name_input.setText(step.name)
idx = self.provider_combo.findText(step.provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
@ -130,11 +139,14 @@ class PromptStepDialog(QDialog):
# Don't store "None" profile
if profile == "None":
profile = None
# Get name, use None if empty
name = self.name_input.text().strip() or None
return PromptStep(
prompt=self.prompt_input.toPlainText(),
provider=self.provider_combo.currentText(),
output_var=self.output_input.text().strip(),
profile=profile
profile=profile,
name=name
)
@ -179,9 +191,15 @@ class CodeStepDialog(QDialog):
layout = QVBoxLayout(self)
layout.setSpacing(12)
# Top: Output variable
# Top: Step name and Output variable
form = QFormLayout()
form.setSpacing(8)
# Step name (optional)
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Optional display name")
form.addRow("Step name:", self.name_input)
self.output_input = QLineEdit()
self.output_input.setPlaceholderText("result")
self.output_input.setText("result")
@ -365,6 +383,9 @@ IMPORTANT: Return ONLY executable inline Python code. No function definitions, n
def _load_step(self, step: CodeStep):
"""Load step data into form."""
# Load name
if step.name:
self.name_input.setText(step.name)
self.output_input.setText(step.output_var)
self.code_input.setPlainText(step.code)
@ -396,7 +417,10 @@ IMPORTANT: Return ONLY executable inline Python code. No function definitions, n
def get_step(self) -> CodeStep:
"""Get the step from form data."""
# Get name, use None if empty
name = self.name_input.text().strip() or None
return CodeStep(
code=self.code_input.toPlainText(),
output_var=self.output_input.text().strip()
output_var=self.output_input.text().strip(),
name=name
)

View File

@ -71,6 +71,7 @@ class MainWindow(QMainWindow):
def _setup_sidebar(self):
"""Set up sidebar navigation items."""
items = [
("CmdForge", "Welcome to CmdForge"),
("Tools", "Manage your tools"),
("Registry", "Browse and install tools"),
("Providers", "Configure AI providers"),
@ -80,26 +81,34 @@ class MainWindow(QMainWindow):
font = QFont()
font.setPointSize(11)
for name, tooltip in items:
for i, (name, tooltip) in enumerate(items):
item = QListWidgetItem(name)
item.setFont(font)
item.setToolTip(tooltip)
item.setSizeHint(QSize(180, 48))
# Style the CmdForge item differently
if i == 0:
bold_font = QFont(font)
bold_font.setBold(True)
item.setFont(bold_font)
self.sidebar.addItem(item)
def _setup_pages(self):
"""Set up content pages."""
# Import pages here to avoid circular imports
from .pages.welcome_page import WelcomePage
from .pages.tools_page import ToolsPage
from .pages.registry_page import RegistryPage
from .pages.providers_page import ProvidersPage
from .pages.profiles_page import ProfilesPage
self.welcome_page = WelcomePage(self)
self.tools_page = ToolsPage(self)
self.registry_page = RegistryPage(self)
self.providers_page = ProvidersPage(self)
self.profiles_page = ProfilesPage(self)
self.pages.addWidget(self.welcome_page)
self.pages.addWidget(self.tools_page)
self.pages.addWidget(self.registry_page)
self.pages.addWidget(self.providers_page)
@ -121,10 +130,12 @@ class MainWindow(QMainWindow):
def navigate_to(self, page_name: str):
"""Navigate to a specific page by name."""
page_map = {
"tools": 0,
"registry": 1,
"providers": 2,
"profiles": 3,
"welcome": 0,
"cmdforge": 0,
"tools": 1,
"registry": 2,
"providers": 3,
"profiles": 4,
}
if page_name.lower() in page_map:
self.sidebar.setCurrentRow(page_map[page_name.lower()])

View File

@ -1,8 +1,9 @@
"""GUI pages."""
from .welcome_page import WelcomePage
from .tools_page import ToolsPage
from .tool_builder_page import ToolBuilderPage
from .registry_page import RegistryPage
from .providers_page import ProvidersPage
__all__ = ["ToolsPage", "ToolBuilderPage", "RegistryPage", "ProvidersPage"]
__all__ = ["WelcomePage", "ToolsPage", "ToolBuilderPage", "RegistryPage", "ProvidersPage"]

View File

@ -4,7 +4,8 @@ from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFormLayout,
QLineEdit, QTextEdit, QComboBox, QPushButton,
QGroupBox, QListWidget, QListWidgetItem, QLabel,
QMessageBox, QSplitter, QFrame
QMessageBox, QSplitter, QFrame, QStackedWidget,
QButtonGroup
)
from PySide6.QtCore import Qt
@ -12,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):
@ -23,6 +25,7 @@ class ToolBuilderPage(QWidget):
self.editing = tool_name is not None
self.original_name = tool_name
self._tool = None
self._flow_widget = None # Lazy-loaded
self._setup_ui()
@ -123,14 +126,61 @@ class ToolBuilderPage(QWidget):
right_layout.setContentsMargins(0, 0, 0, 0)
right_layout.setSpacing(16)
# Steps group
# Steps group with view toggle
steps_box = QGroupBox("Steps")
steps_layout = QVBoxLayout(steps_box)
# View toggle header
view_header = QHBoxLayout()
self.btn_list_view = QPushButton("List")
self.btn_list_view.setCheckable(True)
self.btn_list_view.setChecked(True)
self.btn_list_view.setObjectName("viewToggle")
self.btn_list_view.clicked.connect(lambda: self._set_view_mode(0))
self.btn_flow_view = QPushButton("Flow")
self.btn_flow_view.setCheckable(True)
self.btn_flow_view.setObjectName("viewToggle")
self.btn_flow_view.clicked.connect(lambda: self._set_view_mode(1))
# Button group for mutual exclusivity
self.view_group = QButtonGroup(self)
self.view_group.addButton(self.btn_list_view, 0)
self.view_group.addButton(self.btn_flow_view, 1)
view_header.addWidget(self.btn_list_view)
view_header.addWidget(self.btn_flow_view)
view_header.addStretch()
steps_layout.addLayout(view_header)
# Stacked widget for list/flow views
self.steps_stack = QStackedWidget()
# List view (index 0)
list_container = QWidget()
list_layout = QVBoxLayout(list_container)
list_layout.setContentsMargins(0, 0, 0, 0)
self.steps_list = QListWidget()
self.steps_list.itemDoubleClicked.connect(self._edit_step)
steps_layout.addWidget(self.steps_list)
# 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)
# Flow view placeholder (index 1) - lazy loaded
flow_placeholder = QLabel("Loading flow visualization...")
flow_placeholder.setAlignment(Qt.AlignCenter)
flow_placeholder.setStyleSheet("color: #718096; padding: 40px;")
self.steps_stack.addWidget(flow_placeholder)
steps_layout.addWidget(self.steps_stack)
# Step action buttons
steps_btns = QHBoxLayout()
self.btn_add_prompt = QPushButton("Add Prompt")
self.btn_add_prompt.clicked.connect(self._add_prompt_step)
@ -173,6 +223,108 @@ class ToolBuilderPage(QWidget):
layout.addWidget(splitter, 1)
def _set_view_mode(self, mode: int):
"""Switch between list (0) and flow (1) views."""
if mode == 1 and self._flow_widget is None:
# Lazy-load flow widget
self._init_flow_widget()
self.steps_stack.setCurrentIndex(mode)
# Update flow widget if visible
if mode == 1 and self._flow_widget:
self._flow_widget.set_tool(self._tool)
def _init_flow_widget(self):
"""Initialize the flow visualization widget."""
try:
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)
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)
self.steps_stack.removeWidget(old_widget)
old_widget.deleteLater()
self.steps_stack.insertWidget(1, self._flow_widget)
# Set current tool
if self._tool:
self._flow_widget.set_tool(self._tool)
except ImportError as e:
# NodeGraphQt not available
error_label = QLabel(f"Flow visualization unavailable:\n{e}")
error_label.setAlignment(Qt.AlignCenter)
error_label.setStyleSheet("color: #e53e3e; padding: 40px;")
old_widget = self.steps_stack.widget(1)
self.steps_stack.removeWidget(old_widget)
old_widget.deleteLater()
self.steps_stack.insertWidget(1, error_label)
def _on_flow_node_double_clicked(self, step_index: int, step_type: str):
"""Handle double-click on a flow node."""
if not self._tool or step_index < 0 or step_index >= len(self._tool.steps):
return
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 _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.
Args:
new_order: List of old indices in new order.
e.g., [0, 2, 1] means step 0 stays first,
step 2 moves to second, step 1 moves to third.
"""
if not self._tool or not self._tool.steps:
return
# Reorder steps according to new_order
old_steps = self._tool.steps[:]
new_steps = [old_steps[i] for i in new_order if i < len(old_steps)]
# 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)
self._tool.steps = new_steps
# Refresh both views
self._refresh_steps()
def _load_tool(self, name: str):
"""Load an existing tool for editing."""
tool = load_tool(name)
@ -213,20 +365,113 @@ class ToolBuilderPage(QWidget):
self.args_list.addItem(item)
def _refresh_steps(self):
"""Refresh steps list."""
"""Refresh steps list and flow view."""
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):
text = f"{i}. Prompt [{step.provider}] → ${step.output_var}"
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"{step_name} [{step.provider}] → ${step.output_var}"
icon = get_prompt_icon(20)
elif isinstance(step, CodeStep):
text = f"{i}. Code [python] → ${step.output_var}"
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"{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
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)
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
@ -315,14 +560,17 @@ class ToolBuilderPage(QWidget):
self._refresh_steps()
def _edit_step(self):
"""Edit selected step."""
"""Edit selected step from list view."""
items = self.steps_list.selectedItems()
if not items:
return
step = items[0].data(Qt.UserRole)
idx = self.steps_list.row(items[0])
self._edit_step_at_index(idx, step)
def _edit_step_at_index(self, idx: int, step):
"""Edit a step at a specific index."""
if isinstance(step, PromptStep):
from ..dialogs.step_dialog import PromptStepDialog
dialog = PromptStepDialog(self, step)
@ -347,6 +595,90 @@ 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.
Note: CodeSteps are NOT parsed because variables are available directly
as Python variables, and {var} in code is typically Python f-string
or .format() syntax, not CmdForge substitution.
"""
import re
refs = set()
# Pattern to match {variable} but not {{escaped}}
pattern = r'\{(\w+)\}'
if isinstance(step, PromptStep):
# Parse prompt template - these ARE CmdForge substitutions
matches = re.findall(pattern, step.prompt or "")
refs.update(matches)
# CodeStep: Don't parse - variables are available as Python vars directly
# and {var} syntax is typically Python string formatting
return refs
def _save(self):
"""Save the tool."""
name = self.name_input.text().strip()

View File

@ -0,0 +1,175 @@
"""Welcome page - CmdForge landing page."""
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
QFrame, QGridLayout, QSizePolicy
)
from PySide6.QtCore import Qt
class WelcomePage(QWidget):
"""CmdForge welcome/landing page."""
def __init__(self, main_window):
super().__init__()
self.main_window = main_window
self._setup_ui()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(48, 48, 48, 48)
layout.setSpacing(32)
# Top spacer
layout.addStretch(1)
# Logo/title
title = QLabel("CmdForge")
title.setStyleSheet("""
font-size: 48px;
font-weight: bold;
color: #667eea;
""")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# Tagline
tagline = QLabel("Build AI-powered command-line tools")
tagline.setStyleSheet("""
font-size: 18px;
color: #718096;
""")
tagline.setAlignment(Qt.AlignCenter)
layout.addWidget(tagline)
layout.addSpacing(24)
# Quick actions
actions_container = QWidget()
actions_container.setMaximumWidth(600)
actions_layout = QGridLayout(actions_container)
actions_layout.setSpacing(16)
# Create tool card
create_card = self._create_action_card(
"Create Tool",
"Build a new AI-powered command-line tool with prompts, code, and data flow",
"New Tool",
self._on_create_tool
)
actions_layout.addWidget(create_card, 0, 0)
# Browse registry card
registry_card = self._create_action_card(
"Browse Registry",
"Discover and install tools shared by the community",
"Open Registry",
self._on_browse_registry
)
actions_layout.addWidget(registry_card, 0, 1)
# Manage tools card
tools_card = self._create_action_card(
"My Tools",
"View, edit, and run your existing tools",
"Open Tools",
self._on_open_tools
)
actions_layout.addWidget(tools_card, 1, 0)
# Providers card
providers_card = self._create_action_card(
"AI Providers",
"Configure AI backends like Claude, GPT, and custom providers",
"Configure",
self._on_open_providers
)
actions_layout.addWidget(providers_card, 1, 1)
# Center the actions container
actions_wrapper = QHBoxLayout()
actions_wrapper.addStretch()
actions_wrapper.addWidget(actions_container)
actions_wrapper.addStretch()
layout.addLayout(actions_wrapper)
# Bottom spacer
layout.addStretch(2)
# Footer
footer = QLabel("CmdForge - AI Tool Builder")
footer.setStyleSheet("color: #a0aec0; font-size: 12px;")
footer.setAlignment(Qt.AlignCenter)
layout.addWidget(footer)
def _create_action_card(self, title: str, description: str, button_text: str, callback) -> QFrame:
"""Create an action card widget."""
card = QFrame()
card.setStyleSheet("""
QFrame {
background-color: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 16px;
}
QFrame:hover {
border-color: #667eea;
}
""")
card.setMinimumSize(260, 140)
layout = QVBoxLayout(card)
layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(8)
# Title
title_label = QLabel(title)
title_label.setStyleSheet("font-size: 16px; font-weight: 600; color: #2d3748; border: none;")
layout.addWidget(title_label)
# Description
desc_label = QLabel(description)
desc_label.setWordWrap(True)
desc_label.setStyleSheet("font-size: 13px; color: #718096; border: none;")
layout.addWidget(desc_label, 1)
# Button
btn = QPushButton(button_text)
btn.clicked.connect(callback)
btn.setStyleSheet("""
QPushButton {
background-color: #667eea;
color: white;
border: none;
border-radius: 4px;
padding: 8px 16px;
font-weight: 500;
}
QPushButton:hover {
background-color: #5a67d8;
}
""")
layout.addWidget(btn)
return card
def _on_create_tool(self):
"""Handle create tool action."""
self.main_window.open_tool_builder()
def _on_browse_registry(self):
"""Handle browse registry action."""
self.main_window.navigate_to("registry")
def _on_open_tools(self):
"""Handle open tools action."""
self.main_window.navigate_to("tools")
def _on_open_providers(self):
"""Handle open providers action."""
self.main_window.navigate_to("providers")
def refresh(self):
"""Refresh the page (nothing to refresh)."""
pass

View File

@ -76,6 +76,25 @@ QPushButton#danger:hover {
background-color: #c53030;
}
/* View toggle buttons */
QPushButton#viewToggle {
background-color: #e2e8f0;
color: #4a5568;
border-radius: 4px;
padding: 6px 12px;
min-height: 24px;
min-width: 60px;
}
QPushButton#viewToggle:checked {
background-color: #667eea;
color: white;
}
QPushButton#viewToggle:hover:!checked {
background-color: #cbd5e0;
}
/* Input fields */
QLineEdit, QTextEdit, QPlainTextEdit {
border: 1px solid #e2e8f0;

View File

@ -1 +1,5 @@
"""Custom widgets for CmdForge GUI."""
from .flow_graph import FlowGraphWidget
__all__ = ['FlowGraphWidget']

View File

@ -0,0 +1,491 @@
"""Flow visualization widget using NodeGraphQt."""
from typing import Optional, List
from PySide6.QtWidgets import QWidget, QVBoxLayout, QLabel, QMenu
from PySide6.QtCore import Signal, Qt, QTimer, QEvent, QPropertyAnimation, QEasingCurve
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
)
# =============================================================================
# Custom Node Types
# =============================================================================
class CmdForgeBaseNode(BaseNode):
"""Base class for CmdForge nodes with common styling."""
__identifier__ = 'cmdforge'
def __init__(self):
super().__init__()
# Store reference to the step data
self._step_data = None
self._step_index = -1
class InputNode(CmdForgeBaseNode):
"""Node representing tool input (stdin and arguments)."""
NODE_NAME = 'Input'
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))
def set_arguments(self, arguments: list):
"""Add output ports for each argument variable."""
for arg in arguments:
if arg.variable:
# Check if port already exists
existing = [p.name() for p in self.output_ports()]
if arg.variable not in existing:
self.add_output(arg.variable, color=(180, 180, 250))
class PromptNode(CmdForgeBaseNode):
"""Node representing a prompt step."""
NODE_NAME = 'Prompt'
def __init__(self):
super().__init__()
self.set_color(102, 126, 234) # Indigo (matching CmdForge theme)
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
self.add_text_input('provider', 'Provider', text='claude')
self.add_text_input('output_var', 'Output', text='response')
def set_step(self, step: PromptStep, index: int):
"""Configure node from a PromptStep."""
self._step_data = step
self._step_index = index
self.set_property('provider', step.provider or 'claude')
self.set_property('output_var', step.output_var or 'response')
# Update output port name
if self.output_ports():
self.output_ports()[0]._name = step.output_var or 'response'
class CodeNode(CmdForgeBaseNode):
"""Node representing a code step."""
NODE_NAME = 'Code'
def __init__(self):
super().__init__()
self.set_color(72, 187, 120) # Green
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
self.add_text_input('output_var', 'Output', text='result')
def set_step(self, step: CodeStep, index: int):
"""Configure node from a CodeStep."""
self._step_data = step
self._step_index = index
self.set_property('output_var', step.output_var or 'result')
# Update output port name
if self.output_ports():
self.output_ports()[0]._name = step.output_var or 'result'
class OutputNode(CmdForgeBaseNode):
"""Node representing tool output."""
NODE_NAME = 'Output'
def __init__(self):
super().__init__()
self.set_color(237, 137, 54) # Orange
self.set_icon(get_output_icon_path(16))
self.add_input('in', color=(250, 220, 180))
# =============================================================================
# Flow Graph Widget
# =============================================================================
class FlowGraphWidget(QWidget):
"""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)
# 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)
# 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
self._graph: Optional[NodeGraph] = None
self._input_node = None
self._output_node = None
self._step_nodes: List[CmdForgeBaseNode] = []
self._rebuilding = False # Flag to ignore signals during rebuild
self._setup_ui()
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
# Create node graph
self._graph = NodeGraph()
# Register custom node types
self._graph.register_node(InputNode)
self._graph.register_node(PromptNode)
self._graph.register_node(CodeNode)
self._graph.register_node(OutputNode)
# Connect signals
self._graph.node_double_clicked.connect(self._on_node_double_clicked)
self._graph.nodes_deleted.connect(self._on_nodes_deleted)
self._graph.property_changed.connect(self._on_property_changed)
# Add graph widget
layout.addWidget(self._graph.widget, 1)
# Install event filter to catch F key and focus events on the graph widget
self._graph.widget.installEventFilter(self)
# Create floating help banner (overlay on graph widget)
self._help_banner = QLabel(self._graph.widget)
self._help_banner.setText(
"Pan: Middle-drag | "
"Zoom: Scroll | "
"Select: Click/drag | "
"Edit: Double-click | "
"Select All: A | "
"Fit: F"
)
self._help_banner.setStyleSheet("""
QLabel {
background-color: rgba(45, 55, 72, 0.9);
color: #e2e8f0;
font-size: 11px;
padding: 8px 16px;
border-radius: 4px;
}
""")
self._help_banner.adjustSize()
self._help_banner.hide()
# Animation for fading
self._fade_animation = QPropertyAnimation(self._help_banner, b"windowOpacity")
self._fade_animation.setDuration(500)
self._fade_animation.setEasingCurve(QEasingCurve.InOutQuad)
# Timer for auto-hide
self._hide_timer = QTimer(self)
self._hide_timer.setSingleShot(True)
self._hide_timer.timeout.connect(self._fade_out_banner)
# Track if mouse is over banner
self._help_banner.setMouseTracking(True)
self._help_banner.enterEvent = self._on_banner_enter
self._help_banner.leaveEvent = self._on_banner_leave
# Set up context menu
self._graph.widget.setContextMenuPolicy(Qt.CustomContextMenu)
self._graph.widget.customContextMenuRequested.connect(self._show_context_menu)
def set_tool(self, tool: Optional[Tool]):
"""Set the tool to visualize."""
self._tool = tool
self._rebuild_graph()
def _rebuild_graph(self):
"""Rebuild the graph from the tool data."""
if not self._graph:
return
# Set flag to ignore signals during rebuild
self._rebuilding = True
try:
# Clear existing nodes
self._graph.clear_session()
except Exception:
pass # Ignore errors during clear
self._input_node = None
self._output_node = None
self._step_nodes = []
if not self._tool:
self._rebuilding = False
return
# Create input node
self._input_node = self._graph.create_node(
'cmdforge.InputNode',
name='Input',
pos=[-300, 0]
)
if self._tool.arguments:
self._input_node.set_arguments(self._tool.arguments)
# Create step nodes
x_pos = 0
prev_node = self._input_node
# Count steps by type for default naming
prompt_count = 0
code_count = 0
for i, step in enumerate(self._tool.steps or []):
if isinstance(step, PromptStep):
prompt_count += 1
# Use custom name if set, otherwise default to "Prompt N" (per type)
node_name = step.name if step.name else f'Prompt {prompt_count}'
node = self._graph.create_node(
'cmdforge.PromptNode',
name=node_name,
pos=[x_pos, 0]
)
node.set_step(step, i)
elif isinstance(step, CodeStep):
code_count += 1
# Use custom name if set, otherwise default to "Code N" (per type)
node_name = step.name if step.name else f'Code {code_count}'
node = self._graph.create_node(
'cmdforge.CodeNode',
name=node_name,
pos=[x_pos, 0]
)
node.set_step(step, i)
else:
continue
self._step_nodes.append(node)
# Connect to previous node
if prev_node and prev_node.output_ports():
prev_node.output_ports()[0].connect_to(node.input_ports()[0])
prev_node = node
x_pos += 250
# Create output node
self._output_node = self._graph.create_node(
'cmdforge.OutputNode',
name='Output',
pos=[x_pos, 0]
)
# Connect last step to output
if prev_node and prev_node.output_ports():
prev_node.output_ports()[0].connect_to(self._output_node.input_ports()[0])
# Auto-layout and fit view
self._graph.auto_layout_nodes()
# Select all nodes then fit to selection, then clear selection
all_nodes = self._graph.all_nodes()
for node in all_nodes:
node.set_selected(True)
# Use a timer to fit after the widget is fully rendered
# Also clears the rebuilding flag after everything settles
QTimer.singleShot(50, self._fit_and_clear_selection)
def _fit_and_clear_selection(self):
"""Fit view to all nodes and clear selection."""
if self._graph:
self._graph.fit_to_selection()
# Clear selection
self._graph.clear_selection()
# Clear rebuilding flag - now safe to handle user interactions
self._rebuilding = False
def select_all_nodes(self):
"""Select all nodes in the graph."""
if not self._graph:
return
all_nodes = self._graph.all_nodes()
for node in all_nodes:
node.set_selected(True)
def fit_selection(self):
"""Fit view to show selected nodes (or all if none selected)."""
if not self._graph:
return
selected = self._graph.selected_nodes()
if not selected:
# No selection - fit all nodes
all_nodes = self._graph.all_nodes()
if not all_nodes:
return
for node in all_nodes:
node.set_selected(True)
self._graph.fit_to_selection()
self._graph.clear_selection()
else:
# Fit to current selection (don't clear it)
self._graph.fit_to_selection()
def keyPressEvent(self, event: QKeyEvent):
"""Handle keyboard shortcuts."""
if event.key() == Qt.Key_F:
self.fit_selection()
event.accept()
elif event.key() == Qt.Key_A:
self.select_all_nodes()
event.accept()
else:
super().keyPressEvent(event)
def showEvent(self, event):
"""Handle widget becoming visible."""
super().showEvent(event)
# Show help banner when flow view becomes visible
QTimer.singleShot(100, self._show_help_banner)
def eventFilter(self, obj, event):
"""Filter events from the graph widget to catch keyboard shortcuts."""
if event.type() == QEvent.KeyPress:
if event.key() == Qt.Key_F:
self.fit_selection()
return True # Event handled
elif event.key() == Qt.Key_A:
self.select_all_nodes()
return True # Event handled
elif event.type() == QEvent.Enter:
# Show banner when mouse enters the graph area
self._show_help_banner()
return super().eventFilter(obj, event)
def _show_help_banner(self):
"""Show the help banner with fade effect."""
if not self._help_banner or not self._graph:
return
# Make sure size is calculated
self._help_banner.adjustSize()
# Position at top center of the graph widget
parent_width = self._graph.widget.width()
banner_width = self._help_banner.width()
x = max(10, (parent_width - banner_width) // 2)
self._help_banner.move(x, 10)
# Raise to front and show
self._help_banner.raise_()
self._help_banner.show()
# Start auto-hide timer
self._hide_timer.start(3000) # Hide after 3 seconds
def _fade_out_banner(self):
"""Fade out the help banner."""
if self._help_banner and self._help_banner.isVisible():
# Use a simple hide with timer since windowOpacity doesn't work well on child widgets
self._help_banner.hide()
def _on_banner_enter(self, event):
"""Mouse entered banner - stop hide timer."""
self._hide_timer.stop()
def _on_banner_leave(self, event):
"""Mouse left banner - restart hide timer."""
self._hide_timer.start(1500) # Hide 1.5 seconds after mouse leaves
def _show_context_menu(self, pos):
"""Show context menu."""
menu = QMenu(self._graph.widget)
select_all_action = QAction("Select All (A)", menu)
select_all_action.triggered.connect(self.select_all_nodes)
menu.addAction(select_all_action)
fit_action = QAction("Fit Selection (F)", menu)
fit_action.triggered.connect(self.fit_selection)
menu.addAction(fit_action)
menu.exec_(self._graph.widget.mapToGlobal(pos))
def _on_node_double_clicked(self, node):
"""Handle node double-click."""
if hasattr(node, '_step_index') and node._step_index >= 0:
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."""
# Ignore during rebuild
if self._rebuilding:
return
# 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 _on_property_changed(self, node, prop_name, value):
"""Handle node property change.
Filters for 'name' property to detect step name changes.
"""
# Ignore during rebuild
if self._rebuilding:
return
# Only interested in name changes
if prop_name != 'name':
return
# Only handle step nodes (not Input/Output)
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."""
self._rebuild_graph()
def get_graph(self) -> Optional[NodeGraph]:
"""Get the underlying NodeGraph instance."""
return self._graph

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]

View File

@ -50,6 +50,7 @@ class PromptStep:
output_var: str # Variable to store output
prompt_file: Optional[str] = None # Optional filename for external prompt
profile: Optional[str] = None # Optional AI persona profile name
name: Optional[str] = None # Optional display name for the step
def to_dict(self) -> dict:
d = {
@ -62,6 +63,8 @@ class PromptStep:
d["prompt_file"] = self.prompt_file
if self.profile:
d["profile"] = self.profile
if self.name:
d["name"] = self.name
return d
@classmethod
@ -71,7 +74,8 @@ class PromptStep:
provider=data["provider"],
output_var=data["output_var"],
prompt_file=data.get("prompt_file"),
profile=data.get("profile")
profile=data.get("profile"),
name=data.get("name")
)
@ -81,6 +85,7 @@ class CodeStep:
code: str # Python code (inline or loaded from file)
output_var: str # Variable name(s) to capture (comma-separated for multiple)
code_file: Optional[str] = None # Optional filename for external code
name: Optional[str] = None # Optional display name for the step
def to_dict(self) -> dict:
d = {
@ -90,6 +95,8 @@ class CodeStep:
}
if self.code_file:
d["code_file"] = self.code_file
if self.name:
d["name"] = self.name
return d
@classmethod
@ -97,7 +104,8 @@ class CodeStep:
return cls(
code=data.get("code", ""),
output_var=data["output_var"],
code_file=data.get("code_file")
code_file=data.get("code_file"),
name=data.get("name")
)
@ -109,6 +117,7 @@ class ToolStep:
input_template: str = "{input}" # Input template (supports variable substitution)
args: dict = field(default_factory=dict) # Arguments to pass to the tool
provider: Optional[str] = None # Provider override for the called tool
name: Optional[str] = None # Optional display name for the step
def to_dict(self) -> dict:
d = {
@ -122,6 +131,8 @@ class ToolStep:
d["args"] = self.args
if self.provider:
d["provider"] = self.provider
if self.name:
d["name"] = self.name
return d
@classmethod
@ -131,7 +142,8 @@ class ToolStep:
output_var=data["output_var"],
input_template=data.get("input", "{input}"),
args=data.get("args", {}),
provider=data.get("provider")
provider=data.get("provider"),
name=data.get("name")
)