From ba63cdc5423264917cbdd67d20670702d2b02ce2 Mon Sep 17 00:00:00 2001 From: Bruno Bornsztein Date: Wed, 18 Feb 2026 09:06:18 -0600 Subject: [PATCH] Add undo/redo keyboard shortcuts to task form Support Ctrl+Z (undo) and Ctrl+Y / Ctrl+Shift+Z (redo) in the title and body text fields of the task form. Uses a time-based merge window to group rapid keystrokes into single undo entries. Co-Authored-By: Claude Opus 4.6 --- internal/ui/form.go | 68 +++++++++++++++++++++- internal/ui/undo.go | 108 +++++++++++++++++++++++++++++++++++ internal/ui/undo_test.go | 118 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 internal/ui/undo.go create mode 100644 internal/ui/undo_test.go diff --git a/internal/ui/form.go b/internal/ui/form.go index 58231ad8..f1c4474f 100644 --- a/internal/ui/form.go +++ b/internal/ui/form.go @@ -89,6 +89,10 @@ type FormModel struct { // Progressive disclosure: hide advanced fields for simpler first experience showAdvanced bool + + // Undo/redo stacks for text fields + titleUndo *undoStack + bodyUndo *undoStack } // Autocomplete message types for async LLM suggestions @@ -203,6 +207,8 @@ func NewEditFormModel(database *db.DB, task *db.Task, width, height int, availab taskRefAutocomplete: NewTaskRefAutocompleteModel(database, width-24), attachmentCursor: -1, showAdvanced: true, // Always show all fields when editing + titleUndo: newUndoStack(), + bodyUndo: newUndoStack(), } // Load task types from database @@ -312,6 +318,8 @@ func NewFormModel(database *db.DB, width, height int, workingDir string, availab taskRefAutocomplete: NewTaskRefAutocompleteModel(database, width-24), attachmentCursor: -1, showAdvanced: showAdvanced, // Load from user preference + titleUndo: newUndoStack(), + bodyUndo: newUndoStack(), } // Load task types from database @@ -614,6 +622,52 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.submitted = true return m, nil + case "ctrl+z": + // Undo in text fields + if m.focused == FieldTitle { + if val, pos, ok := m.titleUndo.Undo(m.titleInput.Value(), m.titleInput.Position()); ok { + m.titleInput.SetValue(val) + m.titleInput.SetCursor(pos) + m.lastTitleValue = val + m.clearGhostText() + } + return m, nil + } + if m.focused == FieldBody { + if val, pos, ok := m.bodyUndo.Undo(m.bodyInput.Value(), m.bodyInput.LineInfo().ColumnOffset); ok { + m.bodyInput.SetValue(val) + m.bodyInput.SetCursor(pos) + m.lastBodyValue = val + m.updateBodyHeight() + m.clearGhostText() + } + return m, nil + } + return m, nil + + case "ctrl+y", "ctrl+shift+z": + // Redo in text fields + if m.focused == FieldTitle { + if val, pos, ok := m.titleUndo.Redo(m.titleInput.Value(), m.titleInput.Position()); ok { + m.titleInput.SetValue(val) + m.titleInput.SetCursor(pos) + m.lastTitleValue = val + m.clearGhostText() + } + return m, nil + } + if m.focused == FieldBody { + if val, pos, ok := m.bodyUndo.Redo(m.bodyInput.Value(), m.bodyInput.LineInfo().ColumnOffset); ok { + m.bodyInput.SetValue(val) + m.bodyInput.SetCursor(pos) + m.lastBodyValue = val + m.updateBodyHeight() + m.clearGhostText() + } + return m, nil + } + return m, nil + case "tab": // If there's ghost text, accept it instead of moving to next field if m.ghostText != "" && (m.focused == FieldTitle || m.focused == FieldBody) { @@ -739,11 +793,17 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } - // Update the focused input + // Update the focused input, capturing state before for undo tracking var cmd tea.Cmd switch m.focused { case FieldTitle: + prevValue := m.titleInput.Value() + prevCursor := m.titleInput.Position() m.titleInput, cmd = m.titleInput.Update(msg) + // Save to undo stack if value changed + if m.titleInput.Value() != prevValue { + m.titleUndo.Save(prevValue, prevCursor) + } // Trigger debounced autocomplete if title changed if m.titleInput.Value() != m.lastTitleValue { m.lastTitleValue = m.titleInput.Value() @@ -752,7 +812,13 @@ func (m *FormModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmd, debounceCmd) } case FieldBody: + prevValue := m.bodyInput.Value() + prevCursor := m.bodyInput.LineInfo().ColumnOffset m.bodyInput, cmd = m.bodyInput.Update(msg) + // Save to undo stack if value changed + if m.bodyInput.Value() != prevValue { + m.bodyUndo.Save(prevValue, prevCursor) + } m.updateBodyHeight() // Autogrow as content changes // Check for task reference autocomplete trigger (#) m.updateTaskRefAutocomplete() diff --git a/internal/ui/undo.go b/internal/ui/undo.go new file mode 100644 index 00000000..0ca731ab --- /dev/null +++ b/internal/ui/undo.go @@ -0,0 +1,108 @@ +package ui + +import "time" + +// undoEntry represents a snapshot of a text field's state. +type undoEntry struct { + value string + cursorPos int // For textinput: character position; for textarea: we store a combined value + timestamp time.Time +} + +// undoStack provides undo/redo functionality for a text field. +// It merges rapid consecutive changes (within mergeWindow) into a single undo entry +// to avoid creating one entry per keystroke. +type undoStack struct { + history []undoEntry + future []undoEntry + mergeWindow time.Duration +} + +// newUndoStack creates a new undo stack with a default merge window. +func newUndoStack() *undoStack { + return &undoStack{ + mergeWindow: 800 * time.Millisecond, + } +} + +// Save records the current state. Rapid changes are merged into the most recent entry. +func (u *undoStack) Save(value string, cursorPos int) { + now := time.Now() + + entry := undoEntry{ + value: value, + cursorPos: cursorPos, + timestamp: now, + } + + // Merge with the last entry if the change was very recent (typing stream) + if len(u.history) > 0 { + last := u.history[len(u.history)-1] + if now.Sub(last.timestamp) < u.mergeWindow { + // Update the last entry in-place instead of pushing a new one + u.history[len(u.history)-1] = entry + // Still clear future on new input + u.future = u.future[:0] + return + } + } + + u.history = append(u.history, entry) + // Any new change invalidates the redo stack + u.future = u.future[:0] + + // Cap history at 100 entries to bound memory + if len(u.history) > 100 { + u.history = u.history[len(u.history)-100:] + } +} + +// Undo reverts to the previous state. Returns the restored state and true if successful. +func (u *undoStack) Undo(currentValue string, currentCursor int) (string, int, bool) { + if len(u.history) == 0 { + return "", 0, false + } + + // Push current state to future (redo) stack + u.future = append(u.future, undoEntry{ + value: currentValue, + cursorPos: currentCursor, + timestamp: time.Now(), + }) + + // Pop from history + entry := u.history[len(u.history)-1] + u.history = u.history[:len(u.history)-1] + + return entry.value, entry.cursorPos, true +} + +// Redo re-applies a previously undone change. Returns the restored state and true if successful. +func (u *undoStack) Redo(currentValue string, currentCursor int) (string, int, bool) { + if len(u.future) == 0 { + return "", 0, false + } + + // Push current state to history + u.history = append(u.history, undoEntry{ + value: currentValue, + cursorPos: currentCursor, + timestamp: time.Now(), + }) + + // Pop from future + entry := u.future[len(u.future)-1] + u.future = u.future[:len(u.future)-1] + + return entry.value, entry.cursorPos, true +} + +// CanUndo returns true if there are entries to undo. +func (u *undoStack) CanUndo() bool { + return len(u.history) > 0 +} + +// CanRedo returns true if there are entries to redo. +func (u *undoStack) CanRedo() bool { + return len(u.future) > 0 +} diff --git a/internal/ui/undo_test.go b/internal/ui/undo_test.go new file mode 100644 index 00000000..2ba413f4 --- /dev/null +++ b/internal/ui/undo_test.go @@ -0,0 +1,118 @@ +package ui + +import ( + "testing" + "time" +) + +func TestUndoStack_BasicUndoRedo(t *testing.T) { + u := newUndoStack() + // Override merge window to avoid test timing issues + u.mergeWindow = 0 + + // Type "hello" one character at a time + u.Save("", 0) // empty -> h + u.Save("h", 1) // h -> he + u.Save("he", 2) // he -> hel + u.Save("hel", 3) // hel -> hell + u.Save("hell", 4) // hell -> hello + + // Current state is "hello" at position 5 + + // Undo should restore "hell" + val, pos, ok := u.Undo("hello", 5) + if !ok || val != "hell" || pos != 4 { + t.Fatalf("expected (hell, 4, true), got (%s, %d, %t)", val, pos, ok) + } + + // Redo should restore "hello" + val, pos, ok = u.Redo("hell", 4) + if !ok || val != "hello" || pos != 5 { + t.Fatalf("expected (hello, 5, true), got (%s, %d, %t)", val, pos, ok) + } +} + +func TestUndoStack_MergeRapidChanges(t *testing.T) { + u := newUndoStack() + u.mergeWindow = 2 * time.Second // Very large window to ensure merging + + // Simulate rapid typing - all within merge window + u.Save("", 0) + u.Save("h", 1) + u.Save("he", 2) + u.Save("hel", 3) + u.Save("hell", 4) + + // All rapid changes should be merged into one entry + if len(u.history) != 1 { + t.Fatalf("expected 1 merged entry, got %d", len(u.history)) + } + + // The merged entry should have the last saved state + val, _, ok := u.Undo("hello", 5) + if !ok || val != "hell" { + t.Fatalf("expected (hell, true), got (%s, %t)", val, ok) + } + + // No more history + _, _, ok = u.Undo("hell", 4) + if ok { + t.Fatal("expected no more undo history") + } +} + +func TestUndoStack_RedoClearedOnNewChange(t *testing.T) { + u := newUndoStack() + u.mergeWindow = 0 + + u.Save("", 0) + u.Save("a", 1) + + // Undo + u.Undo("ab", 2) + if !u.CanRedo() { + t.Fatal("expected redo to be available") + } + + // New change should clear redo + u.Save("x", 1) + if u.CanRedo() { + t.Fatal("expected redo to be cleared after new change") + } +} + +func TestUndoStack_EmptyStack(t *testing.T) { + u := newUndoStack() + + _, _, ok := u.Undo("test", 4) + if ok { + t.Fatal("expected undo to fail on empty stack") + } + + _, _, ok = u.Redo("test", 4) + if ok { + t.Fatal("expected redo to fail on empty stack") + } +} + +func TestUndoStack_CanUndoRedo(t *testing.T) { + u := newUndoStack() + u.mergeWindow = 0 + + if u.CanUndo() { + t.Fatal("expected CanUndo false on empty stack") + } + if u.CanRedo() { + t.Fatal("expected CanRedo false on empty stack") + } + + u.Save("", 0) + if !u.CanUndo() { + t.Fatal("expected CanUndo true after save") + } + + u.Undo("a", 1) + if !u.CanRedo() { + t.Fatal("expected CanRedo true after undo") + } +}