Compare commits

..

5 Commits

Author SHA1 Message Date
rob cff3b674ac docs: Add undo/redo and $EDITOR to UI navigation docs
- Added Alt+U/Alt+R for undo/redo in key bindings table
- Added tips about undo/redo (50 states) and $EDITOR button

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 03:35:55 -04:00
rob f84e06aa05 fix: Use Alt+U/Alt+R for undo/redo, fix state tracking
- Changed undo/redo keys to Alt+U and Alt+R (meta keys work in urwid)
- Fixed undo logic: now saves state BEFORE edit happens, not after
- Added duplicate state prevention
- Simplified the tracking logic

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 03:33:13 -04:00
rob 67a83e5379 fix: Change undo/redo keys to Ctrl+U/Ctrl+R
Ctrl+Z is the Unix suspend signal (SIGTSTP) which suspends the process
before urwid can intercept it. Changed to:
- Ctrl+U for undo
- Ctrl+R for redo

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:48:51 -04:00
rob 6a4e153a62 feat: Add undo/redo for code editor
- Add UndoableEdit class with undo/redo support:
  - Ctrl+Z to undo (up to 50 states)
  - Ctrl+Y or Ctrl+Shift+Z to redo
  - Automatically tracks edit history and cursor position
  - Tab passes through for focus cycling

- Remove syntax highlighting attempt (incompatible with urwid Edit widget's
  text layout engine which requires raw text strings, not markup)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:44:03 -04:00
rob f227831feb Add $EDITOR button to open code in external editor
When editing a code step, users can now click the "$EDITOR" button
to open the current code in their preferred external editor:

- Uses $EDITOR environment variable (falls back to $VISUAL, then nano)
- Creates a temp .py file with current code
- Suspends urwid UI while editor runs
- Imports edited code back when editor closes
- Shows success/error status in the dialog

This allows using vim, nano, VS Code, or any editor for complex
code editing while still using the SmartTools UI for workflow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-05 02:04:42 -04:00
2 changed files with 185 additions and 3 deletions

View File

@ -298,11 +298,15 @@ vnoremap <leader>ec :!explain-code<CR>
| **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` |
| **Undo** (code editor) | `Alt+U` |
| **Redo** (code editor) | `Alt+R` |
**Tips:** **Tips:**
- 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. - Code/Prompt editors have DOS-style scrollbars with `▲` and `▼` arrow buttons.
- In step dialogs, use `Tab` to cycle between File, Editor, and Output fields. - In step dialogs, use `Tab` to cycle between File, Editor, and Output fields.
- The code editor supports undo/redo (up to 50 states) with `Alt+U` and `Alt+R`.
- Use the `$EDITOR` button to open code or prompts in your external editor.
## AI-Assisted Code Generation ## AI-Assisted Code Generation

View File

@ -341,6 +341,86 @@ class TabPassEdit(urwid.Edit):
return super().keypress(size, 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): class DOSScrollBar(urwid.WidgetWrap):
"""A DOS-style scrollbar with arrow buttons at top and bottom. """A DOS-style scrollbar with arrow buttons at top and bottom.
@ -1506,7 +1586,54 @@ class SmartToolsUI:
def on_cancel(_): def on_cancel(_):
self.close_overlay() 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) 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 # Prompt editor in a box - use ListBox for proper focus handling and scrolling
# Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons
@ -1533,6 +1660,8 @@ class SmartToolsUI:
('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', urwid.Text(" ")),
('pack', edit_btn),
])), ])),
('pack', status_text), ('pack', status_text),
('weight', 1, prompt_box), ('weight', 1, prompt_box),
@ -1542,7 +1671,7 @@ class SmartToolsUI:
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=75, 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, file support, and AI auto-adjust."""
@ -1554,9 +1683,9 @@ class SmartToolsUI:
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 with undo/redo (Alt+U / Alt+R)
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 = UndoableEdit(
edit_text=default_code, edit_text=default_code,
multiline=True multiline=True
) )
@ -1788,7 +1917,54 @@ Return ONLY the Python code, no explanations or markdown fencing."""
def on_cancel(_): def on_cancel(_):
self.close_overlay() 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) 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 # Code editor in a box - use ListBox for proper focus handling and scrolling
# Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons # Wrap in DOSScrollBar for DOS-style scrollbar with arrow buttons
@ -1814,6 +1990,8 @@ Return ONLY the Python code, no explanations or markdown fencing."""
('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', urwid.Text(" ")),
('pack', edit_btn),
])), ])),
('pack', status_text), ('pack', status_text),
('weight', 1, main_columns), ('weight', 1, main_columns),