"""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 )