"""Step editor dialogs."""
import ast
import json
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QFormLayout, QLineEdit,
QComboBox, QPushButton, QHBoxLayout, QLabel,
QPlainTextEdit, QSplitter, QGroupBox, QTextEdit, QMessageBox,
QCheckBox, QSpinBox, QTableWidget, QTableWidgetItem, QHeaderView,
QWidget, QAbstractItemView
)
from PySide6.QtCore import Qt, QThread, Signal
from ...tool import PromptStep, CodeStep, ToolStep, list_tools, load_tool
from ...providers import load_providers, call_provider
from ...profiles import list_profiles
class SchemaBuilderDialog(QDialog):
"""Visual builder for JSON output schemas."""
TYPES = ["string", "number", "integer", "boolean", "array", "object"]
def __init__(self, parent, schema: dict = None):
super().__init__(parent)
self.setWindowTitle("Output Schema Builder")
self.setMinimumSize(850, 500)
self._setup_ui()
if schema:
self._load_schema(schema)
def _setup_ui(self):
"""Set up the UI."""
layout = QVBoxLayout(self)
layout.setSpacing(12)
# Instructions
info = QLabel(
"Define the fields that the AI should return. "
"The AI will be instructed to respond with JSON matching this schema."
)
info.setWordWrap(True)
info.setStyleSheet("color: #718096;")
layout.addWidget(info)
# Fields table
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["Field Name", "Type", "Description", "Required", "Delete"])
self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Interactive)
self.table.horizontalHeader().setSectionResizeMode(1, QHeaderView.Interactive)
self.table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
self.table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Fixed)
self.table.horizontalHeader().setSectionResizeMode(4, QHeaderView.Fixed)
self.table.setColumnWidth(0, 150) # Field Name
self.table.setColumnWidth(1, 120) # Type
self.table.setColumnWidth(3, 80) # Required checkbox
self.table.setColumnWidth(4, 100) # Delete button
self.table.verticalHeader().setDefaultSectionSize(50) # Row height
self.table.setSelectionMode(QAbstractItemView.NoSelection) # No selection needed
self.table.setFocusPolicy(Qt.NoFocus) # Remove focus rectangle
self.table.verticalHeader().setVisible(False)
layout.addWidget(self.table, 1)
# Add field button
btn_row = QHBoxLayout()
self.btn_add = QPushButton("+ Add Field")
self.btn_add.clicked.connect(self._add_field)
btn_row.addWidget(self.btn_add)
btn_row.addStretch()
layout.addLayout(btn_row)
# Preview section
preview_group = QGroupBox("Schema Preview (JSON)")
preview_layout = QVBoxLayout(preview_group)
self.preview = QPlainTextEdit()
self.preview.setReadOnly(True)
self.preview.setMaximumHeight(120)
font = self.preview.font()
font.setFamily("Consolas, Monaco, monospace")
font.setPointSize(9)
self.preview.setFont(font)
preview_layout.addWidget(self.preview)
layout.addWidget(preview_group)
# Buttons
buttons = QHBoxLayout()
self.btn_clear = QPushButton("Clear All")
self.btn_clear.clicked.connect(self._clear_all)
buttons.addWidget(self.btn_clear)
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("Apply Schema")
self.btn_ok.clicked.connect(self.accept)
buttons.addWidget(self.btn_ok)
layout.addLayout(buttons)
# Add default fields for new schema
if not self.table.rowCount():
self._add_field("output", "string", "The main output content", True)
self._add_field("reasoning", "string", "Explanation of the response", False)
self._update_preview()
def _add_field(self, name: str = "", field_type: str = "string",
description: str = "", required: bool = True):
"""Add a field row to the table."""
row = self.table.rowCount()
self.table.insertRow(row)
# Field name
name_input = QLineEdit(name)
name_input.setPlaceholderText("field_name")
name_input.setMinimumHeight(36)
name_input.textChanged.connect(self._update_preview)
self.table.setCellWidget(row, 0, name_input)
# Type combo
type_combo = QComboBox()
type_combo.addItems(self.TYPES)
type_combo.setMinimumHeight(36)
if field_type in self.TYPES:
type_combo.setCurrentText(field_type)
type_combo.currentTextChanged.connect(self._update_preview)
self.table.setCellWidget(row, 1, type_combo)
# Description
desc_input = QLineEdit(description)
desc_input.setPlaceholderText("Description of this field")
desc_input.setMinimumHeight(36)
desc_input.textChanged.connect(self._update_preview)
self.table.setCellWidget(row, 2, desc_input)
# Required checkbox - center it
req_widget = QWidget()
req_layout = QHBoxLayout(req_widget)
req_layout.setContentsMargins(0, 0, 0, 0)
req_layout.setAlignment(Qt.AlignCenter)
req_check = QCheckBox()
req_check.setChecked(required)
req_check.stateChanged.connect(self._update_preview)
req_layout.addWidget(req_check)
self.table.setCellWidget(row, 3, req_widget)
# Delete button
btn_delete = QPushButton("Delete")
btn_delete.setMinimumHeight(36)
btn_delete.setStyleSheet("""
QPushButton {
background-color: #e53e3e;
color: white;
border: none;
border-radius: 4px;
padding: 4px 8px;
}
QPushButton:hover {
background-color: #c53030;
}
""")
btn_delete.clicked.connect(lambda: self._remove_field(row))
self.table.setCellWidget(row, 4, btn_delete)
self._update_preview()
def _remove_field(self, row: int):
"""Remove a field row."""
# Find the actual row (it may have shifted)
sender = self.sender()
for r in range(self.table.rowCount()):
if self.table.cellWidget(r, 4) == sender:
self.table.removeRow(r)
break
self._update_preview()
def _clear_all(self):
"""Clear all fields."""
self.table.setRowCount(0)
self._update_preview()
def _update_preview(self):
"""Update the JSON schema preview."""
schema = self.get_schema()
if schema:
self.preview.setPlainText(json.dumps(schema, indent=2))
else:
self.preview.setPlainText("(no fields defined)")
def _load_schema(self, schema: dict):
"""Load an existing schema into the builder."""
self.table.setRowCount(0)
if not schema or "properties" not in schema:
return
required = schema.get("required", [])
properties = schema.get("properties", {})
for name, prop in properties.items():
field_type = prop.get("type", "string")
description = prop.get("description", "")
is_required = name in required
self._add_field(name, field_type, description, is_required)
def get_schema(self) -> dict:
"""Build and return the JSON schema from the table."""
properties = {}
required = []
for row in range(self.table.rowCount()):
name_widget = self.table.cellWidget(row, 0)
type_widget = self.table.cellWidget(row, 1)
desc_widget = self.table.cellWidget(row, 2)
req_widget = self.table.cellWidget(row, 3)
if not name_widget:
continue
name = name_widget.text().strip()
if not name:
continue
field_type = type_widget.currentText() if type_widget else "string"
description = desc_widget.text().strip() if desc_widget else ""
# Get checkbox from the widget container
req_check = req_widget.findChild(QCheckBox) if req_widget else None
is_required = req_check.isChecked() if req_check else False
prop = {"type": field_type}
if description:
prop["description"] = description
# Add array items type
if field_type == "array":
prop["items"] = {"type": "string"}
properties[name] = prop
if is_required:
required.append(name)
if not properties:
return None
schema = {
"type": "object",
"properties": properties
}
if required:
schema["required"] = required
return schema
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, 500)
self._step = step
self._output_schema = step.output_schema if step else None
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)
# Strip fences checkbox
self.strip_fences_check = QCheckBox("Strip markdown code fences from output")
form.addRow("", self.strip_fences_check)
self.allow_fallback_check = QCheckBox(
"Allow configured fallback providers if this provider fails"
)
self.allow_fallback_check.setChecked(True)
self.allow_fallback_check.setToolTip(
"Disable for privacy-sensitive or reproducible steps that must fail closed."
)
form.addRow("", self.allow_fallback_check)
# Structured output options
form.addRow(QLabel("")) # Spacer
structured_label = QLabel("Structured Output")
form.addRow(structured_label)
# Plain text checkbox (bypass structured output)
self.plain_text_check = QCheckBox("Plain text mode (bypass JSON validation)")
self.plain_text_check.setToolTip(
"When checked, output is returned as-is without JSON parsing.\n"
"When unchecked, output must be valid JSON matching a schema."
)
self.plain_text_check.stateChanged.connect(self._on_plain_text_changed)
form.addRow("", self.plain_text_check)
# Max retries (only visible when structured output is enabled)
self.retries_spin = QSpinBox()
self.retries_spin.setRange(0, 5)
self.retries_spin.setValue(1)
self.retries_spin.setToolTip(
"Number of retries if JSON validation fails.\n"
"The AI will receive error feedback and try again."
)
self.retries_label = QLabel("Max retries:")
form.addRow(self.retries_label, self.retries_spin)
# Output schema builder
schema_row = QHBoxLayout()
self.schema_status = QLabel("Default schema (output, reasoning)")
self.schema_status.setStyleSheet("color: #718096;")
schema_row.addWidget(self.schema_status)
schema_row.addStretch()
self.btn_edit_schema = QPushButton("Edit Schema...")
self.btn_edit_schema.clicked.connect(self._edit_schema)
schema_row.addWidget(self.btn_edit_schema)
self.btn_clear_schema = QPushButton("Reset")
self.btn_clear_schema.setToolTip("Reset to default schema")
self.btn_clear_schema.clicked.connect(self._clear_schema)
self.btn_clear_schema.setVisible(False)
schema_row.addWidget(self.btn_clear_schema)
schema_widget = QWidget()
schema_widget.setLayout(schema_row)
self.schema_label = QLabel("Output schema:")
form.addRow(self.schema_label, schema_widget)
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 _on_plain_text_changed(self, state):
"""Show/hide structured output options based on plain_text checkbox."""
is_plain = bool(state)
self.retries_label.setVisible(not is_plain)
self.retries_spin.setVisible(not is_plain)
self.schema_label.setVisible(not is_plain)
self.schema_status.setVisible(not is_plain)
self.btn_edit_schema.setVisible(not is_plain)
self.btn_clear_schema.setVisible(not is_plain and self._output_schema is not None)
def _edit_schema(self):
"""Open the schema builder dialog."""
dialog = SchemaBuilderDialog(self, self._output_schema)
if dialog.exec():
self._output_schema = dialog.get_schema()
self._update_schema_status()
def _clear_schema(self):
"""Reset to default schema."""
self._output_schema = None
self._update_schema_status()
def _update_schema_status(self):
"""Update the schema status label."""
if self._output_schema:
fields = list(self._output_schema.get("properties", {}).keys())
if len(fields) <= 3:
fields_text = ", ".join(fields)
else:
fields_text = ", ".join(fields[:3]) + f" +{len(fields) - 3} more"
self.schema_status.setText(f"Custom schema ({fields_text})")
self.schema_status.setStyleSheet("color: #38a169;") # Green
self.btn_clear_schema.setVisible(True)
else:
self.schema_status.setText("Default schema (output, reasoning)")
self.schema_status.setStyleSheet("color: #718096;") # Gray
self.btn_clear_schema.setVisible(False)
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)
self.strip_fences_check.setChecked(step.strip_fences)
self.allow_fallback_check.setChecked(step.fallback_policy != "deny")
# Structured output fields
self.plain_text_check.setChecked(step.plain_text)
self.retries_spin.setValue(step.max_retries)
self._output_schema = step.output_schema
self._update_schema_status()
self._on_plain_text_changed(step.plain_text)
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
# Preserve prompt_file from original step if editing
prompt_file = self._step.prompt_file if self._step else None
return PromptStep(
prompt=self.prompt_input.toPlainText(),
provider=self.provider_combo.currentText(),
output_var=self.output_input.text().strip(),
prompt_file=prompt_file,
profile=profile,
name=name,
strip_fences=self.strip_fences_check.isChecked(),
output_schema=self._output_schema,
plain_text=self.plain_text_check.isChecked(),
max_retries=self.retries_spin.value(),
fallback_policy=(
"allow" if self.allow_fallback_check.isChecked() else "deny"
),
)
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, 750)
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)
# User instruction input (always visible)
instruction_label = QLabel("Describe what you want the code to do:")
ai_layout.addWidget(instruction_label)
self.user_instruction_input = QPlainTextEdit()
self.user_instruction_input.setPlaceholderText(
"Example: Parse the input as JSON and extract the 'name' field"
)
self.user_instruction_input.setMaximumHeight(100)
ai_layout.addWidget(self.user_instruction_input, 1)
# Collapsible prompt wrapper section
self.wrapper_toggle = QPushButton("Edit prompt wrapper ▶")
self.wrapper_toggle.setFlat(True)
self.wrapper_toggle.setStyleSheet(
"QPushButton { color: #4a5568; font-size: 12px; text-align: left; "
"padding: 2px 0; } QPushButton:hover { color: #2d3748; }"
)
self.wrapper_toggle.clicked.connect(self._toggle_wrapper)
ai_layout.addWidget(self.wrapper_toggle)
self.ai_prompt_input = QPlainTextEdit()
self._set_default_prompt()
self.ai_prompt_input.hide()
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 _toggle_wrapper(self):
"""Toggle visibility of the prompt wrapper editor."""
if self.ai_prompt_input.isVisible():
self.ai_prompt_input.hide()
self.wrapper_toggle.setText("Edit prompt wrapper ▶")
else:
self.ai_prompt_input.show()
self.wrapper_toggle.setText("Edit prompt wrapper ▼")
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: {{user_instruction}}
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."""
user_instruction = self.user_instruction_input.toPlainText().strip()
if not user_instruction:
self.ai_output.setHtml("Please describe what you want the code to do")
return
prompt_template = self.ai_prompt_input.toPlainText().strip()
if not prompt_template:
self.ai_output.setHtml("Prompt wrapper is empty")
return
# Inject user instruction and current code into the wrapper
current_code = self.code_input.toPlainText().strip() or "# No code yet"
prompt = prompt_template.replace("{user_instruction}", user_instruction)
prompt = prompt.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"Calling {provider}...")
# 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"Code generated successfully!
"
f"Response length: {len(result)} chars"
)
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"Error: {error}")
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
)
class ToolStepDialog(QDialog):
"""Dialog for adding/editing tool steps (calling another tool)."""
def __init__(self, parent, step: ToolStep = None, available_vars: list = None, current_tool_name: str = None):
super().__init__(parent)
self.setWindowTitle("Edit Tool Step" if step else "Add Tool Step")
self.setMinimumSize(550, 500)
self._step = step
self._available_vars = available_vars or ["input"]
self._current_tool_name = current_tool_name # To prevent self-referencing
self._tool_args = {} # Cache of tool arguments
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)
# Tool selection
self.tool_combo = QComboBox()
self._populate_tools()
self.tool_combo.currentTextChanged.connect(self._on_tool_changed)
form.addRow("Tool:", self.tool_combo)
# Tool description (read-only)
self.tool_desc = QLabel("")
self.tool_desc.setStyleSheet("color: #718096; font-size: 11px;")
self.tool_desc.setWordWrap(True)
form.addRow("", self.tool_desc)
# Output variable
self.output_input = QLineEdit()
self.output_input.setPlaceholderText("tool_output")
self.output_input.setText("tool_output")
form.addRow("Output variable:", self.output_input)
# Provider override (optional)
self.provider_combo = QComboBox()
self.provider_combo.addItem("(use tool's default)")
providers = load_providers()
for provider in sorted(providers, key=lambda p: p.name):
self.provider_combo.addItem(provider.name)
form.addRow("Provider override:", self.provider_combo)
layout.addLayout(form)
# Input template
input_group = QGroupBox("Input")
input_layout = QVBoxLayout(input_group)
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
input_help = QLabel(f"Available variables: {vars_text}")
input_help.setStyleSheet("color: #718096; font-size: 11px;")
input_layout.addWidget(input_help)
self.input_template = QPlainTextEdit()
self.input_template.setPlaceholderText("{input}")
self.input_template.setPlainText("{input}")
self.input_template.setMaximumHeight(80)
input_layout.addWidget(self.input_template)
layout.addWidget(input_group)
# Tool arguments
self.args_group = QGroupBox("Tool Arguments")
self.args_layout = QFormLayout(self.args_group)
self.args_layout.setSpacing(8)
self._arg_inputs = {} # variable -> QLineEdit
layout.addWidget(self.args_group)
# 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)
# Trigger initial tool selection
if self.tool_combo.count() > 0:
self._on_tool_changed(self.tool_combo.currentText())
def _populate_tools(self):
"""Populate the tool dropdown."""
tools = list_tools()
for tool_name in sorted(tools):
# Skip the current tool to prevent self-referencing
if tool_name != self._current_tool_name:
self.tool_combo.addItem(tool_name)
def _on_tool_changed(self, tool_name: str):
"""Handle tool selection change."""
# Clear existing arg inputs
for widget in self._arg_inputs.values():
self.args_layout.removeRow(widget)
self._arg_inputs.clear()
if not tool_name:
self.tool_desc.setText("")
return
tool = load_tool(tool_name)
if not tool:
self.tool_desc.setText("(Tool not found)")
return
# Update description
self.tool_desc.setText(tool.description or "(No description)")
# Cache arguments
self._tool_args[tool_name] = tool.arguments
# Create input fields for each argument
vars_text = ", ".join(f"{{{v}}}" for v in self._available_vars)
for arg in tool.arguments:
line = QLineEdit()
line.setPlaceholderText(f"Default: {arg.default}" if arg.default else f"Variable: {vars_text}")
if arg.default:
line.setText(arg.default)
self._arg_inputs[arg.variable] = line
label = f"{arg.flag}:"
if arg.description:
label = f"{arg.flag} ({arg.description}):"
self.args_layout.addRow(label, line)
def _load_step(self, step: ToolStep):
"""Load step data into form."""
# Load name
if step.name:
self.name_input.setText(step.name)
# Select tool
idx = self.tool_combo.findText(step.tool)
if idx >= 0:
self.tool_combo.setCurrentIndex(idx)
else:
# Tool might be qualified name - try to add it
self.tool_combo.addItem(step.tool)
self.tool_combo.setCurrentText(step.tool)
self.output_input.setText(step.output_var)
self.input_template.setPlainText(step.input_template)
# Set provider override
if step.provider:
idx = self.provider_combo.findText(step.provider)
if idx >= 0:
self.provider_combo.setCurrentIndex(idx)
# Set argument values (after tool is selected and args are loaded)
for var, value in step.args.items():
if var in self._arg_inputs:
self._arg_inputs[var].setText(str(value))
def _validate_and_accept(self):
"""Validate and accept."""
tool = self.tool_combo.currentText().strip()
if not tool:
self.tool_combo.setFocus()
return
output = self.output_input.text().strip()
if not output:
self.output_input.setFocus()
return
self.accept()
def get_step(self) -> ToolStep:
"""Get the step from form data."""
# Collect arguments
args = {}
for var, line in self._arg_inputs.items():
value = line.text().strip()
if value:
args[var] = value
# Get provider override
provider = self.provider_combo.currentText()
if provider == "(use tool's default)":
provider = None
# Get name, use None if empty
name = self.name_input.text().strip() or None
return ToolStep(
tool=self.tool_combo.currentText(),
output_var=self.output_input.text().strip(),
input_template=self.input_template.toPlainText() or "{input}",
args=args,
provider=provider,
name=name
)