- 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 <[email protected]>
427 lines
14 KiB
Python
427 lines
14 KiB
Python
"""Step editor dialogs."""
|
|
|
|
import ast
|
|
|
|
from PySide6.QtWidgets import (
|
|
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
|
|
QComboBox, QPushButton, QHBoxLayout, QLabel,
|
|
QPlainTextEdit, QSplitter, QGroupBox, QTextEdit, QMessageBox
|
|
)
|
|
from PySide6.QtCore import Qt, QThread, Signal
|
|
|
|
from ...tool import PromptStep, CodeStep
|
|
from ...providers import load_providers, call_provider
|
|
from ...profiles import list_profiles
|
|
|
|
|
|
class PromptStepDialog(QDialog):
|
|
"""Dialog for editing prompt steps."""
|
|
|
|
def __init__(self, parent, step: PromptStep = None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Edit Prompt Step" if step else "Add Prompt Step")
|
|
self.setMinimumSize(500, 400)
|
|
self._step = step
|
|
self._setup_ui()
|
|
|
|
if step:
|
|
self._load_step(step)
|
|
|
|
def _setup_ui(self):
|
|
"""Set up the UI."""
|
|
layout = QVBoxLayout(self)
|
|
layout.setSpacing(16)
|
|
|
|
# Form
|
|
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()
|
|
for provider in sorted(providers, key=lambda p: p.name):
|
|
self.provider_combo.addItem(provider.name)
|
|
# Add common defaults if not present
|
|
for default in ["claude", "gpt", "mock"]:
|
|
if self.provider_combo.findText(default) < 0:
|
|
self.provider_combo.addItem(default)
|
|
form.addRow("Provider:", self.provider_combo)
|
|
|
|
# Profile selection
|
|
self.profile_combo = QComboBox()
|
|
profiles = list_profiles()
|
|
for profile in profiles:
|
|
self.profile_combo.addItem(profile.name)
|
|
# Set tooltip with description
|
|
idx = self.profile_combo.count() - 1
|
|
self.profile_combo.setItemData(idx, profile.description, Qt.ToolTipRole)
|
|
form.addRow("Profile:", self.profile_combo)
|
|
|
|
# Output variable
|
|
self.output_input = QLineEdit()
|
|
self.output_input.setPlaceholderText("response")
|
|
self.output_input.setText("response")
|
|
form.addRow("Output variable:", self.output_input)
|
|
|
|
layout.addLayout(form)
|
|
|
|
# Prompt text
|
|
prompt_label = QLabel("Prompt template:")
|
|
layout.addWidget(prompt_label)
|
|
|
|
self.prompt_input = QPlainTextEdit()
|
|
self.prompt_input.setPlaceholderText(
|
|
"Enter your prompt here...\n\n"
|
|
"Use {input} for stdin input.\n"
|
|
"Use {variable} for argument values.\n"
|
|
"Use {prev_output} for previous step output."
|
|
)
|
|
layout.addWidget(self.prompt_input, 1)
|
|
|
|
# Buttons
|
|
buttons = QHBoxLayout()
|
|
buttons.addStretch()
|
|
|
|
self.btn_cancel = QPushButton("Cancel")
|
|
self.btn_cancel.setObjectName("secondary")
|
|
self.btn_cancel.clicked.connect(self.reject)
|
|
buttons.addWidget(self.btn_cancel)
|
|
|
|
self.btn_ok = QPushButton("OK")
|
|
self.btn_ok.clicked.connect(self._validate_and_accept)
|
|
buttons.addWidget(self.btn_ok)
|
|
|
|
layout.addLayout(buttons)
|
|
|
|
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)
|
|
else:
|
|
self.provider_combo.setCurrentText(step.provider)
|
|
|
|
# Load profile
|
|
if step.profile:
|
|
idx = self.profile_combo.findText(step.profile)
|
|
if idx >= 0:
|
|
self.profile_combo.setCurrentIndex(idx)
|
|
|
|
self.output_input.setText(step.output_var)
|
|
self.prompt_input.setPlainText(step.prompt)
|
|
|
|
def _validate_and_accept(self):
|
|
"""Validate and accept."""
|
|
prompt = self.prompt_input.toPlainText().strip()
|
|
if not prompt:
|
|
self.prompt_input.setFocus()
|
|
return
|
|
|
|
output = self.output_input.text().strip()
|
|
if not output:
|
|
self.output_input.setFocus()
|
|
return
|
|
|
|
self.accept()
|
|
|
|
def get_step(self) -> PromptStep:
|
|
"""Get the step from form data."""
|
|
profile = self.profile_combo.currentText()
|
|
# 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,
|
|
name=name
|
|
)
|
|
|
|
|
|
class AIGenerateWorker(QThread):
|
|
"""Background worker for AI code generation."""
|
|
finished = Signal(str)
|
|
error = Signal(str)
|
|
|
|
def __init__(self, provider: str, prompt: str):
|
|
super().__init__()
|
|
self.provider = provider
|
|
self.prompt = prompt
|
|
|
|
def run(self):
|
|
try:
|
|
result = call_provider(self.provider, self.prompt)
|
|
if result.success:
|
|
self.finished.emit(result.text)
|
|
else:
|
|
self.error.emit(result.error or "Unknown error")
|
|
except Exception as e:
|
|
self.error.emit(str(e))
|
|
|
|
|
|
class CodeStepDialog(QDialog):
|
|
"""Dialog for editing code steps with AI assist."""
|
|
|
|
def __init__(self, parent, step: CodeStep = None, available_vars: list = None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Edit Code Step" if step else "Add Code Step")
|
|
self.setMinimumSize(900, 600)
|
|
self._step = step
|
|
self._available_vars = available_vars or ["input"]
|
|
self._worker = None
|
|
self._setup_ui()
|
|
|
|
if step:
|
|
self._load_step(step)
|
|
|
|
def _setup_ui(self):
|
|
"""Set up the UI with code editor and AI assist panel."""
|
|
layout = QVBoxLayout(self)
|
|
layout.setSpacing(12)
|
|
|
|
# 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")
|
|
form.addRow("Output variable:", self.output_input)
|
|
layout.addLayout(form)
|
|
|
|
# Available variables display
|
|
vars_text = ", ".join(self._available_vars)
|
|
vars_label = QLabel(f"Available variables: {vars_text}")
|
|
vars_label.setStyleSheet("color: #718096; font-size: 11px;")
|
|
layout.addWidget(vars_label)
|
|
|
|
# Main splitter: Code editor | AI Assist
|
|
splitter = QSplitter(Qt.Horizontal)
|
|
|
|
# Left: Code editor
|
|
code_group = QGroupBox("Code")
|
|
code_layout = QVBoxLayout(code_group)
|
|
|
|
self.code_input = QPlainTextEdit()
|
|
self.code_input.setPlaceholderText(
|
|
"# Python code here\n"
|
|
"# Access input with: input\n"
|
|
"# Access args with their variable names\n\n"
|
|
"result = input.upper()"
|
|
)
|
|
font = self.code_input.font()
|
|
font.setFamily("Consolas, Monaco, monospace")
|
|
self.code_input.setFont(font)
|
|
code_layout.addWidget(self.code_input)
|
|
|
|
splitter.addWidget(code_group)
|
|
|
|
# Right: AI Assist panel
|
|
ai_group = QGroupBox("AI Assisted Code Generation")
|
|
ai_layout = QVBoxLayout(ai_group)
|
|
ai_layout.setSpacing(8)
|
|
|
|
# Provider selector
|
|
provider_layout = QHBoxLayout()
|
|
provider_layout.addWidget(QLabel("Provider:"))
|
|
self.ai_provider_combo = QComboBox()
|
|
providers = load_providers()
|
|
for provider in sorted(providers, key=lambda p: p.name):
|
|
self.ai_provider_combo.addItem(provider.name)
|
|
# Add common defaults if not present
|
|
for default in ["claude", "gpt", "mock"]:
|
|
if self.ai_provider_combo.findText(default) < 0:
|
|
self.ai_provider_combo.addItem(default)
|
|
self.ai_provider_combo.setMinimumWidth(150)
|
|
provider_layout.addWidget(self.ai_provider_combo)
|
|
provider_layout.addStretch()
|
|
ai_layout.addLayout(provider_layout)
|
|
|
|
# Prompt editor
|
|
prompt_label = QLabel("Describe what you want the code to do:")
|
|
ai_layout.addWidget(prompt_label)
|
|
|
|
self.ai_prompt_input = QPlainTextEdit()
|
|
self.ai_prompt_input.setPlaceholderText(
|
|
"Example: Parse the input as JSON and extract the 'name' field"
|
|
)
|
|
# Set default prompt template
|
|
self._set_default_prompt()
|
|
ai_layout.addWidget(self.ai_prompt_input, 2)
|
|
|
|
# Generate button
|
|
btn_layout = QHBoxLayout()
|
|
self.btn_generate = QPushButton("Generate Code")
|
|
self.btn_generate.clicked.connect(self._generate_code)
|
|
self.btn_generate.setMinimumHeight(32)
|
|
btn_layout.addStretch()
|
|
btn_layout.addWidget(self.btn_generate)
|
|
btn_layout.addStretch()
|
|
ai_layout.addLayout(btn_layout)
|
|
|
|
# Output/feedback area
|
|
feedback_label = QLabel("AI Response:")
|
|
ai_layout.addWidget(feedback_label)
|
|
|
|
self.ai_output = QTextEdit()
|
|
self.ai_output.setReadOnly(True)
|
|
self.ai_output.setPlaceholderText("AI response will appear here...")
|
|
self.ai_output.setMaximumHeight(100)
|
|
ai_layout.addWidget(self.ai_output, 1)
|
|
|
|
splitter.addWidget(ai_group)
|
|
splitter.setSizes([450, 450])
|
|
|
|
layout.addWidget(splitter, 1)
|
|
|
|
# Bottom: OK/Cancel buttons
|
|
buttons = QHBoxLayout()
|
|
buttons.addStretch()
|
|
|
|
self.btn_cancel = QPushButton("Cancel")
|
|
self.btn_cancel.setObjectName("secondary")
|
|
self.btn_cancel.clicked.connect(self.reject)
|
|
buttons.addWidget(self.btn_cancel)
|
|
|
|
self.btn_ok = QPushButton("OK")
|
|
self.btn_ok.clicked.connect(self._validate_and_accept)
|
|
buttons.addWidget(self.btn_ok)
|
|
|
|
layout.addLayout(buttons)
|
|
|
|
def _set_default_prompt(self):
|
|
"""Set the default AI prompt template."""
|
|
vars_formatted = ', '.join(f'"{{{v}}}"' for v in self._available_vars)
|
|
default_prompt = f"""Write inline Python code (NOT a function definition) according to my instruction.
|
|
|
|
The code runs directly with variable substitution. Assign any available variables to a local variable first using triple quotes for multi-line content safety.
|
|
|
|
Example:
|
|
my_var = \"\"\"{{input}}\"\"\"
|
|
result = my_var.upper()
|
|
|
|
INSTRUCTION: [Describe what you want the code to do]
|
|
|
|
CURRENT CODE:
|
|
{{code}}
|
|
|
|
AVAILABLE VARIABLES: {vars_formatted}
|
|
|
|
IMPORTANT: Return ONLY executable inline Python code. No function definitions, no markdown fencing, no explanations - just the code."""
|
|
self.ai_prompt_input.setPlainText(default_prompt)
|
|
|
|
def _generate_code(self):
|
|
"""Generate code using AI."""
|
|
prompt_template = self.ai_prompt_input.toPlainText().strip()
|
|
if not prompt_template:
|
|
self.ai_output.setHtml("<span style='color: #e53e3e;'>Please enter a prompt</span>")
|
|
return
|
|
|
|
# Replace {code} placeholder with current code
|
|
current_code = self.code_input.toPlainText().strip() or "# No code yet"
|
|
prompt = prompt_template.replace("{code}", current_code)
|
|
|
|
provider = self.ai_provider_combo.currentText()
|
|
|
|
# Disable button and show loading state
|
|
self.btn_generate.setEnabled(False)
|
|
self.btn_generate.setText("Generating...")
|
|
self.ai_output.setHtml(f"<span style='color: #718096;'>Calling {provider}...</span>")
|
|
|
|
# Start worker thread
|
|
self._worker = AIGenerateWorker(provider, prompt)
|
|
self._worker.finished.connect(self._on_generate_finished)
|
|
self._worker.error.connect(self._on_generate_error)
|
|
self._worker.start()
|
|
|
|
def _on_generate_finished(self, result: str):
|
|
"""Handle successful AI generation."""
|
|
self.btn_generate.setEnabled(True)
|
|
self.btn_generate.setText("Generate Code")
|
|
|
|
# Clean up the result - strip markdown code fences if present
|
|
code = result.strip()
|
|
if code.startswith("```python"):
|
|
code = code[9:]
|
|
elif code.startswith("```"):
|
|
code = code[3:]
|
|
if code.endswith("```"):
|
|
code = code[:-3]
|
|
code = code.strip()
|
|
|
|
# Update code editor
|
|
self.code_input.setPlainText(code)
|
|
|
|
# Show success message
|
|
self.ai_output.setHtml(
|
|
f"<span style='color: #38a169;'>Code generated successfully!</span><br>"
|
|
f"<span style='color: #718096;'>Response length: {len(result)} chars</span>"
|
|
)
|
|
|
|
def _on_generate_error(self, error: str):
|
|
"""Handle AI generation error."""
|
|
self.btn_generate.setEnabled(True)
|
|
self.btn_generate.setText("Generate Code")
|
|
self.ai_output.setHtml(f"<span style='color: #e53e3e;'>Error: {error}</span>")
|
|
|
|
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)
|
|
|
|
def _validate_and_accept(self):
|
|
"""Validate and accept."""
|
|
code = self.code_input.toPlainText().strip()
|
|
if not code:
|
|
self.code_input.setFocus()
|
|
return
|
|
|
|
output = self.output_input.text().strip()
|
|
if not output:
|
|
self.output_input.setFocus()
|
|
return
|
|
|
|
# Syntax check the Python code
|
|
try:
|
|
ast.parse(code)
|
|
except SyntaxError as e:
|
|
line_info = f" (line {e.lineno})" if e.lineno else ""
|
|
QMessageBox.warning(
|
|
self, "Syntax Error",
|
|
f"Python syntax error{line_info}:\n\n{e.msg}"
|
|
)
|
|
self.code_input.setFocus()
|
|
return
|
|
|
|
self.accept()
|
|
|
|
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(),
|
|
name=name
|
|
)
|