"""Dialog-based UI for managing SmartTools.""" import subprocess import sys import tempfile from typing import Optional, Tuple, List from .tool import ( Tool, ToolArgument, PromptStep, CodeStep, Step, list_tools, load_tool, save_tool, delete_tool, tool_exists ) from .providers import Provider, load_providers, add_provider, delete_provider, get_provider def _check_urwid() -> bool: """Check if urwid is available (preferred - has mouse support).""" try: import urwid return True except ImportError: return False def _check_snack() -> bool: """Check if snack (python3-newt) is available.""" try: if '/usr/lib/python3/dist-packages' not in sys.path: sys.path.insert(0, '/usr/lib/python3/dist-packages') import snack return True except ImportError: return False def check_dialog() -> str: """Check for available dialog program. Returns 'dialog', 'whiptail', or None.""" for prog in ["dialog", "whiptail"]: try: subprocess.run([prog, "--version"], capture_output=True, check=False) return prog except FileNotFoundError: continue return None def run_dialog(args: list[str], dialog_prog: str = "dialog") -> Tuple[int, str]: """Run a dialog command and return (exit_code, output).""" try: if dialog_prog == "whiptail": result = subprocess.run( [dialog_prog] + args, stderr=subprocess.PIPE, text=True ) return result.returncode, result.stderr.strip() else: cmd_with_stdout = [dialog_prog, "--stdout"] + args result = subprocess.run( cmd_with_stdout, stdout=subprocess.PIPE, text=True ) return result.returncode, result.stdout.strip() except Exception as e: return 1, "" def show_menu(title: str, choices: list[tuple[str, str]], dialog_prog: str) -> Optional[str]: """Show a menu and return the selected item.""" args = ["--title", title, "--menu", "Choose an option:", "20", "75", str(len(choices))] for tag, desc in choices: args.extend([tag, desc]) code, output = run_dialog(args, dialog_prog) return output if code == 0 else None def show_input(title: str, prompt: str, initial: str = "", dialog_prog: str = "dialog") -> Optional[str]: """Show an input box and return the entered text.""" args = ["--title", title, "--inputbox", prompt, "10", "60", initial] code, output = run_dialog(args, dialog_prog) return output if code == 0 else None def show_textbox(title: str, text: str, dialog_prog: str = "dialog") -> Optional[str]: """Show a text editor for multi-line input.""" with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: f.write(text) temp_path = f.name try: args = ["--title", title, "--editbox", temp_path, "20", "75"] code, output = run_dialog(args, dialog_prog) return output if code == 0 else None finally: import os os.unlink(temp_path) def show_yesno(title: str, text: str, dialog_prog: str = "dialog") -> bool: """Show a yes/no dialog.""" args = ["--title", title, "--yesno", text, "10", "60"] code, _ = run_dialog(args, dialog_prog) return code == 0 def show_message(title: str, text: str, dialog_prog: str = "dialog"): """Show a message box.""" args = ["--title", title, "--msgbox", text, "15", "70"] run_dialog(args, dialog_prog) def show_mixed_form(title: str, fields: dict, dialog_prog: str, height: int = 20) -> Optional[dict]: """Show a form with multiple fields using --mixedform.""" args = ["--title", title, "--mixedform", "Tab: next field | Enter: submit | Esc: cancel", str(height), "75", "0"] field_names = list(fields.keys()) y = 1 for name in field_names: label, initial, field_type = fields[name] args.extend([ label, str(y), "1", initial, str(y), "18", "52", "256", str(field_type) ]) y += 1 code, output = run_dialog(args, dialog_prog) if code != 0: return None values = output.split('\n') result = {} for i, name in enumerate(field_names): result[name] = values[i] if i < len(values) else "" return result # ============ Provider Management ============ def select_provider(dialog_prog: str) -> Optional[str]: """Show provider selection menu with option to create new.""" providers = load_providers() choices = [(p.name, f"{p.description} ({p.command})") for p in providers] choices.append(("__new__", "[ + Add New Provider ]")) selected = show_menu("Select Provider", choices, dialog_prog) if selected == "__new__": provider = create_provider_form(dialog_prog) if provider: add_provider(provider) return provider.name return None return selected def create_provider_form(dialog_prog: str, existing: Optional[Provider] = None) -> Optional[Provider]: """Show form for creating/editing a provider.""" title = f"Edit Provider: {existing.name}" if existing else "Add New Provider" fields = { "name": ( "Name:", existing.name if existing else "", 2 if existing else 0 # readonly if editing ), "command": ( "Command:", existing.command if existing else "", 0 ), "description": ( "Description:", existing.description if existing else "", 0 ), } result = show_mixed_form(title, fields, dialog_prog, height=12) if not result: return None name = result["name"].strip() command = result["command"].strip() if not name: show_message("Error", "Provider name is required.", dialog_prog) return None if not command: show_message("Error", "Command is required.", dialog_prog) return None return Provider( name=name, command=command, description=result["description"].strip() ) def ui_manage_providers(dialog_prog: str): """Manage providers menu.""" while True: providers = load_providers() choices = [(p.name, f"{p.command}") for p in providers] choices.append(("__add__", "[ + Add New Provider ]")) choices.append(("__back__", "[ <- Back to Main Menu ]")) selected = show_menu("Manage Providers", choices, dialog_prog) if selected is None or selected == "__back__": break elif selected == "__add__": provider = create_provider_form(dialog_prog) if provider: add_provider(provider) show_message("Success", f"Provider '{provider.name}' added.", dialog_prog) else: # Edit or delete existing provider provider = get_provider(selected) if provider: action = show_menu( f"Provider: {selected}", [ ("edit", "Edit provider"), ("delete", "Delete provider"), ("back", "Back"), ], dialog_prog ) if action == "edit": updated = create_provider_form(dialog_prog, provider) if updated: add_provider(updated) show_message("Success", f"Provider '{updated.name}' updated.", dialog_prog) elif action == "delete": if show_yesno("Confirm", f"Delete provider '{selected}'?", dialog_prog): delete_provider(selected) show_message("Deleted", f"Provider '{selected}' deleted.", dialog_prog) # ============ Tool Builder UI ============ def format_tool_summary(tool: Tool) -> str: """Format a summary of the tool's components.""" lines = [] lines.append(f"Name: {tool.name}") lines.append(f"Description: {tool.description or '(none)'}") lines.append("") if tool.arguments: lines.append("Arguments:") for arg in tool.arguments: default = f" = {arg.default}" if arg.default else "" lines.append(f" {arg.flag} -> {{{arg.variable}}}{default}") lines.append("") if tool.steps: lines.append("Steps:") for i, step in enumerate(tool.steps): if isinstance(step, PromptStep): preview = step.prompt[:40].replace('\n', ' ') + "..." lines.append(f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}") lines.append(f" {preview}") elif isinstance(step, CodeStep): preview = step.code[:40].replace('\n', ' ') + "..." lines.append(f" {i+1}. CODE -> {{{step.output_var}}}") lines.append(f" {preview}") lines.append("") lines.append(f"Output: {tool.output}") return "\n".join(lines) def get_available_variables(tool: Tool, up_to_step: int = -1) -> List[str]: """Get list of available variables at a given point in the tool.""" variables = ["input"] for arg in tool.arguments: variables.append(arg.variable) if up_to_step == -1: up_to_step = len(tool.steps) for i, step in enumerate(tool.steps): if i >= up_to_step: break variables.append(step.output_var) return variables def edit_argument(dialog_prog: str, existing: Optional[ToolArgument] = None) -> Optional[ToolArgument]: """Edit or create an argument.""" title = f"Edit Argument: {existing.flag}" if existing else "Add Argument" fields = { "flag": ("Flag:", existing.flag if existing else "--", 0), "variable": ("Variable:", existing.variable if existing else "", 0), "default": ("Default:", existing.default or "" if existing else "", 0), "description": ("Description:", existing.description if existing else "", 0), } result = show_mixed_form(title, fields, dialog_prog, height=14) if not result: return None flag = result["flag"].strip() variable = result["variable"].strip() if not flag: show_message("Error", "Flag is required (e.g., --max-size).", dialog_prog) return None if not variable: # Auto-generate variable name from flag variable = flag.lstrip("-").replace("-", "_") return ToolArgument( flag=flag, variable=variable, default=result["default"].strip() or None, description=result["description"].strip() ) def edit_prompt_step(dialog_prog: str, existing: Optional[PromptStep] = None, available_vars: List[str] = None) -> Optional[PromptStep]: """Edit or create a prompt step.""" title = "Edit Prompt Step" if existing else "Add Prompt Step" # First, select provider provider = select_provider(dialog_prog) if not provider: provider = existing.provider if existing else "mock" # Show variable help var_help = "Available: " + ", ".join(f"{{{v}}}" for v in (available_vars or ["input"])) # Edit prompt text default_prompt = existing.prompt if existing else f"Process this input:\n\n{{input}}" prompt = show_textbox(f"Prompt Template\n{var_help}", default_prompt, dialog_prog) if prompt is None: return None # Get output variable output_var = show_input( "Output Variable", "Variable name to store the result:", existing.output_var if existing else "result", dialog_prog ) if not output_var: return None return PromptStep( prompt=prompt, provider=provider, output_var=output_var.strip() ) def edit_code_step(dialog_prog: str, existing: Optional[CodeStep] = None, available_vars: List[str] = None) -> Optional[CodeStep]: """Edit or create a code step.""" title = "Edit Code Step" if existing else "Add Code Step" # Show variable help var_help = "Variables: " + ", ".join(available_vars or ["input"]) var_help += "\nSet 'result' variable for output" # Edit code default_code = existing.code if existing else "# Available variables: " + ", ".join(available_vars or ["input"]) + "\n# Set 'result' for output\nresult = input.upper()" code = show_textbox(f"Python Code\n{var_help}", default_code, dialog_prog) if code is None: return None # Get output variable output_var = show_input( "Output Variable", "Variable name to store the result:", existing.output_var if existing else "processed", dialog_prog ) if not output_var: return None return CodeStep( code=code, output_var=output_var.strip() ) def edit_tool_info(tool: Tool, is_edit: bool, dialog_prog: str) -> None: """Edit basic tool info (name, description, output).""" while True: # Build info section menu output_preview = tool.output[:35] + "..." if len(tool.output) > 35 else tool.output args_count = len(tool.arguments) args_summary = f"({args_count} defined)" if args_count else "(none)" choices = [ ("name", f"Name: {tool.name or '(not set)'}"), ("desc", f"Description: {tool.description[:35] + '...' if len(tool.description) > 35 else tool.description or '(none)'}"), ("---1", "─" * 40), ] # Show arguments if tool.arguments: for i, arg in enumerate(tool.arguments): default = f" = {arg.default}" if arg.default else "" choices.append((f"arg_{i}", f" {arg.flag} -> {{{arg.variable}}}{default}")) choices.append(("add_arg", " [ + Add Argument ]")) choices.append(("---2", "─" * 40)) choices.append(("output", f"Output Template: {output_preview}")) choices.append(("---3", "─" * 40)) choices.append(("back", "<- Back to Tool Builder")) selected = show_menu("Tool Info & Arguments", choices, dialog_prog) if selected is None or selected == "back": break elif selected == "name": if is_edit: show_message("Info", "Cannot change tool name after creation.", dialog_prog) else: new_name = show_input("Tool Name", "Enter tool name:", tool.name, dialog_prog) if new_name: tool.name = new_name.strip() elif selected == "desc": new_desc = show_input("Description", "Enter tool description:", tool.description, dialog_prog) if new_desc is not None: tool.description = new_desc.strip() elif selected == "add_arg": arg = edit_argument(dialog_prog) if arg: tool.arguments.append(arg) elif selected.startswith("arg_"): idx = int(selected[4:]) arg = tool.arguments[idx] action = show_menu( f"Argument: {arg.flag}", [("edit", "Edit"), ("delete", "Delete"), ("back", "Back")], dialog_prog ) if action == "edit": updated = edit_argument(dialog_prog, arg) if updated: tool.arguments[idx] = updated elif action == "delete": if show_yesno("Delete", f"Delete argument {arg.flag}?", dialog_prog): tool.arguments.pop(idx) elif selected == "output": available = get_available_variables(tool) var_help = "Variables: " + ", ".join(f"{{{v}}}" for v in available) new_output = show_textbox(f"Output Template\n{var_help}", tool.output, dialog_prog) if new_output is not None: tool.output = new_output def edit_tool_steps(tool: Tool, dialog_prog: str) -> None: """Edit tool processing steps.""" while True: # Build steps section menu choices = [] if tool.steps: for i, step in enumerate(tool.steps): if isinstance(step, PromptStep): choices.append((f"step_{i}", f"{i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}")) elif isinstance(step, CodeStep): choices.append((f"step_{i}", f"{i+1}. CODE -> {{{step.output_var}}}")) else: choices.append(("none", "(no steps defined)")) choices.append(("---1", "─" * 40)) choices.append(("add_prompt", "[ + Add Prompt Step ]")) choices.append(("add_code", "[ + Add Code Step ]")) choices.append(("---2", "─" * 40)) choices.append(("back", "<- Back to Tool Builder")) selected = show_menu("Processing Steps", choices, dialog_prog) if selected is None or selected == "back" or selected == "none": if selected == "none": continue break elif selected == "add_prompt": available = get_available_variables(tool) step = edit_prompt_step(dialog_prog, available_vars=available) if step: tool.steps.append(step) elif selected == "add_code": available = get_available_variables(tool) step = edit_code_step(dialog_prog, available_vars=available) if step: tool.steps.append(step) elif selected.startswith("step_"): idx = int(selected[5:]) step = tool.steps[idx] step_type = "Prompt" if isinstance(step, PromptStep) else "Code" move_choices = [("edit", "Edit"), ("delete", "Delete")] if idx > 0: move_choices.insert(1, ("move_up", "Move Up")) if idx < len(tool.steps) - 1: move_choices.insert(2 if idx > 0 else 1, ("move_down", "Move Down")) move_choices.append(("back", "Back")) action = show_menu(f"Step {idx+1}: {step_type}", move_choices, dialog_prog) if action == "edit": available = get_available_variables(tool, idx) if isinstance(step, PromptStep): updated = edit_prompt_step(dialog_prog, step, available) else: updated = edit_code_step(dialog_prog, step, available) if updated: tool.steps[idx] = updated elif action == "move_up" and idx > 0: tool.steps[idx], tool.steps[idx-1] = tool.steps[idx-1], tool.steps[idx] elif action == "move_down" and idx < len(tool.steps) - 1: tool.steps[idx], tool.steps[idx+1] = tool.steps[idx+1], tool.steps[idx] elif action == "delete": if show_yesno("Delete", f"Delete step {idx+1}?", dialog_prog): tool.steps.pop(idx) def tool_builder(dialog_prog: str, existing: Optional[Tool] = None) -> Optional[Tool]: """Main tool builder interface with tabbed sections.""" is_edit = existing is not None # Initialize tool if existing: tool = Tool( name=existing.name, description=existing.description, arguments=list(existing.arguments), steps=list(existing.steps), output=existing.output ) else: tool = Tool(name="", description="", arguments=[], steps=[], output="{input}") while True: # Build main menu with section summaries args_count = len(tool.arguments) steps_count = len(tool.steps) # Info summary name_display = tool.name or "(not set)" info_summary = f"{name_display}" if tool.arguments: info_summary += f" | {args_count} arg{'s' if args_count != 1 else ''}" # Steps summary if tool.steps: step_types = [] for s in tool.steps: if isinstance(s, PromptStep): step_types.append(f"P:{s.provider}") else: step_types.append("C") steps_summary = " -> ".join(step_types) else: steps_summary = "(none)" choices = [ ("info", f"[1] Info & Args : {info_summary}"), ("steps", f"[2] Steps : {steps_summary}"), ("---", "─" * 50), ("preview", "Preview Full Summary"), ("save", "Save Tool"), ("cancel", "Cancel"), ] title = f"Tool Builder: {tool.name}" if tool.name else "Tool Builder: New Tool" selected = show_menu(title, choices, dialog_prog) if selected is None or selected == "cancel": if show_yesno("Cancel", "Discard changes?", dialog_prog): return None continue elif selected == "info": edit_tool_info(tool, is_edit, dialog_prog) elif selected == "steps": edit_tool_steps(tool, dialog_prog) elif selected == "preview": summary = format_tool_summary(tool) show_message("Tool Summary", summary, dialog_prog) elif selected == "save": if not tool.name: show_message("Error", "Tool name is required. Go to Info & Args to set it.", dialog_prog) continue if not is_edit and tool_exists(tool.name): if not show_yesno("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", dialog_prog): continue return tool # ============ Main Menu Functions ============ def ui_list_tools(dialog_prog: str): """Show list of tools.""" tools = list_tools() if not tools: show_message("Tools", "No tools found.\n\nCreate your first tool from the main menu.", dialog_prog) return text = "Available tools:\n\n" for name in tools: tool = load_tool(name) if tool: text += f" {name}: {tool.description or 'No description'}\n" if tool.arguments: args = ", ".join(arg.flag for arg in tool.arguments) text += f" Arguments: {args}\n" if tool.steps: step_info = [] for step in tool.steps: if isinstance(step, PromptStep): step_info.append(f"PROMPT[{step.provider}]") else: step_info.append("CODE") text += f" Steps: {' -> '.join(step_info)}\n" text += "\n" show_message("Tools", text, dialog_prog) def ui_create_tool(dialog_prog: str): """Create a new tool.""" tool = tool_builder(dialog_prog) if tool: path = save_tool(tool) # Build usage example usage = f"{tool.name}" for arg in tool.arguments: if arg.default: usage += f" [{arg.flag} <{arg.variable}>]" else: usage += f" {arg.flag} <{arg.variable}>" usage += " < input.txt" show_message("Success", f"Tool '{tool.name}' created!\n\n" f"Config: {path}\n\n" f"Usage: {usage}", dialog_prog) def ui_edit_tool(dialog_prog: str): """Edit an existing tool.""" tools = list_tools() if not tools: show_message("Edit Tool", "No tools found.", dialog_prog) return choices = [] for name in tools: tool = load_tool(name) desc = tool.description if tool else "No description" choices.append((name, desc)) selected = show_menu("Select Tool to Edit", choices, dialog_prog) if selected: existing = load_tool(selected) if existing: tool = tool_builder(dialog_prog, existing) if tool: save_tool(tool) show_message("Success", f"Tool '{tool.name}' updated!", dialog_prog) def ui_delete_tool(dialog_prog: str): """Delete a tool.""" tools = list_tools() if not tools: show_message("Delete Tool", "No tools found.", dialog_prog) return choices = [] for name in tools: tool = load_tool(name) desc = tool.description if tool else "No description" choices.append((name, desc)) selected = show_menu("Select Tool to Delete", choices, dialog_prog) if selected: if show_yesno("Confirm Delete", f"Delete tool '{selected}'?\n\nThis cannot be undone.", dialog_prog): if delete_tool(selected): show_message("Deleted", f"Tool '{selected}' deleted.", dialog_prog) else: show_message("Error", f"Failed to delete '{selected}'.", dialog_prog) def ui_test_tool(dialog_prog: str): """Test a tool with mock provider.""" tools = list_tools() if not tools: show_message("Test Tool", "No tools found.", dialog_prog) return choices = [] for name in tools: tool = load_tool(name) desc = tool.description if tool else "No description" choices.append((name, desc)) selected = show_menu("Select Tool to Test", choices, dialog_prog) if selected: tool = load_tool(selected) if tool: test_input = show_textbox("Test Input", "Enter test input here...", dialog_prog) if test_input: from .runner import run_tool output, code = run_tool( tool=tool, input_text=test_input, custom_args={}, provider_override="mock", dry_run=False, show_prompt=False, verbose=False ) result_text = f"Exit code: {code}\n\n--- Output ---\n{output[:1000]}" if len(output) > 1000: result_text += "\n... (truncated)" show_message("Test Result", result_text, dialog_prog) def main_menu(dialog_prog: str): """Show the main menu.""" while True: choice = show_menu( "SmartTools Manager", [ ("list", "List all tools"), ("create", "Create new tool"), ("edit", "Edit existing tool"), ("delete", "Delete tool"), ("test", "Test tool (mock provider)"), ("providers", "Manage providers"), ("exit", "Exit"), ], dialog_prog ) if choice is None or choice == "exit": break elif choice == "list": ui_list_tools(dialog_prog) elif choice == "create": ui_create_tool(dialog_prog) elif choice == "edit": ui_edit_tool(dialog_prog) elif choice == "delete": ui_delete_tool(dialog_prog) elif choice == "test": ui_test_tool(dialog_prog) elif choice == "providers": ui_manage_providers(dialog_prog) def run_ui(): """Entry point for the UI.""" # Prefer urwid (has mouse support) if _check_urwid(): from .ui_urwid import run_ui as run_urwid_ui run_urwid_ui() return # Fallback to snack (BIOS-style) if _check_snack(): from .ui_snack import run_ui as run_snack_ui run_snack_ui() return # Fallback to dialog/whiptail dialog_prog = check_dialog() if not dialog_prog: print("Error: No TUI library found.", file=sys.stderr) print("Install one of:", file=sys.stderr) print(" pip install urwid (recommended - has mouse support)", file=sys.stderr) print(" sudo apt install python3-newt", file=sys.stderr) print(" sudo apt install dialog", file=sys.stderr) sys.exit(1) try: main_menu(dialog_prog) except KeyboardInterrupt: pass finally: subprocess.run(["clear"], check=False) if __name__ == "__main__": run_ui()