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

feat: grammar checking via Harper (harper-ls, green undercurl) (TASK-043)

85cc4e9de595b4e8f06fe3b4f8ec60e5d76b1aad
humdrum <me@humdrum.me> · 2026-07-14 10:29

parent 01d8b00b

feat: grammar checking via Harper (harper-ls, green undercurl) (TASK-043)

Add optional grammar checking with a green curly underline, distinct from
the red spellcheck undercurl. glint speaks LSP to a harper-ls subprocess
over stdio, so the build stays pure-Go and zero-cgo (harper-core is Rust).

- internal/grammar: minimal LSP client — Content-Length framing, the
  initialize handshake, and the workspace/configuration + registerCapability
  server requests harper gates diagnostics behind. Full-document sync;
  UTF-16 -> rune column mapping. Available() gates the whole feature on
  harper-ls being on PATH, so an absent binary is a clean no-op.
- Rendering reuses the undercurl span infra (TASK-020): new theme.Grammar
  green in all three palettes; a grammarPass layers green undercurl after
  spellPass. Red spelling wins when a word is both misspelled and in a
  grammar span.
- Async plumbing (first tea.Cmd use): a channel listener delivers diagnostic
  batches; edits trigger a 400ms-debounced didChange with a generation guard
  and text-dedupe. harper starts in Init (not New) so unit tests never spawn
  a subprocess; the process is reaped on quit.
- Runtime toggle folded into the Alt+; proofing popup (g); config key
  grammar = auto | on | off (auto = on when harper-ls is present). Help,
  README, and the config wizard updated.

Tests: framing, position mapping, red-wins overlap, inactive/code-file
gating, plus a live end-to-end harper round-trip (skipped without the
binary or under -short).

TASK-044 filed as follow-up: grammar suggestions & ignore via codeAction.

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

23 files changed

README.md +24 −4
@@ -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 | 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, `t` toggle spellcheck, `Esc` close |
+| `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 |
 | `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 |
@@ -134,6 +134,7 @@ 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)
+grammar       = "auto"       # auto | on | off  (Harper; auto = on when harper-ls is installed)
 font_display  = ""           # PDF export heading/cover font stack; "" = Georgia serif
 font_body     = ""           # PDF export body font stack; "" = system-ui sans
 font_mono     = ""           # PDF export code font stack; "" = ui-monospace
@@ -199,11 +200,30 @@
 `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, `i` to ignore it for the
-session, or `t` to toggle spellcheck off and on (`Alt+;` opens a toggle-only
-popup when no word is flagged). The personal dictionary is a plain, hand-editable
-file at
+session, `t` to toggle spellcheck off and on, or `g` to toggle grammar checking
+(`Alt+;` opens a toggle-only popup when no word is flagged). 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.
+
+## Grammar (Harper)
+
+Grammar checking is optional and off unless you opt in. glint speaks LSP to
+[Harper](https://writewithharper.com/)'s `harper-ls` in a subprocess, so the
+build stays pure-Go with no new linked dependency. Install it and glint picks it
+up automatically:
+
+```sh
+brew install harper
+```
+
+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.
 
 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.
- → Grammar-checking-via-Harper-harper-ls-green-undercurl.md +69 −0
@@ -0,0 +1,69 @@
+---
+id: TASK-043
+title: 'Grammar checking via Harper (harper-ls, green undercurl)'
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-07-14 02:45'
+updated_date: '2026-07-14 15:25'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 42000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Add grammar checking with green undercurl underlines, alongside the red-undercurl spellcheck (TASK-020).
+
+APPROACH (decided):
+- Engine: Harper (writewithharper.com). Spawn harper-ls as an LSP subprocess over stdio — keeps glint zero-cgo (harper-core is Rust; no FFI bind).
+- Dependency model: OPTIONAL / auto-detect. Grammar turns on only if harper-ls is found on PATH; silently off otherwise. Power users opt in via `brew install harper`. No Homebrew formula coupling.
+- Rendering: REUSE existing undercurl span infra (Span.Wavy + Span.UnderColor, raw SGR from TASK-020). Add theme.Grammar (green) beside theme.Spell (red) in internal/theme/theme.go + all 3 palettes in themes.go. Grammar diagnostics render as Wavy spans with UnderColor=theme.Grammar.
+
+PLUMBING:
+- Minimal LSP client (one file, internal/grammar/): initialize -> didOpen -> didChange (debounced on edit) -> receive textDocument/publishDiagnostics.
+- Map LSP diagnostic ranges (line/char) to editor rune ranges -> green undercurl spans, layered in a pass like spellPass (internal/editor/spellcheck.go:135). Mind: selection overrides Wavy; cursor cell suppresses undercurl (span.go).
+- Session toggle like SetSpell/ToggleSpell; likely a keybind + config flag. Grammar suggestions could reuse the suggest popup (internal/app/spell.go) later.
+
+OPEN QUESTIONS:
+- Debounce interval / async model in the Bubbletea loop (harper-ls replies asynchronously; need a tea.Cmd + msg for diagnostics).
+- Harper license check before bundling any config.
+- Overlap policy when a word is both misspelled (red) and in a grammar span (green).
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 Grammar underlines render green, distinct from red spellcheck
+- [x] #2 harper-ls run as optional LSP subprocess; feature off cleanly when binary absent
+- [x] #3 Zero-cgo build and Homebrew formula unchanged
+- [x] #4 theme.Grammar added to all themes
+- [x] #5 Debounced diagnostics update on edit without blocking the UI
+- [x] #6 Session toggle to enable/disable grammar checking
+<!-- AC:END -->
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+RESOLVED (probed harper-ls 2.6.0 live):
+- Sync: textDocumentSync.change=1 (Full) -> didChange sends whole buffer, version++.
+- Handshake: initialize -> initialized. MUST answer server->client requests or diagnostics never come: workspace/configuration -> reply [{}] (array len = items), client/registerCapability -> reply null. Ignore other server requests with null.
+- Diagnostics: textDocument/publishDiagnostics, diagnostics[].range{start,end}{line,character(UTF-16)}, message, code, source=Harper, severity 4(Hint). Render ALL as green undercurl.
+- character offsets are UTF-16 code units -> map utf16->rune per line.
+- Use file's real dir as rootUri (missing dir logs a non-fatal backend error).
+- Toggle UX (decided): add 'Toggle grammar' row to the Alt+; spell popup (no new global key). Config: grammar=auto|on|off, default auto=on iff harper-ls on PATH.
+- Overlap: red spelling wins; grammar green only paints spans not already Wavy.
+<!-- SECTION:NOTES:END -->
- → Grammar-suggestions-ignore-via-Harper-codeAction.md +26 −0
@@ -0,0 +1,26 @@
+---
+id: TASK-044
+title: Grammar suggestions & ignore via Harper codeAction
+status: "\U0001F7E6 Backlog"
+assignee: []
+created_date: '2026-07-14 15:48'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 43000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Wire Harper's fixes/ignore into the Alt+; popup. Currently glint only renders green grammar underlines (TASK-043). Harper advertises codeActionProvider + executeCommand commands (HarperIgnoreLint, HarperAddToUserDict, HarperAddToFileDict, HarperAddToWSDict). Add: on a grammar span at cursor/click, send textDocument/codeAction for the diagnostic range -> show returned fixes as replacement rows in the proofing popup (reuse spellOption/applySpell plumbing); Ignore -> executeCommand HarperIgnoreLint; Add to dict -> HarperAddToUserDict. Needs: LSP client request/response correlation (id->reply channel; the current client only fires notifications + answers server requests), codeAction result parsing (WorkspaceEdit / Command), and mapping the grammar Diag back to harper's diagnostic (code+range). Store the raw LSP diagnostics (code, range, data) alongside the rune-mapped Diag so codeAction can reference them.
+<!-- 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
+<!-- AC:END -->
internal/app/app.go +29 −1
@@ -14,6 +14,7 @@
 	"glint/internal/config"
 	"glint/internal/editor"
 	"glint/internal/export"
+	"glint/internal/grammar"
 	"glint/internal/help"
 	"glint/internal/picker"
 	"glint/internal/preview"
@@ -85,6 +86,14 @@ 	quitArmed bool           // true after a dirty Ctrl+Q, awaiting confirm
 	pending   pendingDiscard // armed open-while-dirty confirmation
 
 	mouseDragged bool // a drag motion happened since the last left-press (TASK-027)
+
+	// Harper grammar checking (TASK-043). grammar is nil when disabled or the
+	// binary is absent; everything grammar then no-ops.
+	grammar       *grammar.Client
+	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
 }
 
 // New builds an App with an empty editor.
@@ -174,6 +183,7 @@ 		a.editor.SetCursor(pos) // restore where we left this file
 	}
 	a.mode = ModeEditor
 	a.status = path
+	a.grammarOpen() // hand the freshly loaded buffer to harper
 	return nil
 }
 
@@ -222,7 +232,15 @@ // straight into preview (glint -p), so native terminal text selection works
 // immediately instead of only after the first Ctrl+P round-trip.
 func (a *App) InPreview() bool { return a.mode == ModePreview }
 
-func (a *App) Init() tea.Cmd { return nil }
+// Init starts harper (when enabled and installed), opens the current buffer with
+// it, and returns the diagnostics listener. Starting here — rather than in New —
+// keeps the subprocess out of unit tests, which construct an App but never run
+// the program loop. The listener is a no-op command when grammar is off.
+func (a *App) Init() tea.Cmd {
+	a.initGrammar()
+	a.grammarOpen() // hand harper the buffer main already loaded (client was nil then)
+	return a.grammarListen()
+}
 
 // Update routes messages. Global keys are handled first, then mode-specific.
 func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -234,6 +252,12 @@ 	case tea.KeyMsg:
 		return a.handleKey(msg)
 	case tea.MouseMsg:
 		return a.handleMouse(msg)
+	case grammarDiagMsg:
+		a.applyGrammarDiags(msg.diags)
+		return a, a.grammarListen() // keep listening for the next batch
+	case grammarDebounceMsg:
+		a.grammarFlush(msg.gen)
+		return a, nil
 	}
 	return a, nil
 }
@@ -335,6 +359,7 @@ 			a.quitArmed = true
 			a.status = "Unsaved changes — Ctrl+Q again to quit"
 			return a, nil
 		}
+		a.closeGrammar()
 		return a, tea.Quit
 	case tea.KeyCtrlS:
 		return a.save()
@@ -401,6 +426,7 @@
 	switch a.mode {
 	case ModeEditor:
 		a.editor.HandleKey(msg)
+		return a, a.grammarChanged() // debounced re-lint after the edit settles
 	case ModeSaveAs:
 		if msg.Type == tea.KeyEnter {
 			return a.saveAs()
@@ -564,6 +590,7 @@ 	a.path = p
 	a.editor.Dirty = false
 	a.mode = ModeEditor
 	a.status = "Saved " + p
+	a.grammarOpen() // the buffer got a real name/URI; reopen it with harper
 	return a, nil
 }
 
@@ -677,6 +704,7 @@ 	a.editor.SetContent(nil)
 	a.path = ""
 	a.mode = ModeEditor
 	a.status = "New note"
+	a.grammarOpen() // new blank buffer -> reopen with harper under the untitled URI
 }
 
 // openNoteAt creates the note at p (if absent) and opens it.
internal/app/grammar.go +151 −0
@@ -0,0 +1,151 @@
+package app
+
+import (
+	"strings"
+	"time"
+
+	"glint/internal/grammar"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+// grammarDebounce is how long editing settles before glint sends the buffer to
+// harper. Long enough to coalesce a burst of keystrokes into one didChange,
+// short enough that underlines feel live.
+const grammarDebounce = 400 * time.Millisecond
+
+// grammarDiagMsg carries a fresh batch of diagnostics from the harper listener.
+type grammarDiagMsg struct{ diags []grammar.Diag }
+
+// grammarDebounceMsg fires after an edit settles; gen guards against stale ticks.
+type grammarDebounceMsg struct{ gen int }
+
+// initGrammar starts harper-ls when grammar is enabled (config not "off") and the
+// binary is present, then flags the editor to render grammar underlines. A
+// missing binary or a start failure leaves grammar silently off — glint behaves
+// exactly as before.
+func (a *App) initGrammar() {
+	if strings.EqualFold(a.cfg.Grammar, "off") || !grammar.Available() {
+		return
+	}
+	c, err := grammar.Start()
+	if err != nil {
+		return
+	}
+	a.grammar = c
+	a.editor.SetGrammar(true)
+}
+
+// grammarListen returns a command that blocks for the next diagnostic batch. It
+// is re-issued after each batch so diagnostics keep flowing; nil when grammar is
+// off so Init/Update can compose it unconditionally.
+func (a *App) grammarListen() tea.Cmd {
+	if a.grammar == nil {
+		return nil
+	}
+	ch := a.grammar.Diagnostics()
+	return func() tea.Msg {
+		batch, ok := <-ch
+		if !ok {
+			return nil
+		}
+		return grammarDiagMsg{diags: batch}
+	}
+}
+
+// applyGrammarDiags buckets a diagnostic batch by logical line and hands it to
+// the editor as green-undercurl ranges.
+func (a *App) applyGrammarDiags(diags []grammar.Diag) {
+	byLine := map[int][][2]int{}
+	for _, d := range diags {
+		byLine[d.Line] = append(byLine[d.Line], [2]int{d.StartCol, d.EndCol})
+	}
+	a.editor.SetGrammarDiags(byLine)
+}
+
+// grammarOpen (re)registers the current buffer with harper after the open
+// document changes (a load, a new blank buffer, or a save-as that renames it),
+// closing the previous document first. No-op without a client.
+func (a *App) grammarOpen() {
+	if a.grammar == nil {
+		return
+	}
+	if a.grammarOpened {
+		a.grammar.DidClose(a.grammarPath)
+	}
+	a.grammarPath = a.path
+	a.grammarOpened = true
+	text := string(a.editor.Bytes())
+	a.grammarText = text
+	a.grammar.DidOpen(a.path, text)
+	a.editor.SetGrammarDiags(nil) // drop the previous doc's underlines until harper replies
+}
+
+// grammarChanged schedules a debounced didChange after an editor keystroke and
+// returns the tick command (nil without a client). Only the latest tick's
+// generation survives grammarFlush, so a burst collapses to one sync.
+func (a *App) grammarChanged() tea.Cmd {
+	if a.grammar == nil {
+		return nil
+	}
+	a.grammarGen++
+	gen := a.grammarGen
+	return tea.Tick(grammarDebounce, func(time.Time) tea.Msg {
+		return grammarDebounceMsg{gen: gen}
+	})
+}
+
+// grammarFlush sends the buffer to harper when the debounce tick is the latest
+// one and the text actually changed since the last sync.
+func (a *App) grammarFlush(gen int) {
+	if a.grammar == nil || gen != a.grammarGen {
+		return
+	}
+	text := string(a.editor.Bytes())
+	if text == a.grammarText {
+		return
+	}
+	a.grammarText = text
+	a.grammar.DidChange(a.grammarPath, text)
+}
+
+// toggleGrammar flips grammar checking from the spell popup. With no client it
+// lazily starts harper (so grammar=off in config can still be enabled at
+// runtime), reporting when the binary is missing. It returns the listener
+// command to start after a lazy start, else nil.
+func (a *App) toggleGrammar() tea.Cmd {
+	if a.grammar == nil {
+		if !grammar.Available() {
+			a.status = "Grammar unavailable — brew install harper"
+			return nil
+		}
+		c, err := grammar.Start()
+		if err != nil {
+			a.status = "Grammar start failed: " + err.Error()
+			return nil
+		}
+		a.grammar = c
+		a.editor.SetGrammar(true)
+		a.grammarOpen()
+		a.status = "Grammar on"
+		return a.grammarListen()
+	}
+	if on := a.editor.ToggleGrammar(); on {
+		a.status = "Grammar on"
+	} else {
+		a.status = "Grammar off"
+	}
+	return nil
+}
+
+// closeGrammar shuts the harper subprocess down at exit.
+func (a *App) closeGrammar() {
+	if a.grammar != nil {
+		_ = a.grammar.Close()
+		a.grammar = nil
+	}
+}
+
+// Close releases external resources (the harper subprocess). Safe to call more
+// than once; run() invokes it after the program loop returns.
+func (a *App) Close() { a.closeGrammar() }
internal/app/grammar_test.go +75 −0
@@ -0,0 +1,75 @@
+package app
+
+import (
+	"strings"
+	"testing"
+
+	"glint/internal/grammar"
+	"glint/internal/theme"
+)
+
+// TestApplyGrammarDiagsRendersUnderline checks the app buckets a diagnostic batch
+// by line and the editor renders it as a green undercurl. FlexokiDark's Grammar
+// green is #879A39 -> the underline-color SGR carries its RGB (135,154,57).
+func TestApplyGrammarDiagsRendersUnderline(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)
+
+	a.applyGrammarDiags([]grammar.Diag{{Line: 0, StartCol: 8, EndCol: 11, Message: "repeat"}})
+
+	const greenSGR = "58:2::135:154:57" // undercurl color for #879A39
+	if !strings.Contains(a.editor.View(), greenSGR) {
+		t.Error("expected a green grammar undercurl (SGR " + greenSGR + ") in the rendered view")
+	}
+}
+
+// TestGrammarEndToEnd drives the whole pipeline against the real harper: Init
+// starts the subprocess and opens the buffer, the listener command blocks for the
+// first diagnostic batch, and applying it renders a green undercurl. Skipped
+// without harper on PATH or under -short.
+func TestGrammarEndToEnd(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.SetTheme(theme.FlexokiDark())
+	a.editor.SetContent([]byte("This is a a test.\n"))
+
+	listen := a.Init() // starts harper, opens the buffer, returns the listener cmd
+	defer a.Close()
+	if a.grammar == nil {
+		t.Fatal("Init did not start a harper client")
+	}
+	msg := listen() // blocks until harper publishes diagnostics
+	diag, ok := msg.(grammarDiagMsg)
+	if !ok {
+		t.Fatalf("listener returned %T, want grammarDiagMsg", msg)
+	}
+	a.applyGrammarDiags(diag.diags)
+
+	const greenSGR = "58:2::135:154:57" // #879A39 undercurl
+	if !strings.Contains(a.editor.View(), greenSGR) {
+		t.Error("no green grammar undercurl after live harper round-trip")
+	}
+}
+
+// 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) {
+	a := newApp()
+	if a.grammar != nil {
+		t.Fatal("newApp should not start a harper client")
+	}
+	if cmd := a.grammarChanged(); cmd != nil {
+		t.Error("grammarChanged should return nil without a client")
+	}
+	a.grammarFlush(a.grammarGen) // must not panic
+	a.grammarOpen()              // must not panic
+}
internal/app/spell.go +19 −6
@@ -12,10 +12,11 @@ // 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
-	spellToggle                   // turn spellcheck on/off for the session
+	spellSuggest  spellKind = iota // replace the word with value
+	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)
 )
 
 // spellOption is one selectable row in the misspelled-word popup.
@@ -49,6 +50,7 @@ 	opts = append(opts,
 		spellOption{label: "Add to dictionary", kind: spellAdd},
 		spellOption{label: "Ignore", kind: spellIgnore},
 		spellOption{label: "Toggle spellcheck", kind: spellToggle},
+		spellOption{label: "Toggle grammar", kind: grammarToggle},
 	)
 	a.spell = spellPopup{word: word, row: row, start: start, end: end, options: opts}
 	a.mode = ModeSpell
@@ -67,7 +69,10 @@ 	}
 	if a.openSpellPopupAt(a.editor.Cursor.Row, a.editor.Cursor.Col) {
 		return true
 	}
-	a.spell = spellPopup{options: []spellOption{{label: "Toggle spellcheck", kind: spellToggle}}}
+	a.spell = spellPopup{options: []spellOption{
+		{label: "Toggle spellcheck", kind: spellToggle},
+		{label: "Toggle grammar", kind: grammarToggle},
+	}}
 	a.mode = ModeSpell
 	a.status = ""
 	return true
@@ -102,6 +107,8 @@ 			case r == 'i' || r == 'I':
 				return a.applySpell(a.kindIndex(spellIgnore))
 			case r == 't' || r == 'T':
 				return a.applySpell(a.kindIndex(spellToggle))
+			case r == 'g' || r == 'G':
+				return a.applySpell(a.kindIndex(grammarToggle))
 			}
 		}
 	}
@@ -144,6 +151,10 @@ 			a.status = "Spellcheck on"
 		} else {
 			a.status = "Spellcheck off"
 		}
+	case grammarToggle:
+		cmd := a.toggleGrammar()
+		a.mode = ModeEditor
+		return a, cmd
 	}
 	a.mode = ModeEditor
 	return a, nil
@@ -175,7 +186,9 @@ 			label = "a Add"
 		case spellIgnore:
 			label = "i Ignore"
 		case spellToggle:
-			label = "t Toggle"
+			label = "t Spell"
+		case grammarToggle:
+			label = "g Grammar"
 		}
 		if i == a.spell.sel {
 			label = selStyle.Render(" " + label + " ")
internal/app/spell_test.go +5 −4
@@ -38,13 +38,14 @@ 	a.setSize(100, 24)
 	a.editor.SetContent([]byte("all correct words"))
 	a.editor.SetCursor(editorPos(0, 1))
 	a.handleKey(altSemicolon)
-	// With no flagged word, Alt+; opens a minimal toggle-only popup (no word, a
-	// single Toggle option) so spellcheck can always be turned off/on.
+	// With no flagged word, Alt+; opens a minimal toggle-only popup (no word, just
+	// the spellcheck and grammar toggles) so proofing can always be turned off/on.
 	if a.mode != ModeSpell {
 		t.Fatal("Alt+; did not open the toggle-only popup")
 	}
-	if a.spell.word != "" || len(a.spell.options) != 1 || a.spell.options[0].kind != spellToggle {
-		t.Errorf("want toggle-only popup, got word=%q opts=%+v", a.spell.word, a.spell.options)
+	if a.spell.word != "" || len(a.spell.options) != 2 ||
+		a.spell.options[0].kind != spellToggle || a.spell.options[1].kind != grammarToggle {
+		t.Errorf("want spell+grammar toggle popup, got word=%q opts=%+v", a.spell.word, a.spell.options)
 	}
 }
 
internal/config/config.go +5 −0
@@ -20,6 +20,7 @@ 	GlamourStyle string `toml:"glamour_style"`
 	Theme        string `toml:"theme"`
 	InboxDir     string `toml:"inbox_dir"`
 	Spellcheck   string `toml:"spellcheck"` // auto | on | off (TASK-020)
+	Grammar      string `toml:"grammar"`    // auto | on | off — Harper grammar check (TASK-043)
 
 	// PDF/printable export fonts (TASK-021). CSS font-family stacks that
 	// override the house-style --font-* tokens. Defaults are portable
@@ -39,6 +40,7 @@ 		DailySubdir: "Daily",
 		DailyFormat: "2006-01-02",
 		Theme:       "auto",
 		Spellcheck:  "auto",
+		Grammar:     "auto",
 		FontDisplay: "Georgia, \"Times New Roman\", serif",
 		FontBody:    "system-ui, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif",
 		FontMono:    "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace",
@@ -141,6 +143,9 @@ 		cfg.InboxDir = fileCfg.InboxDir
 	}
 	if fileCfg.Spellcheck != "" {
 		cfg.Spellcheck = fileCfg.Spellcheck
+	}
+	if fileCfg.Grammar != "" {
+		cfg.Grammar = fileCfg.Grammar
 	}
 	if fileCfg.FontDisplay != "" {
 		cfg.FontDisplay = fileCfg.FontDisplay
internal/configui/configui.go +10 −0
@@ -33,6 +33,7 @@ 	inboxDir := cfg.InboxDir
 	dailySubdir := orDefault(cfg.DailySubdir, "Daily")
 	glamour := cfg.GlamourStyle
 	spellcheck := orDefault(cfg.Spellcheck, "auto")
+	grammar := orDefault(cfg.Grammar, "auto")
 
 	// Match the current layout to a preset; otherwise offer it as custom.
 	fmtChoice := customLayout
@@ -95,6 +96,14 @@ 					huh.NewOption("auto", "auto"),
 					huh.NewOption("on", "on"),
 					huh.NewOption("off", "off"),
 				).Value(&spellcheck),
+			huh.NewSelect[string]().
+				Title("Grammar (Harper)").
+				Description("Green undercurl on grammar issues. Needs harper-ls (brew install harper). auto = on when installed.").
+				Options(
+					huh.NewOption("auto", "auto"),
+					huh.NewOption("on", "on"),
+					huh.NewOption("off", "off"),
+				).Value(&grammar),
 		),
 		huh.NewGroup(
 			huh.NewInput().
@@ -121,6 +130,7 @@ 		DailySubdir:  dailySubdir,
 		DailyFormat:  dailyFmt,
 		GlamourStyle: glamour,
 		Spellcheck:   spellcheck,
+		Grammar:      grammar,
 		// Export fonts aren't part of the wizard; preserve any existing values
 		// so a `glint -c` run doesn't silently drop them.
 		FontDisplay: cfg.FontDisplay,
internal/editor/editor.go +3 −0
@@ -46,6 +46,9 @@ 	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
+
+	grammarOn    bool             // session grammar toggle (Harper; TASK-043)
+	grammarDiags map[int][][2]int // logical line -> green-undercurl rune ranges [start,end)
 }
 
 // SetLanguage selects the scanner from the file's extension: markdown/text/no
internal/editor/grammar.go +76 −0
@@ -0,0 +1,76 @@
+package editor
+
+import "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.
+func (e *Editor) SetGrammar(on bool) { e.grammarOn = on; e.invalidate() }
+
+// ToggleGrammar flips grammar checking and returns the new state.
+func (e *Editor) ToggleGrammar() bool { e.grammarOn = !e.grammarOn; e.invalidate(); return e.grammarOn }
+
+// GrammarEnabled reports the user's session toggle for grammar checking.
+func (e *Editor) GrammarEnabled() bool { return e.grammarOn }
+
+// SetGrammarDiags replaces the grammar-underline ranges, keyed by logical line
+// (each range is a [start,end) rune column pair). Callers pass whatever Harper
+// last reported for the current buffer; an empty map clears all grammar
+// underlines. The visual model is invalidated so the change shows on next render.
+func (e *Editor) SetGrammarDiags(byLine map[int][][2]int) {
+	e.grammarDiags = byLine
+	e.invalidate()
+}
+
+// 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
+}
+
+// grammarPass overlays a green undercurl on each grammar range, layered after
+// spellPass so a word already flagged red (misspelled) keeps its red underline —
+// spelling outranks grammar on the same text.
+func (e *Editor) grammarPass(all [][]Span) [][]Span {
+	for li := range all {
+		ranges := e.grammarDiags[li]
+		if len(ranges) == 0 {
+			continue
+		}
+		spans := all[li]
+		for _, r := range ranges {
+			spans = overlayUndercurl(spans, r[0], r[1], e.theme.Grammar)
+		}
+		all[li] = spans
+	}
+	return all
+}
+
+// overlayUndercurl marks the rune range [a,b) of spans with a curly underline in
+// color, splitting spans at the range boundaries (like overlaySelection). Only
+// prose spans not already Wavy are marked, so grammar green never repaints a
+// misspelling's red nor underlines markup punctuation.
+func overlayUndercurl(spans []Span, a, b int, color lipgloss.Color) []Span {
+	total := 0
+	for _, sp := range spans {
+		total += len([]rune(sp.Text))
+	}
+	if a < 0 {
+		a = 0
+	}
+	if b > total {
+		b = total
+	}
+	if a >= b {
+		return spans
+	}
+	out := sliceSpans(spans, 0, a)
+	mid := sliceSpans(spans, a, b)
+	for i := range mid {
+		if mid[i].Prose && !mid[i].Wavy {
+			mid[i].Wavy = true
+			mid[i].UnderColor = color
+		}
+	}
+	out = append(out, mid...)
+	return append(out, sliceSpans(spans, b, total)...)
+}
internal/editor/grammar_test.go +82 −0
@@ -0,0 +1,82 @@
+package editor
+
+import (
+	"testing"
+
+	"glint/internal/spell"
+	"glint/internal/theme"
+)
+
+// grammarSpans returns, for each Wavy span in the built visual model, its text
+// mapped to its undercurl color — enough to tell grammar (green) from spell (red).
+func grammarSpans(e *Editor) map[string]string {
+	got := map[string]string{}
+	for _, vr := range e.buildVisual() {
+		for _, sp := range vr.spans {
+			if sp.Wavy {
+				got[sp.Text] = string(sp.UnderColor)
+			}
+		}
+	}
+	return got
+}
+
+func grammarEditor(t *testing.T, content string) *Editor {
+	t.Helper()
+	e := New()
+	e.SetTheme(theme.FlexokiDark())
+	e.SetContent([]byte(content))
+	e.SetGrammar(true)
+	return e
+}
+
+func TestGrammarUnderlinesRange(t *testing.T) {
+	e := grammarEditor(t, "This is a a test")
+	// "a a" spans rune columns [8,11).
+	e.SetGrammarDiags(map[int][][2]int{0: {{8, 11}}})
+	green := string(theme.FlexokiDark().Grammar)
+	spans := grammarSpans(e)
+	if spans["a a"] != green {
+		t.Errorf("expected \"a a\" underlined green (%s); got wavy spans %v", green, spans)
+	}
+}
+
+func TestGrammarInactiveWhenOff(t *testing.T) {
+	e := grammarEditor(t, "This is a a test")
+	e.SetGrammarDiags(map[int][][2]int{0: {{8, 11}}})
+	e.SetGrammar(false)
+	if s := grammarSpans(e); len(s) != 0 {
+		t.Errorf("grammar off should render no undercurl; got %v", s)
+	}
+}
+
+func TestGrammarInactiveOnCodeFile(t *testing.T) {
+	e := grammarEditor(t, "This is a a test")
+	e.SetLanguage("main.go") // code file: grammar skipped like spellcheck
+	e.SetGrammarDiags(map[int][][2]int{0: {{8, 11}}})
+	if s := grammarSpans(e); len(s) != 0 {
+		t.Errorf("grammar should skip code files; got %v", s)
+	}
+}
+
+// TestSpellingWinsOverGrammar asserts a misspelled word inside a grammar range
+// keeps its red spell underline rather than being repainted green.
+func TestSpellingWinsOverGrammar(t *testing.T) {
+	d, err := spell.Load()
+	if err != nil {
+		t.Fatalf("spell.Load: %v", err)
+	}
+	e := New()
+	e.SetTheme(theme.FlexokiDark())
+	e.SetContent([]byte("This recieve is wrong"))
+	e.SetDict(d)
+	e.SetSpell(true)
+	e.SetGrammar(true)
+	// "recieve" is a misspelling at rune columns [5,12); cover it with a grammar range.
+	e.SetGrammarDiags(map[int][][2]int{0: {{5, 12}}})
+	spans := grammarSpans(e)
+	red := string(theme.FlexokiDark().Spell)
+	if spans["recieve"] != red {
+		t.Errorf("misspelled \"recieve\" should stay red (%s), not grammar green; got %v", red, spans)
+	}
+}
internal/editor/wrap.go +3 −0
@@ -98,6 +98,9 @@ 	}
 	if e.spellActive() {
 		all = e.spellPass(all)
 	}
+	if e.grammarActive() {
+		all = e.grammarPass(all)
+	}
 	applyConflictHighlight(all, e.Lines, e.theme)
 	var rows []vrow
 	for li := range e.Lines {
internal/grammar/client.go +298 −0
@@ -0,0 +1,298 @@
+package grammar
+
+import (
+	"bufio"
+	"encoding/json"
+	"io"
+	"os"
+	"os/exec"
+	"strings"
+	"sync"
+)
+
+// binary is the harper-ls executable name; a var so tests can point it elsewhere.
+var binary = "harper-ls"
+
+// Available reports whether harper-ls is on PATH. When false, callers should skip
+// grammar entirely — no subprocess, no cost.
+func Available() bool {
+	_, err := exec.LookPath(binary)
+	return err == nil
+}
+
+// Client is a running harper-ls session reached over stdio. It tracks the text of
+// each open document so it can translate LSP diagnostic ranges (UTF-16) into
+// glint's rune columns, and publishes diagnostic batches on Diagnostics().
+type Client struct {
+	cmd   *exec.Cmd
+	stdin io.WriteCloser
+
+	writeMu sync.Mutex
+	nextID  int
+
+	docMu sync.Mutex
+	docs  map[string]docState // uri -> latest text/version
+
+	diags chan []Diag
+	ready chan struct{}
+	done  chan struct{}
+}
+
+type docState struct {
+	version int
+	lines   []string
+}
+
+// Start launches harper-ls, performs the LSP initialize handshake, and begins
+// serving diagnostics. The returned Client is ready for DidOpen once Start
+// returns. Callers should guard with Available first.
+func Start() (*Client, error) {
+	cmd := exec.Command(binary, "--stdio")
+	stdin, err := cmd.StdinPipe()
+	if err != nil {
+		return nil, err
+	}
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return nil, err
+	}
+	cmd.Stderr = nil // discard harper's log noise
+	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{}),
+	}
+	go c.readLoop(bufio.NewReader(stdout))
+	c.initialize()
+	return c, nil
+}
+
+// Diagnostics is the stream of diagnostic batches, one per harper
+// publishDiagnostics for a tracked document, already mapped to rune columns. The
+// channel is buffered depth 1 and always holds the latest batch: a slow reader
+// never blocks the client, but never sees a stale batch either.
+func (c *Client) Diagnostics() <-chan []Diag { return c.diags }
+
+// DidOpen registers a document with harper and requests its first diagnostics.
+func (c *Client) DidOpen(path, text string) {
+	uri := pathToURI(path)
+	c.setDoc(uri, 1, text)
+	c.notify("textDocument/didOpen", map[string]any{
+		"textDocument": map[string]any{
+			"uri": uri, "languageId": "markdown", "version": 1, "text": text,
+		},
+	})
+}
+
+// DidChange sends the full new text for an open document (harper uses Full sync),
+// bumping its version so diagnostics refresh.
+func (c *Client) DidChange(path, text string) {
+	uri := pathToURI(path)
+	ver := c.bumpDoc(uri, text)
+	c.notify("textDocument/didChange", map[string]any{
+		"textDocument":   map[string]any{"uri": uri, "version": ver},
+		"contentChanges": []any{map[string]any{"text": text}},
+	})
+}
+
+// DidClose stops diagnostics for a document (e.g. when switching files).
+func (c *Client) DidClose(path string) {
+	uri := pathToURI(path)
+	c.docMu.Lock()
+	delete(c.docs, uri)
+	c.docMu.Unlock()
+	c.notify("textDocument/didClose", map[string]any{
+		"textDocument": map[string]any{"uri": uri},
+	})
+}
+
+// Close shuts the subprocess down. Best-effort: it asks harper to exit, closes
+// stdin, then kills and reaps the process asynchronously so Close never blocks
+// and leaves no zombie behind.
+func (c *Client) Close() error {
+	c.notify("exit", nil)
+	_ = c.stdin.Close()
+	err := c.cmd.Process.Kill()
+	go func() { _ = c.cmd.Wait() }() // reap the killed process
+	return err
+}
+
+// --- document bookkeeping -------------------------------------------------
+
+func (c *Client) setDoc(uri string, version int, text string) {
+	c.docMu.Lock()
+	c.docs[uri] = docState{version: version, lines: strings.Split(text, "\n")}
+	c.docMu.Unlock()
+}
+
+func (c *Client) bumpDoc(uri, text string) int {
+	c.docMu.Lock()
+	defer c.docMu.Unlock()
+	d := c.docs[uri]
+	d.version++
+	if d.version < 1 {
+		d.version = 1
+	}
+	d.lines = strings.Split(text, "\n")
+	c.docs[uri] = d
+	return d.version
+}
+
+func (c *Client) docLines(uri string) ([]string, bool) {
+	c.docMu.Lock()
+	defer c.docMu.Unlock()
+	d, ok := c.docs[uri]
+	return d.lines, ok
+}
+
+// --- LSP wire -------------------------------------------------------------
+
+func (c *Client) initialize() {
+	root := "file://" + osGetwd()
+	c.request("initialize", map[string]any{
+		"processId": os.Getpid(),
+		"rootUri":   root,
+		"capabilities": map[string]any{
+			"workspace":    map[string]any{"configuration": true},
+			"textDocument": map[string]any{"publishDiagnostics": map[string]any{}},
+		},
+	})
+	<-c.ready // block until harper answers initialize
+	c.notify("initialized", map[string]any{})
+}
+
+func (c *Client) request(method string, params any) int {
+	c.writeMu.Lock()
+	c.nextID++
+	id := c.nextID
+	c.writeMu.Unlock()
+	c.send(map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params})
+	return id
+}
+
+func (c *Client) notify(method string, params any) {
+	c.send(map[string]any{"jsonrpc": "2.0", "method": method, "params": params})
+}
+
+func (c *Client) respond(id json.RawMessage, result any) {
+	c.send(map[string]any{"jsonrpc": "2.0", "id": id, "result": result})
+}
+
+func (c *Client) send(v map[string]any) {
+	body, err := json.Marshal(v)
+	if err != nil {
+		return
+	}
+	c.writeMu.Lock()
+	defer c.writeMu.Unlock()
+	_ = writeFrame(c.stdin, body)
+}
+
+// readLoop parses harper's frames until the stream closes, answering the server
+// requests that gate diagnostics and forwarding publishDiagnostics onward.
+func (c *Client) readLoop(r *bufio.Reader) {
+	defer close(c.done)
+	initID := 1 // initialize is always the first request
+	for {
+		body, err := readFrame(r)
+		if err != nil {
+			return
+		}
+		var m struct {
+			ID     json.RawMessage `json:"id"`
+			Method string          `json:"method"`
+			Params json.RawMessage `json:"params"`
+		}
+		if json.Unmarshal(body, &m) != nil {
+			continue
+		}
+		switch {
+		case m.Method == "" && len(m.ID) > 0: // response to one of our requests
+			if string(m.ID) == itoa(initID) {
+				c.signalReady()
+			}
+		case len(m.ID) > 0: // server -> client request: must answer or diagnostics stall
+			c.answerRequest(m.ID, m.Method, m.Params)
+		case m.Method == "textDocument/publishDiagnostics":
+			c.handleDiagnostics(m.Params)
+		}
+	}
+}
+
+func (c *Client) signalReady() {
+	select {
+	case <-c.ready:
+	default:
+		close(c.ready)
+	}
+}
+
+// answerRequest replies to the server-initiated requests harper needs before it
+// will emit diagnostics: workspace/configuration wants one config object per
+// requested item (empty = harper defaults); everything else gets a null result.
+func (c *Client) answerRequest(id json.RawMessage, method string, params json.RawMessage) {
+	if method == "workspace/configuration" {
+		var p struct {
+			Items []json.RawMessage `json:"items"`
+		}
+		_ = json.Unmarshal(params, &p)
+		result := make([]any, len(p.Items))
+		for i := range result {
+			result[i] = map[string]any{}
+		}
+		c.respond(id, result)
+		return
+	}
+	c.respond(id, nil)
+}
+
+func (c *Client) handleDiagnostics(params json.RawMessage) {
+	var p struct {
+		URI         string `json:"uri"`
+		Diagnostics []struct {
+			Range struct {
+				Start struct{ Line, Character int } `json:"start"`
+				End   struct{ Line, Character int } `json:"end"`
+			} `json:"range"`
+			Message string `json:"message"`
+			Code    any    `json:"code"`
+		} `json:"diagnostics"`
+	}
+	if json.Unmarshal(params, &p) != nil {
+		return
+	}
+	lines, ok := c.docLines(p.URI)
+	if !ok {
+		return // diagnostics for a document we no longer track
+	}
+	var batch []Diag
+	for _, d := range p.Diagnostics {
+		batch = append(batch, lspRangeToDiags(lines,
+			d.Range.Start.Line, d.Range.Start.Character,
+			d.Range.End.Line, d.Range.End.Character,
+			d.Message, codeString(d.Code))...)
+	}
+	c.publish(batch)
+}
+
+// publish delivers batch on the depth-1 channel, replacing any undrained batch so
+// the reader always gets the newest diagnostics and the read loop never blocks.
+func (c *Client) publish(batch []Diag) {
+	for {
+		select {
+		case c.diags <- batch:
+			return
+		default:
+			select {
+			case <-c.diags:
+			default:
+			}
+		}
+	}
+}
internal/grammar/framing.go +58 −0
@@ -0,0 +1,58 @@
+// Package grammar drives an optional Harper grammar checker for glint. It speaks
+// LSP (JSON-RPC over stdio) to a harper-ls subprocess, so glint stays pure-Go
+// and zero-cgo: harper-core is Rust, but it lives in a separate process reached
+// only through pipes. The feature is entirely optional — with no harper-ls on
+// PATH the package is inert (Available reports false) and glint behaves exactly
+// as before.
+package grammar
+
+import (
+	"bufio"
+	"fmt"
+	"io"
+	"strconv"
+	"strings"
+)
+
+// writeFrame writes one LSP message: a Content-Length header, a blank line, then
+// the JSON body. Callers serialize concurrent writes; this does no locking.
+func writeFrame(w io.Writer, body []byte) error {
+	if _, err := fmt.Fprintf(w, "Content-Length: %d\r\n\r\n", len(body)); err != nil {
+		return err
+	}
+	_, err := w.Write(body)
+	return err
+}
+
+// readFrame reads one LSP message body, parsing the Content-Length header and
+// discarding any other headers up to the blank separator line. It returns
+// io.EOF (possibly wrapped) once the stream closes.
+func readFrame(r *bufio.Reader) ([]byte, error) {
+	length := -1
+	for {
+		line, err := r.ReadString('\n')
+		if err != nil {
+			return nil, err
+		}
+		trimmed := strings.TrimRight(line, "\r\n")
+		if trimmed == "" { // blank line: headers done
+			break
+		}
+		if name, val, ok := strings.Cut(trimmed, ":"); ok &&
+			strings.EqualFold(strings.TrimSpace(name), "Content-Length") {
+			n, err := strconv.Atoi(strings.TrimSpace(val))
+			if err != nil {
+				return nil, fmt.Errorf("grammar: bad Content-Length %q: %w", val, err)
+			}
+			length = n
+		}
+	}
+	if length < 0 {
+		return nil, fmt.Errorf("grammar: frame missing Content-Length")
+	}
+	body := make([]byte, length)
+	if _, err := io.ReadFull(r, body); err != nil {
+		return nil, err
+	}
+	return body, nil
+}
internal/grammar/grammar_test.go +158 −0
@@ -0,0 +1,158 @@
+package grammar
+
+import (
+	"bufio"
+	"bytes"
+	"testing"
+	"time"
+)
+
+func TestFrameRoundTrip(t *testing.T) {
+	var buf bytes.Buffer
+	bodies := [][]byte{
+		[]byte(`{"jsonrpc":"2.0","id":1}`),
+		[]byte(`{"method":"textDocument/didOpen"}`),
+		[]byte(`{}`),
+	}
+	for _, b := range bodies {
+		if err := writeFrame(&buf, b); err != nil {
+			t.Fatalf("writeFrame: %v", err)
+		}
+	}
+	r := bufio.NewReader(&buf)
+	for i, want := range bodies {
+		got, err := readFrame(r)
+		if err != nil {
+			t.Fatalf("readFrame %d: %v", i, err)
+		}
+		if !bytes.Equal(got, want) {
+			t.Errorf("frame %d = %q, want %q", i, got, want)
+		}
+	}
+}
+
+func TestReadFrameCaseInsensitiveHeader(t *testing.T) {
+	// Some servers vary header casing; the length header must still parse.
+	raw := "content-length: 2\r\n\r\n{}"
+	got, err := readFrame(bufio.NewReader(bytes.NewBufferString(raw)))
+	if err != nil {
+		t.Fatalf("readFrame: %v", err)
+	}
+	if string(got) != "{}" {
+		t.Errorf("got %q, want {}", got)
+	}
+}
+
+func TestUTF16ToRuneCol(t *testing.T) {
+	cases := []struct {
+		line string
+		u16  int
+		want int
+	}{
+		{"hello", 0, 0},
+		{"hello", 3, 3},
+		{"hello", 99, 5}, // clamp past end
+		{"a😀b", 0, 0},    // emoji is 2 UTF-16 units, 1 rune
+		{"a😀b", 1, 1},    // before the emoji
+		{"a😀b", 3, 2},    // after the emoji (1 + 2 units) -> rune col 2
+	}
+	for _, c := range cases {
+		if got := utf16ToRuneCol(c.line, c.u16); got != c.want {
+			t.Errorf("utf16ToRuneCol(%q, %d) = %d, want %d", c.line, c.u16, got, c.want)
+		}
+	}
+}
+
+func TestLSPRangeToDiagsSingleLine(t *testing.T) {
+	lines := []string{"This is a a sentence."}
+	got := lspRangeToDiags(lines, 0, 8, 0, 11, "Did you mean to repeat this word?", "RepeatedWords")
+	if len(got) != 1 {
+		t.Fatalf("got %d diags, want 1", len(got))
+	}
+	d := got[0]
+	if d.Line != 0 || d.StartCol != 8 || d.EndCol != 11 {
+		t.Errorf("range = line %d [%d,%d), want line 0 [8,11)", d.Line, d.StartCol, d.EndCol)
+	}
+	if d.Code != "RepeatedWords" {
+		t.Errorf("code = %q", d.Code)
+	}
+}
+
+func TestLSPRangeToDiagsMultiLine(t *testing.T) {
+	lines := []string{"first line", "second", "third line"}
+	got := lspRangeToDiags(lines, 0, 6, 2, 5, "m", "C")
+	if len(got) != 3 {
+		t.Fatalf("got %d diags, want 3 (one per covered line)", len(got))
+	}
+	// start line: from col 6 to end (10); middle: full; end: 0..5
+	if got[0].StartCol != 6 || got[0].EndCol != 10 {
+		t.Errorf("start line range [%d,%d), want [6,10)", got[0].StartCol, got[0].EndCol)
+	}
+	if got[1].StartCol != 0 || got[1].EndCol != 6 {
+		t.Errorf("middle line range [%d,%d), want [0,6)", got[1].StartCol, got[1].EndCol)
+	}
+	if got[2].StartCol != 0 || got[2].EndCol != 5 {
+		t.Errorf("end line range [%d,%d), want [0,5)", got[2].StartCol, got[2].EndCol)
+	}
+}
+
+func TestLSPRangeToDiagsSkipsOutOfRangeAndEmpty(t *testing.T) {
+	lines := []string{"only line"}
+	// Line 5 doesn't exist -> skipped, no panic.
+	if got := lspRangeToDiags(lines, 5, 0, 5, 3, "m", "C"); got != nil {
+		t.Errorf("out-of-range line produced %v, want nil", got)
+	}
+	// Zero-width range -> no diag.
+	if got := lspRangeToDiags(lines, 0, 2, 0, 2, "m", "C"); got != nil {
+		t.Errorf("zero-width range produced %v, want nil", got)
+	}
+}
+
+func TestCodeString(t *testing.T) {
+	if got := codeString("RepeatedWords"); got != "RepeatedWords" {
+		t.Errorf("string code = %q", got)
+	}
+	if got := codeString(float64(42)); got != "42" {
+		t.Errorf("number code = %q, want 42", got)
+	}
+	if got := codeString(nil); got != "" {
+		t.Errorf("nil code = %q, want empty", 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) {
+	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()
+
+	// "a a" is a repeated word; harper should flag it.
+	c.DidOpen("/tmp/glint-grammar-test.md", "This is a a test.\n")
+
+	select {
+	case batch := <-c.Diagnostics():
+		if len(batch) == 0 {
+			t.Fatal("harper returned an empty diagnostic batch")
+		}
+		found := false
+		for _, d := range batch {
+			if d.Line == 0 && d.Message != "" {
+				found = true
+			}
+		}
+		if !found {
+			t.Errorf("no usable diagnostic in batch: %+v", batch)
+		}
+	case <-time.After(10 * time.Second):
+		t.Fatal("timed out waiting for harper diagnostics")
+	}
+}
internal/grammar/position.go +70 −0
@@ -0,0 +1,70 @@
+package grammar
+
+// Diag is one grammar issue for a single logical line, in glint's coordinate
+// system: rune columns, not the LSP wire's UTF-16 units. Multi-line LSP ranges
+// are split into one Diag per covered line before reaching this type.
+type Diag struct {
+	Line     int    // 0-based logical line
+	StartCol int    // 0-based rune column, inclusive
+	EndCol   int    // rune column, exclusive
+	Message  string // human-readable description from Harper
+	Code     string // Harper rule code (e.g. "RepeatedWords")
+}
+
+// utf16ToRuneCol converts a UTF-16 code-unit offset within line (LSP's default
+// position encoding) to a rune column. Characters outside the BMP count as two
+// UTF-16 units but one rune, so plain len-based indexing would drift once a doc
+// contains emoji or other astral characters. An offset past the line's end
+// clamps to the rune length.
+func utf16ToRuneCol(line string, u16 int) int {
+	if u16 <= 0 {
+		return 0
+	}
+	units, col := 0, 0
+	for _, r := range line {
+		if units >= u16 {
+			return col
+		}
+		if r > 0xFFFF {
+			units += 2
+		} else {
+			units++
+		}
+		col++
+	}
+	return col
+}
+
+// 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
+// spans lines covers the start line from its column to end-of-line, every whole
+// intermediate line, and the end line up to its column. Out-of-range lines are
+// skipped so a stale diagnostic can never index past the buffer.
+func lspRangeToDiags(lines []string, startLine, startChar, endLine, endChar int, msg, code string) []Diag {
+	line := func(i int) (string, bool) {
+		if i < 0 || i >= len(lines) {
+			return "", false
+		}
+		return lines[i], true
+	}
+	var out []Diag
+	for ln := startLine; ln <= endLine; ln++ {
+		text, ok := line(ln)
+		if !ok {
+			continue
+		}
+		runes := len([]rune(text))
+		start, end := 0, runes
+		if ln == startLine {
+			start = utf16ToRuneCol(text, startChar)
+		}
+		if ln == endLine {
+			end = utf16ToRuneCol(text, endChar)
+		}
+		if end > start {
+			out = append(out, Diag{Line: ln, StartCol: start, EndCol: end, Message: msg, Code: code})
+		}
+	}
+	return out
+}
internal/grammar/util.go +49 −0
@@ -0,0 +1,49 @@
+package grammar
+
+import (
+	"fmt"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strconv"
+	"strings"
+)
+
+// pathToURI turns an editor file path into an LSP document URI. Unnamed buffers
+// (empty path) get a stable synthetic URI so harper still checks them.
+func pathToURI(path string) string {
+	if strings.TrimSpace(path) == "" {
+		return "file:///glint-untitled.md"
+	}
+	abs := path
+	if p, err := filepath.Abs(path); err == nil {
+		abs = p
+	}
+	return "file://" + (&url.URL{Path: abs}).EscapedPath()
+}
+
+// osGetwd returns the working directory for the initialize rootUri, falling back
+// to the temp dir so a missing cwd never breaks startup.
+func osGetwd() string {
+	if wd, err := os.Getwd(); err == nil {
+		return wd
+	}
+	return os.TempDir()
+}
+
+func itoa(n int) string { return strconv.Itoa(n) }
+
+// codeString renders an LSP diagnostic code, which may be a string or a number,
+// as a string.
+func codeString(code any) string {
+	switch v := code.(type) {
+	case string:
+		return v
+	case float64:
+		return strconv.FormatFloat(v, 'f', -1, 64)
+	case nil:
+		return ""
+	default:
+		return fmt.Sprint(v)
+	}
+}
internal/help/help.go +9 −3
@@ -51,9 +51,10 @@   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
+  Alt+;                  proofing popup on the misspelled word at the cursor
                         (pick a suggestion 1-9, a add to dictionary, i ignore,
-                        t toggle spellcheck); clicking an underlined word opens it
+                        t toggle spellcheck, g toggle grammar); 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)
   Alt+left / Alt+right  move by word
@@ -69,11 +70,16 @@   ( [ 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
+SPELLCHECK & GRAMMAR
   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.
+
+  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).
 
 CONFIG
   ~/.config/glint/config.toml   (run 'glint -c' to set it up)
internal/theme/theme.go +1 −0
@@ -24,6 +24,7 @@ 	Comment    lipgloss.Color // HTML / %% comments — visible, not dimmed
 	Accent     lipgloss.Color // frontmatter keys, selection
 	Highlight  lipgloss.Color // ==highlight== background tint
 	Spell      lipgloss.Color // misspelled-word undercurl (red)
+	Grammar    lipgloss.Color // grammar-issue undercurl (green; Harper, TASK-043)
 
 	// Merge-conflict highlighting (git markers <<<<<<< ||||||| ======= >>>>>>>).
 	ConflictMarker lipgloss.Color // bold marker lines
internal/theme/themes.go +3 −0
@@ -29,6 +29,7 @@ 		Comment:      lipgloss.Color("#DA702C"), // orange-400 — visible meta
 		Accent:       lipgloss.Color("#D0A215"), // yellow-400
 		Highlight:    lipgloss.Color("#3A3517"), // deep olive — ==highlight== bg
 		Spell:        lipgloss.Color("#D14D41"), // red-400 — misspell undercurl
+		Grammar:      lipgloss.Color("#879A39"), // green-400 — grammar undercurl (Harper)
 
 		ConflictMarker: lipgloss.Color("#8B7EC8"), // purple-400 — conflict marker lines
 		ConflictOurs:   lipgloss.Color("#1A1E0C"), // green-950 — "ours" block tint
@@ -62,6 +63,7 @@ 		Comment:      lipgloss.Color("#BC5215"), // orange-600 — visible meta
 		Accent:       lipgloss.Color("#AD8301"), // yellow-600
 		Highlight:    lipgloss.Color("#F0E6BE"), // pale yellow — ==highlight== bg
 		Spell:        lipgloss.Color("#AF3029"), // red-600 — misspell undercurl
+		Grammar:      lipgloss.Color("#66800B"), // green-600 — grammar undercurl (Harper)
 
 		ConflictMarker: lipgloss.Color("#5E409D"), // purple-600 — conflict marker lines
 		ConflictOurs:   lipgloss.Color("#EDEECF"), // green-50 — "ours" block tint
@@ -95,6 +97,7 @@ 		Comment:      lipgloss.Color("#FFB454"),
 		Accent:       lipgloss.Color("#FFD500"),
 		Highlight:    lipgloss.Color("#3A2E4D"),
 		Spell:        lipgloss.Color("#FF5F87"), // pink-red — misspell undercurl
+		Grammar:      lipgloss.Color("#A6E22E"), // lime — grammar undercurl (Harper)
 
 		ConflictMarker: lipgloss.Color("#B794F6"), // soft purple — conflict marker lines
 		ConflictOurs:   lipgloss.Color("#17251C"), // dark green — "ours" block tint
main.go +3 −1
@@ -177,7 +177,9 @@ 	opts := []tea.ProgramOption{tea.WithAltScreen()}
 	if !a.InPreview() {
 		opts = append(opts, tea.WithMouseCellMotion())
 	}
-	if _, err := tea.NewProgram(a, opts...).Run(); err != nil {
+	_, err := tea.NewProgram(a, opts...).Run()
+	a.Close() // shut down the harper subprocess if one is running
+	if err != nil {
 		fmt.Fprintln(os.Stderr, "glint:", err)
 		os.Exit(1)
 	}