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

docs: implementation plan for the naming dialog & header

083406d3809df143eba20707484663b7995598b5
humdrum-tiv <45084903+humdrum-tiv@users.noreply.github.com> · 2026-07-30 17:39

parent 07a496f3

docs: implementation plan for the naming dialog & header

Seven TDD tasks: move the color helpers into theme, build the dialog
sub-model and its view, paint the filename header into the top pad, wire
ModeNamePrompt in place of ModeSaveAs, implement create/open/rename on
confirm, then the -n entry point and keybind reference (TASK-046).

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

1 files changed

docs/superpowers/plans/2026-07-30-new-file-dialog.md +1749 −0
@@ -0,0 +1,1749 @@
+# New-File Naming Dialog & Editor Filename Header 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:** Ask for a filename up front in a centered dialog whenever a new note is created, and show that filename in editor mode as an uneditable header bar styled like the preview's title bar.
+
+**Architecture:** A new `internal/dialog` package holds the centered naming prompt as a self-contained Bubbletea sub-model (title, target-directory hint, `textinput`, Enter/Esc). `App` gains a `ModeNamePrompt` that owns one `*dialog.Model` reused for three jobs: new note, name-this-pathless-buffer, and F2 rename. The color helpers `legibleText`/`relLuminance`/`hexToRGB` move from `internal/preview` to `internal/theme` so the editor header and the preview title bar cannot drift. The header is painted into the existing three-row top pad, so it costs no text rows.
+
+**Tech Stack:** Go 1.x, Bubbletea (`github.com/charmbracelet/bubbletea`), Bubbles (`textinput`), Lipgloss. Tests are stdlib `testing`, table-free, in-package (`package app`, `package dialog`).
+
+**Spec:** `docs/superpowers/specs/2026-07-30-new-file-dialog-design.md`
+**Backlog task:** TASK-046
+
+## Global Constraints
+
+- Module path is `glint`; internal imports are `glint/internal/<pkg>`.
+- Every styled span gets an explicit color from `theme.Theme` — no terminal-default fallbacks (see `internal/theme/theme.go` package doc).
+- `paintCanvas` must emit exactly `height-1` rows; the status bar is the final row. Any change to the pad rows must preserve that invariant.
+- New notes resolve through `picker.NewNotePath(root, query)`, which trims the query, appends `.md` when the extension is not already `.md`, and returns `""` for an empty query.
+- File modes: directories `0o755`, files `0o644`.
+- Tests live beside the code as `<file>_test.go` and use `t.TempDir()` for filesystem work.
+- Run `go test ./...` before every commit. Run `gofmt -l .` and expect empty output.
+- Commit messages: Conventional Commits, subject ≤ 50 chars, reference TASK-046 in the body.
+
+---
+
+## File Structure
+
+**Created:**
+- `internal/dialog/dialog.go` — the centered naming prompt sub-model: state, key handling, view.
+- `internal/dialog/dialog_test.go` — unit tests for the sub-model.
+- `internal/app/header.go` — the editor filename header bar (render + text derivation).
+- `internal/app/header_test.go` — header rendering tests.
+- `internal/app/naming.go` — App-side glue: opening the dialog for new/rename, and the confirm handlers.
+- `internal/app/naming_test.go` — new-file and rename flow tests.
+
+**Modified:**
+- `internal/theme/theme.go` — gains `LegibleText`, `RelLuminance`, `HexToRGB`.
+- `internal/theme/theme_test.go` — tests for the moved helpers.
+- `internal/preview/preview.go` — drops the local helpers, calls the `theme` ones.
+- `internal/preview/header.go` — call sites updated to the `theme` helpers.
+- `internal/app/app.go` — `ModeNamePrompt` replaces `ModeSaveAs`; `saveInput`/`saveBar`/`promptSaveAs`/`saveAs` removed; `F2` key; `newFile` rerouted; `paintCanvas` paints the header.
+- `internal/app/app_test.go` — the two `ModeSaveAs` assertions rewritten.
+- `internal/help/help.go` — `F2` documented, Ctrl+S wording updated.
+- `main.go` — no change needed (`StartNewIn` handles the `-n`-with-no-name case internally); verified in Task 7.
+
+---
+
+## Task 1: Move the color helpers into `theme`
+
+`legibleText`, `relLuminance`, and `hexToRGB` currently live in `internal/preview/preview.go` (lines 127–160ish) and are used by `internal/preview/header.go`. The editor header needs the same math. Move them to `theme`, exported, and repoint preview.
+
+**Files:**
+- Modify: `internal/theme/theme.go` (append at end of file)
+- Modify: `internal/preview/preview.go:127-160` (delete the three funcs)
+- Modify: `internal/preview/header.go:63-64,111,114` (call sites)
+- Test: `internal/theme/theme_test.go` (append)
+
+**Interfaces:**
+- Consumes: nothing.
+- Produces:
+  - `func theme.HexToRGB(hex string) string` — `"#RRGGBB"` → `"R;G;B"` decimal, `""` when malformed.
+  - `func theme.RelLuminance(hex string) float64` — WCAG relative luminance, `0` when malformed.
+  - `func theme.LegibleText(bgHex string) string` — `"#100F0F"` when the background is light (luminance > 0.5), else `"#FFFCF0"`.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/theme/theme_test.go`:
+
+```go
+func TestHexToRGB(t *testing.T) {
+	if got := HexToRGB("#FFFCF0"); got != "255;252;240" {
+		t.Errorf("HexToRGB(#FFFCF0) = %q, want 255;252;240", got)
+	}
+	if got := HexToRGB("nope"); got != "" {
+		t.Errorf("HexToRGB(nope) = %q, want empty", got)
+	}
+}
+
+func TestLegibleTextPicksContrast(t *testing.T) {
+	if got := LegibleText("#FFFCF0"); got != "#100F0F" {
+		t.Errorf("LegibleText(light bg) = %q, want #100F0F", got)
+	}
+	if got := LegibleText("#100F0F"); got != "#FFFCF0" {
+		t.Errorf("LegibleText(dark bg) = %q, want #FFFCF0", got)
+	}
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/theme/ -run 'TestHexToRGB|TestLegibleText' -v`
+Expected: FAIL — `undefined: HexToRGB`, `undefined: LegibleText`.
+
+- [ ] **Step 3: Move the three functions into `theme`**
+
+Append to `internal/theme/theme.go` (and add `"strconv"` + `"strings"` to its import block):
+
+```go
+// HexToRGB converts "#RRGGBB" to the "R;G;B" decimal form used in SGR codes.
+// A malformed color yields "" so callers can fall back to unstyled output.
+func HexToRGB(hex string) string {
+	h := strings.TrimPrefix(hex, "#")
+	if len(h) != 6 {
+		return ""
+	}
+	r, err1 := strconv.ParseInt(h[0:2], 16, 0)
+	g, err2 := strconv.ParseInt(h[2:4], 16, 0)
+	b, err3 := strconv.ParseInt(h[4:6], 16, 0)
+	if err1 != nil || err2 != nil || err3 != nil {
+		return ""
+	}
+	return strconv.FormatInt(r, 10) + ";" + strconv.FormatInt(g, 10) + ";" + strconv.FormatInt(b, 10)
+}
+
+// LegibleText returns a near-black or near-paper text color, whichever
+// contrasts better with the background hex, so heading text stays readable on
+// any heading color.
+func LegibleText(bgHex string) string {
+	if RelLuminance(bgHex) > 0.5 {
+		return "#100F0F"
+	}
+	return "#FFFCF0"
+}
+```
+
+Then move `relLuminance` from `internal/preview/preview.go` verbatim, renaming it `RelLuminance` and exporting it, with this doc comment:
+
+```go
+// RelLuminance is the WCAG relative luminance of an "#RRGGBB" color, 0 for a
+// malformed one.
+func RelLuminance(hex string) float64 {
+```
+
+Delete `hexToRGB`, `legibleText`, and `relLuminance` from `internal/preview/preview.go`, and drop `strconv` from that file's imports if nothing else there uses it (check with `go build ./...`).
+
+- [ ] **Step 4: Repoint the preview call sites**
+
+In `internal/preview/header.go`, add `"glint/internal/theme"` to the imports and replace the call sites:
+
+- line ~63: `if bg := hexToRGB(m.colors.Heading); bg != ""` → `if bg := theme.HexToRGB(m.colors.Heading); bg != ""`
+- line ~64: `hexToRGB(legibleText(m.colors.Heading))` → `theme.HexToRGB(theme.LegibleText(m.colors.Heading))`
+- line ~111: `if c := hexToRGB(m.colors.Muted); c != ""` → `if c := theme.HexToRGB(m.colors.Muted); c != ""`
+- line ~114: `if c := hexToRGB(m.colors.Text); c != ""` → `if c := theme.HexToRGB(m.colors.Text); c != ""`
+
+Search for any remaining lowercase call: `grep -rn 'hexToRGB\|legibleText\|relLuminance' internal/` must return nothing.
+
+- [ ] **Step 5: Run the full suite**
+
+Run: `go test ./... && gofmt -l .`
+Expected: all packages PASS (preview's existing tests still pass — the rendered bytes are unchanged), `gofmt -l .` prints nothing.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/theme/theme.go internal/theme/theme_test.go internal/preview/preview.go internal/preview/header.go
+git commit -m "refactor: move color helpers to theme package
+
+HexToRGB/LegibleText/RelLuminance move out of internal/preview so the
+editor filename header and the preview title bar derive identical colors
+from one place (TASK-046)."
+```
+
+---
+
+## Task 2: The `dialog` package — state and key handling
+
+A self-contained sub-model with no knowledge of `App`. It holds a title, a directory hint, a `textinput`, and an error line; `Update` returns a `Result` telling the caller what happened.
+
+**Files:**
+- Create: `internal/dialog/dialog.go`
+- Test: `internal/dialog/dialog_test.go`
+
+**Interfaces:**
+- Consumes: `theme.Theme` (Task 1's package, unchanged fields).
+- Produces:
+  - `type Model struct` with unexported fields; constructed only via `New`.
+  - `func New(th theme.Theme) *Model`
+  - `func (m *Model) Open(title, dirHint, prefill string)` — resets the input, sets the prefill with the cursor at the end, clears any error, focuses.
+  - `func (m *Model) Title() string`
+  - `func (m *Model) Value() string` — the trimmed input text.
+  - `func (m *Model) SetError(msg string)` — shows `msg` under the input and keeps the dialog open.
+  - `func (m *Model) SetTheme(th theme.Theme)`
+  - `func (m *Model) SetWidth(w int)` — the content-column width the box is sized against.
+  - `func (m *Model) Update(msg tea.Msg) (Result, tea.Cmd)`
+  - `type Result int` with `ResultNone`, `ResultConfirm`, `ResultCancel`.
+  - `func (m *Model) View() string`  *(implemented in Task 3)*
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/dialog/dialog_test.go`:
+
+```go
+package dialog
+
+import (
+	"testing"
+
+	"glint/internal/theme"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func newModel() *Model { return New(theme.FlexokiDark()) }
+
+func typeRunes(m *Model, s string) {
+	for _, r := range s {
+		m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+	}
+}
+
+func TestOpenSetsTitleAndPrefill(t *testing.T) {
+	m := newModel()
+	m.Open("Rename", "~/Notes/", "my-note")
+	if m.Title() != "Rename" {
+		t.Errorf("Title() = %q, want Rename", m.Title())
+	}
+	if m.Value() != "my-note" {
+		t.Errorf("Value() = %q, want my-note", m.Value())
+	}
+}
+
+func TestTypingAppendsAfterPrefill(t *testing.T) {
+	m := newModel()
+	m.Open("New note", "~/Notes/", "draft")
+	typeRunes(m, "-2")
+	if m.Value() != "draft-2" {
+		t.Errorf("Value() = %q, want draft-2 (cursor must start at end of prefill)", m.Value())
+	}
+}
+
+func TestEnterConfirms(t *testing.T) {
+	m := newModel()
+	m.Open("New note", "~/Notes/", "")
+	typeRunes(m, "hello")
+	res, _ := m.Update(tea.KeyMsg{Type: tea.KeyEnter})
+	if res != ResultConfirm {
+		t.Errorf("Enter gave %v, want ResultConfirm", res)
+	}
+	if m.Value() != "hello" {
+		t.Errorf("Value() = %q, want hello", m.Value())
+	}
+}
+
+func TestEscCancels(t *testing.T) {
+	m := newModel()
+	m.Open("New note", "~/Notes/", "")
+	res, _ := m.Update(tea.KeyMsg{Type: tea.KeyEsc})
+	if res != ResultCancel {
+		t.Errorf("Esc gave %v, want ResultCancel", res)
+	}
+}
+
+func TestOrdinaryKeysReturnNone(t *testing.T) {
+	m := newModel()
+	m.Open("New note", "~/Notes/", "")
+	res, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}})
+	if res != ResultNone {
+		t.Errorf("rune key gave %v, want ResultNone", res)
+	}
+}
+
+func TestValueTrimsSurroundingSpace(t *testing.T) {
+	m := newModel()
+	m.Open("New note", "~/Notes/", "")
+	typeRunes(m, "  spaced  ")
+	if m.Value() != "spaced" {
+		t.Errorf("Value() = %q, want spaced", m.Value())
+	}
+}
+
+func TestOpenClearsPreviousError(t *testing.T) {
+	m := newModel()
+	m.Open("New note", "~/Notes/", "")
+	m.SetError("Name taken")
+	m.Open("New note", "~/Notes/", "")
+	if m.err != "" {
+		t.Errorf("err = %q after reopen, want empty", m.err)
+	}
+}
+
+func TestTypingClearsError(t *testing.T) {
+	m := newModel()
+	m.Open("New note", "~/Notes/", "")
+	m.SetError("Type a name first")
+	typeRunes(m, "a")
+	if m.err != "" {
+		t.Errorf("err = %q after typing, want empty", m.err)
+	}
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/dialog/ -v`
+Expected: FAIL — the package does not exist (`no Go files in .../internal/dialog`).
+
+- [ ] **Step 3: Write the implementation (state + keys only)**
+
+Create `internal/dialog/dialog.go`:
+
+```go
+// Package dialog is glint's centered naming prompt: the modal box that asks for
+// a filename when a note is created or renamed. It is a self-contained
+// sub-model — it knows nothing about files, directories, or the app's modes,
+// and only reports whether the user confirmed or cancelled.
+package dialog
+
+import (
+	"strings"
+
+	"glint/internal/theme"
+
+	"github.com/charmbracelet/bubbles/textinput"
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+// Result is what a key press did to the dialog.
+type Result int
+
+const (
+	ResultNone    Result = iota // still typing
+	ResultConfirm               // Enter — the caller reads Value()
+	ResultCancel                // Esc — the caller restores the previous mode
+)
+
+// Model is the naming prompt.
+type Model struct {
+	th      theme.Theme
+	input   textinput.Model
+	title   string // "New note" / "Rename"
+	dirHint string // the folder the name resolves under, shown under the title
+	err     string // validation message shown under the input
+	width   int    // content-column width the box is sized against
+}
+
+// New builds an empty dialog. Open puts it into a usable state.
+func New(th theme.Theme) *Model {
+	ti := textinput.New()
+	ti.Prompt = "› "
+	ti.Placeholder = "name…"
+	return &Model{th: th, input: ti}
+}
+
+// Open resets the dialog for one use: title above, dirHint under it, prefill in
+// the input with the cursor at the end, no error.
+func (m *Model) Open(title, dirHint, prefill string) {
+	m.title = title
+	m.dirHint = dirHint
+	m.err = ""
+	m.input.SetValue(prefill)
+	m.input.CursorEnd()
+	m.input.Focus()
+}
+
+// Title is the dialog's heading.
+func (m *Model) Title() string { return m.title }
+
+// Value is the typed name with surrounding whitespace trimmed.
+func (m *Model) Value() string { return strings.TrimSpace(m.input.Value()) }
+
+// SetError shows msg under the input; the dialog stays open. Typing clears it.
+func (m *Model) SetError(msg string) { m.err = msg }
+
+// SetTheme repaints the dialog after a theme cycle.
+func (m *Model) SetTheme(th theme.Theme) { m.th = th }
+
+// SetWidth sets the content-column width the box is sized against.
+func (m *Model) SetWidth(w int) { m.width = w }
+
+// Update handles one message. Enter confirms, Esc cancels, everything else
+// edits the input (and clears a stale validation message).
+func (m *Model) Update(msg tea.Msg) (Result, tea.Cmd) {
+	if k, ok := msg.(tea.KeyMsg); ok {
+		switch k.Type {
+		case tea.KeyEnter:
+			return ResultConfirm, nil
+		case tea.KeyEsc:
+			return ResultCancel, nil
+		}
+	}
+	m.err = ""
+	var cmd tea.Cmd
+	m.input, cmd = m.input.Update(msg)
+	return ResultNone, cmd
+}
+```
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/dialog/ -v`
+Expected: PASS (8 tests). `View` is not exercised yet.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/dialog/
+git commit -m "feat: naming dialog sub-model (state + keys)
+
+Centered filename prompt as a self-contained Bubbletea sub-model:
+Open/Value/SetError plus Enter-confirms, Esc-cancels (TASK-046)."
+```
+
+---
+
+## Task 3: The `dialog` view
+
+The box: rounded border in `theme.Heading`, themed background, title, dir hint, input, footer, and the error line when set. Same visual family as `App.helpOverlay` (`internal/app/app.go:1021`).
+
+**Files:**
+- Modify: `internal/dialog/dialog.go` (append `View`)
+- Test: `internal/dialog/dialog_test.go` (append)
+
+**Interfaces:**
+- Consumes: `Model` from Task 2.
+- Produces: `func (m *Model) View() string` — the fully rendered box, no trailing newline.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/dialog/dialog_test.go`:
+
+```go
+import "github.com/charmbracelet/lipgloss" // add to the existing import block
+
+func TestViewShowsTitleDirAndFooter(t *testing.T) {
+	m := newModel()
+	m.SetWidth(60)
+	m.Open("New note", "~/Humdrum/Inbox/", "")
+	out := m.View()
+	for _, want := range []string{"New note", "~/Humdrum/Inbox/", "Enter", "Esc"} {
+		if !strings.Contains(out, want) {
+			t.Errorf("View() missing %q:\n%s", want, out)
+		}
+	}
+}
+
+func TestViewShowsErrorWhenSet(t *testing.T) {
+	m := newModel()
+	m.SetWidth(60)
+	m.Open("New note", "~/Notes/", "")
+	if strings.Contains(m.View(), "Name taken") {
+		t.Fatal("View() shows the error before SetError")
+	}
+	m.SetError("Name taken")
+	if !strings.Contains(m.View(), "Name taken") {
+		t.Errorf("View() missing the error line:\n%s", m.View())
+	}
+}
+
+func TestViewFitsContentWidth(t *testing.T) {
+	m := newModel()
+	m.SetWidth(40)
+	m.Open("New note", "~/Notes/", "")
+	for i, ln := range strings.Split(m.View(), "\n") {
+		if w := lipgloss.Width(ln); w > 40 {
+			t.Errorf("line %d width = %d, want <= 40", i, w)
+		}
+	}
+}
+```
+
+Add `"strings"` to the test file's imports.
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/dialog/ -run TestView -v`
+Expected: FAIL — `m.View undefined (type *Model has no field or method View)`.
+
+- [ ] **Step 3: Write the implementation**
+
+Append to `internal/dialog/dialog.go` (add `"github.com/charmbracelet/lipgloss"` to the imports):
+
+```go
+// boxMin is the narrowest the dialog box gets before it just takes the column.
+const boxMin = 24
+
+// View renders the dialog as a centered bordered box: title, the folder the
+// name lands in, the input, an optional validation line, then the key footer.
+func (m *Model) View() string {
+	inner := lipgloss.NewStyle().Foreground(m.th.Heading).Bold(true).Render(m.title)
+	if m.dirHint != "" {
+		inner += "\n" + lipgloss.NewStyle().Foreground(m.th.Muted).Render("in "+m.dirHint)
+	}
+	inner += "\n\n" + m.input.View()
+	if m.err != "" {
+		inner += "\n\n" + lipgloss.NewStyle().Foreground(m.th.Spell).Render(m.err)
+	}
+	inner += "\n\n" + lipgloss.NewStyle().Foreground(m.th.Muted).
+		Render("Enter to confirm · Esc to cancel")
+
+	w := m.width - 2 // the border takes one cell each side
+	if w < boxMin {
+		w = boxMin
+	}
+	box := lipgloss.NewStyle().
+		Border(lipgloss.RoundedBorder()).
+		BorderForeground(m.th.Heading).
+		BorderBackground(m.th.Background).
+		Background(m.th.Background).
+		Foreground(m.th.Text).
+		Padding(0, 1).
+		Width(w)
+	return box.Render(inner)
+}
+```
+
+Note on width: `Padding(0, 1)` sits inside `Width(w)`, so the rendered box is `w + 2` cells wide including the border. `TestViewFitsContentWidth` sets width 40 → `w = 38` → 40 rendered. Keep that arithmetic; if the test fails by exactly 2 cells, the padding/width interaction changed and `w` needs `m.width - 2` re-derived, not the test relaxed.
+
+- [ ] **Step 4: Run the test to verify it passes**
+
+Run: `go test ./internal/dialog/ -v`
+Expected: PASS (11 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/dialog/
+git commit -m "feat: naming dialog view (centered bordered box)
+
+Title, target-folder hint, input, validation line, and key footer in the
+same visual family as the help overlay (TASK-046)."
+```
+
+---
+
+## Task 4: The editor filename header
+
+The bar text and its rendering, independent of the dialog. It goes into the existing top pad, so `paintCanvas` gains a header row rather than an extra one.
+
+**Files:**
+- Create: `internal/app/header.go`
+- Modify: `internal/app/app.go:1070-1090` (`paintCanvas`)
+- Test: `internal/app/header_test.go`
+
+**Interfaces:**
+- Consumes: `theme.LegibleText` (Task 1), `App.path`, `App.editor.Dirty`, `App.contentWidth()`, `App.theme`.
+- Produces:
+  - `func (a *App) headerText() string` — basename minus `.md`, plus `" •"` when dirty; `"Untitled"` (plus the dirty bullet) for a pathless buffer.
+  - `func (a *App) headerBar() string` — one rendered row, content-column wide; `""` when the header should not show.
+  - `func (a *App) showHeader() bool` — true in `ModeEditor`, `ModeFind`, `ModeGotoLine`, `ModeSpell`, `ModeNamePrompt`; false otherwise.
+
+`ModeNamePrompt` does not exist until Task 5. Until then `showHeader` lists only the four existing modes; Task 5 adds the fifth. This is called out again in Task 5, Step 4.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/app/header_test.go`:
+
+```go
+package app
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	"glint/internal/theme"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func TestHeaderTextIsBasenameWithoutExtension(t *testing.T) {
+	a := newApp()
+	a.path = "/tmp/notes/my-note.md"
+	if got := a.headerText(); got != "my-note" {
+		t.Errorf("headerText() = %q, want my-note", got)
+	}
+}
+
+func TestHeaderTextMarksDirty(t *testing.T) {
+	a := newApp()
+	a.path = "/tmp/notes/my-note.md"
+	a.editor.Dirty = true
+	if got := a.headerText(); got != "my-note •" {
+		t.Errorf("headerText() = %q, want 'my-note •'", got)
+	}
+}
+
+func TestHeaderTextForPathlessBuffer(t *testing.T) {
+	a := newApp()
+	a.path = ""
+	if got := a.headerText(); got != "Untitled" {
+		t.Errorf("headerText() = %q, want Untitled", got)
+	}
+}
+
+func TestHeaderIgnoresFrontmatterTitle(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "on-disk.md")
+	os.WriteFile(p, []byte("---\ntitle: Something Else\n---\n\nbody"), 0o644)
+
+	a := newApp()
+	if err := a.Load(p); err != nil {
+		t.Fatal(err)
+	}
+	if got := a.headerText(); got != "on-disk" {
+		t.Errorf("headerText() = %q, want on-disk (edit mode shows the file, not the title)", got)
+	}
+}
+
+func TestHeaderBarAppearsInEditorView(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "visible.md")
+	os.WriteFile(p, []byte("body"), 0o644)
+
+	a := newApp()
+	a.theme = theme.FlexokiDark()
+	a.editor.SetTheme(theme.FlexokiDark())
+	a.Load(p)
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 12})
+	if !strings.Contains(a.View(), "visible") {
+		t.Errorf("editor View() has no filename header:\n%s", a.View())
+	}
+}
+
+func TestHeaderCostsNoTextRows(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "rows.md")
+	os.WriteFile(p, []byte("body"), 0o644)
+
+	a := newApp()
+	a.Load(p)
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	gotW, gotH := a.editor.Size()
+	if gotH != 20-1-canvasTopPad {
+		t.Errorf("editor height = %d, want %d (header must not steal a text row)", gotH, 20-1-canvasTopPad)
+	}
+	if gotW != a.contentWidth() {
+		t.Errorf("editor width = %d, want %d", gotW, a.contentWidth())
+	}
+}
+
+func TestViewIsExactlyHeightRows(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "rows2.md")
+	os.WriteFile(p, []byte("body"), 0o644)
+
+	a := newApp()
+	a.Load(p)
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 14})
+	if n := len(strings.Split(a.View(), "\n")); n != 14 {
+		t.Errorf("View() = %d lines, want 14 (canvas height-1 + status bar)", n)
+	}
+}
+
+func TestHeaderHiddenInPicker(t *testing.T) {
+	a := newApp()
+	a.path = "/tmp/notes/hidden.md"
+	a.mode = ModePicker
+	if a.showHeader() {
+		t.Error("showHeader() = true in ModePicker, want false")
+	}
+}
+
+func TestHeaderHiddenInPreviewAndHelp(t *testing.T) {
+	a := newApp()
+	a.path = "/tmp/notes/hidden.md"
+	for _, m := range []Mode{ModePreview, ModeHelp} {
+		a.mode = m
+		if a.showHeader() {
+			t.Errorf("showHeader() = true in mode %d, want false", m)
+		}
+	}
+}
+
+func TestHeaderShownInOverlayModes(t *testing.T) {
+	a := newApp()
+	a.path = "/tmp/notes/shown.md"
+	for _, m := range []Mode{ModeEditor, ModeFind, ModeGotoLine, ModeSpell} {
+		a.mode = m
+		if !a.showHeader() {
+			t.Errorf("showHeader() = false in mode %d, want true", m)
+		}
+	}
+}
+```
+
+If `editor.Editor` has no `Size()` accessor, replace the `TestHeaderCostsNoTextRows` body's first line with a direct read of whatever the editor exposes — check with `grep -n 'func (e \*Editor) Size\|width\|height' internal/editor/editor.go` first, and if there is no accessor, add one:
+
+```go
+// Size returns the editor's current viewport width and height in cells.
+func (e *Editor) Size() (int, int) { return e.width, e.height }
+```
+
+(placed next to `SetSize` in `internal/editor/editor.go`, using that struct's actual field names).
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/app/ -run TestHeader -v`
+Expected: FAIL — `a.headerText undefined`, `a.showHeader undefined`.
+
+- [ ] **Step 3: Write the implementation**
+
+Create `internal/app/header.go`:
+
+```go
+package app
+
+import (
+	"path/filepath"
+	"strings"
+
+	"glint/internal/theme"
+
+	"github.com/charmbracelet/lipgloss"
+)
+
+// headerText is the name shown in the editor's filename bar: the file's
+// basename without its extension, or "Untitled" for a buffer with no path. A
+// frontmatter "title" deliberately does not override it — edit mode shows what
+// is on disk. Unsaved changes append a bullet.
+func (a *App) headerText() string {
+	name := "Untitled"
+	if a.path != "" {
+		base := filepath.Base(a.path)
+		name = strings.TrimSuffix(base, filepath.Ext(base))
+	}
+	if a.editor.Dirty {
+		name += " •"
+	}
+	return name
+}
+
+// showHeader reports whether the filename bar belongs on screen: in the editor
+// and the overlays that keep it visible beneath them. The picker has its own
+// layout, help replaces the body, and preview draws its own title bar.
+func (a *App) showHeader() bool {
+	switch a.mode {
+	case ModeEditor, ModeFind, ModeGotoLine, ModeSpell:
+		return true
+	}
+	return false
+}
+
+// headerBar renders the filename as one content-column-wide row: a bold filled
+// block in the heading color for a named file, muted plain text for an unnamed
+// buffer so "unsaved and unnamed" reads at a glance. Returns "" when the header
+// does not belong on screen.
+func (a *App) headerBar() string {
+	if !a.showHeader() {
+		return ""
+	}
+	w := a.contentWidth()
+	if w < 3 {
+		return ""
+	}
+	label := truncate(" "+a.headerText()+" ", w)
+	if a.path == "" {
+		return lipgloss.NewStyle().
+			Foreground(a.theme.Muted).
+			Background(a.theme.Background).
+			Width(w).
+			Render(label)
+	}
+	return lipgloss.NewStyle().
+		Bold(true).
+		Foreground(lipgloss.Color(theme.LegibleText(string(a.theme.Heading)))).
+		Background(a.theme.Heading).
+		Width(w).
+		Render(label)
+}
+```
+
+Then modify `paintCanvas` in `internal/app/app.go` — replace the pad loop:
+
+```go
+	for p := 0; p < a.topPad(); p++ {
+		rows = append(rows, bg.Render(""))
+	}
+```
+
+with:
+
+```go
+	// The filename bar lives in the middle row of the top pad, so it costs no
+	// text rows: the canvas is still exactly height-1 rows.
+	header := a.headerBar()
+	for p := 0; p < a.topPad(); p++ {
+		if p == 1 && header != "" {
+			rows = append(rows, bg.Render(lm+header))
+			continue
+		}
+		rows = append(rows, bg.Render(""))
+	}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/app/ -v`
+Expected: PASS, including the pre-existing `TestCanvasPaintsFullWidthBackground` (the header row is background-filled to the terminal width like every other row).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/app/header.go internal/app/header_test.go internal/app/app.go
+git commit -m "feat: filename header bar in editor mode
+
+Basename (minus .md) as a heading-colored bar painted into the existing
+top pad, so it costs no text rows. Pathless buffers show a muted
+'Untitled'; a dirty buffer appends a bullet (TASK-046)."
+```
+
+---
+
+## Task 5: Wire `ModeNamePrompt` into `App`
+
+Swap `ModeSaveAs` for `ModeNamePrompt`, hold the dialog on `App`, and route keys. The confirm handlers land in Task 6 — this task ends with a dialog that opens, types, and cancels, with confirm as a stub that only closes.
+
+**Files:**
+- Modify: `internal/app/app.go` (mode consts, struct, `New`, `handleKey`, `View`, `setSize`, `cycleTheme`)
+- Create: `internal/app/naming.go`
+- Test: `internal/app/naming_test.go`
+
+**Interfaces:**
+- Consumes: `dialog.New/Open/Update/View/SetTheme/SetWidth/Value/SetError`, `Result*` (Tasks 2–3); `a.showHeader` (Task 4).
+- Produces:
+  - `ModeNamePrompt Mode` — replaces `ModeSaveAs` in the const block.
+  - `App.dialog *dialog.Model`, `App.dialogKind namingKind`, `App.dialogDir string`, `App.prevMode Mode`.
+  - `type namingKind int` with `namingNew`, `namingRename`.
+  - `func (a *App) openNamePrompt(kind namingKind, dir, prefill string)` — sets `prevMode`, `dialogKind`, `dialogDir`, opens the dialog, switches mode.
+  - `func (a *App) handleNameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd)` — routes into the dialog and dispatches on `Result`.
+  - `func (a *App) confirmName() (tea.Model, tea.Cmd)` — stubbed here, implemented in Task 6.
+  - `func (a *App) cancelName() (tea.Model, tea.Cmd)` — restores `prevMode`, clears status.
+  - `func (a *App) dirLabel(dir string) string` — `dir` with `$HOME` replaced by `~`, trailing separator added.
+
+- [ ] **Step 1: Write the failing test**
+
+Create `internal/app/naming_test.go`:
+
+```go
+package app
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+	"testing"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+func typeInto(a *App, s string) {
+	for _, r := range s {
+		a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
+	}
+}
+
+func TestCtrlNOpensNamePrompt(t *testing.T) {
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+	if a.mode != ModeNamePrompt {
+		t.Fatalf("mode = %d, want ModeNamePrompt", a.mode)
+	}
+	if a.dialog.Title() != "New note" {
+		t.Errorf("dialog title = %q, want 'New note'", a.dialog.Title())
+	}
+}
+
+func TestNamePromptShowsTargetDir(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.openNamePrompt(namingNew, dir, "")
+	if !strings.Contains(a.View(), filepath.Base(dir)) {
+		t.Errorf("View() does not show the target dir %q:\n%s", dir, a.View())
+	}
+}
+
+func TestEscCancelsBackToPreviousMode(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "open.md")
+	os.WriteFile(p, []byte("body"), 0o644)
+
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.Load(p)
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+	a.Update(tea.KeyMsg{Type: tea.KeyEsc})
+	if a.mode != ModeEditor {
+		t.Errorf("mode = %d after Esc, want ModeEditor", a.mode)
+	}
+	if a.path != p {
+		t.Errorf("path = %q after Esc, want the file we were editing (%q)", a.path, p)
+	}
+	entries, _ := os.ReadDir(dir)
+	if len(entries) != 1 {
+		t.Errorf("Esc wrote %d entries to disk, want 1 (the pre-existing file)", len(entries))
+	}
+}
+
+func TestDirtyBufferConfirmsDiscardBeforePrompt(t *testing.T) {
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.editor.SetContent([]byte("x"))
+	a.editor.Dirty = true
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+	if a.mode == ModeNamePrompt {
+		t.Fatal("first Ctrl+N on a dirty buffer opened the prompt; want a discard confirmation")
+	}
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+	if a.mode != ModeNamePrompt {
+		t.Errorf("second Ctrl+N: mode = %d, want ModeNamePrompt", a.mode)
+	}
+}
+
+func TestPickerCtrlNPrefillsQuery(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	if err := a.StartPickerIn(dir); err != nil {
+		t.Fatal(err)
+	}
+	typeInto(a, "draft")
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN})
+	if a.mode != ModeNamePrompt {
+		t.Fatalf("mode = %d, want ModeNamePrompt", a.mode)
+	}
+	if a.dialog.Value() != "draft" {
+		t.Errorf("dialog prefill = %q, want draft", a.dialog.Value())
+	}
+}
+
+func TestF2OpensRenamePrefilled(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "before.md")
+	os.WriteFile(p, []byte("body"), 0o644)
+
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.Load(p)
+	a.Update(tea.KeyMsg{Type: tea.KeyF2})
+	if a.mode != ModeNamePrompt {
+		t.Fatalf("mode = %d, want ModeNamePrompt", a.mode)
+	}
+	if a.dialog.Title() != "Rename" {
+		t.Errorf("dialog title = %q, want Rename", a.dialog.Title())
+	}
+	if a.dialog.Value() != "before" {
+		t.Errorf("dialog prefill = %q, want before", a.dialog.Value())
+	}
+}
+```
+
+- [ ] **Step 2: Run the test to verify it fails**
+
+Run: `go test ./internal/app/ -run 'TestCtrlNOpens|TestNamePrompt|TestEscCancels|TestDirty|TestPickerCtrlN|TestF2' -v`
+Expected: FAIL — `undefined: ModeNamePrompt`, `a.dialog undefined`, `undefined: namingNew`.
+
+- [ ] **Step 3: Edit `app.go` — modes, struct, constructor**
+
+In the mode const block (`internal/app/app.go:34-42`), replace `ModeSaveAs` with `ModeNamePrompt`:
+
+```go
+const (
+	ModeEditor Mode = iota
+	ModePicker
+	ModePreview
+	ModeNamePrompt // centered dialog naming a new or renamed file (TASK-046)
+	ModeFind
+	ModeHelp
+	ModeGotoLine
+	ModeSpell
+)
+```
+
+In the `App` struct: delete the `saveInput textinput.Model` field and add, next to `picker`:
+
+```go
+	dialog     *dialog.Model // centered new-file / rename prompt (TASK-046)
+	dialogKind namingKind    // what the open dialog will do on confirm
+	dialogDir  string        // directory a new name resolves under
+	prevMode   Mode          // mode to restore when the dialog is cancelled
+```
+
+Keep `saveDir` — `startBlankIn` still sets it and Task 6's confirm reads it as the fallback directory.
+
+In `New` (`internal/app/app.go:96-134`): delete the `ti := textinput.New(); ti.Prompt = "save as › "; ti.Placeholder = "name…"` block and the `saveInput: ti,` field, then after `a.preview = preview.New(...)` add:
+
+```go
+	a.dialog = dialog.New(th)
+```
+
+Add `"glint/internal/dialog"` to the imports. If `textinput` is still used by `findInput`/`gotoInput` (it is), leave that import alone.
+
+- [ ] **Step 4: Edit `app.go` — key routing, view, sizing, theme; extend `showHeader`**
+
+`handleKey`, in the `switch msg.Type` block, add after `case tea.KeyCtrlL:`:
+
+```go
+	case tea.KeyF2:
+		return a.renameFile()
+```
+
+In the same function's `case tea.KeyEsc:` arm, the current body forces `a.mode = ModeEditor`. The dialog must cancel to `prevMode` instead — add this guard as the first statement of the `KeyEsc` arm:
+
+```go
+		if a.mode == ModeNamePrompt {
+			return a.cancelName()
+		}
+```
+
+In the trailing `switch a.mode` block, replace the whole `case ModeSaveAs:` arm with:
+
+```go
+	case ModeNamePrompt:
+		return a.handleNameKey(msg)
+```
+
+In `View()` (`internal/app/app.go:987`), the `switch a.mode` that picks `body` gains an arm before `default:`:
+
+```go
+	case ModeNamePrompt:
+		body = a.dialog.View()
+```
+
+and the second `switch a.mode` that picks `bottom` loses its `case ModeSaveAs: bottom = a.saveBar()` arm entirely (the dialog carries its own footer; the status bar stays).
+
+Delete `saveBar()` (`internal/app/app.go:1056-1063`), `promptSaveAs()` (`:564-570`), and `saveAs()` (`:574-598`).
+
+In `save()` (`internal/app/app.go:468`), replace the two save-as references. The current head is:
+
+```go
+func (a *App) save() (tea.Model, tea.Cmd) {
+	if a.mode == ModeSaveAs {
+		return a.saveAs() // Ctrl+S confirms an open save-as prompt
+```
+
+Read the rest of `save()` and replace that guard with:
+
+```go
+func (a *App) save() (tea.Model, tea.Cmd) {
+	if a.mode == ModeNamePrompt {
+		return a.confirmName() // Ctrl+S confirms an open naming prompt
+	}
+```
+
+and replace the later `return a.promptSaveAs()` (the pathless-buffer branch) with:
+
+```go
+		return a.nameCurrentBuffer()
+```
+
+In `setSize` (`internal/app/app.go:915`), after `a.preview.SetSize(cw, textRows)` add:
+
+```go
+	a.dialog.SetWidth(cw)
+```
+
+In `cycleTheme` (`internal/app/app.go:838`), wherever it repaints the editor/preview/picker, add:
+
+```go
+	a.dialog.SetTheme(a.theme)
+```
+
+In `internal/app/header.go`, extend `showHeader` to keep the header visible behind the dialog:
+
+```go
+	case ModeEditor, ModeFind, ModeGotoLine, ModeSpell, ModeNamePrompt:
+		return true
+```
+
+Wait — the dialog replaces `body` in `View`, so the editor is not visible beneath it, but the header lives in the pad and still reads correctly as "this is the file you were in". Keep it shown. Add a matching case to `TestHeaderShownInOverlayModes` in `internal/app/header_test.go`:
+
+```go
+	for _, m := range []Mode{ModeEditor, ModeFind, ModeGotoLine, ModeSpell, ModeNamePrompt} {
+```
+
+- [ ] **Step 5: Write `naming.go` (open/cancel, confirm stubbed)**
+
+Create `internal/app/naming.go`:
+
+```go
+package app
+
+import (
+	"os"
+	"path/filepath"
+	"strings"
+
+	"glint/internal/dialog"
+
+	tea "github.com/charmbracelet/bubbletea"
+)
+
+// namingKind is what an open naming dialog will do when confirmed.
+type namingKind int
+
+const (
+	namingNew    namingKind = iota // create dialogDir/<name>.md and open it
+	namingRename                   // rename the current file to <name>.md
+)
+
+// openNamePrompt shows the centered naming dialog. dir is the folder a new name
+// resolves under; prefill seeds the input (the picker query, or the current
+// basename for a rename). Cancelling returns to whatever mode was active.
+func (a *App) openNamePrompt(kind namingKind, dir, prefill string) {
+	title := "New note"
+	if kind == namingRename {
+		title = "Rename"
+	}
+	a.prevMode = a.mode
+	a.dialogKind = kind
+	a.dialogDir = dir
+	a.dialog.SetWidth(a.contentWidth())
+	a.dialog.Open(title, a.dirLabel(dir), prefill)
+	a.mode = ModeNamePrompt
+	a.status = ""
+}
+
+// renameFile is the F2 handler: rename the open file, or name a buffer that has
+// no path yet. Only meaningful over the editor.
+func (a *App) renameFile() (tea.Model, tea.Cmd) {
+	if a.mode == ModePicker || a.mode == ModeNamePrompt {
+		return a, nil
+	}
+	if a.path == "" {
+		return a.nameCurrentBuffer()
+	}
+	base := filepath.Base(a.path)
+	a.openNamePrompt(namingRename, filepath.Dir(a.path), strings.TrimSuffix(base, filepath.Ext(base)))
+	return a, nil
+}
+
+// nameCurrentBuffer names a pathless buffer, then writes it — the job the
+// bottom-bar save-as prompt used to do.
+func (a *App) nameCurrentBuffer() (tea.Model, tea.Cmd) {
+	dir := a.saveDir
+	if dir == "" {
+		dir = a.cfg.InboxRoot()
+	}
+	a.openNamePrompt(namingRename, dir, "")
+	return a, nil
+}
+
+// handleNameKey routes a key into the dialog and acts on what it reports.
+func (a *App) handleNameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
+	res, cmd := a.dialog.Update(msg)
+	switch res {
+	case dialog.ResultConfirm:
+		return a.confirmName()
+	case dialog.ResultCancel:
+		return a.cancelName()
+	}
+	return a, cmd
+}
+
+// cancelName closes the dialog and restores the mode it opened over, writing
+// nothing.
+func (a *App) cancelName() (tea.Model, tea.Cmd) {
+	a.mode = a.prevMode
+	a.status = ""
+	return a, nil
+}
+
+// dirLabel renders dir for the dialog's hint: $HOME collapsed to ~, one
+// trailing separator so it reads as a folder.
+func (a *App) dirLabel(dir string) string {
+	if home, err := os.UserHomeDir(); err == nil && home != "" && strings.HasPrefix(dir, home) {
+		dir = "~" + strings.TrimPrefix(dir, home)
+	}
+	return strings.TrimSuffix(dir, string(filepath.Separator)) + string(filepath.Separator)
+}
+
+// confirmName applies the dialog's name. Implemented in the next task.
+func (a *App) confirmName() (tea.Model, tea.Cmd) {
+	return a.cancelName()
+}
+```
+
+Reroute `newFile` (`internal/app/app.go:685-701`) to the dialog — replace its body with:
+
+```go
+func (a *App) newFile(dir string, pend pendingDiscard) (tea.Model, tea.Cmd) {
+	if a.mode == ModePicker {
+		a.openNamePrompt(namingNew, dir, strings.TrimSpace(a.picker.Query()))
+		return a, nil
+	}
+	if a.editor.Dirty && a.pending != pend {
+		a.pending = pend
+		a.status = "Unsaved changes — press again to discard"
+		return a, nil
+	}
+	a.pending = discardNone
+	a.openNamePrompt(namingNew, dir, "")
+	return a, nil
+}
+```
+
+and update its doc comment to match:
+
+```go
+// newFile is the Ctrl+N / Ctrl+B handler: open the naming dialog for a new note
+// in dir. From the picker the typed query prefills the dialog. Outside the
+// picker a dirty buffer must confirm the discard first (keyed by pend, so
+// re-pressing the same key confirms).
+```
+
+- [ ] **Step 6: Fix the two stale assertions in `app_test.go`**
+
+`internal/app/app_test.go:135-136` and `:447-448` both assert `ModeSaveAs`. Change both to:
+
+```go
+	if a.mode != ModeNamePrompt {
+		t.Errorf("Ctrl+S on pathless buffer: mode = %d, want ModeNamePrompt", a.mode)
+	}
+```
+
+(keep each site's original message wording where it differs).
+
+- [ ] **Step 7: Run the tests**
+
+Run: `go test ./... && gofmt -l .`
+Expected: PASS. `TestF2OpensRenamePrefilled`, `TestCtrlNOpensNamePrompt`, `TestEscCancelsBackToPreviousMode`, `TestDirtyBufferConfirmsDiscardBeforePrompt`, `TestPickerCtrlNPrefillsQuery`, `TestNamePromptShowsTargetDir` all pass; confirm is still a no-op stub, which no test asserts against yet.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/app/
+git commit -m "feat: route naming dialog through app modes
+
+ModeNamePrompt replaces ModeSaveAs; Ctrl+N/Ctrl+B and F2 open the
+centered dialog, Esc restores the previous mode. Confirm is stubbed
+until the next task (TASK-046)."
+```
+
+---
+
+## Task 6: Confirm — create, open, rename
+
+The behavior the dialog exists for.
+
+**Files:**
+- Modify: `internal/app/naming.go` (replace the `confirmName` stub)
+- Test: `internal/app/naming_test.go` (append)
+
+**Interfaces:**
+- Consumes: `a.dialogKind`, `a.dialogDir`, `a.dialog.Value()`, `a.dialog.SetError()`, `picker.NewNotePath`, `a.Load`, `a.grammarOpen`.
+- Produces: `func (a *App) confirmName() (tea.Model, tea.Cmd)` — full implementation.
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/app/naming_test.go`:
+
+```go
+func TestConfirmCreatesAndOpensFile(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.openNamePrompt(namingNew, dir, "")
+	typeInto(a, "fresh")
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+	p := filepath.Join(dir, "fresh.md")
+	if _, err := os.Stat(p); err != nil {
+		t.Fatalf("file not created: %v", err)
+	}
+	if a.path != p {
+		t.Errorf("path = %q, want %q", a.path, p)
+	}
+	if a.mode != ModeEditor {
+		t.Errorf("mode = %d after confirm, want ModeEditor", a.mode)
+	}
+}
+
+func TestConfirmAppendsMarkdownExtension(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.openNamePrompt(namingNew, dir, "")
+	typeInto(a, "already.md")
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+	if a.path != filepath.Join(dir, "already.md") {
+		t.Errorf("path = %q, want %q (no double extension)", a.path, filepath.Join(dir, "already.md"))
+	}
+}
+
+func TestConfirmNestsSubdirectories(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.openNamePrompt(namingNew, dir, "")
+	typeInto(a, "projects/glint/notes")
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+	p := filepath.Join(dir, "projects", "glint", "notes.md")
+	if _, err := os.Stat(p); err != nil {
+		t.Fatalf("nested file not created: %v", err)
+	}
+	if a.path != p {
+		t.Errorf("path = %q, want %q", a.path, p)
+	}
+}
+
+func TestConfirmOpensExistingFileInsteadOfClobbering(t *testing.T) {
+	dir := t.TempDir()
+	p := filepath.Join(dir, "taken.md")
+	os.WriteFile(p, []byte("original body"), 0o644)
+
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.openNamePrompt(namingNew, dir, "")
+	typeInto(a, "taken")
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+	if got := string(a.editor.Bytes()); got != "original body" {
+		t.Errorf("editor content = %q, want 'original body' (existing file must not be clobbered)", got)
+	}
+	if a.path != p {
+		t.Errorf("path = %q, want %q", a.path, p)
+	}
+}
+
+func TestEmptyNameKeepsDialogOpen(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.openNamePrompt(namingNew, dir, "")
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+	if a.mode != ModeNamePrompt {
+		t.Errorf("mode = %d after empty confirm, want ModeNamePrompt (dialog stays open)", a.mode)
+	}
+	if !strings.Contains(a.View(), "Type a name first") {
+		t.Errorf("View() missing the validation message:\n%s", a.View())
+	}
+	if entries, _ := os.ReadDir(dir); len(entries) != 0 {
+		t.Errorf("empty confirm wrote %d entries, want 0", len(entries))
+	}
+}
+
+func TestRenameMovesFileOnDisk(t *testing.T) {
+	dir := t.TempDir()
+	old := filepath.Join(dir, "before.md")
+	os.WriteFile(old, []byte("kept body"), 0o644)
+
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.Load(old)
+	a.Update(tea.KeyMsg{Type: tea.KeyF2})
+	// Clear the prefill, then type the new name.
+	for range "before" {
+		a.Update(tea.KeyMsg{Type: tea.KeyBackspace})
+	}
+	typeInto(a, "after")
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+	newPath := filepath.Join(dir, "after.md")
+	if _, err := os.Stat(newPath); err != nil {
+		t.Fatalf("renamed file missing: %v", err)
+	}
+	if _, err := os.Stat(old); !os.IsNotExist(err) {
+		t.Errorf("old path still exists after rename")
+	}
+	if a.path != newPath {
+		t.Errorf("path = %q, want %q", a.path, newPath)
+	}
+	if got, _ := os.ReadFile(newPath); string(got) != "kept body" {
+		t.Errorf("renamed file content = %q, want 'kept body'", string(got))
+	}
+	if a.mode != ModeEditor {
+		t.Errorf("mode = %d after rename, want ModeEditor", a.mode)
+	}
+}
+
+func TestRenameRefusesExistingName(t *testing.T) {
+	dir := t.TempDir()
+	old := filepath.Join(dir, "before.md")
+	other := filepath.Join(dir, "other.md")
+	os.WriteFile(old, []byte("mine"), 0o644)
+	os.WriteFile(other, []byte("theirs"), 0o644)
+
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.Load(old)
+	a.Update(tea.KeyMsg{Type: tea.KeyF2})
+	for range "before" {
+		a.Update(tea.KeyMsg{Type: tea.KeyBackspace})
+	}
+	typeInto(a, "other")
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+	if a.mode != ModeNamePrompt {
+		t.Errorf("mode = %d, want ModeNamePrompt (dialog stays open on a taken name)", a.mode)
+	}
+	if !strings.Contains(a.View(), "Name taken") {
+		t.Errorf("View() missing 'Name taken':\n%s", a.View())
+	}
+	if got, _ := os.ReadFile(other); string(got) != "theirs" {
+		t.Errorf("other file content = %q, want 'theirs' (rename must not clobber)", string(got))
+	}
+}
+
+func TestNamingPathlessBufferWritesIt(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.startBlankIn(dir)
+	a.editor.SetContent([]byte("draft text"))
+	a.editor.Dirty = true
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) // pathless save opens the dialog
+	if a.mode != ModeNamePrompt {
+		t.Fatalf("mode = %d, want ModeNamePrompt", a.mode)
+	}
+	typeInto(a, "named")
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+	p := filepath.Join(dir, "named.md")
+	got, err := os.ReadFile(p)
+	if err != nil {
+		t.Fatalf("file not written: %v", err)
+	}
+	if string(got) != "draft text" {
+		t.Errorf("file content = %q, want 'draft text'", string(got))
+	}
+	if a.path != p {
+		t.Errorf("path = %q, want %q", a.path, p)
+	}
+	if a.editor.Dirty {
+		t.Error("buffer still dirty after naming and writing")
+	}
+}
+```
+
+That last test closes the `TestNamingPathlessBufferWritesIt` case; the whole
+block from `TestConfirmCreatesAndOpensFile` through it is one append to
+`internal/app/naming_test.go`.
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `go test ./internal/app/ -run 'TestConfirm|TestEmptyName|TestRename|TestNamingPathless' -v`
+Expected: FAIL — confirm is still the stub, so no file is created and the mode drops back to the editor.
+
+- [ ] **Step 3: Implement `confirmName`**
+
+Replace the stub at the bottom of `internal/app/naming.go`:
+
+```go
+// confirmName applies the name typed in the dialog. A new note is created (or
+// opened, when the name is already taken) under dialogDir; a rename moves the
+// current file, or writes a pathless buffer for the first time. Validation
+// failures leave the dialog open with a message.
+func (a *App) confirmName() (tea.Model, tea.Cmd) {
+	name := a.dialog.Value()
+	if name == "" {
+		a.dialog.SetError("Type a name first")
+		return a, nil
+	}
+	p := picker.NewNotePath(a.dialogDir, name)
+	if p == "" {
+		a.dialog.SetError("Type a name first")
+		return a, nil
+	}
+	if a.dialogKind == namingRename {
+		return a.applyRename(p)
+	}
+	a.mode = a.prevMode // openNoteAt/Load set the final mode
+	return a.openNoteAt(p)
+}
+
+// applyRename moves the current file to p, or writes a pathless buffer there
+// for the first time, then rebinds the buffer and re-opens it with harper under
+// the new URI.
+func (a *App) applyRename(p string) (tea.Model, tea.Cmd) {
+	if p == a.path {
+		a.mode = ModeEditor // renamed to itself; nothing to do
+		a.status = ""
+		return a, nil
+	}
+	if _, err := os.Stat(p); err == nil {
+		a.dialog.SetError("Name taken")
+		return a, nil
+	}
+	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+		a.dialog.SetError("Folder failed: " + err.Error())
+		return a, nil
+	}
+	if a.path == "" {
+		// A buffer that was never on disk: write it where the dialog points.
+		if err := os.WriteFile(p, a.editor.Bytes(), 0o644); err != nil {
+			a.dialog.SetError("Save failed: " + err.Error())
+			return a, nil
+		}
+	} else if err := os.Rename(a.path, p); err != nil {
+		a.dialog.SetError("Rename failed: " + err.Error())
+		return a, nil
+	}
+	delete(a.cursorMem, a.path) // the old path will never be reopened
+	a.path = p
+	a.saveDir = filepath.Dir(p)
+	a.editor.SetLanguage(p)
+	a.editor.Dirty = false
+	a.mode = ModeEditor
+	a.status = "Saved " + p
+	a.grammarOpen() // the buffer has a new URI; hand it to harper again
+	return a, nil
+}
+```
+
+Add `"glint/internal/picker"` to `internal/app/naming.go`'s imports.
+
+Note: a rename after unsaved edits writes the *old* bytes, because `os.Rename` moves the file on disk and the buffer's edits were never flushed. Guard it — insert this immediately after the `p == a.path` check:
+
+```go
+	if a.path != "" && a.editor.Dirty {
+		if err := os.WriteFile(a.path, a.editor.Bytes(), 0o644); err != nil {
+			a.dialog.SetError("Save failed: " + err.Error())
+			return a, nil
+		}
+	}
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/app/ -v`
+Expected: PASS, all of Task 5's and Task 6's tests plus the pre-existing suite.
+
+- [ ] **Step 5: Add the dirty-rename regression test**
+
+Append to `internal/app/naming_test.go`:
+
+```go
+func TestRenameFlushesUnsavedEdits(t *testing.T) {
+	dir := t.TempDir()
+	old := filepath.Join(dir, "before.md")
+	os.WriteFile(old, []byte("old body"), 0o644)
+
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	a.Load(old)
+	a.editor.SetContent([]byte("edited body"))
+	a.editor.Dirty = true
+	a.Update(tea.KeyMsg{Type: tea.KeyF2})
+	for range "before" {
+		a.Update(tea.KeyMsg{Type: tea.KeyBackspace})
+	}
+	typeInto(a, "after")
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+
+	got, err := os.ReadFile(filepath.Join(dir, "after.md"))
+	if err != nil {
+		t.Fatalf("renamed file missing: %v", err)
+	}
+	if string(got) != "edited body" {
+		t.Errorf("renamed file = %q, want 'edited body' (unsaved edits must survive a rename)", string(got))
+	}
+}
+```
+
+- [ ] **Step 6: Run the tests**
+
+Run: `go test ./... && gofmt -l .`
+Expected: PASS, `gofmt -l .` silent.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/app/
+git commit -m "feat: create, open, and rename from the naming dialog
+
+Confirm creates the file and opens it, opens an existing name instead of
+clobbering it, nests subdirectories, and renames on disk (flushing
+unsaved edits first) with harper reopened under the new URI (TASK-046)."
+```
+
+---
+
+## Task 7: CLI entry point, help text, and manual verification
+
+`glint -n` with no name must land in the dialog, and the keybind reference must document F2.
+
+**Files:**
+- Modify: `internal/app/app.go:806-823` (`StartNewIn`)
+- Modify: `internal/help/help.go`
+- Test: `internal/app/naming_test.go` (append)
+
+**Interfaces:**
+- Consumes: `a.openNamePrompt` (Task 5).
+- Produces: `StartNewIn` opens the dialog when `name` is empty (signature unchanged: `func (a *App) StartNewIn(dir, name string) error`).
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `internal/app/naming_test.go`:
+
+```go
+func TestStartNewWithNoNameOpensDialog(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	if err := a.StartNewIn(dir, ""); err != nil {
+		t.Fatal(err)
+	}
+	if a.mode != ModeNamePrompt {
+		t.Errorf("mode = %d, want ModeNamePrompt", a.mode)
+	}
+	if a.dialogDir != dir {
+		t.Errorf("dialogDir = %q, want %q", a.dialogDir, dir)
+	}
+}
+
+func TestStartNewWithNameSkipsDialog(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 100, Height: 20})
+	if err := a.StartNewIn(dir, "given"); err != nil {
+		t.Fatal(err)
+	}
+	if a.mode != ModeEditor {
+		t.Errorf("mode = %d, want ModeEditor (an explicit name needs no prompt)", a.mode)
+	}
+	if a.path != filepath.Join(dir, "given.md") {
+		t.Errorf("path = %q, want %q", a.path, filepath.Join(dir, "given.md"))
+	}
+}
+```
+
+- [ ] **Step 2: Run the tests to verify the first fails**
+
+Run: `go test ./internal/app/ -run TestStartNew -v`
+Expected: `TestStartNewWithNoNameOpensDialog` FAILs (mode is `ModeEditor` — `startBlankIn` still runs); `TestStartNewWithNameSkipsDialog` PASSes already.
+
+- [ ] **Step 3: Reroute `StartNewIn`**
+
+In `internal/app/app.go`, replace the empty-name branch:
+
+```go
+	if strings.TrimSpace(name) == "" {
+		a.startBlankIn(dir)
+		return nil
+	}
+```
+
+with:
+
+```go
+	if strings.TrimSpace(name) == "" {
+		a.startBlankIn(dir)         // an empty buffer sits behind the dialog
+		a.openNamePrompt(namingNew, dir, "")
+		return nil
+	}
+```
+
+and update the doc comment:
+
+```go
+// StartNewIn is the `glint -n` entry point: the naming dialog over an empty
+// buffer targeting dir when name is empty, or a new note created at
+// dir/<name>.md when it is given.
+```
+
+- [ ] **Step 4: Run the tests to verify they pass**
+
+Run: `go test ./internal/app/ -run TestStartNew -v`
+Expected: PASS (both).
+
+- [ ] **Step 5: Update the keybind reference**
+
+In `internal/help/help.go`, in the `EDITOR KEYS` section:
+
+Change the Ctrl+S line from
+
+```
+  Ctrl+S                save (an unnamed buffer prompts for a name)
+```
+
+to
+
+```
+  Ctrl+S                save (an unnamed buffer opens the naming dialog)
+```
+
+and add, right after the `Ctrl+B` line:
+
+```
+  F2                    rename the open file (a buffer with no name yet gets
+                        named and written)
+```
+
+Also update the two new-note lines so the dialog is not a surprise:
+
+```
+  Ctrl+N                new note in the current directory (asks for a name)
+  Ctrl+B                new note in the inbox (asks for a name)
+```
+
+- [ ] **Step 6: Full suite plus a build**
+
+Run: `go build ./... && go test ./... && gofmt -l .`
+Expected: builds clean, all PASS, `gofmt -l .` silent.
+
+- [ ] **Step 7: Manual smoke test**
+
+In a scratch directory:
+
+```bash
+mkdir -p /tmp/glint-smoke && go run . -n
+```
+
+Verify by eye:
+1. The centered dialog appears with title `New note` and the folder hint.
+2. Typing `smoke` and pressing Enter opens an empty buffer with `smoke` in the header bar at the top of the column.
+3. Typing text turns the header into `smoke •`.
+4. Ctrl+S clears the bullet.
+5. F2 opens `Rename` prefilled with `smoke`; renaming to `smoked` updates the header and `ls /tmp/glint-smoke` shows `smoked.md` only.
+6. Ctrl+P (preview) shows its own title bar, with no doubled header.
+7. Ctrl+F (picker) shows no header bar.
+8. Esc out of a fresh Ctrl+N leaves no new file behind.
+
+- [ ] **Step 8: Commit**
+
+```bash
+git add internal/app/app.go internal/app/naming_test.go internal/help/help.go
+git commit -m "feat: glint -n opens the naming dialog; document F2
+
+An empty -n lands in the dialog instead of an unnamed buffer, and the
+keybind reference covers F2 rename and the new Ctrl+N/Ctrl+B and Ctrl+S
+wording (TASK-046)."
+```
+
+- [ ] **Step 9: Close the task**
+
+```bash
+backlog task edit TASK-046 -s "🏁 Done" --plain
+git add backlog/ && git commit -m "chore: close TASK-046"
+```
+
+---
+
+## Self-Review Notes
+
+**Spec coverage** — every spec section maps to a task:
+
+| Spec section | Task |
+| --- | --- |
+| `legibleText`/`hexToRGB` move to `theme` | 1 |
+| Dialog: title, dir hint, input, Esc/Enter | 2 |
+| Dialog: centered bordered box, error line, footer | 3 |
+| Header: text, bar, top pad, no text rows, `Untitled`, dirty bullet, mode visibility | 4 |
+| `ModeNamePrompt` replaces `ModeSaveAs`; `saveInput`/`saveBar`/`promptSaveAs`/`saveAs` deleted | 5 |
+| `newFile` rerouted; picker prefill; dirty-discard confirm stays in front | 5 |
+| F2 rename; Ctrl+S on pathless buffer | 5 (routing), 6 (behavior) |
+| Create immediately; existing name opens it; slashes nest; empty name reports | 6 |
+| `os.Rename` + rebind + `grammarOpen` | 6 |
+| `glint -n` with no name | 7 |
+| Testing: dialog unit tests, header rendering, F2 round-trip, rewritten `ModeSaveAs` tests | 2, 3, 4, 5 (Step 6), 6 |
+
+**Deliberate additions beyond the spec:** the dirty-buffer flush before `os.Rename` (Task 6, Step 3 note) — without it a rename silently discards unsaved edits, which the spec did not anticipate; and `delete(a.cursorMem, a.path)` on rename, so the cursor memory does not leak an unreachable path.
+
+**Open risk:** `editor.Editor` may not expose `Size()`. Task 4, Step 1 says to check and add the accessor if it is missing.