feat: undo / redo with snapshot history (TASK-006)
56e8e447585a0fbf9f09163b7f5b4f7ef834cee4
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 12:21
parent e9083c8c
feat: undo / redo with snapshot history (TASK-006) Snapshot-based undo/redo in internal/editor: bounded (500) undo/redo stacks capturing buffer, cursor, scroll, and selection. HandleKey wraps a dispatch core and records one checkpoint per edit group — consecutive typing coalesces, structural ops (newline, delete, kill-line, word-delete, paste, cut) are separate groups, no-op keys are skipped. Undo/Redo restore cursor + selection; SetContent clears history; PushUndo() lets the app checkpoint before paste/cut. Keys: Ctrl+Z undo, Ctrl+Y redo (Ctrl+Shift+Z is not distinguishable from Ctrl+Z by terminals). Help text + README updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj
8 files changed
README.md +1 −0
@@ -52,6 +52,7 @@ | `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | copy / cut / paste (system clipboard) |
| `Alt+←` / `Alt+→` | move by word (also `Alt+b` / `Alt+f`) |
| `Alt+Backspace` / `Ctrl+W` | delete the word before the cursor (`Alt+d` deletes the word after) |
| `Ctrl+U` / `Ctrl+K` | delete to start / end of line |
+| `Ctrl+Z` / `Ctrl+Y` | undo / redo |
| `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
| `Ctrl+P` | toggle the Glamour read preview |
| `Ctrl+F` | fuzzy file picker (with live preview) |
- → Undo-redo.md +24 −7
@@ -1,10 +1,11 @@
---
id: TASK-006
title: Undo / redo
-status: "\U0001F7E2 In progress"
-assignee: []
+status: "\U0001F3C1 Done"
+assignee:
+ - '@humdrum'
created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-29 19:21'
labels:
- feature
- release-1
@@ -21,8 +22,24 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 Ctrl+Z reverts the last edit group; Ctrl+Y / Ctrl+Shift+Z redoes
-- [ ] #2 Typing is coalesced; structural ops are separate groups
-- [ ] #3 Cursor and selection state restored on undo/redo
-- [ ] #4 Redo stack cleared on a new edit; history bounded
+- [x] #1 Ctrl+Z reverts the last edit group; Ctrl+Y / Ctrl+Shift+Z redoes
+- [x] #2 Typing is coalesced; structural ops are separate groups
+- [x] #3 Cursor and selection state restored on undo/redo
+- [x] #4 Redo stack cleared on a new edit; history bounded
<!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. editor: snapshot struct {lines,cursor,scroll,anchor}; undo/redo stacks + lastKind; maxUndo=500.
+2. HandleKey wraps dispatch: classify key -> nav (break coalesce) | kindType (coalesce) | kindStructural (own group). Snapshot pre, run, commit if content changed; cap+clear redo.
+3. Undo()/Redo() swap snapshots, restore lines/cursor/scroll/anchor, Dirty=true. SetContent resets history. PushUndo() for app-driven paste/cut.
+4. app: Ctrl+Z->Undo, Ctrl+Y->Redo; PushUndo before paste/cut. (Ctrl+Shift+Z not sendable by terminals; Ctrl+Y is redo.)
+5. TDD: tests for coalesce, structural groups, cursor/selection restore, redo-cleared-on-edit, bounded history.
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Implemented snapshot-based undo/redo in internal/editor/undo.go: bounded (500) undo/redo stacks; HandleKey wraps dispatch to record a checkpoint per edit group — typing coalesces, structural ops (newline/delete/kill/word-delete/paste/cut) are separate groups; no-op keys skipped. Undo/Redo restore lines+cursor+scroll+selection; SetContent clears history; PushUndo() for app paste/cut. App: Ctrl+Z undo, Ctrl+Y redo (Ctrl+Shift+Z not distinguishable by terminals). Tests: internal/editor/undo_test.go + app routing test. Help text + README updated.
+<!-- SECTION:NOTES:END -->
internal/app/app.go +12 −0
@@ -235,6 +235,16 @@ case tea.KeyCtrlN:
return a.newFile(a.currentDir(), discardNew)
case tea.KeyCtrlB:
return a.newFile(a.cfg.InboxRoot(), discardInbox)
+ case tea.KeyCtrlZ:
+ if a.mode == ModeEditor {
+ a.editor.Undo()
+ }
+ return a, nil
+ case tea.KeyCtrlY:
+ if a.mode == ModeEditor {
+ a.editor.Redo()
+ }
+ return a, nil
case tea.KeyCtrlC:
return a.copySelection(false)
case tea.KeyCtrlX:
@@ -392,6 +402,7 @@ a.status = "Copy failed: " + err.Error()
return a, nil
}
if cut {
+ a.editor.PushUndo()
a.editor.DeleteSelection()
a.status = "Cut"
} else {
@@ -409,6 +420,7 @@ text, err := clipboard.ReadAll()
if err != nil || text == "" {
return a, nil
}
+ a.editor.PushUndo()
a.editor.InsertText(text)
return a, nil
}
internal/app/app_test.go +21 −0
@@ -618,3 +618,24 @@ if a.path != want {
t.Errorf("Ctrl+B note saved to %q, want %q (inbox)", a.path, want)
}
}
+
+func TestCtrlZUndoesTypingViaApp(t *testing.T) {
+ a := newApp()
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 12})
+ a.mode = ModeEditor
+ a.editor.SetContent([]byte(""))
+ for _, r := range "hello" {
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+ if a.editor.Lines[0] != "hello" {
+ t.Fatalf("setup: %q", a.editor.Lines[0])
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlZ})
+ if a.editor.Lines[0] != "" {
+ t.Errorf("Ctrl+Z should undo the typing run, got %q", a.editor.Lines[0])
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlY})
+ if a.editor.Lines[0] != "hello" {
+ t.Errorf("Ctrl+Y should redo, got %q", a.editor.Lines[0])
+ }
+}
internal/editor/editor.go +32 −1
@@ -26,6 +26,10 @@ Height int // visible text rows
goalCol int // remembered visual column for vertical moves
anchor *Position // selection anchor; nil = no selection
theme theme.Theme
+
+ undo []snapshot // edit checkpoints, oldest first (TASK-006)
+ redo []snapshot // undone checkpoints awaiting redo
+ lastKind editKind // kind of the last recorded group, for coalescing
}
// New returns an empty editor with one blank line and the default theme.
@@ -52,6 +56,7 @@ e.Cursor = Position{}
e.Scroll = 0
e.goalCol = 0
e.Dirty = false
+ e.resetHistory()
}
// Bytes serializes the buffer with \n line separators.
@@ -427,8 +432,34 @@ }
return col
}
-// HandleKey maps a key message to a buffer operation.
+// HandleKey maps a key message to a buffer operation, recording an undo
+// checkpoint around any mutation. Navigation breaks typing coalescing so a
+// later edit starts a fresh group.
func (e *Editor) HandleKey(k tea.KeyMsg) {
+ kind, mutates := editKindOf(k)
+ if !mutates {
+ e.lastKind = kindNone
+ e.dispatch(k)
+ return
+ }
+ // Continuing a typing run: extend the existing group, no new checkpoint.
+ if kind == kindType && e.lastKind == kindType && len(e.undo) > 0 {
+ e.dispatch(k)
+ e.redo = nil
+ return
+ }
+ pre := e.snapshot()
+ e.dispatch(k)
+ if e.sameContent(pre) {
+ return // no-op key (e.g. backspace at start of doc): don't record
+ }
+ e.pushUndoSnapshot(pre)
+ e.redo = nil
+ e.lastKind = kind
+}
+
+// dispatch performs the raw buffer operation for a key, without undo bookkeeping.
+func (e *Editor) dispatch(k tea.KeyMsg) {
switch k.Type {
// Text input — replaces any active selection.
case tea.KeyRunes:
internal/editor/undo.go +141 −0
@@ -0,0 +1,141 @@
+package editor
+
+import tea "github.com/charmbracelet/bubbletea"
+
+// Undo/redo is snapshot-based: each checkpoint captures the buffer, cursor,
+// scroll, and selection anchor before a change. Consecutive typing coalesces
+// into one group; every structural op (newline, delete, kill, paste, cut) is
+// its own group, per TASK-006.
+
+// maxUndo bounds the history so a long session can't grow it without limit.
+const maxUndo = 500
+
+// editKind classifies a key for grouping. kindType groups coalesce; kindStructural
+// groups never do.
+type editKind int
+
+const (
+ kindNone editKind = iota
+ kindType
+ kindStructural
+)
+
+// snapshot is a restorable point-in-time copy of the editable state.
+type snapshot struct {
+ lines []string
+ cursor Position
+ scroll int
+ anchor *Position
+}
+
+func (e *Editor) snapshot() snapshot {
+ ls := make([]string, len(e.Lines))
+ copy(ls, e.Lines)
+ var a *Position
+ if e.anchor != nil {
+ c := *e.anchor
+ a = &c
+ }
+ return snapshot{lines: ls, cursor: e.Cursor, scroll: e.Scroll, anchor: a}
+}
+
+func (e *Editor) restore(s snapshot) {
+ ls := make([]string, len(s.lines))
+ copy(ls, s.lines)
+ e.Lines = ls
+ e.Cursor = s.cursor
+ e.Scroll = s.scroll
+ if s.anchor != nil {
+ c := *s.anchor
+ e.anchor = &c
+ } else {
+ e.anchor = nil
+ }
+ e.Dirty = true
+ e.setGoal()
+ e.followCursor()
+}
+
+// sameContent reports whether the buffer text matches s (cursor/selection ignored).
+func (e *Editor) sameContent(s snapshot) bool {
+ if len(s.lines) != len(e.Lines) {
+ return false
+ }
+ for i := range s.lines {
+ if s.lines[i] != e.Lines[i] {
+ return false
+ }
+ }
+ return true
+}
+
+// pushUndoSnapshot appends s to the bounded undo stack.
+func (e *Editor) pushUndoSnapshot(s snapshot) {
+ e.undo = append(e.undo, s)
+ if len(e.undo) > maxUndo {
+ e.undo = e.undo[len(e.undo)-maxUndo:]
+ }
+}
+
+// resetHistory clears all undo/redo state (called when the buffer is replaced).
+func (e *Editor) resetHistory() {
+ e.undo = nil
+ e.redo = nil
+ e.lastKind = kindNone
+}
+
+// PushUndo records the current state as a structural checkpoint. The app calls
+// it before driving an edit through InsertText/DeleteSelection (paste, cut).
+func (e *Editor) PushUndo() {
+ e.pushUndoSnapshot(e.snapshot())
+ e.redo = nil
+ e.lastKind = kindStructural
+}
+
+// Undo reverts the most recent edit group, restoring cursor and selection.
+func (e *Editor) Undo() {
+ if len(e.undo) == 0 {
+ return
+ }
+ cur := e.snapshot()
+ s := e.undo[len(e.undo)-1]
+ e.undo = e.undo[:len(e.undo)-1]
+ e.redo = append(e.redo, cur)
+ e.restore(s)
+ e.lastKind = kindNone
+}
+
+// Redo reapplies the most recently undone edit group.
+func (e *Editor) Redo() {
+ if len(e.redo) == 0 {
+ return
+ }
+ cur := e.snapshot()
+ s := e.redo[len(e.redo)-1]
+ e.redo = e.redo[:len(e.redo)-1]
+ e.pushUndoSnapshot(cur)
+ e.restore(s)
+ e.lastKind = kindNone
+}
+
+// editKindOf reports a key's grouping kind and whether it mutates the buffer.
+func editKindOf(k tea.KeyMsg) (editKind, bool) {
+ switch k.Type {
+ case tea.KeyRunes:
+ if k.Alt {
+ if string(k.Runes) == "d" { // Alt+D deletes the next word
+ return kindStructural, true
+ }
+ return kindNone, false // Alt+b/f are word motions
+ }
+ return kindType, true
+ case tea.KeySpace:
+ return kindType, true
+ case tea.KeyEnter, tea.KeyTab:
+ return kindStructural, true
+ case tea.KeyBackspace, tea.KeyDelete, tea.KeyCtrlU, tea.KeyCtrlK, tea.KeyCtrlW:
+ return kindStructural, true
+ default:
+ return kindNone, false
+ }
+}
internal/editor/undo_test.go +153 −0
@@ -0,0 +1,153 @@
+package editor
+
+import (
+ "strings"
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+// typeRunes feeds each rune as a KeyRunes message (the typing path).
+func typeRunes(e *Editor, s string) {
+ for _, r := range s {
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+ }
+}
+
+func TestUndoCoalescesTyping(t *testing.T) {
+ e := newEditorWith("")
+ typeRunes(e, "abc")
+ if e.Lines[0] != "abc" {
+ t.Fatalf("setup: %q", e.Lines[0])
+ }
+ e.Undo()
+ if e.Lines[0] != "" {
+ t.Errorf("one undo should revert the whole typing run, got %q", e.Lines[0])
+ }
+}
+
+func TestUndoStructuralOpsAreSeparateGroups(t *testing.T) {
+ e := newEditorWith("")
+ typeRunes(e, "ab")
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyEnter}) // structural: own group
+ typeRunes(e, "cd")
+ if len(e.Lines) != 2 || e.Lines[1] != "cd" {
+ t.Fatalf("setup: %v", e.Lines)
+ }
+ e.Undo() // removes "cd"
+ if len(e.Lines) != 2 || e.Lines[1] != "" {
+ t.Fatalf("after undo cd: %v", e.Lines)
+ }
+ e.Undo() // removes the newline
+ if len(e.Lines) != 1 || e.Lines[0] != "ab" {
+ t.Fatalf("after undo newline: %v", e.Lines)
+ }
+ e.Undo() // removes "ab"
+ if e.Lines[0] != "" {
+ t.Errorf("after undo ab: %q", e.Lines[0])
+ }
+}
+
+func TestUndoRestoresCursorAndSelection(t *testing.T) {
+ e := newEditorWith("hello world")
+ // Select "world" then type over it.
+ e.Cursor = Position{Row: 0, Col: 6}
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftEnd}) // selects "world"
+ if e.SelectedText() != "world" {
+ t.Fatalf("setup selection: %q", e.SelectedText())
+ }
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'X'}})
+ if e.Lines[0] != "hello X" {
+ t.Fatalf("after type-over: %q", e.Lines[0])
+ }
+ e.Undo()
+ if e.Lines[0] != "hello world" {
+ t.Errorf("undo content: %q", e.Lines[0])
+ }
+ if !e.HasSelection() || e.SelectedText() != "world" {
+ t.Errorf("undo should restore the selection, got %q", e.SelectedText())
+ }
+}
+
+func TestRedoReappliesUndoneEdit(t *testing.T) {
+ e := newEditorWith("")
+ typeRunes(e, "abc")
+ e.Undo()
+ e.Redo()
+ if e.Lines[0] != "abc" {
+ t.Errorf("redo should reapply, got %q", e.Lines[0])
+ }
+}
+
+func TestNewEditClearsRedoStack(t *testing.T) {
+ e := newEditorWith("")
+ typeRunes(e, "abc")
+ e.Undo() // now "" with redo available
+ typeRunes(e, "z")
+ e.Redo() // redo stack should be empty → no-op
+ if e.Lines[0] != "z" {
+ t.Errorf("redo after a new edit must be a no-op, got %q", e.Lines[0])
+ }
+}
+
+func TestUndoOnEmptyHistoryIsNoOp(t *testing.T) {
+ e := newEditorWith("seed")
+ e.Undo()
+ e.Redo()
+ if e.Lines[0] != "seed" {
+ t.Errorf("undo/redo with no history changed buffer: %q", e.Lines[0])
+ }
+}
+
+func TestSetContentResetsHistory(t *testing.T) {
+ e := newEditorWith("")
+ typeRunes(e, "abc")
+ e.SetContent([]byte("fresh"))
+ e.Undo()
+ if e.Lines[0] != "fresh" {
+ t.Errorf("SetContent must clear undo history, got %q", e.Lines[0])
+ }
+}
+
+func TestHistoryIsBounded(t *testing.T) {
+ e := newEditorWith("")
+ // Each Enter is its own structural group; exceed the cap.
+ for i := 0; i < maxUndo+50; i++ {
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyEnter})
+ }
+ lines := len(e.Lines)
+ for i := 0; i < maxUndo+50; i++ {
+ e.Undo()
+ }
+ // Capped history can't undo every newline; the oldest are dropped.
+ if len(e.Lines) == 1 {
+ t.Errorf("history should be bounded; expected residual newlines, got fully unwound from %d", lines)
+ }
+}
+
+func TestNoOpKeyDoesNotRecord(t *testing.T) {
+ e := newEditorWith("x")
+ typeRunes(e, "y") // "xy"... actually inserts at col0 unless cursor moved
+ // Move to very start and backspace (no-op at row0,col0).
+ e.Cursor = Position{Row: 0, Col: 0}
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyBackspace}) // no-op
+ before := strings.Join(e.Lines, "\n")
+ e.Undo() // should undo the typing, not the no-op backspace
+ if strings.Join(e.Lines, "\n") == before {
+ t.Errorf("a no-op key must not consume an undo slot")
+ }
+}
+
+func TestPushUndoSupportsAppDrivenEdits(t *testing.T) {
+ e := newEditorWith("hello")
+ e.Cursor = Position{Row: 0, Col: 5}
+ e.PushUndo() // app records before a paste/cut
+ e.InsertText(" world")
+ if e.Lines[0] != "hello world" {
+ t.Fatalf("setup: %q", e.Lines[0])
+ }
+ e.Undo()
+ if e.Lines[0] != "hello" {
+ t.Errorf("PushUndo+Undo should revert app edit, got %q", e.Lines[0])
+ }
+}
main.go +1 −0
@@ -57,6 +57,7 @@ Shift+arrows select text (Ctrl+Shift+left/right by word)
Alt+left / Alt+right move by word
Ctrl+U / Ctrl+K delete to start / end of line
Ctrl+W delete the word before the cursor
+ Ctrl+Z / Ctrl+Y undo / redo
Ctrl+Q quit (press twice if there are unsaved changes)
Esc back to the editor