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

feat: full-text vault search, preview & export CLI flags

a3c1891cbeb4f69a43c60b5ccfa26eedc8d95d3f
Kevin Kortum <kevinkortum@me.com> · 2026-06-29 22:35

parent abf7406c

feat: full-text vault search, preview & export CLI flags

- TASK-013: a '/'-prefixed picker query runs a full-text content search
  (ripgrep with a Go-walk fallback), showing file:line and opening at the match
- TASK-019: glint -p/--preview [file] opens straight into the Glamour read view
- TASK-030: glint -e/--export <file> renders a file to printable HTML headlessly

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

11 files changed

README.md +3 −1
@@ -30,6 +30,8 @@ ```bash
 glint                 # fuzzy picker over the current directory
 glint notes.md        # open a file
 glint -n [name]       # new note in the current dir (-n -i → inbox, -n -v → vault)
+glint -p [file]       # open straight into the read preview (glow-style reader)
+glint -e <file>       # export a file to printable house-style HTML and open it
 glint -t              # today's daily note
 glint -d              # browse the daily-notes folder
 glint -v              # fuzzy picker over your vault, from anywhere
@@ -67,7 +69,7 @@ | `Ctrl+Z` / `Ctrl+Y` | undo / redo |
 | `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
 | `Ctrl+E` | export a printable HTML document and open it in the browser → Print → Save as PDF (see [Export](#export-pdf)) |
 | `Ctrl+P` | toggle the Glamour read preview |
-| `Ctrl+F` | fuzzy file picker (with live preview) |
+| `Ctrl+F` | fuzzy file picker (with live preview); prefix the query with `/` to full-text search note contents — results show `file:line  text` and open at the match |
 | `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 |
- → Full-text-vault-search.md +16 −4
@@ -1,10 +1,10 @@
 ---
 id: TASK-013
 title: Full-text vault search
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 16:26'
-updated_date: '2026-06-30 00:21'
+updated_date: '2026-06-30 05:07'
 labels:
   - feature
   - release-1
@@ -21,6 +21,18 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 Search note contents across the vault; results show file + matching line
-- [ ] #2 Selecting a result opens the file at the match
+- [x] #1 Search note contents across the vault; results show file + matching line
+- [x] #2 Selecting a result opens the file at the match
 <!-- AC:END -->
+
+## Implementation Plan
+
+<!-- SECTION:PLAN:BEGIN -->
+1. search.go: contentSearch(root,query) → ripgrep (rg -g *.md, exit-1=empty) with goWalkSearch fallback; SearchHit{Path,Line,Text}; cap maxHits. 2. picker: result type {path,line,text}; '/'-prefixed query runs content search, else filename fuzzy; Selected()/SelectedLine(); listView shows relpath:line text. 3. app: Enter on a content hit Loads then GotoLine(ln). 4. README+help document '/query' content search. 5. TDD throughout.
+<!-- SECTION:PLAN:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+search.go (rg with go-walk fallback, SearchHit, maxHits cap); picker '/'-prefix runs contentSearch, result{path,line,text}, Selected()/SelectedLine(), rowLabel shows file:line text; app Enter GotoLine on a hit; README+help document /query. 7 tests, all green.
+<!-- SECTION:NOTES:END -->
- → Open-directly-in-preview-reading-mode-p-preview.md +11 −5
@@ -1,10 +1,10 @@
 ---
 id: TASK-019
 title: 'Open directly in preview / reading mode (-p, --preview)'
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
 assignee: []
 created_date: '2026-06-29 16:56'
-updated_date: '2026-06-29 17:49'
+updated_date: '2026-06-30 05:11'
 labels:
   - feature
   - release-1
@@ -21,7 +21,13 @@ <!-- SECTION:DESCRIPTION:END -->
 
 ## Acceptance Criteria
 <!-- AC:BEGIN -->
-- [ ] #1 glint -p [file] (and --preview / -preview / --p) opens the file directly in the read preview
-- [ ] #2 Ctrl+P toggles back to the editor; quit/esc behave normally
-- [ ] #3 Works as a glow-style reader (themed preview, no dark panels)
+- [x] #1 glint -p [file] (and --preview / -preview / --p) opens the file directly in the read preview
+- [x] #2 Ctrl+P toggles back to the editor; quit/esc behave normally
+- [x] #3 Works as a glow-style reader (themed preview, no dark panels)
 <!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+main.go -p/--preview flag → App.StartPreview(path): Load then togglePreview (ModePreview); no file falls back to picker. setSize re-renders preview at new width. README usage + help COMMANDS document -p. 3 app tests, all green.
+<!-- SECTION:NOTES:END -->
- → CLI-export-flag-e-export-to-HTML.md +32 −0
@@ -0,0 +1,32 @@
+---
+id: TASK-030
+title: CLI export flag -e / --export to HTML
+status: "\U0001F3C1 Done"
+assignee: []
+created_date: '2026-06-30 05:13'
+updated_date: '2026-06-30 05:31'
+labels:
+  - feature
+dependencies: []
+priority: medium
+ordinal: 29000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+Like -p (preview), add -e / --export [file] to export a named markdown file straight to printable house-style HTML from the command line — headless, no TUI. Mirrors Ctrl+E: render via export.Write, open in browser, print the output path, exit. Requires a file arg.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [x] #1 glint -e <file> (and --export / -export / --e) exports the file to house-style HTML and opens it in the browser
+- [x] #2 Prints the output path; headless (no editor TUI); errors if no file given
+- [x] #3 README + help document the flag
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+main.go -e/--export standalone command → App.ExportFile(path): writeExport (read+export.Write, no browser) then OpenInBrowser best-effort; prints output path; errors if no file. Extracted shared exportOptions (Ctrl+E reuses it). README usage + help document -e. 2 app tests green.
+<!-- SECTION:NOTES:END -->
internal/app/app.go +56 −9
@@ -188,6 +188,20 @@ 		return nil
 	}
 }
 
+// StartPreview opens path straight into the Glamour read view (glint -p), making
+// glint a glow-style reader. Ctrl+P toggles back to the editor, which already
+// holds the loaded file. With no path it falls back to the picker (TASK-019).
+func (a *App) StartPreview(path string) error {
+	if path == "" {
+		return a.Start("", false)
+	}
+	if err := a.Load(path); err != nil {
+		return err
+	}
+	a.togglePreview() // render the buffer and switch to ModePreview
+	return nil
+}
+
 func (a *App) Init() tea.Cmd { return nil }
 
 // Update routes messages. Global keys are handled first, then mode-specific.
@@ -389,6 +403,8 @@ 		if msg.Type == tea.KeyEnter {
 			if sel := a.picker.Selected(); sel != "" {
 				if err := a.Load(sel); err != nil {
 					a.status = "Open failed: " + err.Error()
+				} else if ln := a.picker.SelectedLine(); ln > 0 {
+					a.editor.GotoLine(ln) // a content-search hit opens at the match (TASK-013)
 				}
 			}
 			return a, nil
@@ -419,15 +435,7 @@ // the house style and opens it in the browser, where the user picks
 // Print → Save as PDF (TASK-021). No external converter is required.
 func (a *App) exportPDF() (tea.Model, tea.Cmd) {
 	md := string(a.editor.Bytes())
-	opts := export.Options{
-		Title:       export.Title(a.path, md),
-		Theme:       export.MapTheme(a.theme.Name),
-		FontDisplay: a.cfg.FontDisplay,
-		FontBody:    a.cfg.FontBody,
-		FontMono:    a.cfg.FontMono,
-		Cover:       true,
-	}
-	out, err := export.Write(a.path, md, opts)
+	out, err := export.Write(a.path, md, a.exportOptions(a.path, md))
 	if err != nil {
 		a.status = "Export failed: " + err.Error()
 		return a, nil
@@ -439,6 +447,42 @@ 		return a, nil
 	}
 	a.status = "Exported " + out + " — Print → Save as PDF in the browser"
 	return a, nil
+}
+
+// exportOptions builds the house-style export options for path/markdown, shared
+// by the in-editor Ctrl+E and the headless `glint -e` command (TASK-030).
+func (a *App) exportOptions(path, md string) export.Options {
+	return export.Options{
+		Title:       export.Title(path, md),
+		Theme:       export.MapTheme(a.theme.Name),
+		FontDisplay: a.cfg.FontDisplay,
+		FontBody:    a.cfg.FontBody,
+		FontMono:    a.cfg.FontMono,
+		Cover:       true,
+	}
+}
+
+// writeExport renders the markdown file at path to a printable HTML document
+// beside it and returns the output path, without opening a browser (TASK-030).
+func (a *App) writeExport(path string) (string, error) {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return "", err
+	}
+	md := string(data)
+	return export.Write(path, md, a.exportOptions(path, md))
+}
+
+// ExportFile is the headless form of Ctrl+E for `glint -e <file>`: it renders
+// path to house-style HTML, opens it in the browser (best-effort), and returns
+// the output path (TASK-030).
+func (a *App) ExportFile(path string) (string, error) {
+	out, err := a.writeExport(path)
+	if err != nil {
+		return "", err
+	}
+	_ = export.OpenInBrowser(out) // best-effort; the path is returned regardless
+	return out, nil
 }
 
 // promptSaveAs opens the one-line save-as prompt for an unnamed buffer.
@@ -804,6 +848,9 @@ 	a.helpView.Width = maxInt(cw-2, 1)
 	a.helpView.Height = maxInt(textRows-4, 1)
 	if a.picker != nil {
 		a.picker.SetSize(w, h-1) // picker keeps its full-width split
+	}
+	if a.mode == ModePreview {
+		_ = a.preview.Render(string(a.editor.Bytes())) // re-wrap at the new width
 	}
 }
 
internal/app/app_test.go +70 −0
@@ -756,3 +756,73 @@ 	if got := a.editor.Lines[0]; got != "- [ ] task" {
 		t.Fatalf("click on content toggled the box: line = %q", got)
 	}
 }
+
+func TestStartPreviewOpensFileInReadView(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "doc.md")
+	os.WriteFile(p, []byte("# Title\n\nbody"), 0o644)
+	a := New(config.Default())
+	if err := a.StartPreview(p); err != nil {
+		t.Fatal(err)
+	}
+	if a.mode != ModePreview {
+		t.Errorf("StartPreview should start in ModePreview, mode = %d", a.mode)
+	}
+	if string(a.editor.Bytes()) != "# Title\n\nbody" {
+		t.Errorf("StartPreview should load the file into the editor too")
+	}
+}
+
+func TestStartPreviewCtrlPTogglesToEditor(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "doc.md")
+	os.WriteFile(p, []byte("# Title"), 0o644)
+	a := New(config.Default())
+	if err := a.StartPreview(p); err != nil {
+		t.Fatal(err)
+	}
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
+	if a.mode != ModeEditor {
+		t.Errorf("Ctrl+P from preview should return to the editor, mode = %d", a.mode)
+	}
+}
+
+func TestStartPreviewNoFileFallsBackToPicker(t *testing.T) {
+	dir := t.TempDir()
+	a := New(config.Default())
+	t.Setenv("GLINT_VAULT", dir)
+	if err := a.StartPreview(""); err != nil {
+		t.Fatal(err)
+	}
+	if a.mode != ModePicker {
+		t.Errorf("StartPreview with no file should fall back to the picker, mode = %d", a.mode)
+	}
+}
+
+func TestWriteExportProducesHtml(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "doc.md")
+	os.WriteFile(p, []byte("# My Title\n\nbody text"), 0o644)
+	a := New(config.Default())
+	out, err := a.writeExport(p)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if out != filepath.Join(dir, "doc.html") {
+		t.Errorf("out = %q, want doc.html beside the source", out)
+	}
+	data, err := os.ReadFile(out)
+	if err != nil {
+		t.Fatalf("export file not written: %v", err)
+	}
+	if !strings.Contains(string(data), "My Title") {
+		t.Errorf("exported HTML should contain the title")
+	}
+}
+
+func TestWriteExportMissingFile(t *testing.T) {
+	a := New(config.Default())
+	if _, err := a.writeExport(filepath.Join(t.TempDir(), "nope.md")); err == nil {
+		t.Error("writeExport should error on a missing file")
+	}
+}
internal/help/help.go +4 −1
@@ -14,6 +14,8 @@ COMMANDS  (each takes a short letter or the full word, one or two dashes:
            -n / --n / -new / --new all work)
   -n, --new [name]      new note in the current directory
                         combine with -i or -v to target the inbox or vault
+  -p, --preview [file]  open a file straight into the read preview (glow-style)
+  -e, --export <file>   export a file to printable house-style HTML and open it
   -t, --today           open today's daily note (in the vault)
   -d, --daily           browse the daily-notes folder
   -v, --vault           fuzzy picker over your vault, from anywhere
@@ -35,7 +37,8 @@   Ctrl+S                save (an unnamed buffer prompts for a name)
   Ctrl+E                export a printable HTML doc (house style) and open it in
                         the browser → Print → Save as PDF
   Ctrl+P                toggle the read preview
-  Ctrl+F                fuzzy file picker
+  Ctrl+F                fuzzy file picker (prefix the query with / to full-text
+                        search note contents; results open at the matching line)
   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
internal/picker/picker.go +59 −5
@@ -4,6 +4,7 @@ // top, and paints with the active theme.
 package picker
 
 import (
+	"fmt"
 	"io/fs"
 	"os"
 	"path/filepath"
@@ -25,13 +26,22 @@ 	path string
 	mod  time.Time
 }
 
+// result is one match shown in the list: a file path, plus (for content-search
+// hits) the 1-based line number and the matching line's text. line == 0 marks a
+// plain filename match (TASK-013).
+type result struct {
+	path string
+	line int
+	text string
+}
+
 // Model holds the query input, the file set, and the current match list.
 type Model struct {
 	input     textinput.Model
 	all       []fileEntry
 	root      string
 	todayPath string
-	matches   []string
+	matches   []result
 	sel       int
 	width     int
 	height    int
@@ -113,15 +123,40 @@ 	m.recompute()
 	return cmd
 }
 
-// recompute refilters and reranks against the current query.
+// recompute refilters against the current query. A query prefixed with "/" runs
+// a full-text content search; otherwise it fuzzy-matches filenames (TASK-013).
 func (m *Model) recompute() {
-	m.matches = rankEntries(m.all, m.input.Value(), m.todayPath)
+	if rest, ok := strings.CutPrefix(m.input.Value(), "/"); ok {
+		m.matches = m.contentMatches(rest)
+	} else {
+		m.matches = m.fileMatches(m.input.Value())
+	}
 	if m.sel >= len(m.matches) {
 		m.sel = 0
 	}
 	m.renderPreview()
 }
 
+// fileMatches ranks filename matches as line-less results.
+func (m *Model) fileMatches(query string) []result {
+	paths := rankEntries(m.all, query, m.todayPath)
+	out := make([]result, len(paths))
+	for i, p := range paths {
+		out[i] = result{path: p}
+	}
+	return out
+}
+
+// contentMatches searches note bodies for query, returning file+line hits.
+func (m *Model) contentMatches(query string) []result {
+	hits := contentSearch(m.root, query)
+	out := make([]result, len(hits))
+	for i, h := range hits {
+		out[i] = result{path: h.Path, line: h.Line, text: h.Text}
+	}
+	return out
+}
+
 // rankEntries filters all entries by the fuzzy query and orders the survivors.
 // Empty query: today's note first, then modified-date descending. Non-empty:
 // fuzzy score descending, then modified-date descending.
@@ -166,12 +201,31 @@ 	}
 	return p
 }
 
+// rowLabel is the list text for a match: the relative path, plus ":line  text"
+// for a content-search hit (TASK-013).
+func (m *Model) rowLabel(r result) string {
+	if r.line > 0 {
+		return fmt.Sprintf("%s:%d  %s", m.label(r.path), r.line, r.text)
+	}
+	return m.label(r.path)
+}
+
 // Selected returns the highlighted match's absolute path, or "".
 func (m *Model) Selected() string {
 	if m.sel < 0 || m.sel >= len(m.matches) {
 		return ""
 	}
-	return m.matches[m.sel]
+	return m.matches[m.sel].path
+}
+
+// SelectedLine returns the 1-based line of the highlighted content-search hit,
+// or 0 for a filename match (or no selection) — the app jumps there on open
+// (TASK-013).
+func (m *Model) SelectedLine() int {
+	if m.sel < 0 || m.sel >= len(m.matches) {
+		return 0
+	}
+	return m.matches[m.sel].line
 }
 
 // previewWidth is the column width of the preview pane (~60%), or 0 when the
@@ -264,7 +318,7 @@ 		rows = 1
 	}
 	labelW := m.listColWidth() - 2 // leave room for the "▸ "/"  " prefix
 	for i := 0; i < len(m.matches) && i < rows; i++ {
-		label := truncateTail(m.label(m.matches[i]), labelW)
+		label := truncateTail(m.rowLabel(m.matches[i]), labelW)
 		if i == m.sel {
 			b.WriteString(pointer.Render("▸ "))
 			b.WriteString(selStyle.Render(label))
internal/picker/search.go +119 −0
@@ -0,0 +1,119 @@
+package picker
+
+import (
+	"bufio"
+	"os"
+	"os/exec"
+	"strconv"
+	"strings"
+)
+
+// SearchHit is one content match: a file, the 1-based line number, and the
+// matching line's text (TASK-013).
+type SearchHit struct {
+	Path string
+	Line int
+	Text string
+}
+
+// maxHits bounds a content search so a common word can't flood the list.
+const maxHits = 200
+
+// contentSearch finds query as a case-insensitive substring across the markdown
+// files under root, returning file + line hits. It shells out to ripgrep when
+// available and falls back to an in-process walk otherwise (TASK-013).
+func contentSearch(root, query string) []SearchHit {
+	query = strings.TrimSpace(query)
+	if query == "" {
+		return nil
+	}
+	if hits, ok := ripgrepSearch(root, query); ok {
+		return hits
+	}
+	return goWalkSearch(root, query)
+}
+
+// ripgrepSearch runs rg over the markdown files under root; ok is false when rg
+// is missing or errored, so the caller can fall back to the in-process walk. A
+// no-match exit (code 1) is a clean empty result, not a fallback trigger.
+func ripgrepSearch(root, query string) (hits []SearchHit, ok bool) {
+	rg, err := exec.LookPath("rg")
+	if err != nil {
+		return nil, false
+	}
+	cmd := exec.Command(rg,
+		"--line-number", "--no-heading", "--color=never",
+		"--fixed-strings", "--ignore-case",
+		"-g", "*.md",
+		"--", query, root)
+	out, err := cmd.Output()
+	if err != nil {
+		if ee, isExit := err.(*exec.ExitError); isExit && ee.ExitCode() == 1 {
+			return nil, true // no matches
+		}
+		return nil, false
+	}
+	return parseRgOutput(string(out)), true
+}
+
+// parseRgOutput turns ripgrep's "path:line:text" lines into hits, skipping
+// malformed lines and capping the count at maxHits.
+func parseRgOutput(out string) []SearchHit {
+	var hits []SearchHit
+	sc := bufio.NewScanner(strings.NewReader(out))
+	sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+	for sc.Scan() && len(hits) < maxHits {
+		if h, ok := parseHitLine(sc.Text()); ok {
+			hits = append(hits, h)
+		}
+	}
+	return hits
+}
+
+// parseHitLine splits one "path:line:text" record. A line whose second field is
+// not a number (e.g. a path containing a colon, or rg banner noise) is rejected.
+func parseHitLine(line string) (SearchHit, bool) {
+	parts := strings.SplitN(line, ":", 3)
+	if len(parts) < 3 {
+		return SearchHit{}, false
+	}
+	n, err := strconv.Atoi(parts[1])
+	if err != nil {
+		return SearchHit{}, false
+	}
+	return SearchHit{Path: parts[0], Line: n, Text: strings.TrimSpace(parts[2])}, true
+}
+
+// goWalkSearch is the ripgrep-free fallback: walk the markdown files under root
+// and scan each line for a case-insensitive substring match (TASK-013).
+func goWalkSearch(root, query string) []SearchHit {
+	files, err := walkMarkdown(root)
+	if err != nil {
+		return nil
+	}
+	needle := strings.ToLower(query)
+	var hits []SearchHit
+	for _, f := range files {
+		if len(hits) >= maxHits {
+			break
+		}
+		data, err := os.ReadFile(f.path)
+		if err != nil {
+			continue
+		}
+		sc := bufio.NewScanner(strings.NewReader(string(data)))
+		sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
+		ln := 0
+		for sc.Scan() {
+			ln++
+			text := sc.Text()
+			if strings.Contains(strings.ToLower(text), needle) {
+				hits = append(hits, SearchHit{Path: f.path, Line: ln, Text: strings.TrimSpace(text)})
+				if len(hits) >= maxHits {
+					break
+				}
+			}
+		}
+	}
+	return hits
+}
internal/picker/search_test.go +94 −0
@@ -0,0 +1,94 @@
+package picker
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"glint/internal/theme"
+)
+
+func TestGoWalkSearchFindsLineMatches(t *testing.T) {
+	root := t.TempDir()
+	os.WriteFile(filepath.Join(root, "a.md"), []byte("alpha\nfind me here\nbeta"), 0o644)
+	os.WriteFile(filepath.Join(root, "b.md"), []byte("nothing relevant"), 0o644)
+
+	hits := goWalkSearch(root, "find me")
+	if len(hits) != 1 {
+		t.Fatalf("got %d hits, want 1: %+v", len(hits), hits)
+	}
+	h := hits[0]
+	if h.Path != filepath.Join(root, "a.md") {
+		t.Errorf("path = %q, want a.md", h.Path)
+	}
+	if h.Line != 2 {
+		t.Errorf("line = %d, want 2", h.Line)
+	}
+	if h.Text != "find me here" {
+		t.Errorf("text = %q, want %q", h.Text, "find me here")
+	}
+}
+
+func TestGoWalkSearchCaseInsensitive(t *testing.T) {
+	root := t.TempDir()
+	os.WriteFile(filepath.Join(root, "a.md"), []byte("The Quick Brown Fox"), 0o644)
+	hits := goWalkSearch(root, "quick brown")
+	if len(hits) != 1 {
+		t.Fatalf("case-insensitive search should find 1 hit, got %d", len(hits))
+	}
+}
+
+func TestParseRgOutput(t *testing.T) {
+	out := "/v/a.md:2:find me here\n/v/sub/b.md:10:another match\nmalformed line\n"
+	hits := parseRgOutput(out)
+	if len(hits) != 2 {
+		t.Fatalf("got %d hits, want 2 (malformed skipped): %+v", len(hits), hits)
+	}
+	if hits[0].Path != "/v/a.md" || hits[0].Line != 2 || hits[0].Text != "find me here" {
+		t.Errorf("hit0 = %+v", hits[0])
+	}
+	if hits[1].Line != 10 {
+		t.Errorf("hit1 line = %d, want 10", hits[1].Line)
+	}
+}
+
+func TestContentSearchEmptyQuery(t *testing.T) {
+	if hits := contentSearch(t.TempDir(), "   "); hits != nil {
+		t.Errorf("blank query should yield no hits, got %+v", hits)
+	}
+}
+
+func TestPickerSlashPrefixContentSearch(t *testing.T) {
+	root := t.TempDir()
+	os.WriteFile(filepath.Join(root, "note.md"), []byte("intro\nthe needle line\noutro"), 0o644)
+
+	m, err := New(root, theme.FlexokiDark(), "", "dark")
+	if err != nil {
+		t.Fatal(err)
+	}
+	m.input.SetValue("/needle")
+	m.recompute()
+
+	if got := m.Selected(); got != filepath.Join(root, "note.md") {
+		t.Errorf("Selected() = %q, want note.md", got)
+	}
+	if got := m.SelectedLine(); got != 2 {
+		t.Errorf("SelectedLine() = %d, want 2", got)
+	}
+}
+
+func TestPickerFilenameModeHasNoLine(t *testing.T) {
+	root := t.TempDir()
+	os.WriteFile(filepath.Join(root, "note.md"), []byte("body"), 0o644)
+
+	m, err := New(root, theme.FlexokiDark(), "", "dark")
+	if err != nil {
+		t.Fatal(err)
+	}
+	m.input.SetValue("note")
+	m.recompute()
+
+	if m.SelectedLine() != 0 {
+		t.Errorf("filename match should have line 0, got %d", m.SelectedLine())
+	}
+}
main.go +22 −0
@@ -53,6 +53,8 @@ 	flagDaily := boolFlag("d", "daily")
 	flagVault := boolFlag("v", "vault")
 	flagConfig := boolFlag("c", "config")
 	flagInbox := boolFlag("i", "inbox")
+	flagPreview := boolFlag("p", "preview")
+	flagExport := boolFlag("e", "export")
 	flagKeys := flag.Bool("keys", false, "show what the terminal sends for each key")
 	flag.Parse()
 
@@ -62,6 +64,8 @@ 	isDaily := *flagDaily[0] || *flagDaily[1]
 	isVault := *flagVault[0] || *flagVault[1]
 	isConfig := *flagConfig[0] || *flagConfig[1]
 	isInbox := *flagInbox[0] || *flagInbox[1]
+	isPreview := *flagPreview[0] || *flagPreview[1]
+	isExport := *flagExport[0] || *flagExport[1]
 
 	// Standalone commands (no editor TUI).
 	if isConfig {
@@ -79,8 +83,26 @@ 		name = args[0]
 	}
 
 	a := app.New(cfg)
+
+	// Headless export: render a named file to HTML and exit, no TUI.
+	if isExport {
+		if name == "" {
+			fmt.Fprintln(os.Stderr, "glint: -e needs a file to export")
+			os.Exit(1)
+		}
+		out, err := a.ExportFile(name)
+		if err != nil {
+			fmt.Fprintln(os.Stderr, "glint:", err)
+			os.Exit(1)
+		}
+		fmt.Println("glint: exported", out, "— open it, then Print → Save as PDF")
+		return
+	}
+
 	var startErr error
 	switch {
+	case isPreview:
+		startErr = a.StartPreview(name) // open straight into the read view (glow-style)
 	case isNew:
 		// New note in the current dir, or the inbox/vault when combined.
 		dir := cfg.WorkingDir()