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.