diff --git a/src/smarttools/ui_urwid.py b/src/smarttools/ui_urwid.py index 8138628..5a99219 100644 --- a/src/smarttools/ui_urwid.py +++ b/src/smarttools/ui_urwid.py @@ -341,6 +341,88 @@ class TabPassEdit(urwid.Edit): return super().keypress(size, key) +class UndoableEdit(urwid.Edit): + """A multiline Edit with undo/redo support. + + Features: + - Undo with Ctrl+Z (up to 50 states) + - Redo with Ctrl+Y or Ctrl+Shift+Z + - 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 = [] + self._last_saved_text = self.edit_text # Track for change detection + + def keypress(self, size, key): + if key in ('tab', 'shift tab'): + return key + + # Save state before any editing operation (for undo) + current_text = self.edit_text + if current_text != self._last_saved_text: + self._save_undo_state(self._last_saved_text, self.edit_pos) + self._last_saved_text = current_text + + # Handle undo (Ctrl+Z) + if key == 'ctrl z': + self._undo() + return None + + # Handle redo (Ctrl+Y or Ctrl+Shift+Z) + if key in ('ctrl y', 'ctrl shift z'): + self._redo() + return None + + result = super().keypress(size, key) + + # After edit, check if text changed and save for potential undo + new_text = self.edit_text + if new_text != self._last_saved_text: + self._redo_stack.clear() # Clear redo on new edit + self._last_saved_text = new_text + + return result + + def _save_undo_state(self, text, pos): + """Save current state to undo stack.""" + 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))) + self._last_saved_text = 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))) + self._last_saved_text = text + + class DOSScrollBar(urwid.WidgetWrap): """A DOS-style scrollbar with arrow buttons at top and bottom. @@ -1506,7 +1588,54 @@ class SmartToolsUI: 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 @@ -1533,6 +1662,8 @@ class SmartToolsUI: ('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), @@ -1542,7 +1673,7 @@ class SmartToolsUI: 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=70, height=22) + 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.""" @@ -1554,9 +1685,9 @@ class SmartToolsUI: 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 - use TabPassEdit so Tab passes through for navigation + # Multiline code editor with undo/redo (Ctrl+Z / Ctrl+Y) default_code = existing.code if existing else f"{default_output_var} = input.upper()" - code_edit = TabPassEdit( + code_edit = UndoableEdit( edit_text=default_code, multiline=True )