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

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


File Structure

Created:

Modified:


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:

Interfaces:

Append to internal/theme/theme_test.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)
	}
}

Run: go test ./internal/theme/ -run 'TestHexToRGB|TestLegibleText' -v Expected: FAIL — undefined: HexToRGB, undefined: LegibleText.

Append to internal/theme/theme.go (and add "strconv" + "strings" to its import block):

// 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:

// 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 ./...).

In internal/preview/header.go, add "glint/internal/theme" to the imports and replace the call sites:

Search for any remaining lowercase call: grep -rn 'hexToRGB\|legibleText\|relLuminance' internal/ must return nothing.

Run: go test ./... && gofmt -l . Expected: all packages PASS (preview's existing tests still pass — the rendered bytes are unchanged), gofmt -l . prints nothing.

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:

Interfaces:

Create internal/dialog/dialog_test.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)
	}
}

Run: go test ./internal/dialog/ -v Expected: FAIL — the package does not exist (no Go files in .../internal/dialog).

Create internal/dialog/dialog.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
}

Run: go test ./internal/dialog/ -v Expected: PASS (8 tests). View is not exercised yet.

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:

Interfaces:

Append to internal/dialog/dialog_test.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.

Run: go test ./internal/dialog/ -run TestView -v Expected: FAIL — m.View undefined (type *Model has no field or method View).

Append to internal/dialog/dialog.go (add "github.com/charmbracelet/lipgloss" to the imports):

// 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.

Run: go test ./internal/dialog/ -v Expected: PASS (11 tests).

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:

Interfaces:

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.

Create internal/app/header_test.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:

// 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).

Run: go test ./internal/app/ -run TestHeader -v Expected: FAIL — a.headerText undefined, a.showHeader undefined.

Create internal/app/header.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:

	for p := 0; p < a.topPad(); p++ {
		rows = append(rows, bg.Render(""))
	}

with:

	// 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(""))
	}

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).

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:

Interfaces:

Create internal/app/naming_test.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())
	}
}

Run: go test ./internal/app/ -run 'TestCtrlNOpens|TestNamePrompt|TestEscCancels|TestDirty|TestPickerCtrlN|TestF2' -v Expected: FAIL — undefined: ModeNamePrompt, a.dialog undefined, undefined: namingNew.

In the mode const block (internal/app/app.go:34-42), replace ModeSaveAs with ModeNamePrompt:

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:

	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 saveDirstartBlankIn 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:

	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.

handleKey, in the switch msg.Type block, add after case tea.KeyCtrlL::

	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:

		if a.mode == ModeNamePrompt {
			return a.cancelName()
		}

In the trailing switch a.mode block, replace the whole case ModeSaveAs: arm with:

	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::

	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:

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:

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:

		return a.nameCurrentBuffer()

In setSize (internal/app/app.go:915), after a.preview.SetSize(cw, textRows) add:

	a.dialog.SetWidth(cw)

In cycleTheme (internal/app/app.go:838), wherever it repaints the editor/preview/picker, add:

	a.dialog.SetTheme(a.theme)

In internal/app/header.go, extend showHeader to keep the header visible behind the dialog:

	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:

	for _, m := range []Mode{ModeEditor, ModeFind, ModeGotoLine, ModeSpell, ModeNamePrompt} {

Create internal/app/naming.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:

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:

// 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).

internal/app/app_test.go:135-136 and :447-448 both assert ModeSaveAs. Change both to:

	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).

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.

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:

Interfaces:

Append to internal/app/naming_test.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.

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.

Replace the stub at the bottom of internal/app/naming.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:

	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
		}
	}

Run: go test ./internal/app/ -v Expected: PASS, all of Task 5's and Task 6's tests plus the pre-existing suite.

Append to internal/app/naming_test.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))
	}
}

Run: go test ./... && gofmt -l . Expected: PASS, gofmt -l . silent.

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:

Interfaces:

Append to internal/app/naming_test.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"))
	}
}

Run: go test ./internal/app/ -run TestStartNew -v Expected: TestStartNewWithNoNameOpensDialog FAILs (mode is ModeEditorstartBlankIn still runs); TestStartNewWithNameSkipsDialog PASSes already.

In internal/app/app.go, replace the empty-name branch:

	if strings.TrimSpace(name) == "" {
		a.startBlankIn(dir)
		return nil
	}

with:

	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:

// 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.

Run: go test ./internal/app/ -run TestStartNew -v Expected: PASS (both).

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)

Run: go build ./... && go test ./... && gofmt -l . Expected: builds clean, all PASS, gofmt -l . silent.

In a scratch directory:

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.
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)."
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.