Compare commits

...

6 Commits

Author SHA1 Message Date
rob 765a53df1c Add DOS scrollbar to AI prompt editor
Wrap the AI prompt ListBox in DOSScrollBar for consistent
scrolling experience in the auto-adjust panel.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 01:54:38 -04:00
rob aef5c0f99f Add 3D-style buttons with box-drawing characters
Create DOS/BIOS-style 3D button effects using Unicode characters:

Button3D (multi-line):
  ┌──────────┐
  │  Label   │▄
  └──────────┘█

Button3DCompact (single-line):
  ▐ Label ▌▄

Changes:
- Add Button3D class for large standalone buttons
- Add Button3DCompact class for inline/dialog buttons
- Update palette with shadow colors (shadow_edge, button_highlight, button_shadow)
- Replace all ClickableButton/urwid.Button with Button3DCompact throughout:
  - Main menu action buttons (Create, Edit, Delete, Test, Providers, EXIT)
  - Dialog OK/Cancel buttons
  - Load buttons in code/prompt dialogs
  - Provider dropdown buttons
  - Auto-adjust button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 01:46:20 -04:00
rob b8c85df398 docs: Add AI-Assisted Code Generation section to README
Document the new AI assist panel in the code step dialog:
- Provider selection dropdown
- Editable prompt template with {code} placeholder
- Output/feedback area showing status and errors
- Auto-adjust button to generate/modify code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 01:40:52 -04:00
rob bdaf9e48de Redesign AI auto-adjust UI with boxed section
- Move auto-adjust feature to a dedicated LineBox panel
- Add full editable prompt template with {code} placeholder
- Add scrollable output/feedback area showing AI responses
- Display success/error messages with provider info
- Side-by-side layout: Code editor on left, AI assist on right
- Increase dialog size to accommodate new layout (90x30)
- Default prompt shows available variables and clear instructions

The AI assist panel now shows:
- Provider selector with dropdown
- Editable prompt (user can customize the full AI instruction)
- Output area with status messages and error details
- Centered Auto-adjust button

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 01:27:22 -04:00
rob c4b5492dc7 Cleanup: remove dead code and improve documentation
- Remove 6 unused methods from ui_urwid.py:
  - select_edit_tool, select_delete_tool, select_test_tool (replaced by main menu)
  - show_tools_list (replaced by info panel)
  - _edit_step_dialog, _edit_argument_dialog (replaced by direct dialog calls)
- Remove unused variable assignment in _edit_argument_at
- Remove ToolInput legacy alias from tool.py (never used)
- Enhance docstrings for DOSScrollBar and TabCyclePile classes
- Net reduction of ~120 lines of dead code

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 01:01:01 -04:00
rob 039df4a6a5 Add DOS-style scrollbars and improve dialog navigation
- Add DOSScrollBar class with ▲/▼ arrow buttons for code/prompt editors
- Expand scrollbar click sensitivity (last 2 columns, zone-based scrolling)
- Fix urwid 3.x compatibility: use explicit ('pack', widget) tuples in Piles
- Add TabPassEdit class for proper Tab cycling in multiline editors
- Add TabCyclePile for focus cycling with Tab key in dialogs
- Add auto-adjust AI feature to automatically set output_var from code
- Update README with new UI navigation documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 00:47:25 -04:00
3 changed files with 581 additions and 213 deletions

View File

@ -291,11 +291,43 @@ 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` |
**Tip:** Hold `Shift` while using mouse for terminal-native text selection. **Tips:**
- 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,10 +154,6 @@ 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,22 +10,33 @@ 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 # Color palette - BIOS style with 3D button effects
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'),
] ]
@ -55,8 +66,149 @@ 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.""" """A button that responds to mouse clicks (legacy wrapper)."""
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
@ -150,7 +302,13 @@ 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)
@ -173,6 +331,152 @@ 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."""
@ -314,25 +618,34 @@ class ToolBuilderLayout(urwid.WidgetWrap):
class Dialog(urwid.WidgetWrap): class Dialog(urwid.WidgetWrap):
"""A dialog box overlay.""" """A dialog box overlay with 3D-style buttons."""
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 # Buttons row - use 3D compact buttons for dialog actions
button_widgets = [] button_widgets = []
for label, callback in buttons: for label, callback in buttons:
btn = ClickableButton(label, callback) btn = Button3DCompact(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 (like ListBox) # Check if body is a box widget
is_box_widget = isinstance(body, urwid.ListBox) # 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: if is_box_widget:
# ListBox is a box widget - use directly with weight # Box widget - use directly with weight
pile = urwid.Pile([ pile = urwid.Pile([
('pack', title_widget), ('pack', title_widget),
('pack', urwid.Divider('')), ('pack', urwid.Divider('')),
@ -486,22 +799,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 # Action buttons - Tab navigates here from tool list (3D style)
create_btn = ClickableButton("Create", lambda _: self._create_tool_before_selected()) create_btn = Button3DCompact("Create", lambda _: self._create_tool_before_selected())
edit_btn = ClickableButton("Edit", lambda _: self._edit_selected_tool()) edit_btn = Button3DCompact("Edit", lambda _: self._edit_selected_tool())
delete_btn = ClickableButton("Delete", lambda _: self._delete_selected_tool()) delete_btn = Button3DCompact("Delete", lambda _: self._delete_selected_tool())
test_btn = ClickableButton("Test", lambda _: self._test_selected_tool()) test_btn = Button3DCompact("Test", lambda _: self._test_selected_tool())
providers_btn = ClickableButton("Providers", lambda _: self.manage_providers()) providers_btn = Button3DCompact("Providers", lambda _: self.manage_providers())
buttons_row = urwid.Columns([ buttons_row = urwid.Columns([
('pack', create_btn), ('pack', create_btn),
('pack', urwid.Text(" ")), ('pack', urwid.Text(" ")),
('pack', edit_btn), ('pack', edit_btn),
('pack', urwid.Text(" ")), ('pack', urwid.Text(" ")),
('pack', delete_btn), ('pack', delete_btn),
('pack', urwid.Text(" ")), ('pack', urwid.Text(" ")),
('pack', test_btn), ('pack', test_btn),
('pack', urwid.Text(" ")), ('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)
@ -526,9 +839,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 # Exit button at bottom (3D style)
exit_btn = ClickableButton("EXIT", lambda _: self.exit_app()) exit_btn = Button3DCompact("EXIT", lambda _: self.exit_app())
exit_centered = urwid.Padding(exit_btn, align='center', width=10) exit_centered = urwid.Padding(exit_btn, align='center', width=12)
# 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([
@ -894,7 +1207,6 @@ 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):
@ -947,26 +1259,6 @@ 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
@ -1006,11 +1298,19 @@ class SmartToolsUI:
def on_prompt(_): def on_prompt(_):
self.close_overlay() self.close_overlay()
self._add_prompt_dialog() # 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(_): def on_code(_):
self.close_overlay() self.close_overlay()
self._add_code_dialog() # 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(_): def on_cancel(_):
self.close_overlay() self.close_overlay()
@ -1119,17 +1419,14 @@ 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 = urwid.AttrMap( provider_select_btn = Button3DCompact("", on_press=show_provider_dropdown)
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 # Multiline prompt editor - use TabPassEdit so Tab passes through for navigation
prompt_edit = urwid.Edit( prompt_edit = TabPassEdit(
edit_text=existing.prompt if existing else "{input}", edit_text=existing.prompt if existing else "{input}",
multiline=True multiline=True
) )
@ -1209,51 +1506,57 @@ class SmartToolsUI:
def on_cancel(_): def on_cancel(_):
self.close_overlay() self.close_overlay()
load_btn = urwid.AttrMap(urwid.Button("Load", on_load), 'button', 'button_focus') load_btn = Button3DCompact("Load", on_load)
# Prompt editor in a box # Prompt editor in a box - use ListBox for proper focus handling and scrolling
prompt_box = urwid.LineBox( # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons
urwid.Filler(urwid.AttrMap(prompt_edit, 'edit', 'edit_focus'), valign='top'), prompt_edit_styled = urwid.AttrMap(prompt_edit, 'edit', 'edit_focus')
title="Prompt" 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")
body = urwid.Pile([ # Use TabCyclePile so Tab cycles between sections
vars_text, # Note: All flow widgets must be explicitly wrapped in ('pack', ...) when
urwid.Divider(), # the Pile contains weighted items (urwid 3.x requirement)
urwid.Columns([ body = TabCyclePile([
('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),
]), ])),
urwid.Divider(), ('pack', urwid.Divider()),
urwid.Columns([ ('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),
]), ])),
status_text, ('pack', status_text),
('weight', 1, prompt_box), ('weight', 1, prompt_box),
urwid.Divider(), ('pack', urwid.Divider()),
urwid.AttrMap(output_edit, 'edit', 'edit_focus'), ('pack', 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 and file support.""" """Add/edit code step with multiline editor, file support, and AI auto-adjust."""
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 (default uses output_var name) # Multiline code editor - use TabPassEdit so Tab passes through for navigation
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 = urwid.Edit( code_edit = TabPassEdit(
edit_text=default_code, edit_text=default_code,
multiline=True multiline=True
) )
@ -1265,6 +1568,151 @@ 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()
@ -1291,7 +1739,6 @@ class SmartToolsUI:
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()
@ -1310,7 +1757,6 @@ class SmartToolsUI:
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)
@ -1319,7 +1765,6 @@ class SmartToolsUI:
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)
@ -1343,55 +1788,42 @@ class SmartToolsUI:
def on_cancel(_): def on_cancel(_):
self.close_overlay() self.close_overlay()
load_btn = urwid.AttrMap(urwid.Button("Load", on_load), 'button', 'button_focus') load_btn = Button3DCompact("Load", on_load)
# Code editor in a box with scrolling # Code editor in a box - use ListBox for proper focus handling and scrolling
code_box = urwid.LineBox( # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons
urwid.Filler(urwid.AttrMap(code_edit, 'edit', 'edit_focus'), valign='top'), code_edit_styled = urwid.AttrMap(code_edit, 'edit', 'edit_focus')
title="Code" 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")
body = urwid.Pile([ # Layout: Code editor on left, AI assist box on right
vars_text, main_columns = urwid.Columns([
urwid.Divider(), ('weight', 1, code_box),
urwid.Columns([ ('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')), ('weight', 1, urwid.AttrMap(file_edit, 'edit', 'edit_focus')),
('pack', urwid.Text(" ")), ('pack', urwid.Text(" ")),
('pack', load_btn), ('pack', load_btn),
]), ])),
status_text, ('pack', status_text),
('weight', 1, code_box), ('weight', 1, main_columns),
urwid.Divider(), ('pack', urwid.Divider()),
urwid.AttrMap(output_edit, 'edit', 'edit_focus'), ('pack', 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=70, height=20) self.show_overlay(dialog, width=90, height=30)
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."""
@ -1429,84 +1861,6 @@ class SmartToolsUI:
"""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):
@ -1525,20 +1879,6 @@ class SmartToolsUI:
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 ====================