▍ humdrum codex / glint v1.0.2
license AGPL-3.0

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