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

feat: grammar fixes & session ignore via Harper codeAction (TASK-044)

fe49582bfe8df263678c7784e33a7bd50e65487c
humdrum <me@humdrum.me> · 2026-07-14 12:30

parent 20055b72

feat: grammar fixes & session ignore via Harper codeAction (TASK-044)

Make the green grammar underlines actionable. Alt+; (or a click) on a grammar
span opens the proofing popup listing Harper's replacement fixes as numbered
rows plus an Ignore action; picking a fix rewrites the text, Ignore hides the
hint for the session.

Grounded in a live harper-ls 2.6 probe:
- Replacement: textDocument/codeAction returns quickfix edits with clean
  titles; wired end to end. Added LSP request/response correlation to the
  client (id->reply channel) and CodeActions(), which maps harper's UTF-16
  edit ranges to glint rune coords and dedups by title.
- Ignore: HarperIgnoreLint is a no-op over LSP, so ignore is glint-side — a
  session set keyed by (rule code, flagged text), mirroring spellcheck's
  IgnoreWord. Incoming batches are filtered through it; an ignored underline
  clears immediately and resets on next launch.
- Add-to-dictionary: harper offers none for grammar lints; dropped from scope.
- Fixed the workspace/configuration reply to wrap under {"harper-ls":{}};
  the bare {} was making harper log 'Settings must contain a harper-ls key.'

Editor gains GrammarSpanAt, RuneRangeText, and a general ReplaceRuneRange.
TDD throughout, including gated live-harper tests for codeAction and the
popup round-trip. Spec: docs/superpowers/specs/2026-07-14-harper-grammar-actions-design.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

13 files changed

README.md +11 −5
@@ -77,7 +77,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 | proofing popup on the misspelled word at the cursor (or click an underlined word): pick a suggestion `1`–`9`, `a` add to dictionary, `i` ignore, `t` toggle spellcheck, `g` toggle grammar, `Esc` close |
+| `Alt+;` · click | proofing popup on the misspelled word or grammar span at the cursor (or click an underlined word): pick a suggestion / grammar fix `1`–`9`, `a` add to dictionary, `i` ignore, `t` toggle spellcheck, `g` toggle grammar, `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 |
@@ -220,10 +220,16 @@
 Grammar issues get a **green** curly underline, distinct from spellcheck's red.
 When a word is both misspelled and inside a grammar span, the red spelling
 underline wins. Diagnostics refresh as you type (debounced) without blocking the
-UI. Toggle it at runtime with `g` in the `Alt+;` popup, and control it with
-`grammar = auto | on | off` (auto = on when `harper-ls` is on your `PATH`). With
-no `harper-ls` installed, grammar is silently inert and glint behaves exactly as
-before.
+UI.
+
+`Alt+;` on a grammar span (or clicking it) opens the proofing popup with
+**Harper's fixes** as the numbered rows — pick one to rewrite the text — plus
+`i` **Ignore**, which hides that hint for the rest of the session. (Harper's own
+ignore command is a no-op over LSP, so glint keeps the ignore list itself; it
+resets on the next launch.) Toggle grammar at runtime with `g` in the same popup,
+and control it with `grammar = auto | on | off` (auto = on when `harper-ls` is on
+your `PATH`). With no `harper-ls` installed, grammar is silently inert and glint
+behaves exactly as before.
 
 ## TK markers
 
- → Grammar-suggestions-ignore-via-Harper-codeAction.md +17 −5
@@ -1,9 +1,10 @@
 ---
 id: TASK-044
 title: Grammar suggestions & ignore via Harper codeAction
-status: "\U0001F7E6 Backlog"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-07-14 15:48'
+updated_date: '2026-07-14 19:29'
 labels:
   - feature
 dependencies: []
@@ -19,8 +20,19 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 Grammar span at cursor offers Harper's fix suggestions in the Alt+; popup
-- [ ] #2 Selecting a fix applies harper's replacement to the buffer
-- [ ] #3 Ignore removes the grammar underline via HarperIgnoreLint
-- [ ] #4 Add-to-dictionary uses HarperAddToUserDict
+- [x] #1 Grammar span at cursor offers Harper's fix suggestions in the Alt+; popup
+- [x] #2 Selecting a fix applies harper's replacement to the buffer
+- [x] #3 Ignore removes the grammar underline for the session (glint-side; HarperIgnoreLint is a no-op over LSP)
 <!-- AC:END -->
+
+
+
+
+
+
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Implemented via generic relay of harper codeAction results. Live-probed harper-ls 2.6: replacement quickfixes work great; HarperIgnoreLint is a no-op over LSP and harper offers no add-to-dict for grammar lints. So: (1)(2) replacement fixes wired into the Alt+; popup (grammar package request/response correlation + CodeActions mapping UTF-16 edits to rune coords; app applyGrammarFix applies edits last-first); (3) Ignore is glint-side session ignore keyed by rule+text, mirroring spell IgnoreWord (resets next launch); (4) add-to-dict dropped — not offered by harper for grammar. Bonus: fixed workspace/configuration reply to wrap under {"harper-ls":{}} (was triggering harper's 'Settings must contain a harper-ls key' error). Spec: docs/superpowers/specs/2026-07-14-harper-grammar-actions-design.md
+<!-- SECTION:NOTES:END -->
internal/app/app.go +3 −0
@@ -94,6 +94,9 @@ 	grammarPath   string // path last opened with harper (its URI basis)
 	grammarOpened bool   // a document is currently open with harper
 	grammarText   string // text last synced to harper (skips redundant didChange)
 	grammarGen    int    // debounce generation; only the latest tick sends
+
+	lastGrammarDiags []grammar.Diag      // most recent batch, retained so an ignore can re-filter it
+	grammarIgnores   map[string]struct{} // session-ignored grammar lints, keyed by rule+text (TASK-044)
 }
 
 // New builds an App with an empty editor.
internal/app/grammar.go +39 −3
@@ -53,14 +53,50 @@ 		return grammarDiagMsg{diags: batch}
 	}
 }
 
-// applyGrammarDiags buckets a diagnostic batch by logical line and hands it to
-// the editor as green-undercurl ranges.
+// applyGrammarDiags retains the latest batch and renders it, dropping any lint the
+// user has ignored this session.
 func (a *App) applyGrammarDiags(diags []grammar.Diag) {
+	a.lastGrammarDiags = diags
+	a.refreshGrammarDiags()
+}
+
+// refreshGrammarDiags buckets the retained batch by logical line and hands it to the
+// editor, skipping session-ignored lints. Called after a new batch and after an
+// ignore, so an ignored underline clears immediately without waiting for harper.
+func (a *App) refreshGrammarDiags() {
 	byLine := map[int][][2]int{}
-	for _, d := range diags {
+	for _, d := range a.lastGrammarDiags {
+		if a.grammarIgnored(d.Code, a.editor.RuneRangeText(d.Line, d.StartCol, d.EndCol)) {
+			continue
+		}
 		byLine[d.Line] = append(byLine[d.Line], [2]int{d.StartCol, d.EndCol})
 	}
 	a.editor.SetGrammarDiags(byLine)
+}
+
+// grammarIgnoreKey identifies an ignored grammar lint by its rule code and the exact
+// flagged text, so the ignore survives edits that move the span and suppresses every
+// identical occurrence — mirroring spellcheck's whole-word Ignore.
+func grammarIgnoreKey(code, text string) string { return code + "\x00" + text }
+
+// grammarIgnored reports whether a (code, text) lint is ignored this session.
+func (a *App) grammarIgnored(code, text string) bool {
+	if a.grammarIgnores == nil {
+		return false
+	}
+	_, ok := a.grammarIgnores[grammarIgnoreKey(code, text)]
+	return ok
+}
+
+// ignoreGrammar suppresses a grammar lint for the rest of the session and re-renders
+// so its underline disappears at once. (Harper's own HarperIgnoreLint is a no-op over
+// LSP, so glint owns the ignore list; it resets on the next launch.)
+func (a *App) ignoreGrammar(code, text string) {
+	if a.grammarIgnores == nil {
+		a.grammarIgnores = map[string]struct{}{}
+	}
+	a.grammarIgnores[grammarIgnoreKey(code, text)] = struct{}{}
+	a.refreshGrammarDiags()
 }
 
 // grammarOpen (re)registers the current buffer with harper after the open
internal/app/grammar_test.go +132 −0
@@ -60,6 +60,138 @@ 		t.Error("no green grammar undercurl after live harper round-trip")
 	}
 }
 
+// TestGrammarIgnoreClearsUnderline: ignoring a lint drops its underline immediately
+// and keeps it dropped when harper re-publishes the same batch.
+func TestGrammarIgnoreClearsUnderline(t *testing.T) {
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetTheme(theme.FlexokiDark())
+	a.editor.SetContent([]byte("This is a a test"))
+	a.editor.SetGrammar(true)
+
+	const greenSGR = "58:2::135:154:57"
+	batch := []grammar.Diag{{Line: 0, StartCol: 8, EndCol: 11, Code: "RepeatedWords", Message: "repeat"}}
+	a.applyGrammarDiags(batch)
+	if !strings.Contains(a.editor.View(), greenSGR) {
+		t.Fatal("expected a grammar underline before ignore")
+	}
+
+	a.ignoreGrammar("RepeatedWords", "a a")
+	if strings.Contains(a.editor.View(), greenSGR) {
+		t.Error("underline should vanish immediately after ignore")
+	}
+	// A fresh identical batch stays suppressed.
+	a.applyGrammarDiags(batch)
+	if strings.Contains(a.editor.View(), greenSGR) {
+		t.Error("ignored lint should stay suppressed across re-batches")
+	}
+}
+
+// TestApplyGrammarFix applies Harper replacement edits to the buffer.
+func TestApplyGrammarFix(t *testing.T) {
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetContent([]byte("This is a a test"))
+	a.applyGrammarFix([]grammar.TextEdit{{StartLine: 0, StartCol: 8, EndLine: 0, EndCol: 11, NewText: "a"}})
+	if got := string(a.editor.Bytes()); got != "This is a test" {
+		t.Errorf("got %q, want %q", got, "This is a test")
+	}
+}
+
+// TestApplyGrammarFixMultiEdit applies two edits on one line; last-first ordering
+// keeps the earlier edit's range valid after the later one shifts the text.
+func TestApplyGrammarFixMultiEdit(t *testing.T) {
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetContent([]byte("aa bb"))
+	a.applyGrammarFix([]grammar.TextEdit{
+		{StartLine: 0, StartCol: 0, EndLine: 0, EndCol: 2, NewText: "AA"},
+		{StartLine: 0, StartCol: 3, EndLine: 0, EndCol: 5, NewText: "BBB"},
+	})
+	if got := string(a.editor.Bytes()); got != "AA BBB" {
+		t.Errorf("got %q, want %q", got, "AA BBB")
+	}
+}
+
+// TestGrammarCodeAt maps a span back to its rule code from the retained batch.
+func TestGrammarCodeAt(t *testing.T) {
+	a := newApp()
+	a.lastGrammarDiags = []grammar.Diag{{Line: 0, StartCol: 8, EndCol: 11, Code: "RepeatedWords"}}
+	if got := a.grammarCodeAt(0, 8, 11); got != "RepeatedWords" {
+		t.Errorf("grammarCodeAt = %q, want RepeatedWords", got)
+	}
+	if got := a.grammarCodeAt(0, 0, 3); got != "" {
+		t.Errorf("unmatched span code = %q, want empty", got)
+	}
+}
+
+// TestGrammarPopupApplyFix drives the popup apply path directly (no client needed):
+// a grammarFix option, when applied, edits the buffer and returns to editor mode.
+func TestGrammarPopupApplyFix(t *testing.T) {
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetContent([]byte("This is a a test"))
+	a.mode = ModeSpell
+	a.spell = spellPopup{
+		word: "a a", code: "RepeatedWords", row: 0, start: 8, end: 11,
+		options: []spellOption{
+			{label: `Replace with: "a"`, kind: grammarFix, edits: []grammar.TextEdit{{StartLine: 0, StartCol: 8, EndLine: 0, EndCol: 11, NewText: "a"}}},
+			{label: "Ignore", kind: grammarIgnore},
+		},
+	}
+	a.applySpell(0)
+	if got := string(a.editor.Bytes()); got != "This is a test" {
+		t.Errorf("got %q, want %q", got, "This is a test")
+	}
+	if a.mode != ModeEditor {
+		t.Error("popup should close after applying a fix")
+	}
+}
+
+// TestGrammarPopupLive opens the popup on a real harper-flagged span and applies the
+// first fix. Gated on harper + not -short.
+func TestGrammarPopupLive(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping live harper test under -short")
+	}
+	if !grammar.Available() {
+		t.Skip("harper-ls not on PATH")
+	}
+	a := newApp()
+	a.setSize(100, 24)
+	a.editor.SetContent([]byte("This is a a test.\n"))
+	listen := a.Init()
+	defer a.Close()
+	msg := listen()
+	diag, ok := msg.(grammarDiagMsg)
+	if !ok {
+		t.Fatalf("listener returned %T", msg)
+	}
+	a.applyGrammarDiags(diag.diags)
+
+	d := diag.diags[0]
+	if !a.openGrammarPopupAt(d.Line, d.StartCol) {
+		t.Fatal("openGrammarPopupAt did not open on a flagged span")
+	}
+	if a.mode != ModeSpell {
+		t.Fatal("expected ModeSpell after opening the grammar popup")
+	}
+	fixes := 0
+	for _, o := range a.spell.options {
+		if o.kind == grammarFix {
+			fixes++
+		}
+	}
+	if fixes == 0 {
+		t.Fatal("grammar popup listed no replacement fixes from harper")
+	}
+	before := string(a.editor.Bytes())
+	a.applySpell(0) // apply the first fix
+	if string(a.editor.Bytes()) == before {
+		t.Error("applying a harper fix did not change the buffer")
+	}
+}
+
 // TestGrammarNilClientNoOps confirms the debounce/flush path is inert without a
 // running harper client, so plain app tests never touch a subprocess.
 func TestGrammarNilClientNoOps(t *testing.T) {
internal/app/spell.go +88 −7
@@ -2,7 +2,10 @@ package app
 
 import (
 	"fmt"
+	"sort"
 	"strings"
+
+	"glint/internal/grammar"
 
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
@@ -17,19 +20,24 @@ 	spellAdd                       // add the word to the personal dictionary
 	spellIgnore                    // ignore the word for this session
 	spellToggle                    // turn spellcheck on/off for the session
 	grammarToggle                  // turn Harper grammar checking on/off (TASK-043)
+	grammarFix                     // apply a Harper replacement (edits) (TASK-044)
+	grammarIgnore                  // ignore this grammar lint for the session (TASK-044)
 )
 
 // spellOption is one selectable row in the misspelled-word popup.
 type spellOption struct {
 	label string
 	kind  spellKind
-	value string // replacement word for spellSuggest
+	value string             // replacement word for spellSuggest
+	edits []grammar.TextEdit // buffer edits for grammarFix (Harper's replacement)
 }
 
-// spellPopup is the state of the active misspelled-word popup: the flagged word,
-// its location, the choice list, and the cursor within it.
+// spellPopup is the state of the active proofing popup: the flagged word (or grammar
+// span text), its location, the grammar rule code (for grammarIgnore), the choice
+// list, and the cursor within it.
 type spellPopup struct {
 	word            string
+	code            string // Harper rule code, set for grammar popups
 	row, start, end int
 	options         []spellOption
 	sel             int
@@ -40,7 +48,8 @@ // 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
+		// Spelling wins on overlap; with no misspelling here, try a grammar span.
+		return a.openGrammarPopupAt(row, col)
 	}
 	opts := make([]spellOption, 0, 7)
 	for _, s := range a.editor.Suggest(word, 5) {
@@ -58,6 +67,53 @@ 	a.status = ""
 	return true
 }
 
+// openGrammarPopupAt opens the proofing popup on a grammar span at (row, col): it
+// asks harper for the fixes on that span and lists them as replacement rows, followed
+// by Ignore and the grammar toggle. Returns false when grammar is off or no grammar
+// span sits there. Fixes may be empty (harper offered none, or the request timed out)
+// — the popup still opens so the span can be ignored.
+func (a *App) openGrammarPopupAt(row, col int) bool {
+	if a.grammar == nil {
+		return false
+	}
+	start, end, ok := a.editor.GrammarSpanAt(row, col)
+	if !ok {
+		return false
+	}
+	text := a.editor.RuneRangeText(row, start, end)
+	actions, _ := a.grammar.CodeActions(a.grammarPath, row, start, end)
+	opts := make([]spellOption, 0, len(actions)+2)
+	for _, ac := range actions {
+		opts = append(opts, spellOption{label: ac.Title, kind: grammarFix, edits: ac.Edits})
+	}
+	opts = append(opts,
+		spellOption{label: "Ignore", kind: grammarIgnore},
+		spellOption{label: "Toggle grammar", kind: grammarToggle},
+	)
+	a.spell = spellPopup{
+		word:    text,
+		code:    a.grammarCodeAt(row, start, end),
+		row:     row,
+		start:   start,
+		end:     end,
+		options: opts,
+	}
+	a.mode = ModeSpell
+	a.status = ""
+	return true
+}
+
+// grammarCodeAt returns the Harper rule code of the retained diagnostic matching the
+// span (row, [start,end)), or "" if none — used to key the session ignore.
+func (a *App) grammarCodeAt(row, start, end int) string {
+	for _, d := range a.lastGrammarDiags {
+		if d.Line == row && d.StartCol == start && d.EndCol == end {
+			return d.Code
+		}
+	}
+	return ""
+}
+
 // openSpellPopup is the Alt+; handler: it opens the full popup on a flagged word
 // at the cursor, or, with no misspelling there, a minimal popup offering just the
 // session toggle (so spellcheck can always be turned back on even when no
@@ -104,7 +160,10 @@ 				}
 			case r == 'a' || r == 'A':
 				return a.applySpell(a.kindIndex(spellAdd))
 			case r == 'i' || r == 'I':
-				return a.applySpell(a.kindIndex(spellIgnore))
+				if idx := a.kindIndex(spellIgnore); idx >= 0 {
+					return a.applySpell(idx)
+				}
+				return a.applySpell(a.kindIndex(grammarIgnore))
 			case r == 't' || r == 'T':
 				return a.applySpell(a.kindIndex(spellToggle))
 			case r == 'g' || r == 'G':
@@ -151,6 +210,12 @@ 			a.status = "Spellcheck on"
 		} else {
 			a.status = "Spellcheck off"
 		}
+	case grammarFix:
+		a.applyGrammarFix(opt.edits)
+		a.status = "Applied: " + opt.label
+	case grammarIgnore:
+		a.ignoreGrammar(a.spell.code, a.spell.word)
+		a.status = "Ignored grammar hint"
 	case grammarToggle:
 		cmd := a.toggleGrammar()
 		a.mode = ModeEditor
@@ -160,6 +225,22 @@ 	a.mode = ModeEditor
 	return a, nil
 }
 
+// applyGrammarFix applies Harper's replacement edits to the buffer. Edits are applied
+// last-first (by document position) so earlier edits don't shift later ranges.
+func (a *App) applyGrammarFix(edits []grammar.TextEdit) {
+	sorted := make([]grammar.TextEdit, len(edits))
+	copy(sorted, edits)
+	sort.Slice(sorted, func(i, j int) bool {
+		if sorted[i].StartLine != sorted[j].StartLine {
+			return sorted[i].StartLine > sorted[j].StartLine
+		}
+		return sorted[i].StartCol > sorted[j].StartCol
+	})
+	for _, e := range sorted {
+		a.editor.ReplaceRuneRange(e.StartLine, e.StartCol, e.EndLine, e.EndCol, e.NewText)
+	}
+}
+
 // 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.
@@ -179,11 +260,11 @@ 	}
 	for i, o := range a.spell.options {
 		var label string
 		switch o.kind {
-		case spellSuggest:
+		case spellSuggest, grammarFix:
 			label = fmt.Sprintf("%d %s", i+1, o.label)
 		case spellAdd:
 			label = "a Add"
-		case spellIgnore:
+		case spellIgnore, grammarIgnore:
 			label = "i Ignore"
 		case spellToggle:
 			label = "t Spell"
internal/editor/grammar.go +83 −1
@@ -1,6 +1,10 @@
 package editor
 
-import "github.com/charmbracelet/lipgloss"
+import (
+	"strings"
+
+	"github.com/charmbracelet/lipgloss"
+)
 
 // SetGrammar sets whether grammar checking is enabled for the session. Diagnostics
 // keep arriving from the app regardless; this only gates their rendering.
@@ -25,6 +29,84 @@ // grammarActive reports whether grammar underlines should render: enabled and the
 // buffer is prose (grammar, like spellcheck, skips code files).
 func (e *Editor) grammarActive() bool {
 	return e.grammarOn && e.codeFile == "" && len(e.grammarDiags) > 0
+}
+
+// GrammarSpanAt returns the rune range [start,end) of the grammar underline covering
+// rune column col on row (inclusive of the column just past the span, so a cursor
+// resting at its end still resolves it). ok is false when grammar is inactive or no
+// span sits there.
+func (e *Editor) GrammarSpanAt(row, col int) (start, end int, ok bool) {
+	if !e.grammarActive() {
+		return 0, 0, false
+	}
+	for _, r := range e.grammarDiags[row] {
+		if col >= r[0] && col <= r[1] {
+			return r[0], r[1], true
+		}
+	}
+	return 0, 0, false
+}
+
+// RuneRangeText returns the substring of row over rune range [start,end), clamped to
+// the line, or "" when the row is out of range.
+func (e *Editor) RuneRangeText(row, start, end int) string {
+	if row < 0 || row >= len(e.Lines) {
+		return ""
+	}
+	r := []rune(e.Lines[row])
+	if start < 0 {
+		start = 0
+	}
+	if end > len(r) {
+		end = len(r)
+	}
+	if start >= end {
+		return ""
+	}
+	return string(r[start:end])
+}
+
+// ReplaceRuneRange replaces the document span from (startRow,startCol) to
+// (endRow,endCol) — rune columns, end exclusive — with s, which may itself contain
+// newlines. It parks the cursor after the inserted text, marks the buffer dirty, and
+// records an undo checkpoint. Out-of-range coordinates are a no-op. This applies a
+// single Harper fix edit (usually single-line, but multi-line ranges are honored).
+func (e *Editor) ReplaceRuneRange(startRow, startCol, endRow, endCol int, s string) {
+	if startRow < 0 || endRow >= len(e.Lines) || startRow > endRow {
+		return
+	}
+	first := []rune(e.Lines[startRow])
+	last := []rune(e.Lines[endRow])
+	if startCol < 0 || startCol > len(first) || endCol < 0 || endCol > len(last) {
+		return
+	}
+	if startRow == endRow && startCol > endCol {
+		return
+	}
+	e.PushUndo()
+	merged := string(first[:startCol]) + s + string(last[endCol:])
+	parts := strings.Split(merged, "\n")
+
+	rebuilt := make([]string, 0, len(e.Lines)-(endRow-startRow)+len(parts)-1)
+	rebuilt = append(rebuilt, e.Lines[:startRow]...)
+	rebuilt = append(rebuilt, parts...)
+	rebuilt = append(rebuilt, e.Lines[endRow+1:]...)
+	e.Lines = rebuilt
+
+	// Park the cursor at the end of the inserted text (not the merged line): count
+	// s's own newlines, so a trailing suffix after the edit stays to the cursor's right.
+	sLines := strings.Split(s, "\n")
+	curRow := startRow + len(sLines) - 1
+	curCol := len([]rune(sLines[len(sLines)-1]))
+	if len(sLines) == 1 {
+		curCol = startCol + curCol
+	}
+	e.Cursor = Position{Row: curRow, Col: curCol}
+	e.anchor = nil
+	e.Dirty = true
+	e.invalidate()
+	e.setGoal()
+	e.followCursor()
 }
 
 // grammarPass overlays a green undercurl on each grammar range, layered after
internal/editor/grammar_test.go +68 −0
@@ -41,6 +41,74 @@ 		t.Errorf("expected \"a a\" underlined green (%s); got wavy spans %v", green, spans)
 	}
 }
 
+func TestGrammarSpanAt(t *testing.T) {
+	e := grammarEditor(t, "This is a a test")
+	e.SetGrammarDiags(map[int][][2]int{0: {{8, 11}}})
+	// Inside and at both boundaries (end inclusive) resolves the span.
+	for _, col := range []int{8, 9, 11} {
+		s, en, ok := e.GrammarSpanAt(0, col)
+		if !ok || s != 8 || en != 11 {
+			t.Errorf("GrammarSpanAt(0,%d) = %d,%d,%v; want 8,11,true", col, s, en, ok)
+		}
+	}
+	// Outside the span, and when grammar is off.
+	if _, _, ok := e.GrammarSpanAt(0, 3); ok {
+		t.Error("col 3 should not be in a grammar span")
+	}
+	e.SetGrammar(false)
+	if _, _, ok := e.GrammarSpanAt(0, 9); ok {
+		t.Error("grammar off should report no span")
+	}
+}
+
+func TestReplaceRuneRangeSingleLine(t *testing.T) {
+	e := grammarEditor(t, "This is a a test")
+	e.ReplaceRuneRange(0, 8, 0, 11, "a") // collapse "a a" -> "a"
+	if got := string(e.Bytes()); got != "This is a test" {
+		t.Errorf("got %q, want %q", got, "This is a test")
+	}
+	if e.Cursor.Row != 0 || e.Cursor.Col != 9 {
+		t.Errorf("cursor = %d,%d; want 0,9 (after inserted text)", e.Cursor.Row, e.Cursor.Col)
+	}
+	if !e.Dirty {
+		t.Error("replacement should mark the buffer dirty")
+	}
+}
+
+func TestReplaceRuneRangeMultiLineInsert(t *testing.T) {
+	e := grammarEditor(t, "one two three")
+	// Replace "two" (runes [4,7)) with a two-line insertion.
+	e.ReplaceRuneRange(0, 4, 0, 7, "X\nY")
+	if got := string(e.Bytes()); got != "one X\nY three" {
+		t.Errorf("got %q, want %q", got, "one X\nY three")
+	}
+	if e.Cursor.Row != 1 || e.Cursor.Col != 1 {
+		t.Errorf("cursor = %d,%d; want 1,1", e.Cursor.Row, e.Cursor.Col)
+	}
+}
+
+func TestReplaceRuneRangeUndo(t *testing.T) {
+	e := grammarEditor(t, "This is a a test")
+	e.ReplaceRuneRange(0, 8, 0, 11, "a")
+	e.Undo()
+	if got := string(e.Bytes()); got != "This is a a test" {
+		t.Errorf("undo left %q, want original", got)
+	}
+}
+
+func TestRuneRangeText(t *testing.T) {
+	e := grammarEditor(t, "This is a a test")
+	if got := e.RuneRangeText(0, 8, 11); got != "a a" {
+		t.Errorf("RuneRangeText = %q, want %q", got, "a a")
+	}
+	if got := e.RuneRangeText(0, 8, 999); got != "a a test" {
+		t.Errorf("clamped RuneRangeText = %q", got)
+	}
+	if got := e.RuneRangeText(5, 0, 3); got != "" {
+		t.Errorf("out-of-range row = %q, want empty", got)
+	}
+}
+
 func TestGrammarInactiveWhenOff(t *testing.T) {
 	e := grammarEditor(t, "This is a a test")
 	e.SetGrammarDiags(map[int][][2]int{0: {{8, 11}}})
internal/grammar/actions.go +81 −0
@@ -0,0 +1,81 @@
+package grammar
+
+import "encoding/json"
+
+// Action is one grammar fix harper offers for a diagnostic: a human-readable title
+// (e.g. `Replace with: "an"`) and the buffer edits that apply it, already mapped to
+// glint's rune coordinates. Only replacement (quickfix) actions are surfaced; harper's
+// ignore/telemetry commands are not — ignore is handled glint-side.
+type Action struct {
+	Title string
+	Edits []TextEdit
+}
+
+// TextEdit replaces the rune range [Start..End) of the document with NewText. Ranges
+// are inclusive of the start position and exclusive of the end, in rune columns per
+// line. Grammar fixes are single-line in practice, but a multi-line range (Start and
+// End on different lines) is represented faithfully so callers can apply it correctly.
+type TextEdit struct {
+	StartLine, StartCol int
+	EndLine, EndCol     int
+	NewText             string
+}
+
+// rawAction mirrors the subset of an LSP CodeAction glint needs: the title and, for
+// quickfix actions, a WorkspaceEdit's per-URI TextEdits. Bare Command actions decode
+// with an empty Edit and are dropped.
+type rawAction struct {
+	Title string `json:"title"`
+	Edit  struct {
+		Changes map[string][]struct {
+			Range struct {
+				Start struct{ Line, Character int } `json:"start"`
+				End   struct{ Line, Character int } `json:"end"`
+			} `json:"range"`
+			NewText string `json:"newText"`
+		} `json:"changes"`
+	} `json:"edit"`
+}
+
+// parseActions decodes a textDocument/codeAction result into replacement Actions,
+// mapping each edit's UTF-16 ranges to rune columns against lines (the authoritative
+// document text). Actions without edits (ignore/telemetry commands) are skipped, and
+// duplicate titles — harper returns one per overlapping lint — are collapsed to the
+// first, preserving order.
+func parseActions(result json.RawMessage, lines []string) ([]Action, error) {
+	var raws []rawAction
+	if err := json.Unmarshal(result, &raws); err != nil {
+		return nil, err
+	}
+	lineAt := func(i int) string {
+		if i >= 0 && i < len(lines) {
+			return lines[i]
+		}
+		return ""
+	}
+	var out []Action
+	seen := map[string]bool{}
+	for _, ra := range raws {
+		if seen[ra.Title] {
+			continue
+		}
+		var edits []TextEdit
+		for _, tes := range ra.Edit.Changes {
+			for _, te := range tes {
+				edits = append(edits, TextEdit{
+					StartLine: te.Range.Start.Line,
+					StartCol:  utf16ToRuneCol(lineAt(te.Range.Start.Line), te.Range.Start.Character),
+					EndLine:   te.Range.End.Line,
+					EndCol:    utf16ToRuneCol(lineAt(te.Range.End.Line), te.Range.End.Character),
+					NewText:   te.NewText,
+				})
+			}
+		}
+		if len(edits) == 0 {
+			continue // ignore / telemetry command, not a replacement
+		}
+		seen[ra.Title] = true
+		out = append(out, Action{Title: ra.Title, Edits: edits})
+	}
+	return out, nil
+}
internal/grammar/client.go +107 −9
@@ -3,16 +3,29 @@
 import (
 	"bufio"
 	"encoding/json"
+	"errors"
 	"io"
 	"os"
 	"os/exec"
 	"strings"
 	"sync"
+	"time"
 )
 
 // binary is the harper-ls executable name; a var so tests can point it elsewhere.
 var binary = "harper-ls"
 
+// codeActionTimeout bounds how long CodeActions waits for harper's reply. Harper is
+// a local subprocess, so a fix list normally returns in a few milliseconds; the cap
+// keeps a hung server from freezing the popup.
+const codeActionTimeout = 400 * time.Millisecond
+
+var (
+	errTimeout = errors.New("grammar: request timed out")
+	errClosed  = errors.New("grammar: client closed")
+	errNoDoc   = errors.New("grammar: document not open")
+)
+
 // Available reports whether harper-ls is on PATH. When false, callers should skip
 // grammar entirely — no subprocess, no cost.
 func Available() bool {
@@ -28,7 +41,10 @@ 	cmd   *exec.Cmd
 	stdin io.WriteCloser
 
 	writeMu sync.Mutex
+
+	pendMu  sync.Mutex
 	nextID  int
+	pending map[int]chan json.RawMessage // id -> reply channel for in-flight requests
 
 	docMu sync.Mutex
 	docs  map[string]docState // uri -> latest text/version
@@ -61,12 +77,13 @@ 	if err := cmd.Start(); err != nil {
 		return nil, err
 	}
 	c := &Client{
-		cmd:   cmd,
-		stdin: stdin,
-		docs:  map[string]docState{},
-		diags: make(chan []Diag, 1),
-		ready: make(chan struct{}),
-		done:  make(chan struct{}),
+		cmd:     cmd,
+		stdin:   stdin,
+		pending: map[int]chan json.RawMessage{},
+		docs:    map[string]docState{},
+		diags:   make(chan []Diag, 1),
+		ready:   make(chan struct{}),
+		done:    make(chan struct{}),
 	}
 	go c.readLoop(bufio.NewReader(stdout))
 	c.initialize()
@@ -168,14 +185,71 @@ 	c.notify("initialized", map[string]any{})
 }
 
 func (c *Client) request(method string, params any) int {
-	c.writeMu.Lock()
+	c.pendMu.Lock()
 	c.nextID++
 	id := c.nextID
-	c.writeMu.Unlock()
+	c.pendMu.Unlock()
 	c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
 	return id
 }
 
+// requestWait sends a request and blocks for its response, up to timeout. It
+// registers a reply channel keyed by the request id before sending, so the read
+// loop can route the matching response back. Returns errTimeout on timeout and
+// errClosed if the client shuts down first.
+func (c *Client) requestWait(method string, params any, timeout time.Duration) (json.RawMessage, error) {
+	c.pendMu.Lock()
+	c.nextID++
+	id := c.nextID
+	ch := make(chan json.RawMessage, 1)
+	c.pending[id] = ch
+	c.pendMu.Unlock()
+	defer func() {
+		c.pendMu.Lock()
+		delete(c.pending, id)
+		c.pendMu.Unlock()
+	}()
+	c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
+	select {
+	case res := <-ch:
+		return res, nil
+	case <-time.After(timeout):
+		return nil, errTimeout
+	case <-c.done:
+		return nil, errClosed
+	}
+}
+
+// CodeActions asks harper for the fixes it offers on the grammar span covering rune
+// range [startRune,endRune) of the given line, returning only replacement actions
+// with their edits mapped to rune coordinates. The rune range is phrased as an LSP
+// (UTF-16) range using the client's tracked document text. A missing document, a
+// timeout, or a shutdown returns an error and no actions.
+func (c *Client) CodeActions(path string, line, startRune, endRune int) ([]Action, error) {
+	uri := pathToURI(path)
+	lines, ok := c.docLines(uri)
+	if !ok {
+		return nil, errNoDoc
+	}
+	ltext := ""
+	if line >= 0 && line < len(lines) {
+		ltext = lines[line]
+	}
+	rng := map[string]any{
+		"start": map[string]any{"line": line, "character": runeColToUTF16(ltext, startRune)},
+		"end":   map[string]any{"line": line, "character": runeColToUTF16(ltext, endRune)},
+	}
+	res, err := c.requestWait("textDocument/codeAction", map[string]any{
+		"textDocument": map[string]any{"uri": uri},
+		"range":        rng,
+		"context":      map[string]any{"diagnostics": []any{}},
+	}, codeActionTimeout)
+	if err != nil {
+		return nil, err
+	}
+	return parseActions(res, lines)
+}
+
 func (c *Client) notify(method string, params any) {
 	c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
 }
@@ -208,12 +282,14 @@ 		var m struct {
 			ID     json.RawMessage `json:"id"`
 			Method string          `json:"method"`
 			Params json.RawMessage `json:"params"`
+			Result json.RawMessage `json:"result"`
 		}
 		if json.Unmarshal(body, &m) != nil {
 			continue
 		}
 		switch {
 		case m.Method == "" && len(m.ID) > 0: // response to one of our requests
+			c.deliverResponse(m.ID, m.Result)
 			if string(m.ID) == itoa(initID) {
 				c.signalReady()
 			}
@@ -225,6 +301,25 @@ 		}
 	}
 }
 
+// deliverResponse routes a response body to the goroutine waiting on its request id,
+// if one registered via requestWait. Fire-and-forget requests (initialize) have no
+// pending channel and are ignored here.
+func (c *Client) deliverResponse(rawID json.RawMessage, result json.RawMessage) {
+	var id int
+	if json.Unmarshal(rawID, &id) != nil {
+		return
+	}
+	c.pendMu.Lock()
+	ch, ok := c.pending[id]
+	c.pendMu.Unlock()
+	if ok {
+		select {
+		case ch <- result:
+		default:
+		}
+	}
+}
+
 func (c *Client) signalReady() {
 	select {
 	case <-c.ready:
@@ -242,9 +337,12 @@ 		var p struct {
 			Items []json.RawMessage `json:"items"`
 		}
 		_ = json.Unmarshal(params, &p)
+		// Each item must be wrapped under the "harper-ls" key; a bare {} makes harper
+		// log `Settings must contain a "harper-ls" key.` and skip our config. An empty
+		// inner object still means "use harper's defaults".
 		result := make([]any, len(p.Items))
 		for i := range result {
-			result[i] = map[string]any{}
+			result[i] = map[string]any{"harper-ls": map[string]any{}}
 		}
 		c.respond(id, result)
 		return
internal/grammar/grammar_test.go +113 −0
@@ -120,6 +120,76 @@ 		t.Errorf("nil code = %q, want empty", got)
 	}
 }
 
+func TestRuneColToUTF16(t *testing.T) {
+	cases := []struct {
+		line string
+		col  int
+		want int
+	}{
+		{"hello", 0, 0},
+		{"hello", 3, 3},
+		{"hello", 99, 5}, // clamp past end
+		{"a😀b", 1, 1},    // before the emoji
+		{"a😀b", 2, 3},    // after the emoji: 1 + 2 UTF-16 units
+		{"a😀b", 3, 4},    // whole string
+	}
+	for _, c := range cases {
+		if got := runeColToUTF16(c.line, c.col); got != c.want {
+			t.Errorf("runeColToUTF16(%q, %d) = %d, want %d", c.line, c.col, got, c.want)
+		}
+	}
+	// Round-trips with utf16ToRuneCol at rune boundaries.
+	for _, line := range []string{"plain", "a😀b😀c"} {
+		for col := 0; col <= len([]rune(line)); col++ {
+			if got := utf16ToRuneCol(line, runeColToUTF16(line, col)); got != col {
+				t.Errorf("round-trip %q col %d -> %d", line, col, got)
+			}
+		}
+	}
+}
+
+func TestParseActions(t *testing.T) {
+	lines := []string{"This is a a test."}
+	// A quickfix replacement plus a bare ignore command and a duplicate title.
+	result := []byte(`[
+		{"title":"Replace with: \"a\"","kind":"quickfix","edit":{"changes":{"file:///x.md":[
+			{"range":{"start":{"line":0,"character":8},"end":{"line":0,"character":11}},"newText":"a"}]}}},
+		{"title":"Ignore Harper error.","command":"HarperIgnoreLint","arguments":["file:///x.md",{}]},
+		{"title":"Replace with: \"a\"","kind":"quickfix","edit":{"changes":{"file:///x.md":[
+			{"range":{"start":{"line":0,"character":8},"end":{"line":0,"character":11}},"newText":"a"}]}}}
+	]`)
+	got, err := parseActions(result, lines)
+	if err != nil {
+		t.Fatalf("parseActions: %v", err)
+	}
+	if len(got) != 1 {
+		t.Fatalf("got %d actions, want 1 (ignore excluded, duplicate collapsed): %+v", len(got), got)
+	}
+	a := got[0]
+	if a.Title != `Replace with: "a"` {
+		t.Errorf("title = %q", a.Title)
+	}
+	if len(a.Edits) != 1 {
+		t.Fatalf("got %d edits, want 1", len(a.Edits))
+	}
+	e := a.Edits[0]
+	if e.StartLine != 0 || e.StartCol != 8 || e.EndLine != 0 || e.EndCol != 11 || e.NewText != "a" {
+		t.Errorf("edit = %+v, want line0 [8,11) -> \"a\"", e)
+	}
+}
+
+func TestParseActionsEmpty(t *testing.T) {
+	// A response of only ignore/telemetry commands yields no replacement actions.
+	result := []byte(`[{"title":"Ignore Harper error.","command":"HarperIgnoreLint","arguments":[]}]`)
+	got, err := parseActions(result, []string{"x"})
+	if err != nil {
+		t.Fatalf("parseActions: %v", err)
+	}
+	if len(got) != 0 {
+		t.Errorf("got %d actions, want 0", len(got))
+	}
+}
+
 // TestLiveHarper exercises the real harper-ls end to end. Skipped when the binary
 // is absent or under -short, so CI without harper stays green.
 func TestLiveHarper(t *testing.T) {
@@ -156,3 +226,46 @@ 	case <-time.After(10 * time.Second):
 		t.Fatal("timed out waiting for harper diagnostics")
 	}
 }
+
+// TestLiveHarperCodeActions asks the real harper-ls for fixes on a flagged span and
+// expects at least one replacement action. Gated like TestLiveHarper.
+func TestLiveHarperCodeActions(t *testing.T) {
+	if testing.Short() {
+		t.Skip("skipping live harper test under -short")
+	}
+	if !Available() {
+		t.Skip("harper-ls not on PATH")
+	}
+	c, err := Start()
+	if err != nil {
+		t.Fatalf("Start: %v", err)
+	}
+	defer c.Close()
+
+	path := "/tmp/glint-grammar-ca-test.md"
+	c.DidOpen(path, "This is a a test.\n") // "a a" repeated word, runes [8,11)
+
+	var d Diag
+	select {
+	case batch := <-c.Diagnostics():
+		if len(batch) == 0 {
+			t.Fatal("empty diagnostic batch")
+		}
+		d = batch[0]
+	case <-time.After(10 * time.Second):
+		t.Fatal("timed out waiting for diagnostics")
+	}
+
+	actions, err := c.CodeActions(path, d.Line, d.StartCol, d.EndCol)
+	if err != nil {
+		t.Fatalf("CodeActions: %v", err)
+	}
+	if len(actions) == 0 {
+		t.Fatal("harper offered no replacement actions for a flagged span")
+	}
+	for _, a := range actions {
+		if a.Title == "" || len(a.Edits) == 0 {
+			t.Errorf("action missing title or edits: %+v", a)
+		}
+	}
+}
internal/grammar/position.go +23 −0
@@ -35,6 +35,29 @@ 	}
 	return col
 }
 
+// runeColToUTF16 converts a rune column within line to a UTF-16 code-unit offset,
+// the inverse of utf16ToRuneCol. It is used to phrase a glint rune range as an LSP
+// position when requesting code actions. A column past the line's end clamps to the
+// line's total UTF-16 length.
+func runeColToUTF16(line string, col int) int {
+	if col <= 0 {
+		return 0
+	}
+	units, seen := 0, 0
+	for _, r := range line {
+		if seen >= col {
+			return units
+		}
+		if r > 0xFFFF {
+			units += 2
+		} else {
+			units++
+		}
+		seen++
+	}
+	return units
+}
+
 // lspRangeToDiags splits one LSP diagnostic range (start/end line+character in
 // UTF-16 units) into per-line Diags with rune columns, using lines as the
 // authoritative document text. A single-line range yields one Diag; a range that
internal/help/help.go +8 −4
@@ -51,9 +51,11 @@   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+;                  proofing popup on the misspelled word at the cursor
-                        (pick a suggestion 1-9, a add to dictionary, i ignore,
-                        t toggle spellcheck, g toggle grammar); clicking an
+  Alt+;                  proofing popup on the misspelled word OR grammar span at
+                        the cursor (pick a suggestion 1-9, a add to dictionary,
+                        i ignore, t toggle spellcheck, g toggle grammar); on a
+                        grammar span the numbered rows are Harper's fixes and
+                        i ignores the hint for the session; clicking an
                         underlined word opens it
   Ctrl+C / Ctrl+X / Ctrl+V   copy / cut / paste (system clipboard)
   Shift+arrows          select text (Ctrl+Shift+left/right by word)
@@ -79,7 +81,9 @@
   Grammar checking (green curly underline) is optional and powered by Harper:
   install harper-ls (brew install harper) and glint uses it automatically. A
   word that is both misspelled and in a grammar span keeps its red underline.
-  Config key grammar = auto | on | off (auto = on when harper-ls is present).
+  Alt+; on a grammar span lists Harper's fixes (apply one to rewrite the text)
+  and an Ignore that hides the hint for the session. Config key
+  grammar = auto | on | off (auto = on when harper-ls is present).
 
   TK markers: the journalism "TK" placeholder (to come) — TK, tk, or TKTK as a
   whole word — renders as a bold filled badge so unfinished spots pop. Prose