Add glint editor v1 implementation plan
e6baa68389839285b9deefb81121ef02073fb57d
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-06-27 20:39
parent 736cd3bf
Add glint editor v1 implementation plan Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 files changed
docs/superpowers/plans/2026-06-27-glint-editor.md +2418 −0
@@ -0,0 +1,2418 @@
+# glint Editor (v1) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Build a modeless terminal markdown editor with live inline styling (markup stays visible), a glamour read-preview, a fuzzy file picker, and a daily-note jump.
+
+**Architecture:** Bubbletea MVU. A top-level `app` model holds a `mode` enum (Editor | Picker | Preview) and routes messages to the active sub-model. The `editor` package is a self-contained text buffer plus a hand-rolled, line-oriented markdown styling scanner that emits Lipgloss-styled spans char-for-char aligned with the raw text (no concealment). `preview` wraps Glamour; `picker` walks a directory and fuzzy-filters; `config` loads TOML.
+
+**Tech Stack:** Go 1.26, Bubbletea v1.3.10, Bubbles v1.0.0 (viewport, textinput), Lipgloss v1.1.0, Glamour v1.0.0, BurntSushi/toml v1.6.0.
+
+## Global Constraints
+
+- Module path: `glint`. Internal packages import as `glint/internal/<pkg>`.
+- Go version floor: `go 1.26`.
+- Pinned dependency versions (exact): bubbletea `v1.3.10`, bubbles `v1.0.0`, lipgloss `v1.1.0`, glamour `v1.0.0`, BurntSushi/toml `v1.6.0`.
+- **Styling invariant:** every emitted span — including plain prose and empty lines — gets an explicit `Foreground` from the theme. Never `lipgloss.Style{}` / terminal default. This is the root-cause fix for the ekphos light-terminal bug.
+- **Markup-visible invariant:** the styling scanner never inserts or deletes characters. The concatenation of a line's span texts equals the raw line exactly.
+- Modeless editing only. No vim modes.
+- Config keys (TOML, `~/.config/glint/config.toml`): `vault_dir`, `daily_subdir`, `daily_format` (Go reference-time layout), `glamour_style`.
+- Config defaults: `vault_dir = "<home>/Humdrum"`, `daily_subdir = "Daily"`, `daily_format = "2006-01-02"`, `glamour_style = "dark"`.
+- Keybinds: `Ctrl+S` save, `Ctrl+P` picker, `Ctrl+D` daily, `Ctrl+R` preview toggle, `Esc` back to editor, `Ctrl+Q` quit (confirm if dirty).
+
+---
+
+## File Structure
+
+```
+glint/
+ go.mod
+ main.go entrypoint: flags, config load, build app, run program
+ internal/
+ config/
+ config.go Config struct, Load, Default, DailyPath
+ config_test.go
+ editor/
+ theme.go Theme struct + DefaultDarkTheme
+ span.go Span type + render helpers (with/without cursor)
+ scanner.go blockState, ScanLines, scanLine, scanInline, matchToken
+ scanner_test.go
+ editor.go Editor model: buffer, cursor, scroll, dirty, ops, View
+ editor_test.go
+ preview/
+ preview.go Glamour render -> viewport wrapper
+ preview_test.go
+ picker/
+ picker.go dir walk, fuzzy match, textinput query, selection
+ picker_test.go
+ app/
+ app.go top model: mode enum, routing, status bar, save/load
+ app_test.go
+```
+
+Each `internal/` package has one responsibility and is testable without a running terminal. `editor` knows nothing about files or the vault (the app wires save/load). `config`, the scanner, the editor ops, and the fuzzy matcher are all pure and carry the high-value unit tests.
+
+---
+
+## Task 1: Project scaffold + config package
+
+**Files:**
+- Create: `go.mod` (via `go mod init`)
+- Create: `internal/config/config.go`
+- Test: `internal/config/config_test.go`
+
+**Interfaces:**
+- Consumes: nothing (first task).
+- Produces:
+ - `config.Config` struct with fields `VaultDir, DailySubdir, DailyFormat, GlamourStyle string`.
+ - `config.Default() Config`
+ - `config.Load() (Config, error)` — reads `~/.config/glint/config.toml`, overlays onto defaults; absent file → defaults + nil error; malformed → defaults + non-nil error.
+ - `config.loadFromFile(path string) (Config, error)` — unexported, testable core of `Load`.
+ - `func (c Config) DailyPath(t time.Time) string`
+
+- [ ] **Step 1: Initialize the module and fetch pinned dependencies**
+
+Run:
+```bash
+cd /Users/kortum/Developer/Home/glint
+go mod init glint
+go get github.com/charmbracelet/bubbletea@v1.3.10
+go get github.com/charmbracelet/bubbles@v1.0.0
+go get github.com/charmbracelet/lipgloss@v1.1.0
+go get github.com/charmbracelet/glamour@v1.0.0
+go get github.com/BurntSushi/toml@v1.6.0
+```
+Expected: `go.mod` created with `module glint`, `go 1.26`, and the five `require` lines.
+
+- [ ] **Step 2: Write the failing test**
+
+Create `internal/config/config_test.go`:
+```go
+package config
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestDefaultHasSaneValues(t *testing.T) {
+ d := Default()
+ if d.DailySubdir != "Daily" {
+ t.Errorf("DailySubdir = %q, want Daily", d.DailySubdir)
+ }
+ if d.DailyFormat != "2006-01-02" {
+ t.Errorf("DailyFormat = %q, want 2006-01-02", d.DailyFormat)
+ }
+ if d.GlamourStyle != "dark" {
+ t.Errorf("GlamourStyle = %q, want dark", d.GlamourStyle)
+ }
+ if d.VaultDir == "" {
+ t.Error("VaultDir should not be empty")
+ }
+}
+
+func TestLoadFromFileMissingReturnsDefaults(t *testing.T) {
+ cfg, err := loadFromFile(filepath.Join(t.TempDir(), "nope.toml"))
+ if err != nil {
+ t.Fatalf("missing file should not error, got %v", err)
+ }
+ if cfg.DailyFormat != "2006-01-02" {
+ t.Errorf("DailyFormat = %q, want default", cfg.DailyFormat)
+ }
+}
+
+func TestLoadFromFileOverlays(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "config.toml")
+ body := `vault_dir = "/tmp/vault"
+daily_subdir = "journal"
+daily_format = "20060102"
+`
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg, err := loadFromFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if cfg.VaultDir != "/tmp/vault" {
+ t.Errorf("VaultDir = %q", cfg.VaultDir)
+ }
+ if cfg.DailySubdir != "journal" {
+ t.Errorf("DailySubdir = %q", cfg.DailySubdir)
+ }
+ if cfg.DailyFormat != "20060102" {
+ t.Errorf("DailyFormat = %q", cfg.DailyFormat)
+ }
+ // unset key keeps default
+ if cfg.GlamourStyle != "dark" {
+ t.Errorf("GlamourStyle = %q, want default dark", cfg.GlamourStyle)
+ }
+}
+
+func TestLoadFromFileMalformedErrors(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "bad.toml")
+ if err := os.WriteFile(path, []byte("vault_dir = = ="), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := loadFromFile(path); err == nil {
+ t.Error("malformed config should return an error")
+ }
+}
+
+func TestDailyPath(t *testing.T) {
+ c := Config{VaultDir: "/v", DailySubdir: "Daily", DailyFormat: "2006-01-02"}
+ got := c.DailyPath(time.Date(2026, 6, 27, 0, 0, 0, 0, time.UTC))
+ want := filepath.Join("/v", "Daily", "2026-06-27.md")
+ if got != want {
+ t.Errorf("DailyPath = %q, want %q", got, want)
+ }
+}
+```
+
+- [ ] **Step 3: Run the test to verify it fails**
+
+Run: `go test ./internal/config/`
+Expected: FAIL — `undefined: Default`, `undefined: loadFromFile`, `undefined: Config`.
+
+- [ ] **Step 4: Write the implementation**
+
+Create `internal/config/config.go`:
+```go
+// Package config loads glint's TOML configuration, falling back to defaults.
+package config
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/BurntSushi/toml"
+)
+
+// Config holds the user-tunable settings glint reads at startup.
+type Config struct {
+ VaultDir string `toml:"vault_dir"`
+ DailySubdir string `toml:"daily_subdir"`
+ DailyFormat string `toml:"daily_format"`
+ GlamourStyle string `toml:"glamour_style"`
+}
+
+// Default returns the built-in configuration used when no file is present.
+func Default() Config {
+ home, _ := os.UserHomeDir()
+ return Config{
+ VaultDir: filepath.Join(home, "Humdrum"),
+ DailySubdir: "Daily",
+ DailyFormat: "2006-01-02",
+ GlamourStyle: "dark",
+ }
+}
+
+// Load reads ~/.config/glint/config.toml and overlays it onto the defaults.
+func Load() (Config, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return Default(), nil
+ }
+ return loadFromFile(filepath.Join(home, ".config", "glint", "config.toml"))
+}
+
+// loadFromFile is the testable core of Load. A missing file yields the
+// defaults with no error; a malformed file yields the defaults plus an error.
+func loadFromFile(path string) (Config, error) {
+ cfg := Default()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return cfg, nil // absent config is fine
+ }
+ var fileCfg Config
+ if _, err := toml.Decode(string(data), &fileCfg); err != nil {
+ return cfg, fmt.Errorf("parse %s: %w", path, err)
+ }
+ if fileCfg.VaultDir != "" {
+ cfg.VaultDir = fileCfg.VaultDir
+ }
+ if fileCfg.DailySubdir != "" {
+ cfg.DailySubdir = fileCfg.DailySubdir
+ }
+ if fileCfg.DailyFormat != "" {
+ cfg.DailyFormat = fileCfg.DailyFormat
+ }
+ if fileCfg.GlamourStyle != "" {
+ cfg.GlamourStyle = fileCfg.GlamourStyle
+ }
+ return cfg, nil
+}
+
+// DailyPath builds the absolute path to the daily note for time t.
+func (c Config) DailyPath(t time.Time) string {
+ return filepath.Join(c.VaultDir, c.DailySubdir, t.Format(c.DailyFormat)+".md")
+}
+```
+
+- [ ] **Step 5: Run the test to verify it passes**
+
+Run: `go test ./internal/config/`
+Expected: PASS (all five tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add go.mod go.sum internal/config/
+git commit -m "feat: project scaffold + config package"
+```
+
+---
+
+## Task 2: Theme + styling scanner
+
+**Files:**
+- Create: `internal/editor/theme.go`
+- Create: `internal/editor/span.go`
+- Create: `internal/editor/scanner.go`
+- Test: `internal/editor/scanner_test.go`
+
+**Interfaces:**
+- Consumes: nothing from earlier tasks.
+- Produces:
+ - `editor.Theme` struct with `lipgloss.Color` fields: `Text, Heading, Code, Link, Wikilink, ListMarker, Blockquote, Accent`.
+ - `editor.DefaultDarkTheme() Theme`
+ - `editor.Span` struct `{ Text string; Style lipgloss.Style }`
+ - `editor.renderSpans(spans []Span) string`
+ - `editor.renderSpansCursor(spans []Span, col int, cursorStyle lipgloss.Style) string`
+ - `editor.ScanLines(lines []string, th Theme) [][]Span` — one span slice per input line, char-for-char aligned.
+
+- [ ] **Step 1: Write the failing test for the theme and span renderers**
+
+Create `internal/editor/scanner_test.go`:
+```go
+package editor
+
+import (
+ "testing"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// spanText concatenates the raw text of a line's spans. It must equal the
+// original raw line exactly (markup-visible invariant).
+func spanText(spans []Span) string {
+ s := ""
+ for _, sp := range spans {
+ s += sp.Text
+ }
+ return s
+}
+
+func TestDefaultThemeAllColorsSet(t *testing.T) {
+ th := DefaultDarkTheme()
+ colors := []lipgloss.Color{
+ th.Text, th.Heading, th.Code, th.Link,
+ th.Wikilink, th.ListMarker, th.Blockquote, th.Accent,
+ }
+ for i, c := range colors {
+ if c == "" {
+ t.Errorf("theme color %d is empty", i)
+ }
+ }
+}
+
+func TestRenderSpansPreservesText(t *testing.T) {
+ spans := []Span{
+ {Text: "ab", Style: lipgloss.NewStyle()},
+ {Text: "cd", Style: lipgloss.NewStyle()},
+ }
+ // rendering may add ANSI, but stripped content is checked elsewhere;
+ // here we only assert it does not panic and is non-empty.
+ if renderSpans(spans) == "" {
+ t.Error("renderSpans returned empty for non-empty spans")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/editor/`
+Expected: FAIL — `undefined: Span`, `undefined: DefaultDarkTheme`, `undefined: renderSpans`.
+
+- [ ] **Step 3: Implement the theme**
+
+Create `internal/editor/theme.go`:
+```go
+package editor
+
+import "github.com/charmbracelet/lipgloss"
+
+// Theme holds the explicit foreground colors used by the styling scanner.
+// Every span gets one of these — none ever defaults to the terminal foreground.
+type Theme struct {
+ Text lipgloss.Color
+ Heading lipgloss.Color
+ Code lipgloss.Color
+ Link lipgloss.Color
+ Wikilink lipgloss.Color
+ ListMarker lipgloss.Color
+ Blockquote lipgloss.Color
+ Accent lipgloss.Color
+}
+
+// DefaultDarkTheme is a Tokyo-Night-ish palette that reads on dark terminals.
+func DefaultDarkTheme() Theme {
+ return Theme{
+ Text: lipgloss.Color("#c0caf5"),
+ Heading: lipgloss.Color("#7aa2f7"),
+ Code: lipgloss.Color("#9ece6a"),
+ Link: lipgloss.Color("#7dcfff"),
+ Wikilink: lipgloss.Color("#bb9af7"),
+ ListMarker: lipgloss.Color("#bb9af7"),
+ Blockquote: lipgloss.Color("#565f89"),
+ Accent: lipgloss.Color("#e0af68"),
+ }
+}
+```
+
+- [ ] **Step 4: Implement the span type and renderers**
+
+Create `internal/editor/span.go`:
+```go
+package editor
+
+import (
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Span is a run of text with a single Lipgloss style.
+type Span struct {
+ Text string
+ Style lipgloss.Style
+}
+
+// renderSpans concatenates styled spans into a single string.
+func renderSpans(spans []Span) string {
+ var b strings.Builder
+ for _, s := range spans {
+ b.WriteString(s.Style.Render(s.Text))
+ }
+ return b.String()
+}
+
+// renderSpansCursor renders spans rune-by-rune, drawing the cursor cell at the
+// given rune column with cursorStyle. A cursor at or past the end of the line
+// is drawn as a styled space.
+func renderSpansCursor(spans []Span, col int, cursorStyle lipgloss.Style) string {
+ var b strings.Builder
+ idx := 0
+ for _, s := range spans {
+ for _, r := range s.Text {
+ if idx == col {
+ b.WriteString(cursorStyle.Render(string(r)))
+ } else {
+ b.WriteString(s.Style.Render(string(r)))
+ }
+ idx++
+ }
+ }
+ if col >= idx {
+ b.WriteString(cursorStyle.Render(" "))
+ }
+ return b.String()
+}
+```
+
+- [ ] **Step 5: Run to verify theme/renderer tests pass**
+
+Run: `go test ./internal/editor/ -run 'TestDefaultTheme|TestRenderSpans'`
+Expected: PASS.
+
+- [ ] **Step 6: Write the failing test for the scanner**
+
+Append to `internal/editor/scanner_test.go`:
+```go
+func TestScanPlainTextGetsExplicitForeground(t *testing.T) {
+ th := DefaultDarkTheme()
+ out := ScanLines([]string{"hello world"}, th)
+ if len(out) != 1 || len(out[0]) == 0 {
+ t.Fatalf("expected spans for one line, got %v", out)
+ }
+ for _, sp := range out[0] {
+ // A style with no foreground returns "" from GetForeground().
+ if sp.Style.GetForeground() == lipgloss.Color("") {
+ t.Errorf("plain span %q has no explicit foreground", sp.Text)
+ }
+ }
+}
+
+func TestScanPreservesRawTextAcrossConstructs(t *testing.T) {
+ th := DefaultDarkTheme()
+ lines := []string{
+ "# Heading",
+ "plain **bold** and *italic* and `code`",
+ "- a list item with a [link](http://x) and [[wikilink]]",
+ "> a quote",
+ "```",
+ "raw **not bold** here",
+ "```",
+ "---",
+ }
+ out := ScanLines(lines, th)
+ for i, raw := range lines {
+ if got := spanText(out[i]); got != raw {
+ t.Errorf("line %d: span text %q != raw %q", i, got, raw)
+ }
+ }
+}
+
+func TestScanFencedCodeSuppressesInline(t *testing.T) {
+ th := DefaultDarkTheme()
+ lines := []string{"```", "**x**", "```"}
+ out := ScanLines(lines, th)
+ // The fence body line should be a single code-colored span, not split
+ // into bold spans.
+ if len(out[1]) != 1 {
+ t.Errorf("fenced line split into %d spans, want 1", len(out[1]))
+ }
+ if out[1][0].Style.GetForeground() != th.Code {
+ t.Errorf("fenced body not code-colored")
+ }
+}
+
+func TestScanLeadingFrontmatter(t *testing.T) {
+ th := DefaultDarkTheme()
+ lines := []string{"---", "title: x", "---", "body"}
+ out := ScanLines(lines, th)
+ if out[1][0].Style.GetForeground() != th.Blockquote {
+ t.Errorf("frontmatter body not muted")
+ }
+ // after the closing ---, normal text resumes
+ if out[3][0].Style.GetForeground() != th.Text {
+ t.Errorf("post-frontmatter line should be plain text")
+ }
+}
+
+func TestScanEmptyLineYieldsNoSpans(t *testing.T) {
+ out := ScanLines([]string{""}, DefaultDarkTheme())
+ if len(out[0]) != 0 {
+ t.Errorf("empty line should yield no spans, got %d", len(out[0]))
+ }
+}
+
+func TestScanHeadingKeepsHashes(t *testing.T) {
+ th := DefaultDarkTheme()
+ out := ScanLines([]string{"### Title"}, th)
+ if spanText(out[0]) != "### Title" {
+ t.Errorf("heading hashes dropped: %q", spanText(out[0]))
+ }
+ if out[0][0].Style.GetForeground() != th.Heading {
+ t.Errorf("heading not heading-colored")
+ }
+}
+
+func TestScanListMarkerThenInline(t *testing.T) {
+ th := DefaultDarkTheme()
+ out := ScanLines([]string{"- **x**"}, th)
+ if out[0][0].Style.GetForeground() != th.ListMarker {
+ t.Errorf("first span should be the list marker, got fg %v", out[0][0].Style.GetForeground())
+ }
+ if spanText(out[0]) != "- **x**" {
+ t.Errorf("list line text altered: %q", spanText(out[0]))
+ }
+}
+```
+
+- [ ] **Step 7: Run to verify the scanner tests fail**
+
+Run: `go test ./internal/editor/ -run TestScan`
+Expected: FAIL — `undefined: ScanLines`.
+
+- [ ] **Step 8: Implement the scanner**
+
+Create `internal/editor/scanner.go`:
+```go
+package editor
+
+import (
+ "regexp"
+ "strings"
+
+ "github.com/charmbracelet/lipgloss"
+)
+
+var (
+ headingRe = regexp.MustCompile(`^\s*#{1,6}\s`)
+ listRe = regexp.MustCompile(`^\s*([-*+]|\d+\.)\s`)
+)
+
+// blockState carries cross-line context (fenced code, leading frontmatter).
+type blockState struct {
+ inFence bool
+ inFrontmatter bool
+ frontmatterDone bool
+}
+
+// ScanLines styles every line, threading block state across lines. The returned
+// slice is parallel to the input: out[i] are the spans for lines[i], whose
+// concatenated text equals lines[i] exactly.
+func ScanLines(lines []string, th Theme) [][]Span {
+ st := &blockState{}
+ out := make([][]Span, len(lines))
+ for i, ln := range lines {
+ out[i] = scanLine(i, ln, st, th)
+ }
+ return out
+}
+
+func scanLine(row int, line string, st *blockState, th Theme) []Span {
+ code := lipgloss.NewStyle().Foreground(th.Code)
+ muted := lipgloss.NewStyle().Foreground(th.Blockquote)
+ trimmed := strings.TrimSpace(line)
+
+ // Fenced code block: body lines are entirely code-colored.
+ if st.inFence {
+ if strings.HasPrefix(trimmed, "```") {
+ st.inFence = false
+ }
+ return wholeLine(line, code)
+ }
+ if strings.HasPrefix(trimmed, "```") {
+ st.inFence = true
+ return wholeLine(line, code)
+ }
+
+ // Leading YAML frontmatter: only valid starting at row 0.
+ if st.inFrontmatter {
+ if trimmed == "---" {
+ st.inFrontmatter = false
+ st.frontmatterDone = true
+ }
+ return wholeLine(line, muted)
+ }
+ if row == 0 && trimmed == "---" {
+ st.inFrontmatter = true
+ return wholeLine(line, muted)
+ }
+
+ // Heading: color the whole line, hashes included.
+ if headingRe.MatchString(line) {
+ return wholeLine(line, lipgloss.NewStyle().Foreground(th.Heading).Bold(true))
+ }
+
+ // Blockquote.
+ if strings.HasPrefix(trimmed, ">") {
+ return wholeLine(line, muted)
+ }
+
+ // List marker, then inline-scan the remainder.
+ if loc := listRe.FindStringIndex(line); loc != nil {
+ marker := line[:loc[1]]
+ rest := line[loc[1]:]
+ spans := []Span{{Text: marker, Style: lipgloss.NewStyle().Foreground(th.ListMarker)}}
+ return append(spans, scanInline(rest, th)...)
+ }
+
+ return scanInline(line, th)
+}
+
+// wholeLine returns a single span for the whole line, or no spans if empty.
+func wholeLine(line string, style lipgloss.Style) []Span {
+ if line == "" {
+ return nil
+ }
+ return []Span{{Text: line, Style: style}}
+}
+
+// scanInline emits styled spans for inline constructs, keeping all markup
+// characters visible. Plain text gets the theme's Text foreground.
+func scanInline(text string, th Theme) []Span {
+ plain := lipgloss.NewStyle().Foreground(th.Text)
+ r := []rune(text)
+ var spans []Span
+ start, i := 0, 0
+ flush := func(end int) {
+ if end > start {
+ spans = append(spans, Span{Text: string(r[start:end]), Style: plain})
+ }
+ }
+ for i < len(r) {
+ if tok, style, n, ok := matchToken(r, i, th); ok {
+ flush(i)
+ spans = append(spans, Span{Text: tok, Style: style})
+ i += n
+ start = i
+ continue
+ }
+ i++
+ }
+ flush(len(r))
+ return spans
+}
+
+// matchToken tries to match an inline construct starting at r[i]. It returns the
+// token (including its markup), the style, the rune length, and whether matched.
+func matchToken(r []rune, i int, th Theme) (string, lipgloss.Style, int, bool) {
+ // Inline code: `...`
+ if r[i] == '`' {
+ if j := indexRune(r, '`', i+1); j > i {
+ return string(r[i : j+1]), lipgloss.NewStyle().Foreground(th.Code), j + 1 - i, true
+ }
+ }
+ // Wikilink: [[...]]
+ if r[i] == '[' && i+1 < len(r) && r[i+1] == '[' {
+ if j := indexSeq(r, "]]", i+2); j >= 0 {
+ end := j + 2
+ return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Wikilink), end - i, true
+ }
+ }
+ // Link: [text](url)
+ if r[i] == '[' {
+ if c := indexRune(r, ']', i+1); c > i && c+1 < len(r) && r[c+1] == '(' {
+ if p := indexRune(r, ')', c+2); p > c {
+ end := p + 1
+ return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Link), end - i, true
+ }
+ }
+ }
+ // Bold: ** or __
+ for _, d := range []string{"**", "__"} {
+ if hasPrefixRunes(r, i, d) {
+ if j := indexSeq(r, d, i+2); j >= 0 {
+ end := j + 2
+ return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Text).Bold(true), end - i, true
+ }
+ }
+ }
+ // Italic: * or _
+ if r[i] == '*' || r[i] == '_' {
+ if j := indexRune(r, r[i], i+1); j > i {
+ end := j + 1
+ return string(r[i:end]), lipgloss.NewStyle().Foreground(th.Text).Italic(true), end - i, true
+ }
+ }
+ return "", lipgloss.Style{}, 0, false
+}
+
+func indexRune(r []rune, c rune, from int) int {
+ for i := from; i < len(r); i++ {
+ if r[i] == c {
+ return i
+ }
+ }
+ return -1
+}
+
+func indexSeq(r []rune, seq string, from int) int {
+ s := []rune(seq)
+ for i := from; i+len(s) <= len(r); i++ {
+ match := true
+ for k := range s {
+ if r[i+k] != s[k] {
+ match = false
+ break
+ }
+ }
+ if match {
+ return i
+ }
+ }
+ return -1
+}
+
+func hasPrefixRunes(r []rune, i int, prefix string) bool {
+ p := []rune(prefix)
+ if i+len(p) > len(r) {
+ return false
+ }
+ for k := range p {
+ if r[i+k] != p[k] {
+ return false
+ }
+ }
+ return true
+}
+```
+
+- [ ] **Step 9: Run all editor tests to verify they pass**
+
+Run: `go test ./internal/editor/`
+Expected: PASS (all theme, renderer, and scanner tests).
+
+- [ ] **Step 10: Commit**
+
+```bash
+git add internal/editor/theme.go internal/editor/span.go internal/editor/scanner.go internal/editor/scanner_test.go
+git commit -m "feat: markdown styling scanner with explicit foregrounds"
+```
+
+---
+
+## Task 3: Editor buffer, ops, and view
+
+**Files:**
+- Create: `internal/editor/editor.go`
+- Test: `internal/editor/editor_test.go`
+
+**Interfaces:**
+- Consumes: `Span`, `Theme`, `ScanLines`, `renderSpans`, `renderSpansCursor` (Task 2).
+- Produces:
+ - `editor.Position` struct `{ Row, Col int }`
+ - `editor.Editor` struct (exported fields `Lines []string`, `Cursor Position`, `Scroll int`, `Dirty bool`, `Width int`, `Height int`).
+ - `editor.New() *Editor`
+ - `func (e *Editor) SetContent(b []byte)` — resets buffer, cursor, scroll, dirty.
+ - `func (e *Editor) Bytes() []byte`
+ - `func (e *Editor) SetSize(w, h int)`
+ - Ops: `InsertRune(r rune)`, `InsertNewline()`, `Backspace()`, `Delete()`, `MoveLeft()`, `MoveRight()`, `MoveUp()`, `MoveDown()`, `MoveHome()`, `MoveEnd()`.
+ - `func (e *Editor) HandleKey(k tea.KeyMsg)` — maps keys to ops.
+ - `func (e *Editor) View() string`
+
+- [ ] **Step 1: Write the failing test for buffer ops**
+
+Create `internal/editor/editor_test.go`:
+```go
+package editor
+
+import (
+ "testing"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func newEditorWith(lines ...string) *Editor {
+ e := New()
+ e.Lines = append([]string{}, lines...)
+ e.SetSize(80, 10)
+ return e
+}
+
+func TestSetContentSplitsLines(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("a\nb\nc"))
+ if len(e.Lines) != 3 || e.Lines[1] != "b" {
+ t.Fatalf("Lines = %v", e.Lines)
+ }
+ if e.Dirty {
+ t.Error("SetContent should clear Dirty")
+ }
+}
+
+func TestBytesRoundTrip(t *testing.T) {
+ e := New()
+ e.SetContent([]byte("x\ny"))
+ if string(e.Bytes()) != "x\ny" {
+ t.Errorf("Bytes = %q", string(e.Bytes()))
+ }
+}
+
+func TestInsertRune(t *testing.T) {
+ e := newEditorWith("ac")
+ e.Cursor = Position{Row: 0, Col: 1}
+ e.InsertRune('b')
+ if e.Lines[0] != "abc" {
+ t.Errorf("Lines[0] = %q, want abc", e.Lines[0])
+ }
+ if e.Cursor.Col != 2 {
+ t.Errorf("Cursor.Col = %d, want 2", e.Cursor.Col)
+ }
+ if !e.Dirty {
+ t.Error("insert should set Dirty")
+ }
+}
+
+func TestInsertNewlineSplitsLine(t *testing.T) {
+ e := newEditorWith("abcd")
+ e.Cursor = Position{Row: 0, Col: 2}
+ e.InsertNewline()
+ if len(e.Lines) != 2 || e.Lines[0] != "ab" || e.Lines[1] != "cd" {
+ t.Fatalf("Lines = %v", e.Lines)
+ }
+ if e.Cursor.Row != 1 || e.Cursor.Col != 0 {
+ t.Errorf("Cursor = %+v, want {1 0}", e.Cursor)
+ }
+}
+
+func TestBackspaceJoinsLines(t *testing.T) {
+ e := newEditorWith("ab", "cd")
+ e.Cursor = Position{Row: 1, Col: 0}
+ e.Backspace()
+ if len(e.Lines) != 1 || e.Lines[0] != "abcd" {
+ t.Fatalf("Lines = %v", e.Lines)
+ }
+ if e.Cursor.Row != 0 || e.Cursor.Col != 2 {
+ t.Errorf("Cursor = %+v, want {0 2}", e.Cursor)
+ }
+}
+
+func TestBackspaceWithinLine(t *testing.T) {
+ e := newEditorWith("abc")
+ e.Cursor = Position{Row: 0, Col: 2}
+ e.Backspace()
+ if e.Lines[0] != "ac" || e.Cursor.Col != 1 {
+ t.Errorf("Lines[0]=%q Cursor.Col=%d", e.Lines[0], e.Cursor.Col)
+ }
+}
+
+func TestMovementClampsAndCrossesLines(t *testing.T) {
+ e := newEditorWith("ab", "cde")
+ e.Cursor = Position{Row: 0, Col: 2} // end of "ab"
+ e.MoveRight() // wraps to start of next line
+ if e.Cursor != (Position{Row: 1, Col: 0}) {
+ t.Errorf("after MoveRight: %+v", e.Cursor)
+ }
+ e.MoveLeft() // back to end of "ab"
+ if e.Cursor != (Position{Row: 0, Col: 2}) {
+ t.Errorf("after MoveLeft: %+v", e.Cursor)
+ }
+ e.MoveEnd()
+ e.MoveDown() // col clamps to len("cde")=3
+ if e.Cursor != (Position{Row: 1, Col: 2}) {
+ t.Errorf("after MoveDown: %+v, want {1 2}", e.Cursor)
+ }
+}
+
+func TestScrollFollowsCursorDown(t *testing.T) {
+ e := New()
+ e.SetSize(80, 3) // 3 visible rows
+ for i := 0; i < 10; i++ {
+ e.Lines = append(e.Lines, "x")
+ }
+ e.Cursor = Position{Row: 0, Col: 0}
+ for i := 0; i < 9; i++ {
+ e.MoveDown()
+ }
+ // cursor at row 9 must be visible within [Scroll, Scroll+3)
+ if e.Cursor.Row < e.Scroll || e.Cursor.Row >= e.Scroll+e.Height {
+ t.Errorf("cursor row %d not in viewport [%d,%d)", e.Cursor.Row, e.Scroll, e.Scroll+e.Height)
+ }
+}
+
+func TestHandleKeyInsertsRunes(t *testing.T) {
+ e := newEditorWith("")
+ e.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h', 'i'}})
+ if e.Lines[0] != "hi" {
+ t.Errorf("Lines[0] = %q, want hi", e.Lines[0])
+ }
+}
+
+func TestViewRendersVisibleRowsOnly(t *testing.T) {
+ e := New()
+ e.SetSize(80, 2)
+ e.Lines = []string{"one", "two", "three"}
+ e.Cursor = Position{Row: 0, Col: 0}
+ view := e.View()
+ // 2 visible rows -> exactly 2 newline-terminated lines
+ if got := countByte(view, '\n'); got != 2 {
+ t.Errorf("view has %d newlines, want 2", got)
+ }
+}
+
+func countByte(s string, b byte) int {
+ n := 0
+ for i := 0; i < len(s); i++ {
+ if s[i] == b {
+ n++
+ }
+ }
+ return n
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/editor/ -run 'TestSetContent|TestBytes|TestInsert|TestBackspace|TestMovement|TestScroll|TestHandleKey|TestView'`
+Expected: FAIL — `undefined: New`, `undefined: Position`, etc.
+
+- [ ] **Step 3: Implement the editor**
+
+Create `internal/editor/editor.go`:
+```go
+package editor
+
+import (
+ "strings"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Position is a cursor location in rune coordinates.
+type Position struct {
+ Row, Col int
+}
+
+// Editor is a self-contained markdown text buffer. It knows nothing about
+// files or the vault; the app wires load and save around it.
+type Editor struct {
+ Lines []string
+ Cursor Position
+ Scroll int
+ Dirty bool
+ Width int
+ Height int // visible text rows
+ theme Theme
+}
+
+// New returns an empty editor with one blank line and the default theme.
+func New() *Editor {
+ return &Editor{
+ Lines: []string{""},
+ theme: DefaultDarkTheme(),
+ Width: 80,
+ Height: 24,
+ }
+}
+
+// SetContent replaces the buffer, resetting cursor, scroll, and dirty state.
+func (e *Editor) SetContent(b []byte) {
+ text := strings.ReplaceAll(string(b), "\r\n", "\n")
+ e.Lines = strings.Split(text, "\n")
+ if len(e.Lines) == 0 {
+ e.Lines = []string{""}
+ }
+ e.Cursor = Position{}
+ e.Scroll = 0
+ e.Dirty = false
+}
+
+// Bytes serializes the buffer with \n line separators.
+func (e *Editor) Bytes() []byte {
+ return []byte(strings.Join(e.Lines, "\n"))
+}
+
+// SetSize records the viewport dimensions (h = visible text rows).
+func (e *Editor) SetSize(w, h int) {
+ e.Width = w
+ if h < 1 {
+ h = 1
+ }
+ e.Height = h
+ e.followCursor()
+}
+
+func (e *Editor) curLine() []rune { return []rune(e.Lines[e.Cursor.Row]) }
+func (e *Editor) setLine(rs []rune) { e.Lines[e.Cursor.Row] = string(rs) }
+
+// InsertRune inserts r at the cursor and advances it.
+func (e *Editor) InsertRune(r rune) {
+ rs := e.curLine()
+ col := clamp(e.Cursor.Col, 0, len(rs))
+ rs = append(rs[:col], append([]rune{r}, rs[col:]...)...)
+ e.setLine(rs)
+ e.Cursor.Col = col + 1
+ e.Dirty = true
+}
+
+// InsertNewline splits the current line at the cursor.
+func (e *Editor) InsertNewline() {
+ rs := e.curLine()
+ col := clamp(e.Cursor.Col, 0, len(rs))
+ left, right := string(rs[:col]), string(rs[col:])
+ e.Lines[e.Cursor.Row] = left
+ rest := append([]string{right}, e.Lines[e.Cursor.Row+1:]...)
+ e.Lines = append(e.Lines[:e.Cursor.Row+1], rest...)
+ e.Cursor.Row++
+ e.Cursor.Col = 0
+ e.Dirty = true
+ e.followCursor()
+}
+
+// Backspace deletes the rune before the cursor, joining lines at column 0.
+func (e *Editor) Backspace() {
+ if e.Cursor.Col > 0 {
+ rs := e.curLine()
+ rs = append(rs[:e.Cursor.Col-1], rs[e.Cursor.Col:]...)
+ e.setLine(rs)
+ e.Cursor.Col--
+ e.Dirty = true
+ return
+ }
+ if e.Cursor.Row == 0 {
+ return
+ }
+ prev := []rune(e.Lines[e.Cursor.Row-1])
+ joinCol := len(prev)
+ merged := string(prev) + e.Lines[e.Cursor.Row]
+ e.Lines[e.Cursor.Row-1] = merged
+ e.Lines = append(e.Lines[:e.Cursor.Row], e.Lines[e.Cursor.Row+1:]...)
+ e.Cursor.Row--
+ e.Cursor.Col = joinCol
+ e.Dirty = true
+ e.followCursor()
+}
+
+// Delete removes the rune at the cursor, joining the next line at end-of-line.
+func (e *Editor) Delete() {
+ rs := e.curLine()
+ if e.Cursor.Col < len(rs) {
+ rs = append(rs[:e.Cursor.Col], rs[e.Cursor.Col+1:]...)
+ e.setLine(rs)
+ e.Dirty = true
+ return
+ }
+ if e.Cursor.Row >= len(e.Lines)-1 {
+ return
+ }
+ e.Lines[e.Cursor.Row] = e.Lines[e.Cursor.Row] + e.Lines[e.Cursor.Row+1]
+ e.Lines = append(e.Lines[:e.Cursor.Row+1], e.Lines[e.Cursor.Row+2:]...)
+ e.Dirty = true
+}
+
+// MoveLeft moves one rune left, wrapping to the end of the previous line.
+func (e *Editor) MoveLeft() {
+ if e.Cursor.Col > 0 {
+ e.Cursor.Col--
+ } else if e.Cursor.Row > 0 {
+ e.Cursor.Row--
+ e.Cursor.Col = len([]rune(e.Lines[e.Cursor.Row]))
+ }
+ e.followCursor()
+}
+
+// MoveRight moves one rune right, wrapping to the start of the next line.
+func (e *Editor) MoveRight() {
+ if e.Cursor.Col < len(e.curLine()) {
+ e.Cursor.Col++
+ } else if e.Cursor.Row < len(e.Lines)-1 {
+ e.Cursor.Row++
+ e.Cursor.Col = 0
+ }
+ e.followCursor()
+}
+
+// MoveUp moves to the previous line, clamping the column.
+func (e *Editor) MoveUp() {
+ if e.Cursor.Row > 0 {
+ e.Cursor.Row--
+ e.Cursor.Col = clamp(e.Cursor.Col, 0, len([]rune(e.Lines[e.Cursor.Row])))
+ }
+ e.followCursor()
+}
+
+// MoveDown moves to the next line, clamping the column.
+func (e *Editor) MoveDown() {
+ if e.Cursor.Row < len(e.Lines)-1 {
+ e.Cursor.Row++
+ e.Cursor.Col = clamp(e.Cursor.Col, 0, len([]rune(e.Lines[e.Cursor.Row])))
+ }
+ e.followCursor()
+}
+
+// MoveHome moves to column 0; MoveEnd to end of line.
+func (e *Editor) MoveHome() { e.Cursor.Col = 0 }
+func (e *Editor) MoveEnd() { e.Cursor.Col = len(e.curLine()) }
+
+// followCursor scrolls the viewport so the cursor row stays visible.
+func (e *Editor) followCursor() {
+ if e.Cursor.Row < e.Scroll {
+ e.Scroll = e.Cursor.Row
+ }
+ if e.Cursor.Row >= e.Scroll+e.Height {
+ e.Scroll = e.Cursor.Row - e.Height + 1
+ }
+ if e.Scroll < 0 {
+ e.Scroll = 0
+ }
+}
+
+// HandleKey maps a key message to a buffer operation.
+func (e *Editor) HandleKey(k tea.KeyMsg) {
+ switch k.Type {
+ case tea.KeyRunes, tea.KeySpace:
+ for _, r := range k.Runes {
+ e.InsertRune(r)
+ }
+ if k.Type == tea.KeySpace {
+ e.InsertRune(' ')
+ }
+ case tea.KeyEnter:
+ e.InsertNewline()
+ case tea.KeyBackspace:
+ e.Backspace()
+ case tea.KeyDelete:
+ e.Delete()
+ case tea.KeyLeft:
+ e.MoveLeft()
+ case tea.KeyRight:
+ e.MoveRight()
+ case tea.KeyUp:
+ e.MoveUp()
+ case tea.KeyDown:
+ e.MoveDown()
+ case tea.KeyHome:
+ e.MoveHome()
+ case tea.KeyEnd:
+ e.MoveEnd()
+ case tea.KeyTab:
+ e.InsertRune('\t')
+ }
+}
+
+// View renders the visible rows, styled, with the cursor drawn on its row.
+func (e *Editor) View() string {
+ cursorStyle := lipgloss.NewStyle().Reverse(true)
+ all := ScanLines(e.Lines, e.theme)
+ var b strings.Builder
+ end := e.Scroll + e.Height
+ if end > len(e.Lines) {
+ end = len(e.Lines)
+ }
+ for row := e.Scroll; row < end; row++ {
+ if row == e.Cursor.Row {
+ b.WriteString(renderSpansCursor(all[row], e.Cursor.Col, cursorStyle))
+ } else {
+ b.WriteString(renderSpans(all[row]))
+ }
+ b.WriteByte('\n')
+ }
+ for row := end; row < e.Scroll+e.Height; row++ {
+ b.WriteByte('\n')
+ }
+ return b.String()
+}
+
+func clamp(v, lo, hi int) int {
+ if v < lo {
+ return lo
+ }
+ if v > hi {
+ return hi
+ }
+ return v
+}
+```
+
+- [ ] **Step 4: Run to verify all editor tests pass**
+
+Run: `go test ./internal/editor/`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/editor/editor.go internal/editor/editor_test.go
+git commit -m "feat: editor buffer, ops, and styled view"
+```
+
+---
+
+## Task 4: App model — routing, status bar, save/load, quit
+
+**Files:**
+- Create: `internal/app/app.go`
+- Test: `internal/app/app_test.go`
+
+**Interfaces:**
+- Consumes: `config.Config` (Task 1); `editor.New`, `editor.Editor.HandleKey/SetContent/Bytes/SetSize/View`, `editor.Editor.Dirty` (Tasks 2–3).
+- Produces:
+ - `app.Mode` int enum: `ModeEditor`, `ModePicker`, `ModePreview`.
+ - `app.App` struct implementing `tea.Model` (`Init`, `Update`, `View`).
+ - `app.New(cfg config.Config) *App`
+ - `func (a *App) Load(path string) error` — reads file into editor, sets `a.path`, switches to `ModeEditor`.
+ - `func (a *App) save() (tea.Model, tea.Cmd)` — writes editor bytes to `a.path`.
+ - Status-bar rendering in `View`.
+ - (Preview/picker wiring is stubbed here and filled in Tasks 5–6. `ModePreview`/`ModePicker` exist now; their keys become active later.)
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/app/app_test.go`:
+```go
+package app
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "glint/internal/config"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func newApp() *App {
+ return New(config.Default())
+}
+
+func TestLoadReadsFileIntoEditor(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "n.md")
+ os.WriteFile(p, []byte("# hi\nbody"), 0o644)
+
+ a := newApp()
+ if err := a.Load(p); err != nil {
+ t.Fatal(err)
+ }
+ if a.mode != ModeEditor {
+ t.Errorf("mode = %d, want ModeEditor", a.mode)
+ }
+ if string(a.editor.Bytes()) != "# hi\nbody" {
+ t.Errorf("editor content = %q", string(a.editor.Bytes()))
+ }
+}
+
+func TestCtrlSSavesToDisk(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "n.md")
+ os.WriteFile(p, []byte("old"), 0o644)
+
+ a := newApp()
+ a.Load(p)
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'!'}})
+ if !a.editor.Dirty {
+ t.Fatal("expected dirty after edit")
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
+
+ got, _ := os.ReadFile(p)
+ if string(got) != "!old" {
+ t.Errorf("file on disk = %q, want %q", string(got), "!old")
+ }
+ if a.editor.Dirty {
+ t.Error("save should clear dirty")
+ }
+}
+
+func TestTypingRoutesToEditor(t *testing.T) {
+ a := newApp()
+ a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+ if a.editor.Lines[0] != "x" {
+ t.Errorf("editor first line = %q, want x", a.editor.Lines[0])
+ }
+}
+
+func TestCtrlQCleanQuits(t *testing.T) {
+ a := newApp()
+ _, cmd := a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+ if cmd == nil {
+ t.Error("clean buffer Ctrl+Q should return a quit command")
+ }
+}
+
+func TestCtrlQDirtyNeedsConfirm(t *testing.T) {
+ a := newApp()
+ a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}})
+ _, cmd := a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+ if cmd != nil {
+ t.Error("first dirty Ctrl+Q should not quit")
+ }
+ _, cmd = a.Update(tea.KeyMsg{Type: tea.KeyCtrlQ})
+ if cmd == nil {
+ t.Error("second Ctrl+Q should quit")
+ }
+}
+
+func TestWindowSizePropagates(t *testing.T) {
+ a := newApp()
+ a.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
+ if a.editor.Width != 100 {
+ t.Errorf("editor width = %d, want 100", a.editor.Width)
+ }
+ if a.editor.Height != 29 { // minus status bar
+ t.Errorf("editor height = %d, want 29", a.editor.Height)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/app/`
+Expected: FAIL — `undefined: New`, `undefined: App`, `undefined: ModeEditor`.
+
+- [ ] **Step 3: Implement the app**
+
+Create `internal/app/app.go`:
+```go
+// Package app is glint's top-level Bubbletea model. It owns the active mode and
+// routes messages to the editor, picker, or preview sub-models.
+package app
+
+import (
+ "fmt"
+ "os"
+
+ "glint/internal/config"
+ "glint/internal/editor"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Mode selects which sub-view is active.
+type Mode int
+
+const (
+ ModeEditor Mode = iota
+ ModePicker
+ ModePreview
+)
+
+// App is the root model.
+type App struct {
+ mode Mode
+ cfg config.Config
+ editor *editor.Editor
+ path string
+ status string
+ width int
+ height int
+
+ quitArmed bool // true after a dirty Ctrl+Q, awaiting confirm
+}
+
+// New builds an App with an empty editor.
+func New(cfg config.Config) *App {
+ return &App{
+ mode: ModeEditor,
+ cfg: cfg,
+ editor: editor.New(),
+ }
+}
+
+// Load reads a file into the editor and switches to edit mode.
+func (a *App) Load(path string) error {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ a.editor.SetContent(data)
+ a.path = path
+ a.mode = ModeEditor
+ a.status = path
+ return nil
+}
+
+func (a *App) Init() tea.Cmd { return nil }
+
+// Update routes messages. Global keys are handled first, then mode-specific.
+func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
+ switch msg := msg.(type) {
+ case tea.WindowSizeMsg:
+ a.setSize(msg.Width, msg.Height)
+ return a, nil
+ case tea.KeyMsg:
+ return a.handleKey(msg)
+ }
+ return a, nil
+}
+
+func (a *App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+ // Any key other than a second Ctrl+Q disarms the quit confirmation.
+ if msg.Type != tea.KeyCtrlQ {
+ a.quitArmed = false
+ }
+
+ switch msg.Type {
+ case tea.KeyCtrlQ:
+ if a.editor.Dirty && !a.quitArmed {
+ a.quitArmed = true
+ a.status = "Unsaved changes — Ctrl+Q again to quit"
+ return a, nil
+ }
+ return a, tea.Quit
+ case tea.KeyCtrlS:
+ return a.save()
+ }
+
+ // Mode-specific routing (picker/preview keys wired in later tasks).
+ switch a.mode {
+ case ModeEditor:
+ a.editor.HandleKey(msg)
+ }
+ return a, nil
+}
+
+func (a *App) save() (tea.Model, tea.Cmd) {
+ if a.path == "" {
+ a.status = "No file to save"
+ return a, nil
+ }
+ if err := os.WriteFile(a.path, a.editor.Bytes(), 0o644); err != nil {
+ a.status = "Save failed: " + err.Error()
+ return a, nil
+ }
+ a.editor.Dirty = false
+ a.status = "Saved " + a.path
+ return a, nil
+}
+
+func (a *App) setSize(w, h int) {
+ a.width = w
+ a.height = h
+ a.editor.SetSize(w, h-1) // reserve one row for the status bar
+}
+
+// View renders the active sub-view plus the status bar.
+func (a *App) View() string {
+ var body string
+ switch a.mode {
+ case ModeEditor:
+ body = a.editor.View()
+ default:
+ body = a.editor.View()
+ }
+ return body + a.statusBar()
+}
+
+func (a *App) statusBar() string {
+ bar := lipgloss.NewStyle().
+ Foreground(lipgloss.Color("#1a1a24")).
+ Background(lipgloss.Color("#7aa2f7")).
+ Width(maxInt(a.width, 1))
+ dirty := ""
+ if a.editor.Dirty {
+ dirty = " ●"
+ }
+ left := a.status
+ if left == "" {
+ left = "glint"
+ }
+ return bar.Render(fmt.Sprintf(" %s%s ", left, dirty))
+}
+
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `go test ./internal/app/`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/app/
+git commit -m "feat: app model with routing, save/load, quit-confirm, status bar"
+```
+
+---
+
+## Task 5: Preview (Glamour read view)
+
+**Files:**
+- Create: `internal/preview/preview.go`
+- Test: `internal/preview/preview_test.go`
+- Modify: `internal/app/app.go` (wire `Ctrl+R` toggle and `Esc`)
+
+**Interfaces:**
+- Consumes: `config.Config.GlamourStyle`.
+- Produces:
+ - `preview.Model` struct.
+ - `preview.New(style string) *Model`
+ - `func (m *Model) SetSize(w, h int)`
+ - `func (m *Model) Render(markdown string) error` — Glamour-render into the viewport.
+ - `func (m *Model) Update(msg tea.Msg) tea.Cmd` — forwards scroll keys to the viewport.
+ - `func (m *Model) View() string`
+- App additions: `Ctrl+R` toggles `ModeEditor`↔`ModePreview` (rendering current buffer); `Esc` returns to `ModeEditor`.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/preview/preview_test.go`:
+```go
+package preview
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRenderProducesContent(t *testing.T) {
+ m := New("dark")
+ m.SetSize(80, 20)
+ if err := m.Render("# Title\n\nsome **bold** text"); err != nil {
+ t.Fatal(err)
+ }
+ out := m.View()
+ if strings.TrimSpace(out) == "" {
+ t.Error("preview view is empty after Render")
+ }
+ if !strings.Contains(out, "Title") {
+ t.Errorf("rendered output missing heading text: %q", out)
+ }
+}
+
+func TestRenderUnknownStyleFallsBack(t *testing.T) {
+ m := New("definitely-not-a-real-style")
+ m.SetSize(80, 20)
+ if err := m.Render("hello"); err != nil {
+ t.Errorf("unknown style should fall back, got error %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/preview/`
+Expected: FAIL — `undefined: New`.
+
+- [ ] **Step 3: Implement the preview**
+
+Create `internal/preview/preview.go`:
+```go
+// Package preview renders the current buffer through Glamour into a scrollable,
+// read-only viewport — the full glow read experience, markup concealed.
+package preview
+
+import (
+ "os"
+
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/bubbles/viewport"
+ "github.com/charmbracelet/glamour"
+)
+
+// Model wraps a Glamour renderer and a viewport.
+type Model struct {
+ vp viewport.Model
+ style string
+ width int
+ height int
+}
+
+// New returns a preview using the given Glamour style (builtin name or a path
+// to a style JSON file).
+func New(style string) *Model {
+ return &Model{
+ vp: viewport.New(0, 0),
+ style: style,
+ width: 80,
+ height: 24,
+ }
+}
+
+// SetSize resizes the viewport.
+func (m *Model) SetSize(w, h int) {
+ m.width = w
+ if h < 1 {
+ h = 1
+ }
+ m.height = h
+ m.vp.Width = w
+ m.vp.Height = h
+}
+
+// Render runs markdown through Glamour and loads it into the viewport.
+func (m *Model) Render(markdown string) error {
+ r, err := m.renderer()
+ if err != nil {
+ return err
+ }
+ out, err := r.Render(markdown)
+ if err != nil {
+ return err
+ }
+ m.vp.SetContent(out)
+ m.vp.GotoTop()
+ return nil
+}
+
+// renderer builds a Glamour renderer, treating m.style as a file path when it
+// exists on disk and as a builtin style name otherwise.
+func (m *Model) renderer() (*glamour.TermRenderer, error) {
+ width := m.width
+ if width < 1 {
+ width = 80
+ }
+ opts := []glamour.TermRendererOption{glamour.WithWordWrap(width)}
+ if _, err := os.Stat(m.style); err == nil {
+ opts = append(opts, glamour.WithStylePath(m.style))
+ } else {
+ opts = append(opts, glamour.WithStandardStyle(m.style))
+ }
+ return glamour.NewTermRenderer(opts...)
+}
+
+// Update forwards scroll keys to the viewport.
+func (m *Model) Update(msg tea.Msg) tea.Cmd {
+ var cmd tea.Cmd
+ m.vp, cmd = m.vp.Update(msg)
+ return cmd
+}
+
+// View renders the viewport.
+func (m *Model) View() string { return m.vp.View() }
+```
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `go test ./internal/preview/`
+Expected: PASS.
+
+NOTE: if `glamour.WithStandardStyle` errors on an unknown name at render time rather than falling back, the test `TestRenderUnknownStyleFallsBack` will catch it. If it fails, change `renderer()` to fall back to `glamour.WithStandardStyle("dark")` when the requested builtin name is not one of glamour's known styles (`ascii`, `dark`, `light`, `dracula`, `tokyo-night`, `notty`, `pink`). Implement the fallback as: keep a `map[string]bool` of known names; if `m.style` is neither an existing file nor a known name, use `"dark"`.
+
+- [ ] **Step 5: Wire the preview into the app**
+
+In `internal/app/app.go`, add the import and a `preview` field, construct it in `New`, propagate size, and handle `Ctrl+R`/`Esc`.
+
+Add to imports:
+```go
+ "glint/internal/preview"
+```
+
+Add field to `App` struct (after `editor *editor.Editor`):
+```go
+ preview *preview.Model
+```
+
+In `New`, set it:
+```go
+func New(cfg config.Config) *App {
+ return &App{
+ mode: ModeEditor,
+ cfg: cfg,
+ editor: editor.New(),
+ preview: preview.New(cfg.GlamourStyle),
+ }
+}
+```
+
+In `setSize`, also size the preview:
+```go
+func (a *App) setSize(w, h int) {
+ a.width = w
+ a.height = h
+ a.editor.SetSize(w, h-1)
+ a.preview.SetSize(w, h-1)
+}
+```
+
+In `handleKey`, add a `Ctrl+R` case and an `Esc` case before the mode switch. Replace the global-key switch with:
+```go
+ switch msg.Type {
+ case tea.KeyCtrlQ:
+ if a.editor.Dirty && !a.quitArmed {
+ a.quitArmed = true
+ a.status = "Unsaved changes — Ctrl+Q again to quit"
+ return a, nil
+ }
+ return a, tea.Quit
+ case tea.KeyCtrlS:
+ return a.save()
+ case tea.KeyCtrlR:
+ return a.togglePreview()
+ case tea.KeyEsc:
+ a.mode = ModeEditor
+ return a, nil
+ }
+
+ switch a.mode {
+ case ModeEditor:
+ a.editor.HandleKey(msg)
+ case ModePreview:
+ return a, a.preview.Update(msg)
+ }
+ return a, nil
+```
+
+Add the toggle method:
+```go
+// togglePreview switches between the editor and the Glamour read view.
+func (a *App) togglePreview() (tea.Model, tea.Cmd) {
+ if a.mode == ModePreview {
+ a.mode = ModeEditor
+ return a, nil
+ }
+ if err := a.preview.Render(string(a.editor.Bytes())); err != nil {
+ a.status = "Preview failed: " + err.Error()
+ return a, nil
+ }
+ a.mode = ModePreview
+ return a, nil
+}
+```
+
+Update `View` to render the preview when active:
+```go
+func (a *App) View() string {
+ var body string
+ switch a.mode {
+ case ModePreview:
+ body = a.preview.View()
+ default:
+ body = a.editor.View()
+ }
+ return body + a.statusBar()
+}
+```
+
+- [ ] **Step 6: Add an app test for the preview toggle**
+
+Append to `internal/app/app_test.go`:
+```go
+func TestCtrlRTogglesPreview(t *testing.T) {
+ a := newApp()
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ a.editor.SetContent([]byte("# hello"))
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
+ if a.mode != ModePreview {
+ t.Errorf("mode = %d, want ModePreview", a.mode)
+ }
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
+ if a.mode != ModeEditor {
+ t.Errorf("mode = %d, want ModeEditor after second toggle", a.mode)
+ }
+}
+
+func TestEscReturnsToEditor(t *testing.T) {
+ a := newApp()
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ a.editor.SetContent([]byte("x"))
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlR})
+ a.Update(tea.KeyMsg{Type: tea.KeyEsc})
+ if a.mode != ModeEditor {
+ t.Errorf("Esc should return to editor, mode = %d", a.mode)
+ }
+}
+```
+
+- [ ] **Step 7: Run preview and app tests**
+
+Run: `go test ./internal/preview/ ./internal/app/`
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/preview/ internal/app/
+git commit -m "feat: glamour read-preview with Ctrl+R toggle"
+```
+
+---
+
+## Task 6: Picker (fuzzy file browser) + daily note
+
+**Files:**
+- Create: `internal/picker/picker.go`
+- Test: `internal/picker/picker_test.go`
+- Modify: `internal/app/app.go` (wire `Ctrl+P` picker, `Ctrl+D` daily, `Enter` selection)
+
+**Interfaces:**
+- Consumes: `config.Config` (for `DailyPath` and `VaultDir`).
+- Produces:
+ - `picker.fuzzyMatch(query, candidate string) (score int, ok bool)` — unexported, pure, tested.
+ - `picker.walkMarkdown(root string) ([]string, error)` — unexported; absolute `.md` paths, skipping dot-directories.
+ - `picker.Model` struct.
+ - `picker.New(root string) (*Model, error)`
+ - `func (m *Model) SetSize(w, h int)`
+ - `func (m *Model) Update(msg tea.Msg) tea.Cmd` — updates the query input, recomputes matches, moves selection on Up/Down.
+ - `func (m *Model) Selected() string` — absolute path of the highlighted match, or `""`.
+ - `func (m *Model) View() string`
+- App additions: `Ctrl+P` enters `ModePicker`; `Ctrl+D` opens/creates today's daily note; in `ModePicker`, `Enter` loads `Selected()`.
+
+- [ ] **Step 1: Write the failing test for the pure helpers**
+
+Create `internal/picker/picker_test.go`:
+```go
+package picker
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "testing"
+)
+
+func TestFuzzyMatchSubsequence(t *testing.T) {
+ if _, ok := fuzzyMatch("abc", "axbxc"); !ok {
+ t.Error("abc should match axbxc as a subsequence")
+ }
+ if _, ok := fuzzyMatch("abc", "acb"); ok {
+ t.Error("abc should NOT match acb (out of order)")
+ }
+ if _, ok := fuzzyMatch("", "anything"); !ok {
+ t.Error("empty query should match anything")
+ }
+}
+
+func TestFuzzyMatchCaseInsensitive(t *testing.T) {
+ if _, ok := fuzzyMatch("DOC", "my-document"); !ok {
+ t.Error("DOC should match my-document case-insensitively")
+ }
+}
+
+func TestFuzzyMatchContiguousScoresHigher(t *testing.T) {
+ tight, ok1 := fuzzyMatch("ab", "abxx")
+ loose, ok2 := fuzzyMatch("ab", "axxb")
+ if !ok1 || !ok2 {
+ t.Fatal("both should match")
+ }
+ if tight <= loose {
+ t.Errorf("contiguous match (%d) should score higher than gapped (%d)", tight, loose)
+ }
+}
+
+func TestWalkMarkdownSkipsDotDirsAndNonMarkdown(t *testing.T) {
+ root := t.TempDir()
+ os.WriteFile(filepath.Join(root, "a.md"), nil, 0o644)
+ os.WriteFile(filepath.Join(root, "b.txt"), nil, 0o644)
+ os.MkdirAll(filepath.Join(root, ".git"), 0o755)
+ os.WriteFile(filepath.Join(root, ".git", "c.md"), nil, 0o644)
+ os.MkdirAll(filepath.Join(root, "sub"), 0o755)
+ os.WriteFile(filepath.Join(root, "sub", "d.md"), nil, 0o644)
+
+ got, err := walkMarkdown(root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sort.Strings(got)
+ want := []string{
+ filepath.Join(root, "a.md"),
+ filepath.Join(root, "sub", "d.md"),
+ }
+ if len(got) != len(want) {
+ t.Fatalf("walkMarkdown = %v, want %v", got, want)
+ }
+ for i := range want {
+ if got[i] != want[i] {
+ t.Errorf("got[%d] = %q, want %q", i, got[i], want[i])
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/picker/`
+Expected: FAIL — `undefined: fuzzyMatch`, `undefined: walkMarkdown`.
+
+- [ ] **Step 3: Implement the picker**
+
+Create `internal/picker/picker.go`:
+```go
+// Package picker is a fuzzy file browser over a directory of markdown files.
+package picker
+
+import (
+ "io/fs"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "github.com/charmbracelet/bubbles/textinput"
+ tea "github.com/charmbracelet/bubbletea"
+ "github.com/charmbracelet/lipgloss"
+)
+
+// Model holds the query input and the current match list.
+type Model struct {
+ input textinput.Model
+ all []string // absolute .md paths
+ root string
+ matches []string
+ sel int
+ width int
+ height int
+}
+
+// New walks root for markdown files and returns a ready picker.
+func New(root string) (*Model, error) {
+ files, err := walkMarkdown(root)
+ if err != nil {
+ return nil, err
+ }
+ ti := textinput.New()
+ ti.Placeholder = "filter…"
+ ti.Focus()
+ m := &Model{input: ti, all: files, root: root, width: 80, height: 24}
+ m.recompute()
+ return m, nil
+}
+
+// SetSize records dimensions.
+func (m *Model) SetSize(w, h int) {
+ m.width = w
+ m.height = h
+}
+
+// Update handles query edits and selection movement.
+func (m *Model) Update(msg tea.Msg) tea.Cmd {
+ if key, ok := msg.(tea.KeyMsg); ok {
+ switch key.Type {
+ case tea.KeyUp:
+ if m.sel > 0 {
+ m.sel--
+ }
+ return nil
+ case tea.KeyDown:
+ if m.sel < len(m.matches)-1 {
+ m.sel++
+ }
+ return nil
+ }
+ }
+ var cmd tea.Cmd
+ m.input, cmd = m.input.Update(msg)
+ m.recompute()
+ return cmd
+}
+
+// recompute filters and ranks all paths against the current query.
+func (m *Model) recompute() {
+ q := m.input.Value()
+ type scored struct {
+ path string
+ score int
+ }
+ var hits []scored
+ for _, p := range m.all {
+ label := m.label(p)
+ if s, ok := fuzzyMatch(q, label); ok {
+ hits = append(hits, scored{p, s})
+ }
+ }
+ sort.SliceStable(hits, func(i, j int) bool {
+ if hits[i].score != hits[j].score {
+ return hits[i].score > hits[j].score
+ }
+ return hits[i].path < hits[j].path
+ })
+ m.matches = m.matches[:0]
+ for _, h := range hits {
+ m.matches = append(m.matches, h.path)
+ }
+ if m.sel >= len(m.matches) {
+ m.sel = 0
+ }
+}
+
+// label is the vault-relative path shown to the user and matched against.
+func (m *Model) label(p string) string {
+ if rel, err := filepath.Rel(m.root, p); err == nil {
+ return rel
+ }
+ return p
+}
+
+// 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]
+}
+
+// View renders the query line and the match list.
+func (m *Model) View() string {
+ var b strings.Builder
+ b.WriteString(m.input.View())
+ b.WriteByte('\n')
+ selStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#1a1a24")).Background(lipgloss.Color("#e0af68"))
+ plain := lipgloss.NewStyle().Foreground(lipgloss.Color("#c0caf5"))
+ rows := m.height - 2
+ if rows < 1 {
+ rows = 1
+ }
+ for i := 0; i < len(m.matches) && i < rows; i++ {
+ label := m.label(m.matches[i])
+ if i == m.sel {
+ b.WriteString(selStyle.Render("> " + label))
+ } else {
+ b.WriteString(plain.Render(" " + label))
+ }
+ b.WriteByte('\n')
+ }
+ return b.String()
+}
+
+// walkMarkdown returns absolute paths of .md files under root, skipping any
+// directory whose name begins with a dot.
+func walkMarkdown(root string) ([]string, error) {
+ var out []string
+ err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return nil // skip unreadable entries
+ }
+ if d.IsDir() {
+ if path != root && strings.HasPrefix(d.Name(), ".") {
+ return filepath.SkipDir
+ }
+ return nil
+ }
+ if strings.EqualFold(filepath.Ext(d.Name()), ".md") {
+ abs, aerr := filepath.Abs(path)
+ if aerr == nil {
+ out = append(out, abs)
+ }
+ }
+ return nil
+ })
+ return out, err
+}
+
+// fuzzyMatch reports whether query is a case-insensitive subsequence of
+// candidate, scoring contiguous matches higher (fewer gaps -> higher score).
+func fuzzyMatch(query, candidate string) (int, bool) {
+ if query == "" {
+ return 0, true
+ }
+ q := []rune(strings.ToLower(query))
+ c := []rune(strings.ToLower(candidate))
+ score, qi, last := 0, 0, -1
+ for ci := 0; ci < len(c) && qi < len(q); ci++ {
+ if c[ci] == q[qi] {
+ if last >= 0 {
+ score -= ci - last - 1 // penalize gaps
+ }
+ last = ci
+ qi++
+ }
+ }
+ if qi != len(q) {
+ return 0, false
+ }
+ return score, true
+}
+```
+
+- [ ] **Step 4: Run picker tests to verify they pass**
+
+Run: `go test ./internal/picker/`
+Expected: PASS.
+
+- [ ] **Step 5: Wire the picker and daily note into the app**
+
+In `internal/app/app.go`:
+
+Add imports:
+```go
+ "path/filepath"
+ "time"
+
+ "glint/internal/picker"
+```
+
+Add a field to `App` (after `preview`):
+```go
+ picker *picker.Model
+```
+
+Add `Ctrl+P`, `Ctrl+D` cases and picker `Enter` routing. In `handleKey`, extend the global switch:
+```go
+ case tea.KeyCtrlP:
+ return a.openPicker()
+ case tea.KeyCtrlD:
+ return a.openDaily()
+```
+
+And change the mode switch to handle picker keys, with `Enter` selecting:
+```go
+ switch a.mode {
+ case ModeEditor:
+ a.editor.HandleKey(msg)
+ case ModePreview:
+ return a, a.preview.Update(msg)
+ case ModePicker:
+ if msg.Type == tea.KeyEnter {
+ if sel := a.picker.Selected(); sel != "" {
+ if err := a.Load(sel); err != nil {
+ a.status = "Open failed: " + err.Error()
+ }
+ }
+ return a, nil
+ }
+ return a, a.picker.Update(msg)
+ }
+ return a, nil
+```
+
+Add the helper methods:
+```go
+// openPicker builds a fresh picker over the vault and switches to it.
+func (a *App) openPicker() (tea.Model, tea.Cmd) {
+ p, err := picker.New(a.cfg.VaultDir)
+ if err != nil {
+ a.status = "Picker failed: " + err.Error()
+ return a, nil
+ }
+ p.SetSize(a.width, a.height-1)
+ a.picker = p
+ a.mode = ModePicker
+ return a, nil
+}
+
+// openDaily opens today's daily note, creating the file and directory if needed.
+func (a *App) openDaily() (tea.Model, tea.Cmd) {
+ path := a.cfg.DailyPath(time.Now())
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ a.status = "Daily dir failed: " + err.Error()
+ return a, nil
+ }
+ if err := os.WriteFile(path, []byte{}, 0o644); err != nil {
+ a.status = "Daily create failed: " + err.Error()
+ return a, nil
+ }
+ }
+ if err := a.Load(path); err != nil {
+ a.status = "Daily open failed: " + err.Error()
+ }
+ return a, nil
+}
+```
+
+Update `View` to render the picker when active:
+```go
+func (a *App) View() string {
+ var body string
+ switch a.mode {
+ case ModePreview:
+ body = a.preview.View()
+ case ModePicker:
+ body = a.picker.View()
+ default:
+ body = a.editor.View()
+ }
+ return body + a.statusBar()
+}
+```
+
+Guard the `quitArmed` reset and `Esc` so they don't fire while typing in the picker query: the existing `Esc` case already returns to `ModeEditor`, which is the desired "cancel picker" behavior. Leave it.
+
+- [ ] **Step 6: Add app tests for picker and daily**
+
+Append to `internal/app/app_test.go`:
+```go
+func TestCtrlPOpensPicker(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "note.md"), []byte("x"), 0o644)
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlP})
+ if a.mode != ModePicker {
+ t.Errorf("mode = %d, want ModePicker", a.mode)
+ }
+}
+
+func TestCtrlDCreatesAndOpensDaily(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ cfg.DailySubdir = "Daily"
+ cfg.DailyFormat = "2006-01-02"
+ a := New(cfg)
+ a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+ a.Update(tea.KeyMsg{Type: tea.KeyCtrlD})
+ if a.mode != ModeEditor {
+ t.Errorf("mode = %d, want ModeEditor after daily open", a.mode)
+ }
+ if a.path == "" {
+ t.Error("daily path should be set")
+ }
+ if _, err := os.Stat(a.path); err != nil {
+ t.Errorf("daily file should exist on disk: %v", err)
+ }
+}
+```
+
+Add the missing import to the app test file's import block: `"path/filepath"` (already present from Task 4's tests) and `"glint/internal/config"` (already present).
+
+- [ ] **Step 7: Run picker and app tests**
+
+Run: `go test ./internal/picker/ ./internal/app/`
+Expected: PASS.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/picker/ internal/app/
+git commit -m "feat: fuzzy file picker (Ctrl+P) and daily note (Ctrl+D)"
+```
+
+---
+
+## Task 7: Entrypoint and end-to-end wiring
+
+**Files:**
+- Create: `main.go`
+- Test: manual smoke (no automated test — `main` is thin glue over tested units).
+
+**Interfaces:**
+- Consumes: `config.Load`, `app.New`, `app.App.Load`, and (for `--daily`) the same daily-note creation path used by the app. To avoid duplicating the daily logic, `main` does NOT recreate the daily file; it sets a flag the app honors on the first frame.
+- Produces: the `glint` binary.
+
+To keep the daily logic in one place, add one small method to the app that `main` calls.
+
+- [ ] **Step 1: Add an app entry helper (test-first)**
+
+Append to `internal/app/app_test.go`:
+```go
+func TestStartInPickerWhenNoPath(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "n.md"), []byte("x"), 0o644)
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ a := New(cfg)
+ if err := a.Start("", false); err != nil {
+ t.Fatal(err)
+ }
+ if a.mode != ModePicker {
+ t.Errorf("no path/daily should start in picker, mode = %d", a.mode)
+ }
+}
+
+func TestStartWithPathLoadsFile(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "n.md")
+ os.WriteFile(p, []byte("hello"), 0o644)
+ a := New(config.Default())
+ if err := a.Start(p, false); err != nil {
+ t.Fatal(err)
+ }
+ if a.mode != ModeEditor || string(a.editor.Bytes()) != "hello" {
+ t.Errorf("Start(path) should load file into editor")
+ }
+}
+
+func TestStartDailyCreatesFile(t *testing.T) {
+ dir := t.TempDir()
+ cfg := config.Default()
+ cfg.VaultDir = dir
+ a := New(cfg)
+ if err := a.Start("", true); err != nil {
+ t.Fatal(err)
+ }
+ if a.mode != ModeEditor || a.path == "" {
+ t.Errorf("Start(daily) should open the daily note")
+ }
+ if _, err := os.Stat(a.path); err != nil {
+ t.Errorf("daily file should exist: %v", err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails**
+
+Run: `go test ./internal/app/ -run TestStart`
+Expected: FAIL — `undefined: (*App).Start`.
+
+- [ ] **Step 3: Implement `Start` and refactor daily creation**
+
+In `internal/app/app.go`, factor the daily file creation into a helper and add `Start`. Replace `openDaily` with a version that calls the shared helper, and add `Start`:
+```go
+// 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 {
+ switch {
+ case path != "":
+ return a.Load(path)
+ case daily:
+ _, cmd := a.openDaily()
+ _ = cmd
+ return nil
+ default:
+ _, cmd := a.openPicker()
+ _ = cmd
+ return nil
+ }
+}
+```
+
+(`openDaily` and `openPicker` already set `a.status` on failure and never return an error; `Start` surfaces hard load errors only for the explicit-path case, which is the one a user can mistype.)
+
+- [ ] **Step 4: Run to verify it passes**
+
+Run: `go test ./internal/app/`
+Expected: PASS.
+
+- [ ] **Step 5: Write the entrypoint**
+
+Create `main.go`:
+```go
+// Command glint is a modeless terminal markdown editor with live styling.
+package main
+
+import (
+ "flag"
+ "fmt"
+ "os"
+
+ "glint/internal/app"
+ "glint/internal/config"
+
+ tea "github.com/charmbracelet/bubbletea"
+)
+
+func main() {
+ daily := flag.Bool("daily", false, "open today's daily note")
+ flag.Parse()
+
+ cfg, err := config.Load()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "glint: config:", err)
+ }
+
+ var path string
+ if args := flag.Args(); len(args) > 0 {
+ path = args[0]
+ }
+
+ a := app.New(cfg)
+ if err := a.Start(path, *daily); err != nil {
+ fmt.Fprintln(os.Stderr, "glint:", err)
+ os.Exit(1)
+ }
+
+ if _, err := tea.NewProgram(a, tea.WithAltScreen()).Run(); err != nil {
+ fmt.Fprintln(os.Stderr, "glint:", err)
+ os.Exit(1)
+ }
+}
+```
+
+- [ ] **Step 6: Build, vet, and run the full test suite**
+
+Run:
+```bash
+go vet ./...
+go build ./...
+go test ./...
+```
+Expected: no vet errors, a built binary, all tests PASS.
+
+- [ ] **Step 7: Manual smoke test**
+
+Run:
+```bash
+go run . README.md
+```
+Expected: README opens in the editor with headings/bold/links styled and the markup characters visible; arrows move the cursor; typing inserts; `Ctrl+R` shows the glamour preview; `Esc` returns; `Ctrl+P` opens the fuzzy picker; `Ctrl+D` opens today's daily note; `Ctrl+S` saves; `Ctrl+Q` quits (twice if dirty).
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add main.go internal/app/
+git commit -m "feat: glint entrypoint (path / --daily / picker) and Start wiring"
+```
+
+---
+
+## Self-Review Notes
+
+**Spec coverage check:**
+- Modeless editing → Task 3 (`HandleKey`, no modes).
+- Live inline styling, markup not concealed → Task 2 (scanner, markup-visible invariant test) + Task 3 (`View`).
+- Explicit foreground on every span (ekphos fix) → Task 2 (`TestScanPlainTextGetsExplicitForeground`, theme).
+- Editor knows nothing about files → Task 3 (no file I/O in `editor`); load/save in Task 4.
+- Block context (fences, frontmatter) → Task 2 (`blockState`, dedicated tests).
+- Glamour preview, markup concealed → Task 5.
+- Fuzzy picker → Task 6 (`fuzzyMatch`, `walkMarkdown`).
+- Daily note with configurable subdir/format → Tasks 1 (`DailyPath`) + 6/7 (`openDaily`, `Start`).
+- TOML config, defaults, malformed handling → Task 1.
+- Keybinds (Ctrl+S/P/D/R, Esc, Ctrl+Q confirm) → Tasks 4–6.
+- Error handling surfaced in status bar, never silent → Task 4 (`save`, `status`), Tasks 5–6 (status on failure).
+- Entrypoint flags (path, --daily, bare → picker) → Task 7.
+
+**Out of scope (v1), per spec — intentionally absent:** wikilink follow (styled only), in-content search, graph, tag browsing, sort persistence, vim modality, the later Vault TUI layer.
+
+**Type consistency:** `Editor` exported fields and method names are used identically across Tasks 3–7. `Mode` constants (`ModeEditor`, `ModePicker`, `ModePreview`) are defined once in Task 4 and referenced unchanged. `picker.Model.Selected()`, `preview.Model.Render/Update/View`, and `config.Config.DailyPath` signatures match their call sites.
+
+**Known follow-ups (not v1 blockers):** the heading scanner colors all heading levels identically (spec allows per-level later); `glamour` style-name fallback has a documented contingency in Task 5 Step 4; module path `glint` is local-only (change to a `github.com/...` path if the project is ever published).