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

feat: list and checkbox continuation (TASK-008)

482738b88e167ba821b401143dfbe85442d46506
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 14:09

parent 7c8bb9dd

feat: list and checkbox continuation (TASK-008)

Enter on a list line continues the marker: bullets (-, *, +), ordered
lists (N. / N)) with the number incremented, and checkboxes reset to an
unchecked - [ ]. Enter on an empty item removes the marker and exits the
list. Tab / Shift+Tab indent / outdent the current list item by a
two-space unit (a leading tab counts as one level on outdent).

New internal/editor/lists.go (parseListItem, ContinueList, IndentLine,
OutdentLine); dispatch routes KeyEnter/KeyTab/KeyShiftTab; KeyShiftTab is
now a structural undo group. 19 tests in lists_test.go.

Also captures backlog tasks: TASK-021 (PDF/printable export), TASK-022
(track-changes/diff + Fuss-interop markup), TASK-023 (checkbox toggle key,
the deferred non-AC piece of TASK-008).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjNrdAWUdkaxFyGdrPHaBj

10 files changed

README.md +2 −0
@@ -44,6 +44,8 @@
 | Key | Action |
 | --- | --- |
 | type / arrows / `Enter` / `Backspace` / `Del` | edit and move (Up/Down move by visual line) |
+| `Enter` on a list item | continue the list (`-`/`*`/`+`, `N.`/`N)`, `- [ ]` checkboxes; numbers increment); empty item exits the list |
+| `Tab` / `Shift+Tab` | indent / outdent the current list item |
 | mouse click | move the cursor |
 | mouse wheel | scroll the view |
 | `Home` / `End` | start / end of line |
- → List-and-checkbox-continuation.md +21 −5
@@ -1,10 +1,10 @@
 ---
 id: TASK-008
 title: List and checkbox continuation
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-29 21:08'
 labels:
   - feature
   - release-1
@@ -21,7 +21,23 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 Enter continues the list marker (ordered numbers increment)
-- [ ] #2 Enter on an empty item exits the list (removes the marker)
-- [ ] #3 Tab / Shift+Tab indent and outdent list items
+- [x] #1 Enter continues the list marker (ordered numbers increment)
+- [x] #2 Enter on an empty item exits the list (removes the marker)
+- [x] #3 Tab / Shift+Tab indent and outdent list items
 <!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. parseListItem(line): detect indent + bullet (-,*,+) or ordered (N. / N)) + optional [ ]/[x] checkbox; return prefix.
+2. ContinueList(): Enter on list line — empty content removes marker (exit list); else split at cursor, new line gets continued marker (ordered increments, checkbox resets to [ ]). Returns true if handled.
+3. IndentLine()/OutdentLine(): adjust leading whitespace by 2-space unit (tab counts as one level on outdent).
+4. Wire dispatch: KeyEnter -> ContinueList else InsertNewline; KeyTab -> IndentLine on list item else literal tab; KeyShiftTab -> OutdentLine on list item. editKindOf: KeyShiftTab structural+mutating.
+5. TDD: lists_test.go covers AC1 (continue, ordered increment, checkbox), AC2 (empty exit), AC3 (indent/outdent). Lint+vet+build, commit referencing TASK-008.
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Implemented in internal/editor/lists.go (parseListItem, ContinueList, IndentLine, OutdentLine); wired KeyEnter/KeyTab/KeyShiftTab in dispatch; KeyShiftTab added to editKindOf (structural). 19 tests in lists_test.go cover bullet/star/plus/ordered(./)) continuation, checkbox reset to [ ], indent preservation, mid-item split, empty-item exit, non-list plain newline, Tab indent (2-space) vs literal tab, Shift+Tab outdent (space+tab), no-op at zero indent, undo. Checkbox toggle-by-key from the description left out of scope (not an AC) — can be a follow-up. go test/vet/build/lint all clean.
+<!-- SECTION:NOTES:END -->
- → PDF-printable-export-in-the-house-style.md +47 −0
@@ -0,0 +1,47 @@
+---
+id: TASK-021
+title: PDF / printable export in the house style
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-06-29 20:49'
+updated_date: '2026-06-29 20:50'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 21000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Add a glint command/keybind to export the current document as a clean, printable PDF (or print-ready HTML) in the AppKit house style — matching what _shared-app-kit/markdown-doc-kit already produces for the browser.
+
+Reuse that kit as the source of truth rather than reinventing styling: it has doc.css (tokens, the seven themes — flexoki/-dark, uchu/-dark, humdrum/-dark, eink — document typography, and the @media print rules), embedded fonts (Awke, Untitled Sans, Name Mono), and pagination conventions (opening '# Title' becomes a centered cover page; every '## Section' starts a new page; '{.page-break}' forces a break; US Letter, 1in margins; print collapses to black-on-white).
+
+Approach options to decide during design:
+- Generate a self-contained HTML (doc.css + fonts + rendered markdown baked in, like the kit's 'Save HTML') and either (a) open it in the browser so the user hits Print → Save as PDF, or (b) shell out to a headless converter (Chromium --headless --print-to-pdf, wkhtmltopdf, or weasyprint/pandoc+Prince) to write a .pdf directly.
+- Keep glint dependency-light: prefer 'emit HTML + open in browser' as the zero-extra-binary path; treat direct-to-PDF (needs an external tool) as optional/detected.
+- Map glint's active theme to the kit theme where they line up (flexoki light/dark already shared).
+
+Out of scope: reimplementing the kit's interactive features (checkbox toggle, drag reorder) in glint.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 A glint command/keybind exports the current buffer to a printable artifact (PDF or print-ready self-contained HTML) in the markdown-doc-kit house style
+- [ ] #2 Output reuses the kit's doc.css + embedded fonts and its print conventions (cover page from '# Title', page-per '## Section', US Letter, black-on-white print)
+- [ ] #3 The chosen path is documented (browser print-to-PDF vs headless converter) and degrades gracefully when an optional external converter is absent
+- [ ] #4 README/help document the export command
+- [ ] #5 Fonts are user-configurable (config keys for display/body/mono), not hard-wired to the kit's bundled Awke/Untitled Sans/Name Mono — glint is distributed to other people who don't have those licensed fonts
+- [ ] #6 Export is portable off this machine: ships sane open/system-font fallbacks by default and works with no personal assets present; any bundled font must be redistributable, otherwise reference by name with fallbacks
+- [ ] #7 The needed kit assets (doc.css + print rules, HTML skeleton, any redistributable fonts) are vendored INTO this repo and embedded in the binary via go:embed — nothing is read from the external _shared-app-kit path at runtime, so a 'brew install glint' on another machine has everything it needs
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Portability constraint (user): glint is installed by people who are NOT on this machine and do NOT have the kit's bundled fonts (Awke, Untitled Sans, Name Mono — personal/licensed). So the export must NOT hard-embed those. Fonts must be user-configurable (display/body/mono config keys, reusing the doc.css --font-* tokens) with safe open/system fallbacks (e.g. Georgia / system-ui / ui-monospace) so output looks fine with zero personal assets. Only redistributable fonts may be embedded by default.
+
+Packaging: glint must be self-contained. Copy/build the required kit pieces (doc.css + the @media print rules, the render HTML skeleton, only redistributable fonts) into the glint repo (e.g. internal/export/assets/) and embed them with go:embed so they ship inside the binary. Do NOT depend on /Users/kortum/Developer/Home/_shared-app-kit/markdown-doc-kit at runtime — that path doesn't exist on users' machines. Decide a vendor/sync story (a script that re-copies from the kit on update, or a one-time fork) so the embedded copy can be refreshed when the kit's house style changes; strip the licensed Awke/Untitled Sans/Name Mono @font-face blocks during that copy, leaving the --font-* tokens + fallbacks (ties to the configurable-fonts AC).
+<!-- SECTION:NOTES:END -->
- → Track-changes-diff-view-portable-change-markup-Fuss-interop.md +29 −0
@@ -0,0 +1,29 @@
+---
+id: TASK-022
+title: Track-changes / diff view + portable change markup (Fuss interop)
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-06-29 20:57'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 22000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Add a diff / track-changes capability to glint, plus a markup that travels inside the .md file so the same tracked changes are usable in Fuss — the collaborative web app for markdown track changes. The on-disk format is the contract between glint (terminal) and Fuss (web): edits made in one show as reviewable changes in the other. CriticMarkup is the established standard for this ({++ins++}, {--del--}, {~~old~>new~~}, {==highlight==}, {>>comment<<}); glint already renders ==highlight==, so adopting CriticMarkup is the natural path. Scope is two related pieces that can ship independently: (1) a diff / track-changes VIEW in the editor, and (2) read/write of the portable change markup.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 A diff / track-changes view renders insertions, deletions, and (optionally) replacements with distinct theme-driven styling, readable on light and dark terminals
+- [ ] #2 glint reads a portable in-file change markup and displays the encoded changes as tracked changes (not raw markup)
+- [ ] #3 glint can write that same markup so a file edited in glint round-trips into Fuss with changes intact, and vice versa
+- [ ] #4 The chosen markup is a documented, interoperable format (CriticMarkup evaluated first as the de-facto md track-changes standard) — not a glint-only invention; the decision and rationale are recorded
+- [ ] #5 Accept / reject individual changes is supported or explicitly deferred with a note
+- [ ] #6 Files without any change markup open and behave exactly as today (zero overhead, no visual noise)
+- [ ] #7 README / help document the view, the keybind(s), and the on-disk format + Fuss interop
+<!-- AC:END -->
- → Toggle-checkbox-state-with-a-key-x.md +25 −0
@@ -0,0 +1,25 @@
+---
+id: TASK-023
+title: 'Toggle checkbox state with a key (- [ ] <-> - [x])'
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-06-29 21:08'
+labels:
+  - feature
+dependencies: []
+priority: low
+ordinal: 23000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+From TASK-008 description (deferred, not an AC there): toggle the checkbox on the current list line between [ ] and [x] with a keybind, without hand-editing the brackets. Should work whether or not the cursor is on the brackets; no-op on non-checkbox lines.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 A keybind toggles the current line's checkbox [ ] <-> [x]
+- [ ] #2 No-op on lines without a checkbox
+- [ ] #3 README/help document the key
+<!-- AC:END -->
internal/editor/editor.go +13 −3
@@ -495,10 +495,20 @@ 			e.InsertRune(r)
 		}
 	case tea.KeyEnter:
 		e.replaceSelection()
-		e.InsertNewline()
+		if !e.ContinueList() {
+			e.InsertNewline()
+		}
 	case tea.KeyTab:
-		e.replaceSelection()
-		e.InsertRune('\t')
+		if e.onListItem() {
+			e.IndentLine()
+		} else {
+			e.replaceSelection()
+			e.InsertRune('\t')
+		}
+	case tea.KeyShiftTab:
+		if e.onListItem() {
+			e.OutdentLine()
+		}
 
 	// Deletion — a selection is removed wholesale.
 	case tea.KeyBackspace:
internal/editor/lists.go +185 −0
@@ -0,0 +1,185 @@
+package editor
+
+import (
+	"strconv"
+	"strings"
+)
+
+// List and checkbox continuation (TASK-008). Enter on a list line continues the
+// marker (ordered numbers increment, checkboxes reset to unchecked); Enter on an
+// empty item removes the marker and exits the list. Tab / Shift+Tab indent and
+// outdent the current list item by a fixed whitespace unit.
+
+// listIndentUnit is one nesting level of leading whitespace. Two spaces aligns a
+// nested item under the content of a "- " parent and renders as a sublist in
+// CommonMark.
+const listIndentUnit = "  "
+
+// listItem is a parsed list line: its leading whitespace, marker, and the full
+// prefix (everything up to the start of the content).
+type listItem struct {
+	indent   string // leading spaces/tabs
+	marker   string // "-", "*", "+", or the ordered number text ("1")
+	delim    string // "" for bullets; "." or ")" for ordered
+	ordered  bool
+	checkbox bool
+	checked  bool
+	prefix   string // indent + marker + delim + space (+ "[ ] ")
+}
+
+func isDigit(r rune) bool { return r >= '0' && r <= '9' }
+
+// parseListItem recognizes a markdown list line. ok is false for anything that
+// isn't a bullet ("- ", "* ", "+ ") or ordered ("N. ", "N) ") item; a marker
+// must be followed by a space (so "-" and "1.no space" are plain text).
+func parseListItem(line string) (listItem, bool) {
+	rs := []rune(line)
+	i := 0
+	for i < len(rs) && (rs[i] == ' ' || rs[i] == '\t') {
+		i++
+	}
+	var it listItem
+	it.indent = string(rs[:i])
+	if i >= len(rs) {
+		return listItem{}, false
+	}
+
+	switch {
+	case (rs[i] == '-' || rs[i] == '*' || rs[i] == '+') && i+1 < len(rs) && rs[i+1] == ' ':
+		it.marker = string(rs[i])
+		i += 2
+	case isDigit(rs[i]):
+		j := i
+		for j < len(rs) && isDigit(rs[j]) {
+			j++
+		}
+		if j < len(rs) && (rs[j] == '.' || rs[j] == ')') && j+1 < len(rs) && rs[j+1] == ' ' {
+			it.marker = string(rs[i:j])
+			it.delim = string(rs[j])
+			it.ordered = true
+			i = j + 2
+		} else {
+			return listItem{}, false
+		}
+	default:
+		return listItem{}, false
+	}
+
+	// Optional checkbox: "[ ]" / "[x]" / "[X]" followed by a space, or ending the
+	// line (an empty checkbox item "- [ ]").
+	if i+2 < len(rs) && rs[i] == '[' && (rs[i+1] == ' ' || rs[i+1] == 'x' || rs[i+1] == 'X') && rs[i+2] == ']' {
+		switch {
+		case i+3 < len(rs) && rs[i+3] == ' ':
+			it.checkbox = true
+			it.checked = rs[i+1] != ' '
+			i += 4
+		case i+3 == len(rs):
+			it.checkbox = true
+			it.checked = rs[i+1] != ' '
+			i += 3
+		}
+	}
+
+	it.prefix = string(rs[:i])
+	return it, true
+}
+
+// continuationPrefix is the marker a new item below it inherits: same indent and
+// bullet, the next number for ordered lists, and an unchecked box for checkboxes.
+func (it listItem) continuationPrefix() string {
+	var b strings.Builder
+	b.WriteString(it.indent)
+	if it.ordered {
+		n, _ := strconv.Atoi(it.marker)
+		b.WriteString(strconv.Itoa(n + 1))
+		b.WriteString(it.delim)
+		b.WriteByte(' ')
+	} else {
+		b.WriteString(it.marker)
+		b.WriteByte(' ')
+	}
+	if it.checkbox {
+		b.WriteString("[ ] ")
+	}
+	return b.String()
+}
+
+// onListItem reports whether the cursor's line is a list item.
+func (e *Editor) onListItem() bool {
+	_, ok := parseListItem(e.Lines[e.Cursor.Row])
+	return ok
+}
+
+// ContinueList handles Enter on a list line. With empty content it removes the
+// marker (exits the list) and creates no new line; otherwise it splits at the
+// cursor and prefixes the new line with the continued marker. Returns false when
+// the line isn't a list item or the cursor sits inside the marker, so the caller
+// falls back to a plain newline.
+func (e *Editor) ContinueList() bool {
+	line := e.Lines[e.Cursor.Row]
+	it, ok := parseListItem(line)
+	if !ok {
+		return false
+	}
+	rs := []rune(line)
+	prefixLen := len([]rune(it.prefix))
+	if e.Cursor.Col < prefixLen {
+		return false // inside the marker — let Enter split normally
+	}
+
+	if len(rs) == prefixLen {
+		// Empty item: drop the marker and stay on the (now blank) line.
+		e.Lines[e.Cursor.Row] = ""
+		e.Cursor.Col = 0
+		e.Dirty = true
+		e.setGoal()
+		e.followCursor()
+		return true
+	}
+
+	col := clamp(e.Cursor.Col, 0, len(rs))
+	left, right := string(rs[:col]), string(rs[col:])
+	next := it.continuationPrefix()
+	e.Lines[e.Cursor.Row] = left
+	rest := append([]string{next + right}, e.Lines[e.Cursor.Row+1:]...)
+	e.Lines = append(e.Lines[:e.Cursor.Row+1], rest...)
+	e.Cursor.Row++
+	e.Cursor.Col = len([]rune(next))
+	e.Dirty = true
+	e.setGoal()
+	e.followCursor()
+	return true
+}
+
+// IndentLine prepends one indent unit to the current line (Tab on a list item).
+func (e *Editor) IndentLine() {
+	e.Lines[e.Cursor.Row] = listIndentUnit + e.Lines[e.Cursor.Row]
+	e.Cursor.Col += len([]rune(listIndentUnit))
+	e.Dirty = true
+	e.setGoal()
+	e.followCursor()
+}
+
+// OutdentLine removes one indent unit of leading whitespace (Shift+Tab): a tab,
+// or up to listIndentUnit spaces. No-op when there's no leading whitespace.
+func (e *Editor) OutdentLine() {
+	rs := []rune(e.Lines[e.Cursor.Row])
+	removed := 0
+	if len(rs) > 0 && rs[0] == '\t' {
+		removed = 1
+	} else {
+		for removed < len(listIndentUnit) && removed < len(rs) && rs[removed] == ' ' {
+			removed++
+		}
+	}
+	if removed == 0 {
+		return
+	}
+	e.Lines[e.Cursor.Row] = string(rs[removed:])
+	if e.Cursor.Col -= removed; e.Cursor.Col < 0 {
+		e.Cursor.Col = 0
+	}
+	e.Dirty = true
+	e.setGoal()
+	e.followCursor()
+}
internal/editor/lists_test.go +201 −0
@@ -0,0 +1,201 @@
+package editor
+
+import (
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+// enter sends an Enter key through the full HandleKey path (undo-aware), the
+// same way the app drives the editor.
+func enter(e *Editor) { e.HandleKey(tea.KeyMsg{Type: tea.KeyEnter}) }
+func tab(e *Editor)   { e.HandleKey(tea.KeyMsg{Type: tea.KeyTab}) }
+func shiftTab(e *Editor) {
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyShiftTab})
+}
+
+func TestEnterContinuesBulletMarker(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- first"))
+	e.Cursor = Position{Row: 0, Col: len("- first")}
+	enter(e)
+	if got := e.Lines[1]; got != "- " {
+		t.Fatalf("new line = %q, want %q", got, "- ")
+	}
+	if e.Cursor.Row != 1 || e.Cursor.Col != 2 {
+		t.Fatalf("cursor = %+v, want row 1 col 2", e.Cursor)
+	}
+}
+
+func TestEnterContinuesStarAndPlus(t *testing.T) {
+	for _, m := range []string{"* ", "+ "} {
+		e := New()
+		e.SetContent([]byte(m + "x"))
+		e.Cursor = Position{Row: 0, Col: len(m + "x")}
+		enter(e)
+		if got := e.Lines[1]; got != m {
+			t.Fatalf("marker %q: new line = %q, want %q", m, got, m)
+		}
+	}
+}
+
+func TestEnterIncrementsOrderedNumber(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("1. first"))
+	e.Cursor = Position{Row: 0, Col: len("1. first")}
+	enter(e)
+	if got := e.Lines[1]; got != "2. " {
+		t.Fatalf("new line = %q, want %q", got, "2. ")
+	}
+}
+
+func TestEnterOrderedParenDelimiter(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("3) third"))
+	e.Cursor = Position{Row: 0, Col: len("3) third")}
+	enter(e)
+	if got := e.Lines[1]; got != "4) " {
+		t.Fatalf("new line = %q, want %q", got, "4) ")
+	}
+}
+
+func TestEnterContinuesCheckboxUnchecked(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- [x] done"))
+	e.Cursor = Position{Row: 0, Col: len("- [x] done")}
+	enter(e)
+	if got := e.Lines[1]; got != "- [ ] " {
+		t.Fatalf("new line = %q, want %q", got, "- [ ] ")
+	}
+}
+
+func TestEnterPreservesIndentation(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("  - nested"))
+	e.Cursor = Position{Row: 0, Col: len("  - nested")}
+	enter(e)
+	if got := e.Lines[1]; got != "  - " {
+		t.Fatalf("new line = %q, want %q", got, "  - ")
+	}
+}
+
+func TestEnterSplitsContentMidItem(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- foobar"))
+	e.Cursor = Position{Row: 0, Col: len("- foo")}
+	enter(e)
+	if e.Lines[0] != "- foo" || e.Lines[1] != "- bar" {
+		t.Fatalf("lines = %q / %q, want %q / %q", e.Lines[0], e.Lines[1], "- foo", "- bar")
+	}
+}
+
+func TestEnterOnEmptyItemExitsList(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- "))
+	e.Cursor = Position{Row: 0, Col: 2}
+	enter(e)
+	if len(e.Lines) != 1 {
+		t.Fatalf("line count = %d, want 1 (no new line)", len(e.Lines))
+	}
+	if e.Lines[0] != "" {
+		t.Fatalf("line = %q, want empty (marker removed)", e.Lines[0])
+	}
+	if e.Cursor.Col != 0 {
+		t.Fatalf("cursor col = %d, want 0", e.Cursor.Col)
+	}
+}
+
+func TestEnterOnEmptyCheckboxExitsList(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- [ ] "))
+	e.Cursor = Position{Row: 0, Col: len("- [ ] ")}
+	enter(e)
+	if len(e.Lines) != 1 || e.Lines[0] != "" {
+		t.Fatalf("lines = %v, want one empty line", e.Lines)
+	}
+}
+
+func TestEnterOnNonListInsertsPlainNewline(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("hello"))
+	e.Cursor = Position{Row: 0, Col: 5}
+	enter(e)
+	if len(e.Lines) != 2 || e.Lines[1] != "" {
+		t.Fatalf("lines = %v, want plain split", e.Lines)
+	}
+}
+
+func TestTabIndentsListItem(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- item"))
+	e.Cursor = Position{Row: 0, Col: 2}
+	tab(e)
+	if e.Lines[0] != "  - item" {
+		t.Fatalf("line = %q, want %q", e.Lines[0], "  - item")
+	}
+	if e.Cursor.Col != 4 {
+		t.Fatalf("cursor col = %d, want 4", e.Cursor.Col)
+	}
+}
+
+func TestTabOnNonListInsertsTab(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("plain"))
+	e.Cursor = Position{Row: 0, Col: 0}
+	tab(e)
+	if e.Lines[0] != "\tplain" {
+		t.Fatalf("line = %q, want %q", e.Lines[0], "\tplain")
+	}
+}
+
+func TestShiftTabOutdentsListItem(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("  - item"))
+	e.Cursor = Position{Row: 0, Col: 4}
+	shiftTab(e)
+	if e.Lines[0] != "- item" {
+		t.Fatalf("line = %q, want %q", e.Lines[0], "- item")
+	}
+	if e.Cursor.Col != 2 {
+		t.Fatalf("cursor col = %d, want 2", e.Cursor.Col)
+	}
+}
+
+func TestShiftTabOutdentTabIndent(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("\t- item"))
+	e.Cursor = Position{Row: 0, Col: 3}
+	shiftTab(e)
+	if e.Lines[0] != "- item" {
+		t.Fatalf("line = %q, want %q", e.Lines[0], "- item")
+	}
+}
+
+func TestShiftTabAtZeroIndentNoop(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- item"))
+	e.Cursor = Position{Row: 0, Col: 2}
+	shiftTab(e)
+	if e.Lines[0] != "- item" {
+		t.Fatalf("line = %q, want unchanged", e.Lines[0])
+	}
+}
+
+func TestListContinuationIsUndoable(t *testing.T) {
+	e := New()
+	e.SetContent([]byte("- first"))
+	e.Cursor = Position{Row: 0, Col: len("- first")}
+	enter(e)
+	e.Undo()
+	if len(e.Lines) != 1 || e.Lines[0] != "- first" {
+		t.Fatalf("after undo lines = %v, want [- first]", e.Lines)
+	}
+}
+
+func TestParseListItemRejectsNonList(t *testing.T) {
+	for _, s := range []string{"", "plain text", "-nodash", "1.no space", "   ", "-"} {
+		if _, ok := parseListItem(s); ok {
+			t.Errorf("parseListItem(%q) = ok, want not a list", s)
+		}
+	}
+}
internal/editor/undo.go +1 −1
@@ -131,7 +131,7 @@ 		}
 		return kindType, true
 	case tea.KeySpace:
 		return kindType, true
-	case tea.KeyEnter, tea.KeyTab:
+	case tea.KeyEnter, tea.KeyTab, tea.KeyShiftTab:
 		return kindStructural, true
 	case tea.KeyBackspace, tea.KeyDelete, tea.KeyCtrlU, tea.KeyCtrlK, tea.KeyCtrlW:
 		return kindStructural, true
main.go +3 −0
@@ -45,6 +45,9 @@   -h, --help            show this help
       --version         print the version
 
 EDITOR KEYS
+  Enter (on a list)     continue the list marker (numbers increment, checkboxes
+                        reset); Enter on an empty item exits the list
+  Tab / Shift+Tab       indent / outdent the current list item
   Ctrl+S                save (an unnamed buffer prompts for a name)
   Ctrl+P                toggle the read preview
   Ctrl+F                fuzzy file picker