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

feat: spellcheck popup, mouse trigger, config + docs (TASK-020)

1221e42d9efe8f54372d5cd8b13de7993a101408
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 18:32

parent 92f9461e

feat: spellcheck popup, mouse trigger, config + docs (TASK-020)

Wire inline spellcheck into the app end-to-end:
- App loads the embedded dictionary + personal dict (~/.config/glint/dict.txt) at
  startup and enables spellcheck per the new spellcheck=auto|on|off config key.
- Alt+; opens a popup on the misspelled word at the cursor; clicking an underlined
  word opens it too. The popup lists up to five edit-distance suggestions plus
  Add-to-dictionary and Ignore: pick 1-9 to replace in place (marks dirty, clears
  the underline), a to add, i to ignore for the session, Esc to dismiss. Rendered
  as a themed bottom bar like find/goto.
- Editor gains FlaggedWordAt / ReplaceWordAt / Suggest / IgnoreWord.
- glint -c walkthrough, glint -h reference, the in-editor help overlay, README,
  and CHANGELOG document the feature, including the tmux undercurl passthrough.

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

12 files changed

CHANGELOG.md +15 −0
@@ -4,6 +4,21 @@ All notable changes to glint are documented here. Format follows
 [Keep a Changelog](https://keepachangelog.com/); versions follow
 [Semantic Versioning](https://semver.org/).
 
+## [Unreleased]
+
+### Added
+- Inline spellcheck: a red curly underline (undercurl) under misspelled prose,
+  backed by an embedded ~60k common-English dictionary (pure Go, no cgo). Code,
+  inline code, URLs, wikilinks, link targets, and frontmatter are never flagged;
+  code files are skipped entirely. `Alt+;` (or clicking an underlined word) opens
+  a popup with edit-distance suggestions — replace in place, add to a personal
+  dictionary at `~/.config/glint/dict.txt`, or ignore for the session. New
+  `spellcheck = auto | on | off` config key.
+
+### Changed
+- Vertical cursor moves (Up/Down) and clicks rebuild the visual model once per
+  keystroke instead of twice.
+
 ## [0.1.0] — 2026-06-28
 
 First public release.
README.md +28 −1
@@ -7,7 +7,8 @@ preview, fuzzy-find notes with a live preview pane, and theme it to match your
 terminal (Flexoki light/dark + charm). Open any file, jump to today's daily
 note, or start a fresh document from anywhere. Code files open with calm,
 minimal syntax highlighting (strings, comments, and numbers only) so glint works
-as a quick `$EDITOR` too.
+as a quick `$EDITOR` too. Prose gets light inline spellcheck — a red curly
+underline under obvious typos, with one-key suggestions.
 
 ## Install
 
@@ -69,6 +70,7 @@ | `Ctrl+D` | today's daily note |
 | `Ctrl+N` | new note in the current directory (a typed picker query becomes its name) |
 | `Ctrl+B` | new note in the inbox |
 | `Ctrl+T` | cycle theme (flexoki-light → flexoki-dark → charm) |
+| `Alt+;` · click | spellcheck popup on the misspelled word at the cursor (or click an underlined word): pick a suggestion `1`–`9`, `a` add to dictionary, `i` ignore, `Esc` close |
 | `Ctrl+/` | toggle the in-editor help overlay (keys + commands) |
 | `Ctrl+Q` | quit (press twice if there are unsaved changes) |
 | `Esc` | clear the selection, or close find / back to the editor |
@@ -86,6 +88,7 @@ daily_subdir  = "Daily"      # daily notes live in <vault>/<daily_subdir>/ (absolute path used as-is)
 daily_format  = "2006-01-02" # Go time layout for daily-note filenames
 theme         = "auto"       # auto | flexoki-light | flexoki-dark | charm  (auto detects macOS appearance)
 glamour_style = ""           # override the preview style; "" follows the theme
+spellcheck    = "auto"       # auto | on | off  (auto = on for prose/notes, off for code files)
 ```
 
 **Two roots.** Bare `glint`, `glint -n`, and `-d` operate on the **working
@@ -100,6 +103,30 @@ Three palettes, every span explicitly colored so text stays readable on light
 and dark terminals: **flexoki-light**, **flexoki-dark**, and **charm**. `auto`
 picks light or dark from the macOS system appearance; `Ctrl+T` cycles live. The
 read preview and the whole canvas background follow the active theme.
+
+## Spellcheck
+
+Misspelled prose gets a calm red curly underline (undercurl). It's deliberately
+light — an embedded ~60k common-English dictionary catches obvious typos without
+the false positives of a heavy morphological checker. Code fences, inline code,
+URLs, wikilinks, link targets, and frontmatter are never flagged, and recognized
+code files are skipped entirely (same extension routing as syntax highlighting).
+
+`Alt+;` (or clicking an underlined word) opens a popup with up to five
+suggestions ranked by edit distance — pick one with `1`–`9` to replace in place,
+`a` to add the word to your personal dictionary, or `i` to ignore it for the
+session. The personal dictionary is a plain, hand-editable file at
+`~/.config/glint/dict.txt` (one word per line). Set `spellcheck = off` to disable
+it, or `on` to force it on.
+
+The curly underline uses the `4:3` SGR underline-style and `58` underline-color
+codes — supported by Ghostty, kitty, WezTerm, foot, and recent VTE terminals.
+Terminals without them degrade to a straight underline or none. Inside **tmux**,
+enable passthrough so the styles reach the terminal:
+
+```tmux
+set -as terminal-features ',*:usstyle'
+```
 
 ## License
 
- → Spellcheck-with-undercurl-underlines.md +3 −1
@@ -4,7 +4,7 @@ title: Spellcheck with undercurl underlines
 status: "\U0001F7E2 In progress"
 assignee: []
 created_date: '2026-06-29 18:11'
-updated_date: '2026-06-30 01:14'
+updated_date: '2026-06-30 01:20'
 labels:
   - feature
   - release-1
@@ -80,4 +80,6 @@ ## Implementation Notes
 
 <!-- SECTION:NOTES:BEGIN -->
 Slice A/B/C done (commit 2c0a77e9): internal/spell — 60k embedded dict (freq∩curated, rejects common typos), Known() w/ possessive leniency, BK-tree Suggest() OSA-reranked + freq tie-break, personal dict load/Add at ~/.config/glint/dict.txt. 11 tests green.
+
+Slice D/E done (commits 92f9461e, +theme.Spell): undercurl Span rendering (raw SGR 4:3 + 58:2 color, invariant-preserving, graceful degrade), scanner Prose tagging, spellPass with skip rules (code/inline-code/URL/email/wikilink/link-target/frontmatter/acronym/camelcase/<3-char), word->ok cache, codeFile + toggle gates, AddToDictionary clears cache live. 13 editor tests green.
 <!-- SECTION:NOTES:END -->
internal/app/app.go +33 −0
@@ -16,6 +16,7 @@ 	"glint/internal/editor"
 	"glint/internal/help"
 	"glint/internal/picker"
 	"glint/internal/preview"
+	"glint/internal/spell"
 	"glint/internal/theme"
 
 	"github.com/atotto/clipboard"
@@ -36,6 +37,7 @@ 	ModeSaveAs
 	ModeFind
 	ModeHelp
 	ModeGotoLine
+	ModeSpell
 )
 
 // pendingDiscard tracks which open-while-dirty action is awaiting confirmation.
@@ -70,6 +72,7 @@ 	findInput  textinput.Model            // one-line in-document find prompt (TASK-007)
 	gotoInput  textinput.Model            // one-line go-to-line prompt (TASK-012)
 	helpView   viewport.Model             // scrollable keybind overlay (TASK-011)
 	cursorMem  map[string]editor.Position // per-file cursor memory, this session (TASK-012)
+	spell      spellPopup                 // active misspelled-word popup (TASK-020)
 	path       string
 	pickerRoot string // directory the current picker is browsing
 	saveDir    string // where an unnamed buffer's save-as lands ("" → inbox)
@@ -110,9 +113,24 @@ 		cursorMem: map[string]editor.Position{},
 	}
 	a.preview = preview.New(a.glamourStyle())
 	a.preview.SetColors(previewColors(th))
+	a.initSpell()
 	return a
 }
 
+// initSpell loads the embedded dictionary and personal word list, then sets the
+// session spellcheck toggle from config (auto/on enable it; off disables). A load
+// failure leaves spellcheck inert rather than blocking startup.
+func (a *App) initSpell() {
+	d, err := spell.Load()
+	if err != nil {
+		return
+	}
+	d.SetPersonalPath(config.DictPath())
+	_ = d.LoadPersonal()
+	a.editor.SetDict(d)
+	a.editor.SetSpell(!strings.EqualFold(a.cfg.Spellcheck, "off"))
+}
+
 // previewColors maps a theme to the glamour preview's color set.
 func previewColors(th theme.Theme) preview.Colors {
 	return preview.Colors{
@@ -193,6 +211,8 @@ 			// screen row Y is that many rows into the viewport.
 			vi := a.editor.Scroll + msg.Y - a.topPad() + 1
 			col := msg.X - a.leftMargin()
 			a.editor.MoveToVisual(vi, col)
+			// Clicking a flagged (underlined) word opens its suggestion popup.
+			a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col)
 		}
 		return a, nil
 	}
@@ -236,6 +256,15 @@ 	if !repressed {
 		a.pending = discardNone
 	}
 
+	// Alt+; opens the spellcheck popup on a flagged word at the cursor.
+	if msg.Type == tea.KeyRunes && msg.Alt && string(msg.Runes) == ";" {
+		if a.openSpellPopup() {
+			return a, nil
+		}
+		a.status = "No misspelling at the cursor"
+		return a, nil
+	}
+
 	switch msg.Type {
 	case tea.KeyCtrlQ:
 		if a.editor.Dirty && !a.quitArmed {
@@ -316,6 +345,8 @@ 	case ModeFind:
 		return a.handleFindKey(msg)
 	case ModeGotoLine:
 		return a.handleGotoKey(msg)
+	case ModeSpell:
+		return a.handleSpellKey(msg)
 	case ModeHelp:
 		var cmd tea.Cmd
 		a.helpView, cmd = a.helpView.Update(msg) // arrows / PgUp / PgDn scroll
@@ -793,6 +824,8 @@ 	case ModeFind:
 		bottom = a.findBar()
 	case ModeGotoLine:
 		bottom = a.gotoBar()
+	case ModeSpell:
+		bottom = a.spellBar()
 	}
 	return a.paintCanvas(body) + bottom
 }
internal/app/spell.go +165 −0
@@ -0,0 +1,165 @@
+package app
+
+import (
+	"fmt"
+	"strings"
+
+	tea "github.com/charmbracelet/bubbletea"
+	"github.com/charmbracelet/lipgloss"
+)
+
+// spellKind distinguishes the popup's action rows.
+type spellKind int
+
+const (
+	spellSuggest spellKind = iota // replace the word with value
+	spellAdd                      // add the word to the personal dictionary
+	spellIgnore                   // ignore the word for this session
+)
+
+// spellOption is one selectable row in the misspelled-word popup.
+type spellOption struct {
+	label string
+	kind  spellKind
+	value string // replacement word for spellSuggest
+}
+
+// spellPopup is the state of the active misspelled-word popup: the flagged word,
+// its location, the choice list, and the cursor within it.
+type spellPopup struct {
+	word            string
+	row, start, end int
+	options         []spellOption
+	sel             int
+}
+
+// openSpellPopupAt opens the suggestion popup for a flagged word at (row, col),
+// returning false (and doing nothing) when no misspelled word sits there.
+func (a *App) openSpellPopupAt(row, col int) bool {
+	word, start, end, ok := a.editor.FlaggedWordAt(row, col)
+	if !ok {
+		return false
+	}
+	opts := make([]spellOption, 0, 7)
+	for _, s := range a.editor.Suggest(word, 5) {
+		opts = append(opts, spellOption{label: s, kind: spellSuggest, value: s})
+	}
+	opts = append(opts,
+		spellOption{label: "Add to dictionary", kind: spellAdd},
+		spellOption{label: "Ignore", kind: spellIgnore},
+	)
+	a.spell = spellPopup{word: word, row: row, start: start, end: end, options: opts}
+	a.mode = ModeSpell
+	a.status = ""
+	return true
+}
+
+// openSpellPopup triggers the popup for a flagged word at or next to the cursor
+// (the Alt+; handler). It reports whether a popup opened.
+func (a *App) openSpellPopup() bool {
+	if a.mode != ModeEditor {
+		return false
+	}
+	return a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col)
+}
+
+// handleSpellKey drives the popup: arrows/Tab move the selection, a number key
+// jumps to and applies that row, Enter applies the selection, Esc dismisses.
+func (a *App) handleSpellKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	n := len(a.spell.options)
+	switch msg.Type {
+	case tea.KeyEsc:
+		a.mode = ModeEditor
+		return a, nil
+	case tea.KeyUp, tea.KeyShiftTab:
+		a.spell.sel = (a.spell.sel - 1 + n) % n
+		return a, nil
+	case tea.KeyDown, tea.KeyTab:
+		a.spell.sel = (a.spell.sel + 1) % n
+		return a, nil
+	case tea.KeyEnter:
+		return a.applySpell(a.spell.sel)
+	case tea.KeyRunes:
+		if len(msg.Runes) == 1 {
+			switch r := msg.Runes[0]; {
+			case r >= '1' && r <= '9':
+				if i := int(r - '1'); i < n {
+					return a.applySpell(i)
+				}
+			case r == 'a' || r == 'A':
+				return a.applySpell(a.kindIndex(spellAdd))
+			case r == 'i' || r == 'I':
+				return a.applySpell(a.kindIndex(spellIgnore))
+			}
+		}
+	}
+	return a, nil
+}
+
+// kindIndex returns the option index of the first row of the given kind.
+func (a *App) kindIndex(k spellKind) int {
+	for i, o := range a.spell.options {
+		if o.kind == k {
+			return i
+		}
+	}
+	return 0
+}
+
+// applySpell performs option i — replace, add, or ignore — then closes the popup.
+func (a *App) applySpell(i int) (tea.Model, tea.Cmd) {
+	if i < 0 || i >= len(a.spell.options) {
+		a.mode = ModeEditor
+		return a, nil
+	}
+	opt := a.spell.options[i]
+	switch opt.kind {
+	case spellSuggest:
+		a.editor.ReplaceWordAt(a.spell.row, a.spell.start, a.spell.end, opt.value)
+		a.status = "Replaced with " + opt.value
+	case spellAdd:
+		if err := a.editor.AddToDictionary(a.spell.word); err != nil {
+			a.status = "Add to dictionary failed: " + err.Error()
+		} else {
+			a.status = "Added " + a.spell.word + " to dictionary"
+		}
+	case spellIgnore:
+		a.editor.IgnoreWord(a.spell.word)
+		a.status = "Ignored " + a.spell.word
+	}
+	a.mode = ModeEditor
+	return a, nil
+}
+
+// spellBar renders the popup as a themed full-width bottom bar: the misspelled
+// word, then numbered choices with the current selection highlighted, and the
+// add/ignore hints.
+func (a *App) spellBar() string {
+	bar := lipgloss.NewStyle().
+		Foreground(a.theme.StatusFg).
+		Background(a.theme.StatusBg).
+		Width(maxInt(a.width, 1))
+	selStyle := lipgloss.NewStyle().Foreground(a.theme.SelFg).Background(a.theme.SelBg)
+
+	parts := make([]string, 0, len(a.spell.options)+1)
+	parts = append(parts, "“"+a.spell.word+"” →")
+	for i, o := range a.spell.options {
+		var label string
+		switch o.kind {
+		case spellSuggest:
+			label = fmt.Sprintf("%d %s", i+1, o.label)
+		case spellAdd:
+			label = "a Add"
+		case spellIgnore:
+			label = "i Ignore"
+		}
+		if i == a.spell.sel {
+			label = selStyle.Render(" " + label + " ")
+		} else {
+			label = " " + label + " "
+		}
+		parts = append(parts, label)
+	}
+	parts = append(parts, " Esc")
+	return bar.Render(" " + strings.Join(parts, " ") + " ")
+}
internal/app/spell_test.go +82 −0
@@ -0,0 +1,82 @@
+package app
+
+import (
+	"strings"
+	"testing"
+
+	"glint/internal/editor"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+// altSemicolon is the Alt+; key event that opens the spell popup.
+var altSemicolon = tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(";"), Alt: true}
+
+func editorPos(row, col int) editor.Position { return editor.Position{Row: row, Col: col} }
+
+func TestAltSemicolonOpensPopupOnMisspelling(t *testing.T) {
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetContent([]byte("a recieve here"))
+	a.editor.SetCursor(editorPos(0, 4)) // inside "recieve"
+
+	a.handleKey(altSemicolon)
+	if a.mode != ModeSpell {
+		t.Fatalf("mode = %d, want ModeSpell", a.mode)
+	}
+	if a.spell.word != "recieve" {
+		t.Errorf("popup word = %q, want recieve", a.spell.word)
+	}
+	if len(a.spell.options) < 3 {
+		t.Errorf("want suggestions + add + ignore, got %d options", len(a.spell.options))
+	}
+}
+
+func TestAltSemicolonNoMisspellingShowsStatus(t *testing.T) {
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetContent([]byte("all correct words"))
+	a.editor.SetCursor(editorPos(0, 1))
+	a.handleKey(altSemicolon)
+	if a.mode == ModeSpell {
+		t.Error("popup opened with no misspelling under the cursor")
+	}
+}
+
+func TestSpellPopupNumberKeyReplaces(t *testing.T) {
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetContent([]byte("a recieve here"))
+	a.editor.SetCursor(editorPos(0, 4))
+	a.handleKey(altSemicolon)
+
+	// The first suggestion for "recieve" is "receive"; press "1" to apply it.
+	if a.spell.options[0].value != "receive" {
+		t.Fatalf("first suggestion = %q, want receive", a.spell.options[0].value)
+	}
+	a.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("1")})
+	if a.mode != ModeEditor {
+		t.Errorf("popup did not close after applying; mode = %d", a.mode)
+	}
+	if got := a.editor.Lines[0]; got != "a receive here" {
+		t.Errorf("line after replace = %q, want \"a receive here\"", got)
+	}
+}
+
+func TestSpellPopupAddKey(t *testing.T) {
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetContent([]byte("my zzplonk word"))
+	a.editor.SetCursor(editorPos(0, 4))
+	a.handleKey(altSemicolon)
+	if a.mode != ModeSpell {
+		t.Fatalf("popup did not open on zzplonk")
+	}
+	a.handleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("i")}) // ignore for session
+	if a.mode != ModeEditor {
+		t.Errorf("popup did not close after ignore")
+	}
+	if strings.Contains(a.status, "failed") {
+		t.Errorf("ignore reported failure: %q", a.status)
+	}
+}
internal/config/config.go +15 −0
@@ -19,6 +19,7 @@ 	DailyFormat  string `toml:"daily_format"`
 	GlamourStyle string `toml:"glamour_style"`
 	Theme        string `toml:"theme"`
 	InboxDir     string `toml:"inbox_dir"`
+	Spellcheck   string `toml:"spellcheck"` // auto | on | off (TASK-020)
 }
 
 // Default returns the built-in configuration used when no file is present.
@@ -29,6 +30,7 @@ 	return Config{
 		DailySubdir: "Daily",
 		DailyFormat: "2006-01-02",
 		Theme:       "auto",
+		Spellcheck:  "auto",
 	}
 }
 
@@ -62,6 +64,16 @@ 	}
 	return filepath.Join(home, ".config", "glint", "config.toml")
 }
 
+// DictPath is the personal spellcheck dictionary location:
+// ~/.config/glint/dict.txt (TASK-020).
+func DictPath() string {
+	home, err := os.UserHomeDir()
+	if err != nil {
+		return filepath.Join(".config", "glint", "dict.txt")
+	}
+	return filepath.Join(home, ".config", "glint", "dict.txt")
+}
+
 // Load reads the config file and overlays it onto the defaults.
 func Load() (Config, error) {
 	return loadFromFile(Path())
@@ -115,6 +127,9 @@ 		cfg.Theme = fileCfg.Theme
 	}
 	if fileCfg.InboxDir != "" {
 		cfg.InboxDir = fileCfg.InboxDir
+	}
+	if fileCfg.Spellcheck != "" {
+		cfg.Spellcheck = fileCfg.Spellcheck
 	}
 	return cfg, nil
 }
internal/configui/configui.go +10 −0
@@ -32,6 +32,7 @@ 	vaultDir := cfg.VaultDir
 	inboxDir := cfg.InboxDir
 	dailySubdir := orDefault(cfg.DailySubdir, "Daily")
 	glamour := cfg.GlamourStyle
+	spellcheck := orDefault(cfg.Spellcheck, "auto")
 
 	// Match the current layout to a preset; otherwise offer it as custom.
 	fmtChoice := customLayout
@@ -86,6 +87,14 @@ 					huh.NewOption("light", "light"),
 					huh.NewOption("dracula", "dracula"),
 					huh.NewOption("tokyo-night", "tokyo-night"),
 				).Value(&glamour),
+			huh.NewSelect[string]().
+				Title("Spellcheck").
+				Description("Red undercurl on misspelled prose. auto = on for notes, off for code files.").
+				Options(
+					huh.NewOption("auto", "auto"),
+					huh.NewOption("on", "on"),
+					huh.NewOption("off", "off"),
+				).Value(&spellcheck),
 		),
 		huh.NewGroup(
 			huh.NewInput().
@@ -111,6 +120,7 @@ 		InboxDir:     inboxDir,
 		DailySubdir:  dailySubdir,
 		DailyFormat:  dailyFmt,
 		GlamourStyle: glamour,
+		Spellcheck:   spellcheck,
 	}
 	path := config.Path()
 	if err := config.Save(out, path); err != nil {
internal/editor/editor.go +4 −3
@@ -41,9 +41,10 @@ 	codeFile string // filename for the chroma lexer; "" = prose/markdown scanner (TASK-018)
 
 	buildCount int // count of buildVisual scans; perf guard for tests (TASK-004)
 
-	dict       *spell.Dict     // loaded spellchecker; nil = inert (TASK-020)
-	spellOn    bool            // session spellcheck toggle
-	spellCache map[string]bool // word -> known, cleared when the personal dict changes
+	dict        *spell.Dict     // loaded spellchecker; nil = inert (TASK-020)
+	spellOn     bool            // session spellcheck toggle
+	spellCache  map[string]bool // word -> known, cleared when the personal dict changes
+	spellIgnore map[string]bool // words ignored for this session only
 }
 
 // SetLanguage selects the scanner from the file's extension: markdown/text/no
internal/editor/spellcheck.go +62 −1
@@ -50,9 +50,12 @@ 	return err
 }
 
 // spellKnown is a cached membership test; the cache is dropped when the personal
-// dictionary changes.
+// dictionary changes. Session-ignored words always count as known.
 func (e *Editor) spellKnown(word string) bool {
 	lw := strings.ToLower(word)
+	if e.spellIgnore[lw] {
+		return true
+	}
 	if v, ok := e.spellCache[lw]; ok {
 		return v
 	}
@@ -62,6 +65,64 @@ 		e.spellCache = map[string]bool{}
 	}
 	e.spellCache[lw] = k
 	return k
+}
+
+// IgnoreWord marks word correct for this session only (the popup's Ignore
+// action); it is not written to the personal dictionary.
+func (e *Editor) IgnoreWord(word string) {
+	if e.spellIgnore == nil {
+		e.spellIgnore = map[string]bool{}
+	}
+	e.spellIgnore[strings.ToLower(word)] = true
+}
+
+// Suggest returns up to max spelling corrections for word, or nil without a
+// dictionary.
+func (e *Editor) Suggest(word string, max int) []string {
+	if e.dict == nil {
+		return nil
+	}
+	return e.dict.Suggest(word, max)
+}
+
+// FlaggedWordAt returns the misspelled word covering rune column col on the given
+// row (inclusive of the column just past the word, so a cursor resting at the
+// word's end still resolves it), plus its rune range [start,end). ok is false
+// when spellcheck is inactive or no flagged word is there.
+func (e *Editor) FlaggedWordAt(row, col int) (word string, start, end int, ok bool) {
+	if !e.spellActive() || row < 0 || row >= len(e.Lines) {
+		return "", 0, 0, false
+	}
+	all := ScanLines(e.Lines, e.theme)
+	all = e.spellPass(all)
+	pos := 0
+	for _, sp := range all[row] {
+		n := len([]rune(sp.Text))
+		if sp.Wavy && col >= pos && col <= pos+n {
+			return sp.Text, pos, pos + n, true
+		}
+		pos += n
+	}
+	return "", 0, 0, false
+}
+
+// ReplaceWordAt swaps the rune range [start,end) on row for replacement, parks
+// the cursor after it, marks the buffer dirty, and records an undo checkpoint.
+func (e *Editor) ReplaceWordAt(row, start, end int, replacement string) {
+	if row < 0 || row >= len(e.Lines) {
+		return
+	}
+	e.PushUndo()
+	r := []rune(e.Lines[row])
+	if start < 0 || end > len(r) || start > end {
+		return
+	}
+	e.Lines[row] = string(r[:start]) + replacement + string(r[end:])
+	e.Cursor = Position{Row: row, Col: start + len([]rune(replacement))}
+	e.anchor = nil
+	e.Dirty = true
+	e.setGoal()
+	e.followCursor()
 }
 
 // spellPass splits every prose span into words and re-emits misspelled ones as
internal/editor/spellcheck_test.go +58 −0
@@ -106,3 +106,61 @@ 	if wavyWords(e)["zzplonk"] {
 		t.Error("word added to dictionary should clear its underline live")
 	}
 }
+
+func TestFlaggedWordAtFindsMisspelling(t *testing.T) {
+	e := spellEditor(t, "a recieve here")
+	// "recieve" spans columns 2..9.
+	word, start, end, ok := e.FlaggedWordAt(0, 4)
+	if !ok || word != "recieve" {
+		t.Fatalf("FlaggedWordAt(0,4) = %q,%d,%d,%v; want recieve", word, start, end, ok)
+	}
+	if start != 2 || end != 9 {
+		t.Errorf("range = [%d,%d), want [2,9)", start, end)
+	}
+}
+
+func TestFlaggedWordAtNoneOnCorrectWord(t *testing.T) {
+	e := spellEditor(t, "all correct words here")
+	if _, _, _, ok := e.FlaggedWordAt(0, 2); ok {
+		t.Error("FlaggedWordAt found a flag on a correct line")
+	}
+}
+
+func TestReplaceWordAt(t *testing.T) {
+	e := spellEditor(t, "a recieve here")
+	e.ReplaceWordAt(0, 2, 9, "receive")
+	if e.Lines[0] != "a receive here" {
+		t.Errorf("after replace, line = %q", e.Lines[0])
+	}
+	if !e.Dirty {
+		t.Error("replace should mark the buffer dirty")
+	}
+	if wavyWords(e)["receive"] {
+		t.Error("replaced word should no longer be flagged")
+	}
+}
+
+func TestIgnoreWordClearsFlag(t *testing.T) {
+	e := spellEditor(t, "my zzplonk word")
+	if !wavyWords(e)["zzplonk"] {
+		t.Fatal("precondition: zzplonk flagged")
+	}
+	e.IgnoreWord("zzplonk")
+	if wavyWords(e)["zzplonk"] {
+		t.Error("ignored word should not be flagged for the session")
+	}
+}
+
+func TestEditorSuggest(t *testing.T) {
+	e := spellEditor(t, "recieve")
+	got := e.Suggest("recieve", 5)
+	found := false
+	for _, s := range got {
+		if s == "receive" {
+			found = true
+		}
+	}
+	if !found {
+		t.Errorf("Suggest(recieve) = %v, want receive", got)
+	}
+}
internal/help/help.go +9 −0
@@ -35,6 +35,9 @@   Ctrl+D                today's daily note
   Ctrl+N                new note in the current directory
   Ctrl+B                new note in the inbox
   Ctrl+T                cycle theme (flexoki-light / flexoki-dark / charm)
+  Alt+;                  spellcheck popup on the misspelled word at the cursor
+                        (pick a suggestion 1-9, a add to dictionary, i ignore);
+                        clicking an underlined word opens it too
   Ctrl+C / Ctrl+X / Ctrl+V   copy / cut / paste (system clipboard)
   Shift+arrows          select text (Ctrl+Shift+left/right by word)
   Alt+left / Alt+right  move by word
@@ -50,6 +53,12 @@   Home                  jump to the first non-blank column, then to column 0
   ( [ and backtick      auto-close (no selection): inserts the matching closer
                         with the cursor between; type the closer to step past
   paste a URL on a selection   wraps it as a [selection](url) link
+
+SPELLCHECK
+  Misspelled prose gets a red curly underline. Code, inline code, URLs,
+  wikilinks, link targets, and frontmatter are never flagged; code files are
+  off entirely. Personal words live in ~/.config/glint/dict.txt (hand-editable).
+  Config key spellcheck = auto | on | off.
 
 CONFIG
   ~/.config/glint/config.toml   (run 'glint -c' to set it up)