diff --git a/pyproject.toml b/pyproject.toml index e094e7b..a42c5d2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/scripts/test_nodegraph.py b/scripts/test_nodegraph.py new file mode 100644 index 0000000..c2560c9 --- /dev/null +++ b/scripts/test_nodegraph.py @@ -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() diff --git a/src/cmdforge/gui/pages/tool_builder_page.py b/src/cmdforge/gui/pages/tool_builder_page.py index 84ce67c..370a191 100644 --- a/src/cmdforge/gui/pages/tool_builder_page.py +++ b/src/cmdforge/gui/pages/tool_builder_page.py @@ -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 @@ -23,6 +24,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 +125,57 @@ 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) + 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 +218,52 @@ 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) + + # 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 _load_tool(self, name: str): """Load an existing tool for editing.""" tool = load_tool(name) @@ -213,7 +304,7 @@ 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: for i, step in enumerate(self._tool.steps, 1): @@ -227,6 +318,10 @@ class ToolBuilderPage(QWidget): item.setData(Qt.UserRole, step) self.steps_list.addItem(item) + # Update flow widget if initialized + if self._flow_widget: + self._flow_widget.set_tool(self._tool) + def _add_argument(self): """Add a new argument.""" from ..dialogs.argument_dialog import ArgumentDialog @@ -315,14 +410,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) diff --git a/src/cmdforge/gui/styles.py b/src/cmdforge/gui/styles.py index d009d9c..fdf57b5 100644 --- a/src/cmdforge/gui/styles.py +++ b/src/cmdforge/gui/styles.py @@ -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; diff --git a/src/cmdforge/gui/widgets/__init__.py b/src/cmdforge/gui/widgets/__init__.py index 0d84b9a..7362179 100644 --- a/src/cmdforge/gui/widgets/__init__.py +++ b/src/cmdforge/gui/widgets/__init__.py @@ -1 +1,5 @@ """Custom widgets for CmdForge GUI.""" + +from .flow_graph import FlowGraphWidget + +__all__ = ['FlowGraphWidget'] diff --git a/src/cmdforge/gui/widgets/flow_graph.py b/src/cmdforge/gui/widgets/flow_graph.py new file mode 100644 index 0000000..60b2aa0 --- /dev/null +++ b/src/cmdforge/gui/widgets/flow_graph.py @@ -0,0 +1,240 @@ +"""Flow visualization widget using NodeGraphQt.""" + +from typing import Optional, List + +from PySide6.QtWidgets import QWidget, QVBoxLayout +from PySide6.QtCore import Signal + +from NodeGraphQt import NodeGraph, BaseNode + +from ...tool import Tool, PromptStep, CodeStep + + +# ============================================================================= +# 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 + # 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.add_input('in', color=(180, 180, 250), multi_input=True) + 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.add_input('in', color=(180, 250, 180), multi_input=True) + 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.add_input('in', color=(250, 220, 180), multi_input=True) + + +# ============================================================================= +# Flow Graph Widget +# ============================================================================= + +class FlowGraphWidget(QWidget): + """Widget for visualizing tool flow as a node graph.""" + + # 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() + + 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._setup_ui() + + def _setup_ui(self): + """Set up the UI.""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 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) + + # Add graph widget + layout.addWidget(self._graph.widget) + + 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 + + # Clear existing nodes + self._graph.clear_session() + self._input_node = None + self._output_node = None + self._step_nodes = [] + + if not self._tool: + 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 + + for i, step in enumerate(self._tool.steps or []): + if isinstance(step, PromptStep): + node = self._graph.create_node( + 'cmdforge.PromptNode', + name=f'Prompt {i+1}', + pos=[x_pos, 0] + ) + node.set_step(step, i) + elif isinstance(step, CodeStep): + node = self._graph.create_node( + 'cmdforge.CodeNode', + name=f'Code {i+1}', + 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() + self._graph.fit_to_selection() + + 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 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