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>
This commit is contained in:
rob 2025-12-05 02:36:46 -04:00
parent f227831feb
commit 6a4e153a62
1 changed files with 134 additions and 3 deletions

View File

@ -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
)