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

feat: editing polish — auto-close pairs, smart Home, go-to-line, cursor memory, paste-URL link (TASK-012)

8f0914b6892c556119a986e4bdb443163c0b6a23
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 17:06

parent 1dc94dd2

feat: editing polish — auto-close pairs, smart Home, go-to-line, cursor memory, paste-URL link (TASK-012)

- editor: auto-close ( [ ` with step-over; smart Home toggles first-non-blank/col 0; GotoLine + SetCursor (clamped)
- app: Ctrl+L go-to-line prompt; per-file cursor memory restored on reopen; paste a URL over a selection -> [sel](url)
- docs: help.go EDITING section + Ctrl+L, README keys
- TDD throughout (internal/editor/polish_test.go, internal/app/polish_test.go)

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

7 files changed

README.md +4 −2
@@ -48,9 +48,10 @@ | `Enter` on a list item | continue the list (`-`/`*`/`+`, `N.`/`N)`, `- [ ]` checkboxes; numbers increment); empty item exits the list |
 | `Tab` / `Shift+Tab` | indent / outdent the current list item |
 | mouse click | move the cursor |
 | mouse wheel | scroll the view |
-| `Home` / `End` | start / end of line |
+| `Home` / `End` | smart Home (first non-blank, then column 0) / end of line |
+| type `(` `[` `` ` `` with no selection | auto-closes the pair with the cursor inside; type the closer over it to step past |
 | `Shift`+arrows · `Shift+Home/End` | select text (`Ctrl+Shift+←/→` selects by word) |
-| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | copy / cut / paste (system clipboard) |
+| `Ctrl+C` / `Ctrl+X` / `Ctrl+V` | copy / cut / paste (system clipboard); pasting a URL over a selection makes a `[selection](url)` link |
 | `Alt+s` / `Alt+i` / `Alt+c` / `Alt+k` | wrap the selection: **bold** `**` · _italic_ `_` · `code` `` ` `` · link `[sel]()` (toggles off if already wrapped; no selection inserts the empty pair) |
 | type `*` `_` `` ` `` `[` `(` `{` `<` `"` `'` with a selection | surround the selection with that punctuation (repeat to nest, e.g. `*`→`**` for bold) |
 | `Alt+←` / `Alt+→` | move by word (also `Alt+b` / `Alt+f`) |
@@ -61,6 +62,7 @@ | `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
 | `Ctrl+P` | toggle the Glamour read preview |
 | `Ctrl+F` | fuzzy file picker (with live preview) |
 | `Ctrl+G` | find in document (`Enter`/`↓` next, `Shift+Tab`/`↑` prev, `Esc` close) |
+| `Ctrl+L` | go to line (type a number, `Enter` to jump, `Esc` to cancel) |
 | `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 |
- → Editing-polish-bundle.md +25 −5
@@ -1,10 +1,10 @@
 ---
 id: TASK-012
 title: Editing polish bundle
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 16:26'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-30 00:11'
 labels:
   - feature
   - release-1
@@ -21,7 +21,27 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 Auto-close pairs for [] () and backticks (with step-over)
-- [ ] #2 Smart Home (first non-blank, then col 0)
-- [ ] #3 Go-to-line; per-file cursor memory; paste-URL-over-selection
+- [x] #1 Auto-close pairs for [] () and backticks (with step-over)
+- [x] #2 Smart Home (first non-blank, then col 0)
+- [x] #3 Go-to-line; per-file cursor memory; paste-URL-over-selection
 <!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+Editor-level (TDD in internal/editor):
+- AC1 auto-close: dispatch KeyRunes, no selection + single open rune (,[,backtick -> insertPair(open,close,1). Closing rune )/]/backtick with same char right of cursor -> step over (MoveRight). New autoClose(r) helper + TypeRune wrapper.
+- AC2 smart Home: MoveHome -> toggle first-non-blank <-> col 0.
+App-level (TDD in internal/app):
+- AC3 go-to-line: ModeGotoLine + Ctrl+L prompt, editor.GotoLine(n) clamp; gotoBar; Esc closes.
+- AC3 per-file cursor memory: App cursorMem map[path]Position; Load saves leaving file, restores on return; editor.SetCursor clamps.
+- AC3 paste-URL-over-selection: paste() -> if HasSelection && isURL(clip) -> [sel](url).
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+Editor (TDD, internal/editor/polish_test.go): auto-close pairs () [] backtick via typeRune in dispatch (no-selection single rune), insertPair reused; closer steps over a matching char to the right. Smart Home toggles first-non-blank <-> col 0. Added GotoLine + SetCursor (clamped).
+App (TDD, internal/app/polish_test.go): Ctrl+L -> ModeGotoLine prompt (digits-only, Enter jumps, Esc cancels); per-file cursor memory map restored on Load; pasteText wraps a URL pasted over a selection as [sel](url), else verbatim.
+Docs: help.go EDITING section + Ctrl+L; README keys. Full suite + vet green.
+<!-- SECTION:NOTES:END -->
internal/app/app.go +97 −4
@@ -7,6 +7,7 @@ 	"fmt"
 	"math"
 	"os"
 	"path/filepath"
+	"strconv"
 	"strings"
 	"time"
 
@@ -34,6 +35,7 @@ 	ModePreview
 	ModeSaveAs
 	ModeFind
 	ModeHelp
+	ModeGotoLine
 )
 
 // pendingDiscard tracks which open-while-dirty action is awaiting confirmation.
@@ -63,9 +65,11 @@ 	theme      theme.Theme
 	editor     *editor.Editor
 	preview    *preview.Model
 	picker     *picker.Model
-	saveInput  textinput.Model // one-line "save as" prompt for unnamed buffers
-	findInput  textinput.Model // one-line in-document find prompt (TASK-007)
-	helpView   viewport.Model  // scrollable keybind overlay (TASK-011)
+	saveInput  textinput.Model            // one-line "save as" prompt for unnamed buffers
+	findInput  textinput.Model            // one-line in-document find prompt (TASK-007)
+	gotoInput  textinput.Model            // one-line go-to-line prompt (TASK-012)
+	helpView   viewport.Model             // scrollable keybind overlay (TASK-011)
+	cursorMem  map[string]editor.Position // per-file cursor memory, this session (TASK-012)
 	path       string
 	pickerRoot string // directory the current picker is browsing
 	saveDir    string // where an unnamed buffer's save-as lands ("" → inbox)
@@ -88,6 +92,9 @@ 	ti.Placeholder = "name…"
 	fi := textinput.New()
 	fi.Prompt = "find › "
 	fi.Placeholder = "text…"
+	gi := textinput.New()
+	gi.Prompt = "go to line › "
+	gi.Placeholder = "number…"
 	hv := viewport.New(0, 0)
 	hv.SetContent(help.Text)
 	a := &App{
@@ -97,7 +104,9 @@ 		theme:     th,
 		editor:    ed,
 		saveInput: ti,
 		findInput: fi,
+		gotoInput: gi,
 		helpView:  hv,
+		cursorMem: map[string]editor.Position{},
 	}
 	a.preview = preview.New(a.glamourStyle())
 	a.preview.SetColors(previewColors(th))
@@ -121,13 +130,25 @@ 	data, err := os.ReadFile(path)
 	if err != nil {
 		return err
 	}
+	a.rememberCursor() // stash the outgoing file's position before switching
 	a.editor.SetContent(data)
 	a.path = path
+	if pos, ok := a.cursorMem[path]; ok {
+		a.editor.SetCursor(pos) // restore where we left this file
+	}
 	a.mode = ModeEditor
 	a.status = path
 	return nil
 }
 
+// rememberCursor records the current file's cursor so reopening it this session
+// returns to the same spot (TASK-012).
+func (a *App) rememberCursor() {
+	if a.path != "" {
+		a.cursorMem[a.path] = a.editor.Cursor
+	}
+}
+
 // Start picks the initial view: an explicit path, today's daily note, or the
 // picker when neither is given.
 func (a *App) Start(path string, daily bool) error {
@@ -266,6 +287,8 @@ 	case tea.KeyCtrlV:
 		return a.paste()
 	case tea.KeyCtrlG:
 		return a.openFind()
+	case tea.KeyCtrlL:
+		return a.openGoto()
 	case tea.KeyCtrlUnderscore: // Ctrl+/ toggles the help overlay
 		return a.toggleHelp()
 	case tea.KeyEsc:
@@ -290,6 +313,8 @@ 		a.saveInput, cmd = a.saveInput.Update(msg)
 		return a, cmd
 	case ModeFind:
 		return a.handleFindKey(msg)
+	case ModeGotoLine:
+		return a.handleGotoKey(msg)
 	case ModeHelp:
 		var cmd tea.Cmd
 		a.helpView, cmd = a.helpView.Update(msg) // arrows / PgUp / PgDn scroll
@@ -376,6 +401,42 @@ 	a.status = "Find — type to search, Enter/↓ next, Shift+Tab/↑ prev, Esc to close"
 	return a, nil
 }
 
+// openGoto opens the go-to-line prompt over the current editor buffer (Ctrl+L).
+func (a *App) openGoto() (tea.Model, tea.Cmd) {
+	if a.mode != ModeEditor {
+		return a, nil
+	}
+	a.gotoInput.SetValue("")
+	a.gotoInput.Focus()
+	a.mode = ModeGotoLine
+	a.status = "Go to line — type a number, Enter to jump, Esc to cancel"
+	return a, nil
+}
+
+// handleGotoKey drives the go-to-line prompt: Enter jumps to the typed line,
+// other keys edit the (digits-only) query live.
+func (a *App) handleGotoKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	if msg.Type == tea.KeyEnter {
+		if n, err := strconv.Atoi(strings.TrimSpace(a.gotoInput.Value())); err == nil {
+			a.editor.GotoLine(n)
+		}
+		a.mode = ModeEditor
+		a.status = ""
+		return a, nil
+	}
+	// Only accept digits so the prompt stays a line number.
+	if msg.Type == tea.KeyRunes {
+		for _, r := range msg.Runes {
+			if r < '0' || r > '9' {
+				return a, nil
+			}
+		}
+	}
+	var cmd tea.Cmd
+	a.gotoInput, cmd = a.gotoInput.Update(msg)
+	return a, cmd
+}
+
 // handleFindKey drives the find bar: Enter/Down cycle to the next match,
 // Shift+Tab/Up to the previous, and any other key edits the query live.
 func (a *App) handleFindKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
@@ -493,9 +554,30 @@ 	text, err := clipboard.ReadAll()
 	if err != nil || text == "" {
 		return a, nil
 	}
+	a.pasteText(text)
+	return a, nil
+}
+
+// pasteText inserts text at the cursor. Pasting a bare URL over a selection wraps
+// it as a markdown link [selection](url); otherwise it replaces the selection (or
+// inserts at the cursor) verbatim (TASK-012).
+func (a *App) pasteText(text string) {
 	a.editor.PushUndo()
+	if a.editor.HasSelection() && isURL(text) {
+		sel := a.editor.SelectedText()
+		a.editor.DeleteSelection()
+		a.editor.InsertText("[" + sel + "](" + text + ")")
+		return
+	}
 	a.editor.InsertText(text)
-	return a, nil
+}
+
+// isURL reports whether text is a single http(s) URL (no embedded whitespace).
+func isURL(text string) bool {
+	if !strings.HasPrefix(text, "http://") && !strings.HasPrefix(text, "https://") {
+		return false
+	}
+	return !strings.ContainsAny(text, " \t\n")
 }
 
 // currentDir is the "same directory" for Ctrl+N: the picker root in the picker,
@@ -708,6 +790,8 @@ 	case ModeSaveAs:
 		bottom = a.saveBar()
 	case ModeFind:
 		bottom = a.findBar()
+	case ModeGotoLine:
+		bottom = a.gotoBar()
 	}
 	return a.paintCanvas(body) + bottom
 }
@@ -738,6 +822,15 @@ 		Foreground(a.theme.StatusFg).
 		Background(a.theme.StatusBg).
 		Width(maxInt(a.width, 1))
 	return bar.Render(" " + a.findInput.View() + "  " + a.findStatus() + " ")
+}
+
+// gotoBar renders the go-to-line prompt as a themed full-width bottom bar.
+func (a *App) gotoBar() string {
+	bar := lipgloss.NewStyle().
+		Foreground(a.theme.StatusFg).
+		Background(a.theme.StatusBg).
+		Width(maxInt(a.width, 1))
+	return bar.Render(" " + a.gotoInput.View() + " ")
 }
 
 // saveBar renders the save-as prompt as a themed full-width bottom bar.
internal/app/polish_test.go +90 −0
@@ -0,0 +1,90 @@
+package app
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"glint/internal/editor"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestGotoLineJumps(t *testing.T) {
+	a := statusApp()
+	a.editor.SetContent([]byte("one\ntwo\nthree\nfour"))
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlL})
+	if a.mode != ModeGotoLine {
+		t.Fatalf("Ctrl+L did not open go-to-line: mode = %v", a.mode)
+	}
+	a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("3")})
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+	if a.mode != ModeEditor {
+		t.Errorf("after Enter mode = %v, want ModeEditor", a.mode)
+	}
+	if a.editor.Cursor.Row != 2 {
+		t.Errorf("cursor row = %d, want 2 (line 3)", a.editor.Cursor.Row)
+	}
+}
+
+func TestGotoLineEscCloses(t *testing.T) {
+	a := statusApp()
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlL})
+	a.Update(tea.KeyMsg{Type: tea.KeyEsc})
+	if a.mode != ModeEditor {
+		t.Errorf("Esc did not close go-to-line: mode = %v", a.mode)
+	}
+}
+
+func TestPerFileCursorMemory(t *testing.T) {
+	dir := t.TempDir()
+	pa := filepath.Join(dir, "a.md")
+	pb := filepath.Join(dir, "b.md")
+	os.WriteFile(pa, []byte("a1\na2\na3"), 0o644)
+	os.WriteFile(pb, []byte("b1\nb2"), 0o644)
+
+	a := statusApp()
+	a.Load(pa)
+	a.editor.SetCursor(editor.Position{Row: 2, Col: 1}) // remember this in a.md
+	a.Load(pb)                                          // switch away
+	a.Load(pa)                                          // return
+	if got := (a.editor.Cursor); got.Row != 2 {
+		t.Errorf("returning to a.md cursor = %+v, want row 2 restored", got)
+	}
+}
+
+func TestPasteURLOverSelectionMakesLink(t *testing.T) {
+	a := statusApp()
+	a.editor.SetContent([]byte("click here"))
+	a.editor.SetCursor(editor.Position{Row: 0, Col: 0})
+	// select "click"
+	for i := 0; i < 5; i++ {
+		a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight})
+	}
+	a.pasteText("https://example.com")
+	if got := a.editor.Lines[0]; got != "[click](https://example.com) here" {
+		t.Errorf("line = %q, want [click](https://example.com) here", got)
+	}
+}
+
+func TestPasteNonURLOverSelectionReplaces(t *testing.T) {
+	a := statusApp()
+	a.editor.SetContent([]byte("click here"))
+	a.editor.SetCursor(editor.Position{Row: 0, Col: 0})
+	for i := 0; i < 5; i++ {
+		a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyShiftRight})
+	}
+	a.pasteText("tap")
+	if got := a.editor.Lines[0]; got != "tap here" {
+		t.Errorf("line = %q, want tap here", got)
+	}
+}
+
+func TestPasteURLNoSelectionInsertsLiterally(t *testing.T) {
+	a := statusApp()
+	a.editor.SetContent([]byte(""))
+	a.pasteText("https://example.com")
+	if got := a.editor.Lines[0]; got != "https://example.com" {
+		t.Errorf("line = %q, want literal URL", got)
+	}
+}
internal/editor/editor.go +91 −2
@@ -138,6 +138,44 @@ 		e.Scroll = max
 	}
 }
 
+// GotoLine moves the cursor to the start of 1-based line n, clamped to the
+// document (TASK-012).
+func (e *Editor) GotoLine(n int) {
+	row := n - 1
+	if row < 0 {
+		row = 0
+	}
+	if row > len(e.Lines)-1 {
+		row = len(e.Lines) - 1
+	}
+	e.Cursor = Position{Row: row, Col: 0}
+	e.anchor = nil
+	e.setGoal()
+	e.followCursor()
+}
+
+// SetCursor parks the cursor at p, clamped to a valid document position. Used to
+// restore a remembered position when reopening a file (TASK-012).
+func (e *Editor) SetCursor(p Position) {
+	if p.Row < 0 {
+		p.Row = 0
+	}
+	if p.Row > len(e.Lines)-1 {
+		p.Row = len(e.Lines) - 1
+	}
+	maxCol := len([]rune(e.Lines[p.Row]))
+	if p.Col < 0 {
+		p.Col = 0
+	}
+	if p.Col > maxCol {
+		p.Col = maxCol
+	}
+	e.Cursor = p
+	e.anchor = nil
+	e.setGoal()
+	e.followCursor()
+}
+
 // MoveDocStart jumps to the very start of the document (Cmd+Up via the terminal).
 func (e *Editor) MoveDocStart() {
 	e.Cursor = Position{Row: 0, Col: 0}
@@ -267,6 +305,38 @@ 	e.setGoal()
 	e.followCursor()
 }
 
+// autoClose maps an opening rune to the closer auto-inserted after it (TASK-012).
+func autoClose(r rune) (rune, bool) {
+	switch r {
+	case '(':
+		return ')', true
+	case '[':
+		return ']', true
+	case '`':
+		return '`', true
+	}
+	return 0, false
+}
+
+func isAutoCloser(r rune) bool { return r == ')' || r == ']' || r == '`' }
+
+// typeRune inserts a single typed rune with auto-close behavior: an opening
+// bracket or backtick inserts its matching closer with the cursor between the
+// pair; typing a closer when that same closer already sits to the cursor's right
+// steps over it instead of inserting a duplicate. Anything else inserts plainly.
+func (e *Editor) typeRune(r rune) {
+	rs := e.curLine()
+	if isAutoCloser(r) && e.Cursor.Col < len(rs) && rs[e.Cursor.Col] == r {
+		e.MoveRight()
+		return
+	}
+	if closer, ok := autoClose(r); ok {
+		e.insertPair(string(r), string(closer), 1)
+		return
+	}
+	e.InsertRune(r)
+}
+
 // InsertNewline splits the current line at the cursor.
 func (e *Editor) InsertNewline() {
 	rs := e.curLine()
@@ -390,9 +460,23 @@ 	e.Cursor.Row = t.logRow
 	e.Cursor.Col = t.start + min(e.goalCol, maxCol)
 }
 
-// MoveHome moves to column 0.
+// MoveHome implements smart Home: it moves to the first non-whitespace column,
+// or to column 0 if already there (toggling between the two). A blank or
+// whitespace-only line goes straight to column 0 (TASK-012).
 func (e *Editor) MoveHome() {
-	e.Cursor.Col = 0
+	rs := e.curLine()
+	first := 0
+	for first < len(rs) && isWordSpace(rs[first]) {
+		first++
+	}
+	if first == len(rs) {
+		first = 0 // nothing but whitespace
+	}
+	if e.Cursor.Col == first {
+		e.Cursor.Col = 0
+	} else {
+		e.Cursor.Col = first
+	}
 	e.setGoal()
 	e.followCursor()
 }
@@ -496,6 +580,11 @@ 			if rc, ok := wrapPair(k.Runes[0]); ok {
 				e.surroundSelection(string(k.Runes[0]), string(rc))
 				return
 			}
+		}
+		// No selection: a single rune goes through auto-close handling.
+		if !e.HasSelection() && len(k.Runes) == 1 {
+			e.typeRune(k.Runes[0])
+			return
 		}
 		e.replaceSelection()
 		for _, r := range k.Runes {
internal/editor/polish_test.go +124 −0
@@ -0,0 +1,124 @@
+package editor
+
+import (
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func typeRune(e *Editor, r rune) {
+	e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+}
+
+func TestAutoCloseInsertsPair(t *testing.T) {
+	cases := []struct {
+		open rune
+		want string
+	}{
+		{'(', "()"},
+		{'[', "[]"},
+		{'`', "``"},
+	}
+	for _, c := range cases {
+		e := newEditorWith("")
+		typeRune(e, c.open)
+		if e.Lines[0] != c.want {
+			t.Errorf("typing %q: line = %q, want %q", c.open, e.Lines[0], c.want)
+		}
+		if e.Cursor.Col != 1 {
+			t.Errorf("typing %q: cursor col = %d, want 1 (between pair)", c.open, e.Cursor.Col)
+		}
+	}
+}
+
+func TestAutoCloseStepsOverClosing(t *testing.T) {
+	e := newEditorWith("")
+	typeRune(e, '(') // -> "()", cursor 1
+	typeRune(e, ')') // should step over, not insert another )
+	if e.Lines[0] != "()" {
+		t.Errorf("line = %q, want ()", e.Lines[0])
+	}
+	if e.Cursor.Col != 2 {
+		t.Errorf("cursor col = %d, want 2 (past closing)", e.Cursor.Col)
+	}
+}
+
+func TestAutoCloseBacktickStepsOver(t *testing.T) {
+	e := newEditorWith("")
+	typeRune(e, '`') // -> "``", cursor 1
+	typeRune(e, '`') // step over
+	if e.Lines[0] != "``" || e.Cursor.Col != 2 {
+		t.Errorf("line=%q col=%d, want `` col 2", e.Lines[0], e.Cursor.Col)
+	}
+}
+
+func TestClosingWithoutPairInsertsNormally(t *testing.T) {
+	e := newEditorWith("")
+	typeRune(e, ')') // no auto-pair to the right -> plain insert
+	if e.Lines[0] != ")" || e.Cursor.Col != 1 {
+		t.Errorf("line=%q col=%d, want ) col 1", e.Lines[0], e.Cursor.Col)
+	}
+}
+
+func TestAutoCloseSkippedWithSelection(t *testing.T) {
+	e := newEditorWith("word")
+	e.setSelection(Position{0, 0}, Position{0, 4}) // select "word"
+	typeRune(e, '(')                               // surround, not auto-close-empty
+	if e.Lines[0] != "(word)" {
+		t.Errorf("line = %q, want (word)", e.Lines[0])
+	}
+}
+
+func TestSmartHomeTogglesFirstNonBlank(t *testing.T) {
+	e := newEditorWith("  - foo")
+	e.Cursor = Position{Row: 0, Col: 7} // end of line
+	e.MoveHome()
+	if e.Cursor.Col != 2 {
+		t.Errorf("first Home col = %d, want 2 (first non-blank)", e.Cursor.Col)
+	}
+	e.MoveHome()
+	if e.Cursor.Col != 0 {
+		t.Errorf("second Home col = %d, want 0", e.Cursor.Col)
+	}
+	e.MoveHome()
+	if e.Cursor.Col != 2 {
+		t.Errorf("third Home col = %d, want 2 (back to first non-blank)", e.Cursor.Col)
+	}
+}
+
+func TestGotoLineClamps(t *testing.T) {
+	e := newEditorWith("one", "two", "three")
+	e.GotoLine(2)
+	if e.Cursor.Row != 1 || e.Cursor.Col != 0 {
+		t.Errorf("GotoLine(2) cursor = %+v, want {1 0}", e.Cursor)
+	}
+	e.GotoLine(99)
+	if e.Cursor.Row != 2 {
+		t.Errorf("GotoLine(99) row = %d, want 2 (last)", e.Cursor.Row)
+	}
+	e.GotoLine(0)
+	if e.Cursor.Row != 0 {
+		t.Errorf("GotoLine(0) row = %d, want 0", e.Cursor.Row)
+	}
+}
+
+func TestSetCursorClamps(t *testing.T) {
+	e := newEditorWith("ab", "cde")
+	e.SetCursor(Position{Row: 1, Col: 2})
+	if e.Cursor != (Position{Row: 1, Col: 2}) {
+		t.Errorf("cursor = %+v, want {1 2}", e.Cursor)
+	}
+	e.SetCursor(Position{Row: 9, Col: 9}) // out of range -> clamp to {1,3}
+	if e.Cursor != (Position{Row: 1, Col: 3}) {
+		t.Errorf("clamped cursor = %+v, want {1 3}", e.Cursor)
+	}
+}
+
+func TestSmartHomeNoIndent(t *testing.T) {
+	e := newEditorWith("abc")
+	e.Cursor = Position{Row: 0, Col: 3}
+	e.MoveHome()
+	if e.Cursor.Col != 0 {
+		t.Errorf("Home col = %d, want 0", e.Cursor.Col)
+	}
+}
internal/help/help.go +7 −0
@@ -30,6 +30,7 @@   Ctrl+S                save (an unnamed buffer prompts for a name)
   Ctrl+P                toggle the read preview
   Ctrl+F                fuzzy file picker
   Ctrl+G                find in document (Enter/down next, Shift+Tab/up prev)
+  Ctrl+L                go to line (type a number, Enter to jump)
   Ctrl+D                today's daily note
   Ctrl+N                new note in the current directory
   Ctrl+B                new note in the inbox
@@ -43,6 +44,12 @@   Ctrl+Z / Ctrl+Y       undo / redo
   Ctrl+/                toggle this help overlay
   Ctrl+Q                quit (press twice if there are unsaved changes)
   Esc                   back to the editor
+
+EDITING
+  Home                  jump to the first non-blank column, then to column 0
+  ( [ and backtick      auto-close (no selection): inserts the matching closer
+                        with the cursor between; type the closer to step past
+  paste a URL on a selection   wraps it as a [selection](url) link
 
 CONFIG
   ~/.config/glint/config.toml   (run 'glint -c' to set it up)