Compare commits

..

No commits in common. "765a53df1c0c14906fb66377865f0339f969175b" and "4c1d3aa4fd029b9977ac8c6bdd2aca377fb828d1" have entirely different histories.

3 changed files with 215 additions and 583 deletions

View File

@ -291,43 +291,11 @@ vnoremap <leader>ec :!explain-code<CR>
| Go back | `Escape` | | Go back | `Escape` |
| Select | `Enter` or click | | Select | `Enter` or click |
| Navigate | Arrow keys | | Navigate | Arrow keys |
| **Scroll content** | Mouse wheel |
| **Scroll up** | Click top of scrollbar |
| **Scroll down** | Click bottom of scrollbar |
| **Page up/down** | Click middle of scrollbar |
| **Select text** | `Shift` + mouse drag | | **Select text** | `Shift` + mouse drag |
| **Copy** | Terminal native (with Shift) | | **Copy** | Terminal native (with Shift) |
| **Paste** | `Ctrl+Shift+V` | | **Paste** | `Ctrl+Shift+V` |
**Tips:** **Tip:** Hold `Shift` while using mouse for terminal-native text selection.
- Hold `Shift` while using mouse for terminal-native text selection.
- Code/Prompt editors have DOS-style scrollbars with `▲` and `▼` arrow buttons.
- In step dialogs, use `Tab` to cycle between File, Editor, and Output fields.
## AI-Assisted Code Generation
When adding or editing a **Code Step**, the dialog includes an AI assist panel:
```
┌─ Code ─────────────┐ ┌─ AI Assisted Auto-adjust ─────────────┐
│ result = input... │ │ Provider: [opencode-deepseek] [▼] │
│ │ │ ┌─ Prompt ──────────────────────────┐ │
│ │ │ │ Modify this code to... │ │
│ │ │ │ {code} │ │
│ │ │ └──────────────────────────────────┘ │
│ │ │ ┌─ Output & Feedback ───────────────┐ │
│ │ │ │ ✓ Code updated successfully! │ │
│ │ │ └──────────────────────────────────┘ │
│ │ │ < Auto-adjust >
└────────────────────┘ └───────────────────────────────────────┘
```
- **Provider**: Select any configured AI provider
- **Prompt**: Fully editable template - use `{code}` placeholder for current code
- **Output**: Shows status, success/error messages, and provider feedback
- **Auto-adjust**: Sends prompt to AI and replaces code with response
This lets you generate or modify Python code using AI directly within the tool builder.
## Philosophy ## Philosophy

View File

@ -154,6 +154,10 @@ class Tool:
return variables return variables
# Legacy support - map old ToolInput to new ToolArgument
ToolInput = ToolArgument
def get_tools_dir() -> Path: def get_tools_dir() -> Path:
"""Get the tools directory, creating it if needed.""" """Get the tools directory, creating it if needed."""
TOOLS_DIR.mkdir(parents=True, exist_ok=True) TOOLS_DIR.mkdir(parents=True, exist_ok=True)

View File

@ -10,33 +10,22 @@ from .tool import (
from .providers import Provider, load_providers, add_provider, delete_provider, get_provider from .providers import Provider, load_providers, add_provider, delete_provider, get_provider
# Color palette - BIOS style with 3D button effects # Color palette - BIOS style
PALETTE = [ PALETTE = [
('body', 'white', 'dark blue'), ('body', 'white', 'dark blue'),
('header', 'white', 'dark red', 'bold'), ('header', 'white', 'dark red', 'bold'),
('footer', 'black', 'light gray'), ('footer', 'black', 'light gray'),
# Button colors - raised 3D effect
('button', 'black', 'light gray'), ('button', 'black', 'light gray'),
('button_focus', 'white', 'dark red', 'bold'), ('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', 'black', 'light gray'),
('edit_focus', 'black', 'yellow'), ('edit_focus', 'black', 'yellow'),
# List items
('listbox', 'black', 'light gray'), ('listbox', 'black', 'light gray'),
('listbox_focus', 'white', 'dark red'), ('listbox_focus', 'white', 'dark red'),
# Dialog
('dialog', 'black', 'light gray'), ('dialog', 'black', 'light gray'),
('dialog_border', 'white', 'dark blue'), ('dialog_border', 'white', 'dark blue'),
# Text styles
('label', 'yellow', 'dark blue', 'bold'), ('label', 'yellow', 'dark blue', 'bold'),
('error', 'white', 'dark red', 'bold'), ('error', 'white', 'dark red', 'bold'),
('success', 'light green', 'dark blue', 'bold'), ('success', 'light green', 'dark blue', 'bold'),
# 3D shadow elements
('shadow', 'black', 'black'),
('shadow_edge', 'dark gray', 'dark blue'),
] ]
@ -66,149 +55,8 @@ class SelectableText(urwid.WidgetWrap):
return False 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): class ClickableButton(urwid.WidgetWrap):
"""A button that responds to mouse clicks (legacy wrapper).""" """A button that responds to mouse clicks."""
def __init__(self, label, on_press=None, user_data=None): def __init__(self, label, on_press=None, user_data=None):
self.on_press = on_press self.on_press = on_press
@ -302,13 +150,7 @@ class ToolListBox(urwid.ListBox):
class TabCyclePile(urwid.Pile): class TabCyclePile(urwid.Pile):
"""A Pile that uses Tab/Shift-Tab to cycle between specific positions. """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): def __init__(self, widget_list, tab_positions=None):
super().__init__(widget_list) super().__init__(widget_list)
@ -331,152 +173,6 @@ class TabCyclePile(urwid.Pile):
return super().keypress(size, key) 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 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): class ToolBuilderLayout(urwid.WidgetWrap):
"""Custom layout for tool builder that handles Tab cycling across all sections.""" """Custom layout for tool builder that handles Tab cycling across all sections."""
@ -618,34 +314,25 @@ class ToolBuilderLayout(urwid.WidgetWrap):
class Dialog(urwid.WidgetWrap): class Dialog(urwid.WidgetWrap):
"""A dialog box overlay with 3D-style buttons.""" """A dialog box overlay."""
def __init__(self, title, body, buttons, width=60, height=None): def __init__(self, title, body, buttons, width=60, height=None):
# Title # Title
title_widget = urwid.Text(('header', f' {title} '), align='center') title_widget = urwid.Text(('header', f' {title} '), align='center')
# Buttons row - use 3D compact buttons for dialog actions # Buttons row
button_widgets = [] button_widgets = []
for label, callback in buttons: for label, callback in buttons:
btn = Button3DCompact(label, callback) btn = ClickableButton(label, callback)
button_widgets.append(btn) button_widgets.append(btn)
buttons_row = urwid.Columns([('pack', b) for b in button_widgets], dividechars=2) buttons_row = urwid.Columns([('pack', b) for b in button_widgets], dividechars=2)
buttons_centered = urwid.Padding(buttons_row, align='center', width='pack') buttons_centered = urwid.Padding(buttons_row, align='center', width='pack')
# Check if body is a box widget # Check if body is a box widget (like ListBox)
# ListBox is always a box widget. For Piles with weighted items, is_box_widget = isinstance(body, urwid.ListBox)
# 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: if is_box_widget:
# Box widget - use directly with weight # ListBox is a box widget - use directly with weight
pile = urwid.Pile([ pile = urwid.Pile([
('pack', title_widget), ('pack', title_widget),
('pack', urwid.Divider('')), ('pack', urwid.Divider('')),
@ -799,22 +486,22 @@ class SmartToolsUI:
tool_listbox = ToolListBox(self._tool_walker, on_focus_change=self._on_tool_focus) tool_listbox = ToolListBox(self._tool_walker, on_focus_change=self._on_tool_focus)
tool_box = urwid.LineBox(tool_listbox, title='Tools') tool_box = urwid.LineBox(tool_listbox, title='Tools')
# Action buttons - Tab navigates here from tool list (3D style) # Action buttons - Tab navigates here from tool list
create_btn = Button3DCompact("Create", lambda _: self._create_tool_before_selected()) create_btn = ClickableButton("Create", lambda _: self._create_tool_before_selected())
edit_btn = Button3DCompact("Edit", lambda _: self._edit_selected_tool()) edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_tool())
delete_btn = Button3DCompact("Delete", lambda _: self._delete_selected_tool()) delete_btn = ClickableButton("Delete", lambda _: self._delete_selected_tool())
test_btn = Button3DCompact("Test", lambda _: self._test_selected_tool()) test_btn = ClickableButton("Test", lambda _: self._test_selected_tool())
providers_btn = Button3DCompact("Providers", lambda _: self.manage_providers()) providers_btn = ClickableButton("Providers", lambda _: self.manage_providers())
buttons_row = urwid.Columns([ buttons_row = urwid.Columns([
('pack', create_btn), ('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', urwid.Text(" ")),
('pack', edit_btn),
('pack', urwid.Text(" ")),
('pack', delete_btn),
('pack', urwid.Text(" ")),
('pack', test_btn),
('pack', urwid.Text(" ")),
('pack', providers_btn), ('pack', providers_btn),
]) ])
buttons_padded = urwid.Padding(buttons_row, align='left', left=1) buttons_padded = urwid.Padding(buttons_row, align='left', left=1)
@ -839,9 +526,9 @@ class SmartToolsUI:
info_filler = urwid.Filler(info_content, valign='top') info_filler = urwid.Filler(info_content, valign='top')
info_box = urwid.LineBox(info_filler, title='Tool Info') info_box = urwid.LineBox(info_filler, title='Tool Info')
# Exit button at bottom (3D style) # Exit button at bottom
exit_btn = Button3DCompact("EXIT", lambda _: self.exit_app()) exit_btn = ClickableButton("EXIT", lambda _: self.exit_app())
exit_centered = urwid.Padding(exit_btn, align='center', width=12) exit_centered = urwid.Padding(exit_btn, align='center', width=10)
# Use a custom Pile that handles Tab to cycle between tool list and buttons # Use a custom Pile that handles Tab to cycle between tool list and buttons
self._main_pile = TabCyclePile([ self._main_pile = TabCyclePile([
@ -1207,6 +894,7 @@ class SmartToolsUI:
def _edit_argument_at(self, idx): def _edit_argument_at(self, idx):
"""Edit argument at index.""" """Edit argument at index."""
arg = self._current_tool.arguments[idx]
self._do_edit_argument(idx) self._do_edit_argument(idx)
def _edit_step_at(self, idx): def _edit_step_at(self, idx):
@ -1259,6 +947,26 @@ class SmartToolsUI:
dialog = Dialog("Add Argument", body, [("OK", on_ok), ("Cancel", on_cancel)]) dialog = Dialog("Add Argument", body, [("OK", on_ok), ("Cancel", on_cancel)])
self.show_overlay(dialog, width=50, height=14) self.show_overlay(dialog, width=50, height=14)
def _edit_argument_dialog(self, idx):
"""Show edit/delete argument dialog."""
arg = self._current_tool.arguments[idx]
def on_edit(_):
self.close_overlay()
self._do_edit_argument(idx)
def on_delete(_):
self._current_tool.arguments.pop(idx)
self.close_overlay()
self._show_tool_builder()
def on_cancel(_):
self.close_overlay()
body = urwid.Text(f"Argument: {arg.flag} -> {{{arg.variable}}}")
dialog = Dialog("Edit Argument", body, [("Edit", on_edit), ("Delete", on_delete), ("Cancel", on_cancel)])
self.show_overlay(dialog, width=50, height=10)
def _do_edit_argument(self, idx): def _do_edit_argument(self, idx):
"""Edit an existing argument.""" """Edit an existing argument."""
# Save current field values before showing dialog # Save current field values before showing dialog
@ -1298,19 +1006,11 @@ class SmartToolsUI:
def on_prompt(_): def on_prompt(_):
self.close_overlay() self.close_overlay()
# Defer dialog opening to avoid overlay rendering issues self._add_prompt_dialog()
if self.loop:
self.loop.set_alarm_in(0, lambda loop, data: self._add_prompt_dialog())
else:
self._add_prompt_dialog()
def on_code(_): def on_code(_):
self.close_overlay() self.close_overlay()
# Defer dialog opening to avoid overlay rendering issues self._add_code_dialog()
if self.loop:
self.loop.set_alarm_in(0, lambda loop, data: self._add_code_dialog())
else:
self._add_code_dialog()
def on_cancel(_): def on_cancel(_):
self.close_overlay() self.close_overlay()
@ -1419,14 +1119,17 @@ class SmartToolsUI:
popup = Dialog("Select Provider", body, []) popup = Dialog("Select Provider", body, [])
self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16)) self.show_overlay(popup, width=50, height=min(len(provider_names) + 6, 16))
provider_select_btn = Button3DCompact("", on_press=show_provider_dropdown) provider_select_btn = urwid.AttrMap(
urwid.Button("Select", on_press=show_provider_dropdown),
'button', 'button_focus'
)
# File input for external prompt # File input for external prompt
default_file = existing.prompt_file if existing and existing.prompt_file else "prompt.txt" default_file = existing.prompt_file if existing and existing.prompt_file else "prompt.txt"
file_edit = urwid.Edit(('label', "File: "), default_file) file_edit = urwid.Edit(('label', "File: "), default_file)
# Multiline prompt editor - use TabPassEdit so Tab passes through for navigation # Multiline prompt editor
prompt_edit = TabPassEdit( prompt_edit = urwid.Edit(
edit_text=existing.prompt if existing else "{input}", edit_text=existing.prompt if existing else "{input}",
multiline=True multiline=True
) )
@ -1506,57 +1209,51 @@ class SmartToolsUI:
def on_cancel(_): def on_cancel(_):
self.close_overlay() self.close_overlay()
load_btn = Button3DCompact("Load", on_load) load_btn = urwid.AttrMap(urwid.Button("Load", on_load), 'button', 'button_focus')
# Prompt editor in a box - use ListBox for proper focus handling and scrolling # Prompt editor in a box
# Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons prompt_box = urwid.LineBox(
prompt_edit_styled = urwid.AttrMap(prompt_edit, 'edit', 'edit_focus') urwid.Filler(urwid.AttrMap(prompt_edit, 'edit', 'edit_focus'), valign='top'),
prompt_walker = urwid.SimpleFocusListWalker([prompt_edit_styled]) title="Prompt"
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 body = urwid.Pile([
# Note: All flow widgets must be explicitly wrapped in ('pack', ...) when vars_text,
# the Pile contains weighted items (urwid 3.x requirement) urwid.Divider(),
body = TabCyclePile([ urwid.Columns([
('pack', vars_text),
('pack', urwid.Divider()),
('pack', urwid.Columns([
('pack', urwid.Text(('label', "Provider: "))), ('pack', urwid.Text(('label', "Provider: "))),
('weight', 1, provider_btn), ('weight', 1, provider_btn),
('pack', urwid.Text(" ")), ('pack', urwid.Text(" ")),
('pack', provider_select_btn), ('pack', provider_select_btn),
])), ]),
('pack', urwid.Divider()), urwid.Divider(),
('pack', urwid.Columns([ urwid.Columns([
('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')), ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')),
('pack', urwid.Text(" ")), ('pack', urwid.Text(" ")),
('pack', load_btn), ('pack', load_btn),
])), ]),
('pack', status_text), status_text,
('weight', 1, prompt_box), ('weight', 1, prompt_box),
('pack', urwid.Divider()), urwid.Divider(),
('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), urwid.AttrMap(output_edit, 'edit', 'edit_focus'),
], tab_positions=[2, 4, 6, 8]) ])
title = "Edit Prompt Step" if existing else "Add Prompt Step" title = "Edit Prompt Step" if existing else "Add Prompt Step"
dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)])
self.show_overlay(dialog, width=70, height=22) self.show_overlay(dialog, width=70, height=22)
def _add_code_dialog(self, existing=None, idx=-1): def _add_code_dialog(self, existing=None, idx=-1):
"""Add/edit code step with multiline editor, file support, and AI auto-adjust.""" """Add/edit code step with multiline editor and file support."""
from .tool import get_tools_dir from .tool import get_tools_dir
from .providers import load_providers, call_provider
# File name input (default based on output_var) # File name input (default based on output_var)
default_output_var = existing.output_var if existing else "processed" 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" 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) file_edit = urwid.Edit(('label', "File: "), default_file)
# Multiline code editor - use TabPassEdit so Tab passes through for navigation # Multiline code editor (default uses output_var name)
default_code = existing.code if existing else f"{default_output_var} = input.upper()" default_code = existing.code if existing else f"{default_output_var} = input.upper()"
code_edit = TabPassEdit( code_edit = urwid.Edit(
edit_text=default_code, edit_text=default_code,
multiline=True multiline=True
) )
@ -1568,151 +1265,6 @@ class SmartToolsUI:
status_text = urwid.Text("") 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
default_ai_prompt = f"""Modify or generate Python code according to my instruction below.
INSTRUCTION: [Describe what you want]
CURRENT CODE:
```python
{{code}}
```
AVAILABLE VARIABLES: {', '.join(vars_available)}
Return ONLY the Python code, no explanations or markdown fencing."""
# 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(): def do_load():
"""Actually load code from file.""" """Actually load code from file."""
filename = file_edit.edit_text.strip() filename = file_edit.edit_text.strip()
@ -1739,6 +1291,7 @@ Return ONLY the Python code, no explanations or markdown fencing."""
status_text.set_text(('error', f"File not found: {filename}")) status_text.set_text(('error', f"File not found: {filename}"))
return return
# Show confirmation dialog
def on_yes(_): def on_yes(_):
self.close_overlay() self.close_overlay()
do_load() do_load()
@ -1757,6 +1310,7 @@ Return ONLY the Python code, no explanations or markdown fencing."""
output_var = output_edit.edit_text.strip() or "processed" output_var = output_edit.edit_text.strip() or "processed"
code_file = file_edit.edit_text.strip() or None code_file = file_edit.edit_text.strip() or None
# Validate Python syntax
if code: if code:
try: try:
ast.parse(code) ast.parse(code)
@ -1765,6 +1319,7 @@ Return ONLY the Python code, no explanations or markdown fencing."""
status_text.set_text(('error', f"Syntax error{line_info}: {e.msg}")) status_text.set_text(('error', f"Syntax error{line_info}: {e.msg}"))
return return
# Auto-save to file if filename is set
if code_file: if code_file:
tool_dir = get_tools_dir() / self._current_tool.name tool_dir = get_tools_dir() / self._current_tool.name
tool_dir.mkdir(parents=True, exist_ok=True) tool_dir.mkdir(parents=True, exist_ok=True)
@ -1788,42 +1343,55 @@ Return ONLY the Python code, no explanations or markdown fencing."""
def on_cancel(_): def on_cancel(_):
self.close_overlay() self.close_overlay()
load_btn = Button3DCompact("Load", on_load) load_btn = urwid.AttrMap(urwid.Button("Load", on_load), 'button', 'button_focus')
# Code editor in a box - use ListBox for proper focus handling and scrolling # Code editor in a box with scrolling
# Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons code_box = urwid.LineBox(
code_edit_styled = urwid.AttrMap(code_edit, 'edit', 'edit_focus') urwid.Filler(urwid.AttrMap(code_edit, 'edit', 'edit_focus'), valign='top'),
code_walker = urwid.SimpleFocusListWalker([code_edit_styled]) title="Code"
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 body = urwid.Pile([
main_columns = urwid.Columns([ vars_text,
('weight', 1, code_box), urwid.Divider(),
('weight', 1, ai_assist_box), urwid.Columns([
], 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')), ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')),
('pack', urwid.Text(" ")), ('pack', urwid.Text(" ")),
('pack', load_btn), ('pack', load_btn),
])), ]),
('pack', status_text), status_text,
('weight', 1, main_columns), ('weight', 1, code_box),
('pack', urwid.Divider()), urwid.Divider(),
('pack', urwid.AttrMap(output_edit, 'edit', 'edit_focus')), urwid.AttrMap(output_edit, 'edit', 'edit_focus'),
], tab_positions=[2, 4, 6]) ])
title = "Edit Code Step" if existing else "Add Code Step" title = "Edit Code Step" if existing else "Add Code Step"
dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)]) dialog = Dialog(title, body, [("OK", on_ok), ("Cancel", on_cancel)])
self.show_overlay(dialog, width=90, height=30) self.show_overlay(dialog, width=70, height=20)
def _edit_step_dialog(self, idx):
"""Show edit/delete step dialog."""
step = self._current_tool.steps[idx]
step_type = "Prompt" if isinstance(step, PromptStep) else "Code"
def on_edit(_):
self.close_overlay()
if isinstance(step, PromptStep):
self._add_prompt_dialog(step, idx)
else:
self._add_code_dialog(step, idx)
def on_delete(_):
self._current_tool.steps.pop(idx)
self.close_overlay()
self._show_tool_builder()
def on_cancel(_):
self.close_overlay()
body = urwid.Text(f"Step {idx+1}: {step_type}")
dialog = Dialog("Edit Step", body, [("Edit", on_edit), ("Delete", on_delete), ("Cancel", on_cancel)])
self.show_overlay(dialog, width=45, height=10)
def _on_save_tool(self, _): def _on_save_tool(self, _):
"""Save the tool.""" """Save the tool."""
@ -1861,6 +1429,84 @@ Return ONLY the Python code, no explanations or markdown fencing."""
"""Cancel tool editing.""" """Cancel tool editing."""
self.show_main_menu() self.show_main_menu()
# ==================== Tool Selection ====================
def select_edit_tool(self):
"""Select a tool to edit."""
tools = list_tools()
if not tools:
self.message_box("Edit Tool", "No tools found.")
return
def on_select(name):
self.close_overlay()
tool = load_tool(name)
if tool:
self.tool_builder(tool)
def on_cancel(_):
self.close_overlay()
items = []
for name in tools:
item = SelectableText(f" {name} ", value=name, on_select=on_select)
items.append(item)
listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items))
dialog = Dialog("Select Tool to Edit", listbox, [("Cancel", on_cancel)])
self.show_overlay(dialog, width=50, height=min(len(tools) + 8, 20))
def select_delete_tool(self):
"""Select a tool to delete."""
tools = list_tools()
if not tools:
self.message_box("Delete Tool", "No tools found.")
return
def on_select(name):
self.close_overlay()
def do_delete():
delete_tool(name)
self.message_box("Deleted", f"Tool '{name}' deleted.")
self.yes_no("Confirm", f"Delete tool '{name}'?", on_yes=do_delete)
def on_cancel(_):
self.close_overlay()
items = []
for name in tools:
item = SelectableText(f" {name} ", value=name, on_select=on_select)
items.append(item)
listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items))
dialog = Dialog("Select Tool to Delete", listbox, [("Cancel", on_cancel)])
self.show_overlay(dialog, width=50, height=min(len(tools) + 8, 20))
def select_test_tool(self):
"""Select a tool to test."""
tools = list_tools()
if not tools:
self.message_box("Test Tool", "No tools found.")
return
def on_select(name):
self.close_overlay()
tool = load_tool(name)
if tool:
self._test_tool(tool)
def on_cancel(_):
self.close_overlay()
items = []
for name in tools:
item = SelectableText(f" {name} ", value=name, on_select=on_select)
items.append(item)
listbox = urwid.ListBox(urwid.SimpleFocusListWalker(items))
dialog = Dialog("Select Tool to Test", listbox, [("Cancel", on_cancel)])
self.show_overlay(dialog, width=50, height=min(len(tools) + 8, 20))
def _test_tool(self, tool): def _test_tool(self, tool):
"""Test a tool with mock input.""" """Test a tool with mock input."""
def on_input(text): def on_input(text):
@ -1879,6 +1525,20 @@ Return ONLY the Python code, no explanations or markdown fencing."""
self.input_dialog("Test Input", "Enter test input", "Hello world", on_input) self.input_dialog("Test Input", "Enter test input", "Hello world", on_input)
def show_tools_list(self):
"""Show list of all tools."""
tools = list_tools()
if not tools:
self.message_box("Tools", "No tools found.")
return
text = ""
for name in tools:
tool = load_tool(name)
if tool:
text += f"{name}: {tool.description or 'No description'}\n"
self.message_box("Available Tools", text.strip())
# ==================== Provider Management ==================== # ==================== Provider Management ====================