orchestrated-discussions/.venv/lib/python3.12/site-packages/smarttools/ui_urwid.py

2314 lines
85 KiB
Python

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