"""BIOS-style TUI for SmartTools using urwid (with mouse support).""" import urwid from typing import Optional, List, Callable from .tool import ( Tool, ToolArgument, PromptStep, CodeStep, Step, list_tools, load_tool, save_tool, delete_tool, tool_exists, validate_tool_name ) from .providers import Provider, load_providers, add_provider, delete_provider, get_provider # Color palette - BIOS style with 3D button effects PALETTE = [ ('body', 'white', 'dark blue'), ('header', 'white', 'dark red', 'bold'), ('footer', 'black', 'light gray'), # Button colors - raised 3D effect ('button', 'black', 'light gray'), ('button_focus', 'white', 'dark red', 'bold'), ('button_highlight', 'white', 'light gray'), # Top/left edge (light) ('button_shadow', 'dark gray', 'light gray'), # Bottom/right edge (dark) ('button_pressed', 'black', 'dark gray'), # Pressed state # Edit fields ('edit', 'black', 'light gray'), ('edit_focus', 'black', 'yellow'), # List items ('listbox', 'black', 'light gray'), ('listbox_focus', 'white', 'dark red'), # Dialog ('dialog', 'black', 'light gray'), ('dialog_border', 'white', 'dark blue'), # Text styles ('label', 'yellow', 'dark blue', 'bold'), ('error', 'white', 'dark red', 'bold'), ('success', 'light green', 'dark blue', 'bold'), # 3D shadow elements ('shadow', 'black', 'black'), ('shadow_edge', 'dark gray', 'dark blue'), ] class SelectableText(urwid.WidgetWrap): """A selectable text widget for list items.""" def __init__(self, text, value=None, on_select=None): self.value = value self.on_select = on_select self.text_widget = urwid.Text(text) display = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus') super().__init__(display) def selectable(self): return True def keypress(self, size, key): if key == 'enter' and self.on_select: self.on_select(self.value) return None return key def mouse_event(self, size, event, button, col, row, focus): if event == 'mouse press' and button == 1 and self.on_select: self.on_select(self.value) return True return False class Button3D(urwid.WidgetWrap): """A 3D-style button using box-drawing characters for depth. Creates a raised button effect like DOS/BIOS interfaces: ┌──────────┐ │ Label │▄ └──────────┘█ When focused, colors change to show selection. """ signals = ['click'] def __init__(self, label, on_press=None, user_data=None): self.label = label self.on_press = on_press self.user_data = user_data self._pressed = False # Build the 3D button structure self._build_widget() super().__init__(self._widget) def _build_widget(self): """Build the 3D button widget structure.""" label = self.label width = len(label) + 4 # Padding inside button # Button face with border # Top edge: ┌────┐ top = '┌' + '─' * (width - 2) + '┐' # Middle: │ Label │ with shadow middle_text = '│ ' + label + ' │' # Bottom edge: └────┘ with shadow bottom = '└' + '─' * (width - 2) + '┘' # Shadow characters (right and bottom) shadow_right = '▄' shadow_bottom = '█' # Create the rows top_row = urwid.Text(top + ' ') # Space for shadow alignment middle_row = urwid.Columns([ ('pack', urwid.Text(middle_text)), ('pack', urwid.Text(('shadow_edge', shadow_right))), ]) bottom_row = urwid.Columns([ ('pack', urwid.Text(bottom)), ('pack', urwid.Text(('shadow_edge', shadow_right))), ]) shadow_row = urwid.Text(('shadow_edge', ' ' + shadow_bottom * (width - 1))) # Stack them pile = urwid.Pile([ top_row, middle_row, bottom_row, shadow_row, ]) self._widget = urwid.AttrMap(pile, 'button', 'button_focus') def selectable(self): return True def keypress(self, size, key): if key == 'enter': self._activate() return None return key def mouse_event(self, size, event, button, col, row, focus): if button == 1: if event == 'mouse press': self._pressed = True return True elif event == 'mouse release' and self._pressed: self._pressed = False self._activate() return True return False def _activate(self): """Trigger the button callback.""" if self.on_press: self.on_press(self.user_data) self._emit('click') class Button3DCompact(urwid.WidgetWrap): """A compact 3D button that fits on a single line with shadow effect. Creates a subtle 3D effect: [ Label ]▌ Better for inline use where vertical space is limited. """ signals = ['click'] def __init__(self, label, on_press=None, user_data=None): self.label = label self.on_press = on_press self.user_data = user_data # Build compact button: ▐ Label ▌ with shadow # Using block characters for edges button_text = urwid.Text([ ('button_highlight', '▐'), ('button', f' {label} '), ('button_shadow', '▌'), ('shadow_edge', '▄'), ]) self._widget = urwid.AttrMap(button_text, None, { 'button': 'button_focus', 'button_highlight': 'button_focus', 'button_shadow': 'button_focus', }) super().__init__(self._widget) def selectable(self): return True def keypress(self, size, key): if key == 'enter': self._activate() return None return key def mouse_event(self, size, event, button, col, row, focus): if button == 1 and event == 'mouse release': self._activate() return True return False def _activate(self): if self.on_press: self.on_press(self.user_data) self._emit('click') class ClickableButton(urwid.WidgetWrap): """A button that responds to mouse clicks (legacy wrapper).""" def __init__(self, label, on_press=None, user_data=None): self.on_press = on_press self.user_data = user_data button = urwid.Button(label) if on_press: urwid.connect_signal(button, 'click', self._handle_click) display = urwid.AttrMap(button, 'button', 'button_focus') super().__init__(display) def _handle_click(self, button): if self.on_press: self.on_press(self.user_data) class SelectableToolItem(urwid.WidgetWrap): """A selectable tool item that maintains selection state.""" def __init__(self, name, on_select=None): self.name = name self.on_select = on_select self._selected = False self.text_widget = urwid.Text(f" {name} ") self.attr_map = urwid.AttrMap(self.text_widget, 'listbox', 'listbox_focus') super().__init__(self.attr_map) def selectable(self): return True def set_selected(self, selected): """Set whether this item is the selected tool.""" self._selected = selected if self._selected: self.attr_map.set_attr_map({None: 'listbox_focus'}) else: self.attr_map.set_attr_map({None: 'listbox'}) def keypress(self, size, key): if key == 'enter' and self.on_select: self.on_select(self.name) return None return key def mouse_event(self, size, event, button, col, row, focus): if event == 'mouse press' and button == 1: # Single click just selects/focuses - don't call on_select # on_select is only called on Enter key (to edit) return True return False class ToolListBox(urwid.ListBox): """A ListBox that keeps arrow keys internal and passes Tab out.""" def __init__(self, body, on_focus_change=None): super().__init__(body) self.on_focus_change = on_focus_change self._last_focus = None def keypress(self, size, key): if key in ('up', 'down'): # Handle arrow keys internally - navigate within list result = super().keypress(size, key) # Check if focus changed self._check_focus_change() return result elif key == 'tab': # Pass tab out to parent for focus cycling return key elif key == 'shift tab': return key else: return super().keypress(size, key) def _check_focus_change(self): """Check if focus changed and notify callback.""" try: current = self.focus if current is not self._last_focus: self._last_focus = current if self.on_focus_change and isinstance(current, SelectableToolItem): self.on_focus_change(current.name) except (IndexError, TypeError): pass def render(self, size, focus=False): # Check focus on render too (for initial display) if focus: self._check_focus_change() return super().render(size, focus) class TabCyclePile(urwid.Pile): """A Pile that uses Tab/Shift-Tab to cycle between specific positions. Args: widget_list: List of widgets (same as urwid.Pile) tab_positions: List of indices in the pile that Tab should cycle between. Default is [0] (only first position). """ def __init__(self, widget_list, tab_positions=None): super().__init__(widget_list) # Positions in the pile that Tab should cycle between self.tab_positions = tab_positions or [0] self._current_tab_idx = 0 def keypress(self, size, key): if key == 'tab': # Move to next tab position self._current_tab_idx = (self._current_tab_idx + 1) % len(self.tab_positions) self.focus_position = self.tab_positions[self._current_tab_idx] return None elif key == 'shift tab': # Move to previous tab position self._current_tab_idx = (self._current_tab_idx - 1) % len(self.tab_positions) self.focus_position = self.tab_positions[self._current_tab_idx] return None else: return super().keypress(size, key) class TabPassEdit(urwid.Edit): """A multiline Edit that passes Tab through for focus cycling instead of inserting tabs.""" def keypress(self, size, key): if key in ('tab', 'shift tab'): # Pass Tab through to parent for focus cycling return key return super().keypress(size, key) class UndoableEdit(urwid.Edit): """A multiline Edit with undo/redo support. Features: - Undo with Alt+U (up to 50 states) - Redo with Alt+R - Tab passes through for focus cycling """ MAX_UNDO = 50 # Maximum undo history size def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._undo_stack = [] # List of (text, cursor_pos) tuples self._redo_stack = [] def keypress(self, size, key): if key in ('tab', 'shift tab'): return key # Handle undo (Alt+U or meta u) if key in ('meta u', 'alt u'): self._undo() return None # Handle redo (Alt+R or meta r) if key in ('meta r', 'alt r'): self._redo() return None # Save current state BEFORE the edit for undo old_text = self.edit_text old_pos = self.edit_pos # Let the parent handle the keypress result = super().keypress(size, key) # If text changed, save the old state to undo stack if self.edit_text != old_text: self._save_undo_state(old_text, old_pos) self._redo_stack.clear() # Clear redo on new edit return result def _save_undo_state(self, text, pos): """Save state to undo stack.""" # Don't save duplicate states if self._undo_stack and self._undo_stack[-1][0] == text: return if len(self._undo_stack) >= self.MAX_UNDO: self._undo_stack.pop(0) self._undo_stack.append((text, pos)) def _undo(self): """Restore previous state from undo stack.""" if not self._undo_stack: return # Save current state to redo stack self._redo_stack.append((self.edit_text, self.edit_pos)) # Restore previous state text, pos = self._undo_stack.pop() self.set_edit_text(text) self.set_edit_pos(min(pos, len(text))) def _redo(self): """Restore state from redo stack.""" if not self._redo_stack: return # Save current state to undo stack self._undo_stack.append((self.edit_text, self.edit_pos)) # Restore redo state text, pos = self._redo_stack.pop() self.set_edit_text(text) self.set_edit_pos(min(pos, len(text))) class DOSScrollBar(urwid.WidgetWrap): """A DOS-style scrollbar with arrow buttons at top and bottom. Renders a scrollbar on the right side of the wrapped widget with: - ▲ arrow at top (click to scroll up) - ░ track with █ thumb showing scroll position - ▼ arrow at bottom (click to scroll down) Click zones (expanded to last 2 columns for easier clicking): - Top 25%: scroll up 3 lines - Bottom 25%: scroll down 3 lines - Middle: page up/down based on which half clicked """ def __init__(self, widget): self._wrapped = widget # Create a columns layout: content on left, scrollbar on right super().__init__(widget) def render(self, size, focus=False): maxcol, maxrow = size # Render the wrapped widget with one less column for scrollbar content_size = (maxcol - 1, maxrow) content_canvas = self._wrapped.render(content_size, focus) # Build the scrollbar column scrollbar_chars = [] # Up arrow at top scrollbar_chars.append('▲') # Calculate thumb position if maxrow > 2: track_height = maxrow - 2 # Minus the two arrow buttons # Get scroll position info from wrapped widget try: if hasattr(self._wrapped, 'rows_max'): rows_max = self._wrapped.rows_max(content_size) scroll_pos = self._wrapped.get_scrollpos(content_size) else: rows_max = maxrow scroll_pos = 0 if rows_max > maxrow: # Calculate thumb position within track thumb_pos = int((scroll_pos / (rows_max - maxrow)) * (track_height - 1)) thumb_pos = max(0, min(thumb_pos, track_height - 1)) else: thumb_pos = 0 except (AttributeError, TypeError, ZeroDivisionError): thumb_pos = 0 # Build track with thumb for i in range(track_height): if i == thumb_pos: scrollbar_chars.append('█') # Thumb else: scrollbar_chars.append('░') # Track # Down arrow at bottom scrollbar_chars.append('▼') # Create scrollbar canvas scrollbar_text = '\n'.join(scrollbar_chars[:maxrow]) scrollbar_canvas = urwid.Text(scrollbar_text).render((1,)) # Combine canvases combined = urwid.CanvasJoin([ (content_canvas, None, focus, content_size[0]), (scrollbar_canvas, None, False, 1), ]) return combined def keypress(self, size, key): maxcol, maxrow = size content_size = (maxcol - 1, maxrow) return self._wrapped.keypress(content_size, key) def mouse_event(self, size, event, button, col, row, focus): maxcol, maxrow = size content_size = (maxcol - 1, maxrow) # Expand clickable area - last 2 columns count as scrollbar if col >= maxcol - 2: if button == 1 and event == 'mouse press': # Top 25% of scrollbar = scroll up if row < maxrow // 4: for _ in range(3): self._wrapped.keypress(content_size, 'up') self._invalidate() return True # Bottom 25% of scrollbar = scroll down elif row >= maxrow - (maxrow // 4): for _ in range(3): self._wrapped.keypress(content_size, 'down') self._invalidate() return True # Middle = page up/down based on which half elif row < maxrow // 2: for _ in range(maxrow // 2): self._wrapped.keypress(content_size, 'up') self._invalidate() return True else: for _ in range(maxrow // 2): self._wrapped.keypress(content_size, 'down') self._invalidate() return True # Handle mouse wheel on scrollbar if button == 4: # Scroll up for _ in range(3): self._wrapped.keypress(content_size, 'up') self._invalidate() return True elif button == 5: # Scroll down for _ in range(3): self._wrapped.keypress(content_size, 'down') self._invalidate() return True return True # Consume other scrollbar clicks # Pass to wrapped widget return self._wrapped.mouse_event(content_size, event, button, col, row, focus) def selectable(self): return self._wrapped.selectable() def sizing(self): return frozenset([urwid.Sizing.BOX]) class ToolBuilderLayout(urwid.WidgetWrap): """Custom layout for tool builder that handles Tab cycling across all sections.""" def __init__(self, left_box, args_box, steps_box, args_section, steps_section, bottom_buttons, on_cancel=None): self._current_section = 0 self.on_cancel = on_cancel # Store references to LineBoxes for title highlighting self.left_box = left_box self.args_box = args_box self.steps_box = steps_box # Build visual layout: left column and right column side by side right_pile = urwid.Pile([ ('weight', 1, args_section), ('pack', urwid.Divider()), ('weight', 1, steps_section), ]) columns = urwid.Columns([ ('weight', 1, left_box), ('weight', 1, right_pile), ], dividechars=1) main_pile = urwid.Pile([ ('weight', 1, columns), ('pack', urwid.Divider()), ('pack', bottom_buttons), ]) super().__init__(main_pile) # Set initial highlight self._update_section_titles() def keypress(self, size, key): if key == 'tab': self._current_section = (self._current_section + 1) % 4 self._focus_section(self._current_section) self._update_section_titles() return None elif key == 'shift tab': self._current_section = (self._current_section - 1) % 4 self._focus_section(self._current_section) self._update_section_titles() return None elif key == 'esc': # Go back to main menu instead of exiting if self.on_cancel: self.on_cancel(None) return None else: return super().keypress(size, key) def mouse_event(self, size, event, button, col, row, focus): # Let the parent handle the mouse event first result = super().mouse_event(size, event, button, col, row, focus) # After mouse click, detect which section has focus and update titles if event == 'mouse press': self._detect_current_section() self._update_section_titles() return result def _detect_current_section(self): """Detect which section currently has focus based on widget hierarchy.""" main_pile = self._w # Check if bottom buttons have focus (position 2) if main_pile.focus_position == 2: self._current_section = 3 return # Focus is on columns (position 0) columns = main_pile.contents[0][0] if columns.focus_position == 0: # Left box (Tool Info) self._current_section = 0 else: # Right pile right_pile = columns.contents[1][0] if right_pile.focus_position == 0: # Args section self._current_section = 1 else: # Steps section self._current_section = 2 def _update_section_titles(self): """Update section titles to highlight the current one with markers.""" # Section 0 = Tool Info, Section 1 = Arguments, Section 2 = Steps, Section 3 = buttons if self._current_section == 0: self.left_box.set_title('[ Tool Info ]') self.args_box.set_title('Arguments') self.steps_box.set_title('Execution Steps') elif self._current_section == 1: self.left_box.set_title('Tool Info') self.args_box.set_title('[ Arguments ]') self.steps_box.set_title('Execution Steps') elif self._current_section == 2: self.left_box.set_title('Tool Info') self.args_box.set_title('Arguments') self.steps_box.set_title('[ Execution Steps ]') else: # Buttons focused - no section highlighted self.left_box.set_title('Tool Info') self.args_box.set_title('Arguments') self.steps_box.set_title('Execution Steps') def _focus_section(self, section_idx): """Set focus to the specified section.""" # Get the main pile main_pile = self._w if section_idx == 0: # Tool Info (left box) - focus columns, then left main_pile.focus_position = 0 # columns columns = main_pile.contents[0][0] columns.focus_position = 0 # left box elif section_idx == 1: # Arguments section - focus columns, then right, then args main_pile.focus_position = 0 # columns columns = main_pile.contents[0][0] columns.focus_position = 1 # right pile right_pile = columns.contents[1][0] right_pile.focus_position = 0 # args section elif section_idx == 2: # Steps section - focus columns, then right, then steps main_pile.focus_position = 0 # columns columns = main_pile.contents[0][0] columns.focus_position = 1 # right pile right_pile = columns.contents[1][0] right_pile.focus_position = 2 # steps section (after divider) elif section_idx == 3: # Save/Cancel buttons main_pile.focus_position = 2 # bottom buttons (after divider) class Dialog(urwid.WidgetWrap): """A dialog box overlay with 3D-style buttons.""" def __init__(self, title, body, buttons, width=60, height=None): # Title title_widget = urwid.Text(('header', f' {title} '), align='center') # Buttons row - use 3D compact buttons for dialog actions button_widgets = [] for label, callback in buttons: btn = Button3DCompact(label, callback) button_widgets.append(btn) buttons_row = urwid.Columns([('pack', b) for b in button_widgets], dividechars=2) buttons_centered = urwid.Padding(buttons_row, align='center', width='pack') # Check if body is a box widget # ListBox is always a box widget. For Piles with weighted items, # check if it ONLY supports BOX sizing (not FLOW). is_box_widget = isinstance(body, (urwid.ListBox, urwid.Scrollable, urwid.ScrollBar)) if not is_box_widget: try: sizing = body.sizing() # Box widget if it ONLY supports BOX sizing is_box_widget = sizing == frozenset({urwid.Sizing.BOX}) except (AttributeError, TypeError): pass if is_box_widget: # Box widget - use directly with weight pile = urwid.Pile([ ('pack', title_widget), ('pack', urwid.Divider('─')), ('weight', 1, body), ('pack', urwid.Divider('─')), ('pack', buttons_centered), ]) else: # Flow widget - wrap in Filler body_padded = urwid.Padding(body, left=1, right=1) body_filled = urwid.Filler(body_padded, valign='top') pile = urwid.Pile([ ('pack', title_widget), ('pack', urwid.Divider('─')), body_filled, ('pack', urwid.Divider('─')), ('pack', buttons_centered), ]) # Box it box = urwid.LineBox(pile, title='', title_align='center') box = urwid.AttrMap(box, 'dialog') super().__init__(box) class SmartToolsUI: """Urwid-based UI for SmartTools with mouse support.""" def __init__(self): self.loop = None self.main_widget = None self.overlay_stack = [] def run(self): """Run the UI.""" self.show_main_menu() self.loop = urwid.MainLoop( self.main_widget, palette=PALETTE, unhandled_input=self.handle_input, handle_mouse=True # Enable mouse support! ) self.loop.run() def handle_input(self, key): """Handle global key input.""" if key in ('q', 'Q', 'esc'): if self.overlay_stack: self.close_overlay() else: raise urwid.ExitMainLoop() def refresh(self): """Refresh the display.""" if self.loop: self.loop.draw_screen() def set_main(self, widget): """Set the main widget.""" self.main_widget = urwid.AttrMap(widget, 'body') if self.loop: self.loop.widget = self.main_widget def show_overlay(self, dialog, width=60, height=20): """Show a dialog overlay.""" overlay = urwid.Overlay( dialog, self.main_widget, align='center', width=width, valign='middle', height=height, ) self.overlay_stack.append(self.main_widget) self.main_widget = overlay if self.loop: self.loop.widget = self.main_widget def close_overlay(self): """Close the current overlay.""" if self.overlay_stack: self.main_widget = self.overlay_stack.pop() if self.loop: self.loop.widget = self.main_widget def message_box(self, title: str, message: str, callback=None): """Show a message box.""" def on_ok(_): self.close_overlay() if callback: callback() body = urwid.Text(message) dialog = Dialog(title, body, [("OK", on_ok)], width=50) self.show_overlay(dialog, width=52, height=min(10 + message.count('\n'), 20)) def yes_no(self, title: str, message: str, on_yes=None, on_no=None): """Show a yes/no dialog.""" def handle_yes(_): self.close_overlay() if on_yes: on_yes() def handle_no(_): self.close_overlay() if on_no: on_no() body = urwid.Text(message) dialog = Dialog(title, body, [("Yes", handle_yes), ("No", handle_no)], width=50) self.show_overlay(dialog, width=52, height=10) def input_dialog(self, title: str, prompt: str, initial: str, callback: Callable[[str], None]): """Show an input dialog.""" edit = urwid.Edit(('label', f"{prompt}: "), initial) edit = urwid.AttrMap(edit, 'edit', 'edit_focus') def on_ok(_): value = edit.base_widget.edit_text self.close_overlay() callback(value) def on_cancel(_): self.close_overlay() body = urwid.Filler(edit, valign='top') dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)], width=50) self.show_overlay(dialog, width=52, height=8) # ==================== Main Menu ==================== def show_main_menu(self): """Show the main menu with tool list and info panel.""" self._selected_tool_name = None self._refresh_main_menu() def _refresh_main_menu(self): """Refresh the main menu display.""" from collections import defaultdict from .tool import DEFAULT_CATEGORIES tools = list_tools() self._tools_list = tools # Group tools by category tools_by_category = defaultdict(list) for name in tools: tool = load_tool(name) category = tool.category if tool else "Other" tools_by_category[category].append(name) # Build tool list with category headers tool_items = [] # Show categories in defined order, then any custom ones all_categories = list(DEFAULT_CATEGORIES) for cat in tools_by_category: if cat not in all_categories: all_categories.append(cat) for category in all_categories: if category in tools_by_category and tools_by_category[category]: # Category header (non-selectable) header = urwid.AttrMap( urwid.Text(f"─── {category} ───"), 'label' ) tool_items.append(header) # Tools in this category for name in sorted(tools_by_category[category]): item = SelectableToolItem(name, on_select=self._on_tool_select) tool_items.append(item) if not tools: tool_items.append(urwid.Text(('label', " (no tools - click Create to add one) "))) self._tool_walker = urwid.SimpleFocusListWalker(tool_items) tool_listbox = ToolListBox(self._tool_walker, on_focus_change=self._on_tool_focus) tool_box = urwid.LineBox(tool_listbox, title='Tools') # Action buttons - Tab navigates here from tool list (3D style) create_btn = Button3DCompact("Create", lambda _: self._create_tool_before_selected()) edit_btn = Button3DCompact("Edit", lambda _: self._edit_selected_tool()) delete_btn = Button3DCompact("Delete", lambda _: self._delete_selected_tool()) test_btn = Button3DCompact("Test", lambda _: self._test_selected_tool()) providers_btn = Button3DCompact("Providers", lambda _: self.manage_providers()) buttons_row = urwid.Columns([ ('pack', create_btn), ('pack', urwid.Text(" ")), ('pack', edit_btn), ('pack', urwid.Text(" ")), ('pack', delete_btn), ('pack', urwid.Text(" ")), ('pack', test_btn), ('pack', urwid.Text(" ")), ('pack', providers_btn), ]) buttons_padded = urwid.Padding(buttons_row, align='left', left=1) # Info panel - shows details of selected tool (not focusable) self._info_name = urwid.Text("") self._info_desc = urwid.Text("") self._info_args = urwid.Text("") self._info_steps = urwid.Text("") self._info_output = urwid.Text("") info_content = urwid.Pile([ self._info_name, self._info_desc, urwid.Divider(), self._info_args, urwid.Divider(), self._info_steps, urwid.Divider(), self._info_output, ]) info_filler = urwid.Filler(info_content, valign='top') info_box = urwid.LineBox(info_filler, title='Tool Info') # Exit button at bottom (3D style) exit_btn = Button3DCompact("EXIT", lambda _: self.exit_app()) exit_centered = urwid.Padding(exit_btn, align='center', width=12) # Use a custom Pile that handles Tab to cycle between tool list and buttons self._main_pile = TabCyclePile([ ('weight', 1, tool_box), ('pack', buttons_padded), ('pack', urwid.Divider('─')), ('weight', 2, info_box), ('pack', urwid.Divider()), ('pack', exit_centered), ], tab_positions=[0, 1, 5]) # Tool list, buttons row, exit button # Header header = urwid.Text(('header', ' SmartTools Manager '), align='center') # Footer footer = urwid.Text(('footer', ' Arrow:Navigate list | Tab:Jump to buttons | Enter/Click:Select | Q:Quit '), align='center') frame = urwid.Frame(self._main_pile, header=header, footer=footer) self.set_main(frame) # Update info for first tool if any if tools: self._on_tool_focus(tools[0]) def _create_tool_before_selected(self): """Create a new tool (will appear in list based on name sorting).""" self.create_tool() def _on_tool_focus(self, name): """Called when a tool is focused/highlighted.""" self._selected_tool_name = name # Update selection state on all tool items if hasattr(self, '_tool_walker'): for item in self._tool_walker: if isinstance(item, SelectableToolItem): item.set_selected(item.name == name) tool = load_tool(name) if tool: self._info_name.set_text(('label', f"Name: {tool.name}")) self._info_desc.set_text(f"Description: {tool.description or '(none)'}") if tool.arguments: args_text = "Arguments:\n" for arg in tool.arguments: default = f" = {arg.default}" if arg.default else "" args_text += f" {arg.flag} -> {{{arg.variable}}}{default}\n" else: args_text = "Arguments: (none)" self._info_args.set_text(args_text.rstrip()) if tool.steps: steps_text = "Execution Steps:\n" for i, step in enumerate(tool.steps): if isinstance(step, PromptStep): steps_text += f" {i+1}. PROMPT [{step.provider}] -> {{{step.output_var}}}\n" else: steps_text += f" {i+1}. CODE -> {{{step.output_var}}}\n" else: steps_text = "Execution Steps: (none)" self._info_steps.set_text(steps_text.rstrip()) self._info_output.set_text(f"Output: {tool.output}") else: self._info_name.set_text("") self._info_desc.set_text("") self._info_args.set_text("") self._info_steps.set_text("") self._info_output.set_text("") def _on_tool_select(self, name): """Called when a tool is selected (Enter/double-click).""" # Edit the tool on select tool = load_tool(name) if tool: self.tool_builder(tool) def _edit_selected_tool(self): """Edit the currently selected tool.""" if self._selected_tool_name: tool = load_tool(self._selected_tool_name) if tool: self.tool_builder(tool) else: self.message_box("Edit", "No tool selected.") def _delete_selected_tool(self): """Delete the currently selected tool.""" if self._selected_tool_name: name = self._selected_tool_name def do_delete(): delete_tool(name) self._selected_tool_name = None self.message_box("Deleted", f"Tool '{name}' deleted.", self._refresh_main_menu) self.yes_no("Confirm", f"Delete tool '{name}'?", on_yes=do_delete) else: self.message_box("Delete", "No tool selected.") def _test_selected_tool(self): """Test the currently selected tool.""" if self._selected_tool_name: tool = load_tool(self._selected_tool_name) if tool: self._test_tool(tool) else: self.message_box("Test", "No tool selected.") def exit_app(self): """Exit the application.""" raise urwid.ExitMainLoop() # ==================== Tool Builder ==================== def create_tool(self): """Create a new tool.""" self.tool_builder(None) def tool_builder(self, existing: Optional[Tool]): """Main tool builder interface.""" 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}") # Store references for callbacks self._current_tool = tool self._is_edit = is_edit self._selected_arg_idx = None self._selected_step_idx = None self._show_tool_builder() def _save_tool_fields(self): """Save current edit field values to the tool object.""" if not hasattr(self, '_name_edit') or not hasattr(self, '_current_tool'): return tool = self._current_tool # Save name (only if it's an edit widget, not a text label) if not self._is_edit and hasattr(self._name_edit, 'base_widget'): name_edit = self._name_edit.base_widget if hasattr(self._name_edit, 'base_widget') else self._name_edit if hasattr(name_edit, 'edit_text'): tool.name = name_edit.edit_text.strip() # Save description if hasattr(self, '_desc_edit') and hasattr(self._desc_edit, 'base_widget'): tool.description = self._desc_edit.base_widget.edit_text.strip() # Save output if hasattr(self, '_output_edit') and hasattr(self._output_edit, 'base_widget'): tool.output = self._output_edit.base_widget.edit_text.strip() def _show_tool_builder(self): """Render the tool builder screen.""" from .tool import DEFAULT_CATEGORIES tool = self._current_tool # Create edit widgets if self._is_edit: name_widget = urwid.Text(('label', f"Name: {tool.name}")) else: name_widget = urwid.AttrMap(urwid.Edit(('label', "Name: "), tool.name), 'edit', 'edit_focus') self._name_edit = name_widget self._desc_edit = urwid.AttrMap(urwid.Edit(('label', "Desc: "), tool.description), 'edit', 'edit_focus') self._output_edit = urwid.AttrMap(urwid.Edit(('label', "Output: "), tool.output), 'edit', 'edit_focus') # Category selector self._selected_category = [tool.category or "Other"] category_btn_text = urwid.Text(self._selected_category[0]) category_btn = urwid.AttrMap( urwid.Padding(category_btn_text, left=1, right=1), 'edit', 'edit_focus' ) def show_category_dropdown(_): """Show category selection popup.""" def select_category(cat): def callback(_): self._selected_category[0] = cat category_btn_text.set_text(cat) tool.category = cat self.close_overlay() return callback items = [] for cat in DEFAULT_CATEGORIES: btn = urwid.Button(cat, on_press=select_category(cat)) items.append(urwid.AttrMap(btn, 'button', 'button_focus')) listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) popup = Dialog("Select Category", listbox, []) self.show_overlay(popup, width=30, height=len(DEFAULT_CATEGORIES) + 4) category_select_btn = Button3DCompact("▼", on_press=show_category_dropdown) category_row = urwid.Columns([ ('pack', urwid.Text(('label', "Category: "))), ('weight', 1, category_btn), ('pack', urwid.Text(" ")), ('pack', category_select_btn), ]) # Left column - fields left_pile = urwid.Pile([ ('pack', name_widget), ('pack', urwid.Divider()), ('pack', self._desc_edit), ('pack', urwid.Divider()), ('pack', category_row), ('pack', urwid.Divider()), ('pack', self._output_edit), ]) left_box = urwid.LineBox(urwid.Filler(left_pile, valign='top'), title='Tool Info') # Arguments list arg_items = [] for i, arg in enumerate(tool.arguments): text = f"{arg.flag} -> {{{arg.variable}}}" item = SelectableToolItem(text, on_select=lambda n, idx=i: self._on_arg_activate(idx)) item.name = i # Store index arg_items.append(item) if not arg_items: arg_items.append(urwid.Text(('label', " (none) "))) self._arg_walker = urwid.SimpleFocusListWalker(arg_items) args_listbox = ToolListBox(self._arg_walker, on_focus_change=self._on_arg_focus) args_box = urwid.LineBox(args_listbox, title='Arguments') # Argument buttons arg_add_btn = ClickableButton("Add", lambda _: self._add_argument_dialog()) arg_edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_arg()) arg_del_btn = ClickableButton("Delete", lambda _: self._delete_selected_arg()) arg_buttons = urwid.Columns([ ('pack', arg_add_btn), ('pack', urwid.Text(" ")), ('pack', arg_edit_btn), ('pack', urwid.Text(" ")), ('pack', arg_del_btn), ]) arg_buttons_padded = urwid.Padding(arg_buttons, align='left', left=1) # Args section (list + buttons) args_section = urwid.Pile([ ('weight', 1, args_box), ('pack', arg_buttons_padded), ]) # Steps list step_items = [] for i, step in enumerate(tool.steps): if isinstance(step, PromptStep): text = f"P:{step.provider} -> {{{step.output_var}}}" else: text = f"C: -> {{{step.output_var}}}" item = SelectableToolItem(text, on_select=lambda n, idx=i: self._on_step_activate(idx)) item.name = i # Store index step_items.append(item) if not step_items: step_items.append(urwid.Text(('label', " (none) "))) self._step_walker = urwid.SimpleFocusListWalker(step_items) steps_listbox = ToolListBox(self._step_walker, on_focus_change=self._on_step_focus) steps_box = urwid.LineBox(steps_listbox, title='Execution Steps') # Step buttons step_add_btn = ClickableButton("Add", lambda _: self._add_step_choice()) step_edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_step()) step_del_btn = ClickableButton("Delete", lambda _: self._delete_selected_step()) step_buttons = urwid.Columns([ ('pack', step_add_btn), ('pack', urwid.Text(" ")), ('pack', step_edit_btn), ('pack', urwid.Text(" ")), ('pack', step_del_btn), ]) step_buttons_padded = urwid.Padding(step_buttons, align='left', left=1) # Steps section (list + buttons) steps_section = urwid.Pile([ ('weight', 1, steps_box), ('pack', step_buttons_padded), ]) # Save/Cancel buttons save_btn = ClickableButton("Save", self._on_save_tool) cancel_btn = ClickableButton("Cancel", self._on_cancel_tool) bottom_buttons = urwid.Columns([ ('pack', save_btn), ('pack', urwid.Text(" ")), ('pack', cancel_btn), ], dividechars=1) bottom_buttons_centered = urwid.Padding(bottom_buttons, align='center', width='pack') # Use ToolBuilderLayout for proper Tab cycling # Pass LineBoxes for title highlighting and on_cancel for Escape key body = ToolBuilderLayout( left_box, args_box, steps_box, args_section, steps_section, bottom_buttons_centered, on_cancel=self._on_cancel_tool ) # Frame title = f"Edit Tool: {tool.name}" if self._is_edit and tool.name else "New Tool" header = urwid.Text(('header', f' {title} '), align='center') footer = urwid.Text(('footer', ' Arrow:Navigate | Tab:Next section | Enter/Click:Select | Esc:Cancel '), align='center') frame = urwid.Frame(body, header=header, footer=footer) self.set_main(frame) # Set initial selection if tool.arguments: self._selected_arg_idx = 0 self._on_arg_focus(0) if tool.steps: self._selected_step_idx = 0 self._on_step_focus(0) def _on_arg_focus(self, idx): """Called when an argument is focused.""" if isinstance(idx, int): self._selected_arg_idx = idx # Update selection display if hasattr(self, '_arg_walker'): for i, item in enumerate(self._arg_walker): if isinstance(item, SelectableToolItem): item.set_selected(i == idx) def _on_arg_activate(self, idx): """Called when an argument is activated (Enter/click).""" self._selected_arg_idx = idx self._edit_argument_at(idx) def _on_step_focus(self, idx): """Called when a step is focused.""" if isinstance(idx, int): self._selected_step_idx = idx # Update selection display if hasattr(self, '_step_walker'): for i, item in enumerate(self._step_walker): if isinstance(item, SelectableToolItem): item.set_selected(i == idx) def _on_step_activate(self, idx): """Called when a step is activated (Enter/click).""" self._selected_step_idx = idx self._edit_step_at(idx) def _edit_selected_arg(self): """Edit the currently selected argument.""" if self._selected_arg_idx is not None and self._selected_arg_idx < len(self._current_tool.arguments): self._edit_argument_at(self._selected_arg_idx) else: self.message_box("Edit", "No argument selected.") def _delete_selected_arg(self): """Delete the currently selected argument.""" if self._selected_arg_idx is not None and self._selected_arg_idx < len(self._current_tool.arguments): idx = self._selected_arg_idx arg = self._current_tool.arguments[idx] def do_delete(): self._save_tool_fields() self._current_tool.arguments.pop(idx) self._selected_arg_idx = None self._show_tool_builder() self.yes_no("Delete", f"Delete argument {arg.flag}?", on_yes=do_delete) else: self.message_box("Delete", "No argument selected.") def _edit_selected_step(self): """Edit the currently selected step.""" if self._selected_step_idx is not None and self._selected_step_idx < len(self._current_tool.steps): self._edit_step_at(self._selected_step_idx) else: self.message_box("Edit", "No step selected.") def _delete_selected_step(self): """Delete the currently selected step.""" if self._selected_step_idx is not None and self._selected_step_idx < len(self._current_tool.steps): idx = self._selected_step_idx def do_delete(): self._save_tool_fields() self._current_tool.steps.pop(idx) self._selected_step_idx = None self._show_tool_builder() self.yes_no("Delete", f"Delete step {idx + 1}?", on_yes=do_delete) else: self.message_box("Delete", "No step selected.") def _edit_argument_at(self, idx): """Edit argument at index.""" self._do_edit_argument(idx) def _edit_step_at(self, idx): """Edit step at index - opens the appropriate dialog based on step type.""" # Save current field values before showing dialog self._save_tool_fields() step = self._current_tool.steps[idx] if isinstance(step, PromptStep): self._add_prompt_dialog(step, idx) else: self._add_code_dialog(step, idx) def _add_argument_dialog(self): """Show add argument dialog.""" # Save current field values before showing dialog self._save_tool_fields() flag_edit = urwid.Edit(('label', "Flag: "), "--") var_edit = urwid.Edit(('label', "Variable: "), "") default_edit = urwid.Edit(('label', "Default: "), "") def on_ok(_): flag = flag_edit.edit_text.strip() var = var_edit.edit_text.strip() default = default_edit.edit_text.strip() or None if not flag: return if not var: var = flag.lstrip("-").replace("-", "_") self._current_tool.arguments.append(ToolArgument( flag=flag, variable=var, default=default, description="" )) self.close_overlay() self._show_tool_builder() def on_cancel(_): self.close_overlay() body = urwid.Pile([ urwid.AttrMap(flag_edit, 'edit', 'edit_focus'), urwid.Divider(), urwid.AttrMap(var_edit, 'edit', 'edit_focus'), urwid.Divider(), urwid.AttrMap(default_edit, 'edit', 'edit_focus'), ]) dialog = Dialog("Add Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) self.show_overlay(dialog, width=50, height=14) def _do_edit_argument(self, idx): """Edit an existing argument.""" # Save current field values before showing dialog self._save_tool_fields() arg = self._current_tool.arguments[idx] flag_edit = urwid.Edit(('label', "Flag: "), arg.flag) var_edit = urwid.Edit(('label', "Variable: "), arg.variable) default_edit = urwid.Edit(('label', "Default: "), arg.default or "") def on_ok(_): arg.flag = flag_edit.edit_text.strip() arg.variable = var_edit.edit_text.strip() arg.default = default_edit.edit_text.strip() or None self.close_overlay() self._show_tool_builder() def on_cancel(_): self.close_overlay() body = urwid.Pile([ urwid.AttrMap(flag_edit, 'edit', 'edit_focus'), urwid.Divider(), urwid.AttrMap(var_edit, 'edit', 'edit_focus'), urwid.Divider(), urwid.AttrMap(default_edit, 'edit', 'edit_focus'), ]) dialog = Dialog("Edit Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) self.show_overlay(dialog, width=50, height=14) def _add_step_choice(self): """Choose step type to add.""" # Save current field values before showing dialog self._save_tool_fields() def on_prompt(_): self.close_overlay() # Defer dialog opening to avoid overlay rendering issues if self.loop: self.loop.set_alarm_in(0, lambda loop, data: self._add_prompt_dialog()) else: self._add_prompt_dialog() def on_code(_): self.close_overlay() # Defer dialog opening to avoid overlay rendering issues if self.loop: self.loop.set_alarm_in(0, lambda loop, data: self._add_code_dialog()) else: self._add_code_dialog() def on_cancel(_): self.close_overlay() body = urwid.Text("Choose step type:") dialog = Dialog("Add Step", body, [("Prompt", on_prompt), ("Code", on_code), ("Cancel", on_cancel)]) self.show_overlay(dialog, width=45, height=9) def _get_available_vars(self, up_to=-1): """Get available variables.""" tool = self._current_tool variables = ["input"] for arg in tool.arguments: variables.append(arg.variable) if up_to == -1: up_to = len(tool.steps) for i, step in enumerate(tool.steps): if i >= up_to: break variables.append(step.output_var) return variables def _add_prompt_dialog(self, existing=None, idx=-1): """Add/edit prompt step with provider dropdown and multiline prompt.""" from .tool import get_tools_dir providers = load_providers() provider_names = [p.name for p in providers] if not provider_names: provider_names = ["mock"] current_provider = existing.provider if existing else provider_names[0] # Provider selector state selected_provider = [current_provider] # Use list to allow mutation in closures # Provider dropdown button provider_btn_text = urwid.Text(current_provider) provider_btn = urwid.AttrMap( urwid.Padding(provider_btn_text, left=1, right=1), 'edit', 'edit_focus' ) def show_provider_dropdown(_): """Show provider selection popup with descriptions.""" # Build provider lookup for descriptions provider_lookup = {p.name: p.description for p in providers} # Description display (updates on focus change) desc_text = urwid.Text("") desc_box = urwid.AttrMap( urwid.Padding(desc_text, left=1, right=1), 'label' ) def update_description(name): """Update the description text for the focused provider.""" desc = provider_lookup.get(name, "") desc_text.set_text(('label', desc if desc else "No description")) def select_provider(name): def callback(_): selected_provider[0] = name provider_btn_text.set_text(name) self.close_overlay() return callback # Create focusable buttons that update description on focus class DescriptiveButton(urwid.Button): def __init__(self, name, desc_callback): super().__init__(name, on_press=select_provider(name)) self._name = name self._desc_callback = desc_callback def render(self, size, focus=False): if focus: self._desc_callback(self._name) return super().render(size, focus) items = [] for name in provider_names: # Show short hint inline: "name | short_desc" short_desc = provider_lookup.get(name, "") # Extract just the key info (after the timing) if "|" in short_desc: short_desc = short_desc.split("|", 1)[1].strip()[:20] else: short_desc = short_desc[:20] label = f"{name:<18} {short_desc}" btn = DescriptiveButton(name, update_description) btn.set_label(label) items.append(urwid.AttrMap(btn, 'button', 'button_focus')) # Set initial description update_description(provider_names[0]) listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) # Combine listbox with description footer body = urwid.Pile([ ('weight', 1, listbox), ('pack', urwid.Divider('─')), ('pack', desc_box), ]) popup = Dialog("Select Provider", body, []) self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) provider_select_btn = Button3DCompact("▼", on_press=show_provider_dropdown) # File input for external prompt default_file = existing.prompt_file if existing and existing.prompt_file else "prompt.txt" file_edit = urwid.Edit(('label', "File: "), default_file) # Multiline prompt editor - use TabPassEdit so Tab passes through for navigation prompt_edit = TabPassEdit( edit_text=existing.prompt if existing else "{input}", multiline=True ) output_edit = urwid.Edit(('label', "Output var: "), existing.output_var if existing else "response") vars_available = self._get_available_vars(idx) vars_text = urwid.Text(('label', f"Variables: {', '.join('{'+v+'}' for v in vars_available)}")) status_text = urwid.Text("") def do_load(): """Actually load prompt from file.""" filename = file_edit.edit_text.strip() tool_dir = get_tools_dir() / self._current_tool.name prompt_path = tool_dir / filename try: prompt_edit.set_edit_text(prompt_path.read_text()) status_text.set_text(('success', f"Loaded from {filename}")) except Exception as e: status_text.set_text(('error', f"Load error: {e}")) def on_load(_): """Load prompt from file with confirmation.""" filename = file_edit.edit_text.strip() if not filename: status_text.set_text(('error', "Enter a filename first")) return tool_dir = get_tools_dir() / self._current_tool.name prompt_path = tool_dir / filename if not prompt_path.exists(): status_text.set_text(('error', f"File not found: {filename}")) return # Show confirmation dialog def on_yes(_): self.close_overlay() do_load() def on_no(_): self.close_overlay() confirm_body = urwid.Text(f"Load from '{filename}'?\nThis will replace the current prompt.") confirm_dialog = Dialog("Confirm Load", confirm_body, [("Yes", on_yes), ("No", on_no)]) self.show_overlay(confirm_dialog, width=50, height=8) def on_ok(_): provider = selected_provider[0] prompt = prompt_edit.edit_text.strip() output_var = output_edit.edit_text.strip() or "response" prompt_file = file_edit.edit_text.strip() or None # Auto-save to file if filename is set if prompt_file: tool_dir = get_tools_dir() / self._current_tool.name tool_dir.mkdir(parents=True, exist_ok=True) prompt_path = tool_dir / prompt_file try: prompt_path.write_text(prompt) except Exception as e: status_text.set_text(('error', f"Save error: {e}")) return step = PromptStep(prompt=prompt, provider=provider, output_var=output_var, prompt_file=prompt_file) if existing and idx >= 0: self._current_tool.steps[idx] = step else: self._current_tool.steps.append(step) self.close_overlay() self._show_tool_builder() def on_cancel(_): self.close_overlay() def on_external_edit(_): """Open prompt in external editor ($EDITOR).""" import os import subprocess import tempfile current_prompt = prompt_edit.edit_text # Stop the urwid loop temporarily if self.loop: self.loop.stop() try: # Create temp file with current prompt with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f: f.write(current_prompt) temp_path = f.name # Get editor from environment editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'nano')) # Run editor subprocess.run([editor, temp_path], check=True) # Read back the edited prompt with open(temp_path, 'r') as f: new_prompt = f.read() # Update the prompt editor prompt_edit.set_edit_text(new_prompt) status_text.set_text(('success', f"Prompt updated from {editor}")) # Clean up temp file os.unlink(temp_path) except subprocess.CalledProcessError: status_text.set_text(('error', "Editor exited with error")) except FileNotFoundError: status_text.set_text(('error', f"Editor '{editor}' not found")) except Exception as e: status_text.set_text(('error', f"Edit error: {e}")) finally: # Restart the urwid loop if self.loop: self.loop.start() load_btn = Button3DCompact("Load", on_load) edit_btn = Button3DCompact("$EDITOR", on_external_edit) # Prompt editor in a box - use ListBox for proper focus handling and scrolling # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons prompt_edit_styled = urwid.AttrMap(prompt_edit, 'edit', 'edit_focus') prompt_walker = urwid.SimpleFocusListWalker([prompt_edit_styled]) prompt_listbox = urwid.ListBox(prompt_walker) prompt_scrollbar = DOSScrollBar(prompt_listbox) prompt_box = urwid.LineBox(prompt_scrollbar, title="Prompt") # Use TabCyclePile so Tab cycles between sections # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when # the Pile contains weighted items (urwid 3.x requirement) body = TabCyclePile([ ('pack', vars_text), ('pack', urwid.Divider()), ('pack', urwid.Columns([ ('pack', urwid.Text(('label', "Provider: "))), ('weight', 1, provider_btn), ('pack', urwid.Text(" ")), ('pack', provider_select_btn), ])), ('pack', urwid.Divider()), ('pack', urwid.Columns([ ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), ('pack', urwid.Text(" ")), ('pack', load_btn), ('pack', urwid.Text(" ")), ('pack', edit_btn), ])), ('pack', status_text), ('weight', 1, prompt_box), ('pack', urwid.Divider()), ('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), ], tab_positions=[2, 4, 6, 8]) title = "Edit Prompt Step" if existing else "Add Prompt Step" dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) self.show_overlay(dialog, width=75, height=22) def _add_code_dialog(self, existing=None, idx=-1): """Add/edit code step with multiline editor, file support, and AI auto-adjust.""" from .tool import get_tools_dir from .providers import load_providers, call_provider # File name input (default based on output_var) default_output_var = existing.output_var if existing else "processed" default_file = existing.code_file if existing and existing.code_file else f"{default_output_var}.py" file_edit = urwid.Edit(('label', "File: "), default_file) # Multiline code editor with undo/redo (Alt+U / Alt+R) default_code = existing.code if existing else f"{default_output_var} = input.upper()" code_edit = UndoableEdit( edit_text=default_code, multiline=True ) output_edit = urwid.Edit(('label', "Output var: "), existing.output_var if existing else "processed") vars_available = self._get_available_vars(idx) vars_text = urwid.Text(('label', f"Variables: {', '.join(vars_available)}")) status_text = urwid.Text("") # --- Auto-adjust AI feature --- providers = load_providers() provider_names = [p.name for p in providers] if not provider_names: provider_names = ["mock"] selected_ai_provider = [provider_names[0]] ai_provider_btn_text = urwid.Text(provider_names[0]) ai_provider_btn = urwid.AttrMap( urwid.Padding(ai_provider_btn_text, left=1, right=1), 'edit', 'edit_focus' ) def show_ai_provider_dropdown(_): provider_lookup = {p.name: p.description for p in providers} desc_text = urwid.Text("") desc_box = urwid.AttrMap(urwid.Padding(desc_text, left=1, right=1), 'label') def update_description(name): desc = provider_lookup.get(name, "") desc_text.set_text(('label', desc if desc else "No description")) def select_provider(name): def callback(_): selected_ai_provider[0] = name ai_provider_btn_text.set_text(name) self.close_overlay() return callback class DescriptiveButton(urwid.Button): def __init__(btn_self, name, desc_callback): super().__init__(name, on_press=select_provider(name)) btn_self._name = name btn_self._desc_callback = desc_callback def render(btn_self, size, focus=False): if focus: btn_self._desc_callback(btn_self._name) return super().render(size, focus) items = [] for name in provider_names: short_desc = provider_lookup.get(name, "") if "|" in short_desc: short_desc = short_desc.split("|", 1)[1].strip()[:20] else: short_desc = short_desc[:20] label = f"{name:<18} {short_desc}" btn = DescriptiveButton(name, update_description) btn.set_label(label) items.append(urwid.AttrMap(btn, 'button', 'button_focus')) update_description(provider_names[0]) listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items)) popup_body = urwid.Pile([ ('weight', 1, listbox), ('pack', urwid.Divider('─')), ('pack', desc_box), ]) popup = Dialog("Select Provider", popup_body, []) self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) ai_provider_select_btn = Button3DCompact("▼", on_press=show_ai_provider_dropdown) # Default prompt template for AI code generation/adjustment # Show variables in triple-quote format so the AI follows the pattern vars_formatted = ', '.join(f'\"\"\"{{{v}}}\"\"\"' for v in vars_available) default_ai_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" used to a new standard variable first, then use that variable in the code. Use triple quotes and curly braces since the substituted content may contain quotes/newlines. Example: my_var = \"\"\"{{variable}}\"\"\" INSTRUCTION: [Describe what you want] CURRENT CODE: ```python {{code}} ``` AVAILABLE VARIABLES: {vars_formatted} IMPORTANT: Return ONLY executable inline code. Do NOT wrap in a function. No explanations, no markdown fencing, just the code.""" # Multiline editable prompt for AI with DOS-style scrollbar ai_prompt_edit = TabPassEdit(edit_text=default_ai_prompt, multiline=True) ai_prompt_styled = urwid.AttrMap(ai_prompt_edit, 'edit', 'edit_focus') ai_prompt_walker = urwid.SimpleFocusListWalker([ai_prompt_styled]) ai_prompt_listbox = urwid.ListBox(ai_prompt_walker) ai_prompt_scrollbar = DOSScrollBar(ai_prompt_listbox) ai_prompt_box = urwid.LineBox(ai_prompt_scrollbar, title="Prompt") # Output/feedback area for AI responses ai_output_text = urwid.Text("") ai_output_walker = urwid.SimpleFocusListWalker([ai_output_text]) ai_output_listbox = urwid.ListBox(ai_output_walker) ai_output_box = urwid.LineBox(ai_output_listbox, title="Output & Feedback") def on_auto_adjust(_): prompt_template = ai_prompt_edit.edit_text.strip() if not prompt_template: ai_output_text.set_text(('error', "Enter a prompt for the AI")) return current_code = code_edit.edit_text.strip() # Replace {code} placeholder with actual code prompt = prompt_template.replace("{code}", current_code) provider_name = selected_ai_provider[0] ai_output_text.set_text(('label', f"Calling {provider_name}...\nPlease wait...")) self.refresh() result = call_provider(provider_name, prompt) if result.success: new_code = result.text.strip() # Strip markdown code fences if present if new_code.startswith("```python"): new_code = new_code[9:] if new_code.startswith("```"): new_code = new_code[3:] if new_code.endswith("```"): new_code = new_code[:-3] new_code = new_code.strip() code_edit.set_edit_text(new_code) ai_output_text.set_text(('success', f"✓ Code updated successfully!\n\nProvider: {provider_name}\nResponse length: {len(result.text)} chars")) else: error_msg = result.error or "Unknown error" ai_output_text.set_text(('error', f"✗ Error from {provider_name}:\n\n{error_msg}")) auto_adjust_btn = Button3DCompact("Auto-adjust", on_auto_adjust) # Build the AI assist box with provider selector, prompt editor, output area, and button ai_provider_row = urwid.Columns([ ('pack', urwid.Text(('label', "Provider: "))), ('pack', ai_provider_btn), ('pack', ai_provider_select_btn), ]) ai_assist_content = urwid.Pile([ ('pack', ai_provider_row), ('pack', urwid.Divider()), ('weight', 2, ai_prompt_box), ('weight', 1, ai_output_box), ('pack', urwid.Padding(auto_adjust_btn, align='center', width=16)), ]) ai_assist_box = urwid.LineBox(ai_assist_content, title="AI Assisted Auto-adjust") # --- End Auto-adjust feature --- def do_load(): """Actually load code from file.""" filename = file_edit.edit_text.strip() tool_dir = get_tools_dir() / self._current_tool.name code_path = tool_dir / filename try: code_edit.set_edit_text(code_path.read_text()) status_text.set_text(('success', f"Loaded from {filename}")) except Exception as e: status_text.set_text(('error', f"Load error: {e}")) def on_load(_): """Load code from file with confirmation.""" filename = file_edit.edit_text.strip() if not filename: status_text.set_text(('error', "Enter a filename first")) return tool_dir = get_tools_dir() / self._current_tool.name code_path = tool_dir / filename if not code_path.exists(): status_text.set_text(('error', f"File not found: {filename}")) return def on_yes(_): self.close_overlay() do_load() def on_no(_): self.close_overlay() confirm_body = urwid.Text(f"Load from '{filename}'?\nThis will replace the current code.") confirm_dialog = Dialog("Confirm Load", confirm_body, [("Yes", on_yes), ("No", on_no)]) self.show_overlay(confirm_dialog, width=50, height=8) def on_ok(_): import ast code = code_edit.edit_text.strip() output_var = output_edit.edit_text.strip() or "processed" code_file = file_edit.edit_text.strip() or None if code: try: ast.parse(code) except SyntaxError as e: line_info = f" (line {e.lineno})" if e.lineno else "" status_text.set_text(('error', f"Syntax error{line_info}: {e.msg}")) return if code_file: tool_dir = get_tools_dir() / self._current_tool.name tool_dir.mkdir(parents=True, exist_ok=True) code_path = tool_dir / code_file try: code_path.write_text(code) except Exception as e: status_text.set_text(('error', f"Save error: {e}")) return step = CodeStep(code=code, output_var=output_var, code_file=code_file) if existing and idx >= 0: self._current_tool.steps[idx] = step else: self._current_tool.steps.append(step) self.close_overlay() self._show_tool_builder() def on_cancel(_): self.close_overlay() def on_external_edit(_): """Open code in external editor ($EDITOR).""" import os import subprocess import tempfile current_code = code_edit.edit_text # Stop the urwid loop temporarily if self.loop: self.loop.stop() try: # Create temp file with current code with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f: f.write(current_code) temp_path = f.name # Get editor from environment editor = os.environ.get('EDITOR', os.environ.get('VISUAL', 'nano')) # Run editor subprocess.run([editor, temp_path], check=True) # Read back the edited code with open(temp_path, 'r') as f: new_code = f.read() # Update the code editor code_edit.set_edit_text(new_code) status_text.set_text(('success', f"Code updated from {editor}")) # Clean up temp file os.unlink(temp_path) except subprocess.CalledProcessError: status_text.set_text(('error', "Editor exited with error")) except FileNotFoundError: status_text.set_text(('error', f"Editor '{editor}' not found")) except Exception as e: status_text.set_text(('error', f"Edit error: {e}")) finally: # Restart the urwid loop if self.loop: self.loop.start() load_btn = Button3DCompact("Load", on_load) edit_btn = Button3DCompact("$EDITOR", on_external_edit) # Code editor in a box - use ListBox for proper focus handling and scrolling # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons code_edit_styled = urwid.AttrMap(code_edit, 'edit', 'edit_focus') code_walker = urwid.SimpleFocusListWalker([code_edit_styled]) code_listbox = urwid.ListBox(code_walker) code_scrollbar = DOSScrollBar(code_listbox) code_box = urwid.LineBox(code_scrollbar, title="Code") # Layout: Code editor on left, AI assist box on right main_columns = urwid.Columns([ ('weight', 1, code_box), ('weight', 1, ai_assist_box), ], dividechars=1) # Use TabCyclePile so Tab cycles between sections # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when # the Pile contains weighted items (urwid 3.x requirement) body = TabCyclePile([ ('pack', vars_text), ('pack', urwid.Divider()), ('pack', urwid.Columns([ ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), ('pack', urwid.Text(" ")), ('pack', load_btn), ('pack', urwid.Text(" ")), ('pack', edit_btn), ])), ('pack', status_text), ('weight', 1, main_columns), ('pack', urwid.Divider()), ('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), ], tab_positions=[2, 4, 6]) title = "Edit Code Step" if existing else "Add Code Step" dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) self.show_overlay(dialog, width=90, height=30) def _on_save_tool(self, _): """Save the tool.""" tool = self._current_tool # Update from edits - widgets are wrapped in AttrMap, access base_widget if not self._is_edit: # Name edit is an AttrMap wrapping an Edit name_edit = self._name_edit.base_widget if hasattr(self._name_edit, 'base_widget') else self._name_edit if hasattr(name_edit, 'edit_text'): tool.name = name_edit.edit_text.strip() tool.description = self._desc_edit.base_widget.edit_text.strip() tool.output = self._output_edit.base_widget.edit_text.strip() if not tool.name: self.message_box("Error", "Tool name is required.") return # Validate tool name is_valid, error_msg = validate_tool_name(tool.name) if not is_valid: self.message_box("Error", error_msg) return if not self._is_edit and tool_exists(tool.name): def on_yes(): save_tool(tool) self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) self.yes_no("Overwrite?", f"Tool '{tool.name}' exists. Overwrite?", on_yes=on_yes) else: save_tool(tool) self.message_box("Success", f"Tool '{tool.name}' saved!", self.show_main_menu) def _on_cancel_tool(self, _): """Cancel tool editing.""" self.show_main_menu() def _test_tool(self, tool): """Test a tool with mock input.""" def on_input(text): from .runner import run_tool output, code = run_tool( tool=tool, input_text=text, custom_args={}, provider_override="mock", dry_run=False, show_prompt=False, verbose=False ) result = f"Exit code: {code}\n\nOutput:\n{output[:300]}" self.message_box("Test Result", result) self.input_dialog("Test Input", "Enter test input", "Hello world", on_input) # ==================== Provider Management ==================== def manage_providers(self): """Manage providers.""" self._show_providers_menu() def _show_providers_menu(self): """Show providers management menu.""" providers = load_providers() self._selected_provider_name = None self._provider_walker = None def on_provider_focus(name): """Called when a provider is focused.""" self._selected_provider_name = name # Update selection state on all items if self._provider_walker: for item in self._provider_walker: if isinstance(item, SelectableToolItem): item.set_selected(item.name == name) def on_provider_activate(name): """Called when Enter is pressed on a provider.""" self.close_overlay() self._edit_provider_menu(name) def on_add(_): self.close_overlay() self._add_provider_dialog() def on_edit(_): if self._selected_provider_name: self.close_overlay() self._edit_provider_menu(self._selected_provider_name) else: self.message_box("Edit", "No provider selected.") def on_cancel(_): self.close_overlay() # Build provider list items = [] for p in providers: item = SelectableToolItem(f"{p.name}: {p.command}", on_select=on_provider_activate) item.name = p.name # Store the actual provider name items.append(item) if not items: items.append(urwid.Text(('label', " (no providers) "))) self._provider_walker = urwid.SimpleFocusListWalker(items) listbox = ToolListBox(self._provider_walker, on_focus_change=on_provider_focus) listbox_box = urwid.LineBox(listbox, title='Providers') # Buttons row add_btn = ClickableButton("Add", on_add) edit_btn = ClickableButton("Edit", on_edit) cancel_btn = ClickableButton("Cancel", on_cancel) buttons = urwid.Columns([ ('pack', add_btn), ('pack', urwid.Text(" ")), ('pack', edit_btn), ('pack', urwid.Text(" ")), ('pack', cancel_btn), ]) buttons_centered = urwid.Padding(buttons, align='center', width='pack') # Layout body = urwid.Pile([ ('weight', 1, listbox_box), ('pack', urwid.Divider()), ('pack', buttons_centered), ]) # Wrap in a frame with title header = urwid.Text(('header', ' Manage Providers '), align='center') frame = urwid.Frame(body, header=header) frame = urwid.LineBox(frame) frame = urwid.AttrMap(frame, 'dialog') height = min(len(providers) + 10, 18) self.show_overlay(frame, width=55, height=height) # Set initial selection if providers: self._selected_provider_name = providers[0].name on_provider_focus(providers[0].name) def _add_provider_dialog(self): """Add a new provider.""" name_edit = urwid.Edit(('label', "Name: "), "") cmd_edit = urwid.Edit(('label', "Command: "), "") desc_edit = urwid.Edit(('label', "Description: "), "") def on_ok(_): name = name_edit.edit_text.strip() cmd = cmd_edit.edit_text.strip() desc = desc_edit.edit_text.strip() if name and cmd: add_provider(Provider(name=name, command=cmd, description=desc)) self.close_overlay() self.message_box("Success", f"Provider '{name}' added.", self._show_providers_menu) else: self.message_box("Error", "Name and command are required.") def on_cancel(_): self.close_overlay() self._show_providers_menu() body = urwid.Pile([ urwid.AttrMap(name_edit, 'edit', 'edit_focus'), urwid.Divider(), urwid.AttrMap(cmd_edit, 'edit', 'edit_focus'), urwid.Divider(), urwid.AttrMap(desc_edit, 'edit', 'edit_focus'), ]) dialog = Dialog("Add Provider", body, [("OK", on_ok), ("Cancel", on_cancel)]) self.show_overlay(dialog, width=55, height=14) def _edit_provider_menu(self, name): """Edit a provider.""" provider = get_provider(name) if not provider: return name_edit = urwid.Edit(('label', "Name: "), provider.name) cmd_edit = urwid.Edit(('label', "Command: "), provider.command) desc_edit = urwid.Edit(('label', "Description: "), provider.description or "") def on_save(_): new_name = name_edit.edit_text.strip() cmd = cmd_edit.edit_text.strip() desc = desc_edit.edit_text.strip() if new_name and cmd: # Delete old provider if name changed if new_name != name: delete_provider(name) # Save with new/same name add_provider(Provider(name=new_name, command=cmd, description=desc)) self.close_overlay() self.message_box("Success", f"Provider '{new_name}' saved.", self._show_providers_menu) else: self.message_box("Error", "Name and command are required.") def on_delete(_): self.close_overlay() def do_delete(): delete_provider(name) self.message_box("Deleted", f"Provider '{name}' deleted.", self._show_providers_menu) self.yes_no("Confirm", f"Delete provider '{name}'?", on_yes=do_delete, on_no=self._show_providers_menu) def on_cancel(_): self.close_overlay() self._show_providers_menu() body = urwid.Pile([ urwid.AttrMap(name_edit, 'edit', 'edit_focus'), urwid.Divider(), urwid.AttrMap(cmd_edit, 'edit', 'edit_focus'), urwid.Divider(), urwid.AttrMap(desc_edit, 'edit', 'edit_focus'), ]) dialog = Dialog("Edit Provider", body, [("Save", on_save), ("Delete", on_delete), ("Cancel", on_cancel)]) self.show_overlay(dialog, width=55, height=16) def run_ui(): """Entry point for the urwid UI.""" ui = SmartToolsUI() ui.run() if __name__ == "__main__": run_ui()