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 areglint/internal/<pkg>. - Every styled span gets an explicit color from
theme.Theme— no terminal-default fallbacks (seeinternal/theme/theme.gopackage doc). paintCanvasmust emit exactlyheight-1rows; 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.mdwhen the extension is not already.md, and returns""for an empty query. - File modes: directories
0o755, files0o644. - Tests live beside the code as
<file>_test.goand uset.TempDir()for filesystem work. - Run
go test ./...before every commit. Rungofmt -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— gainsLegibleText,RelLuminance,HexToRGB.internal/theme/theme_test.go— tests for the moved helpers.internal/preview/preview.go— drops the local helpers, calls thethemeones.internal/preview/header.go— call sites updated to thethemehelpers.internal/app/app.go—ModeNamePromptreplacesModeSaveAs;saveInput/saveBar/promptSaveAs/saveAsremoved;F2key;newFilererouted;paintCanvaspaints the header.internal/app/app_test.go— the twoModeSaveAsassertions rewritten.internal/help/help.go—F2documented, Ctrl+S wording updated.main.go— no change needed (StartNewInhandles 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,0when 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:
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):
// 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 ./...).
- 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
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 structwith unexported fields; constructed only viaNew.func New(th theme.Theme) *Modelfunc (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() stringfunc (m *Model) Value() string— the trimmed input text.func (m *Model) SetError(msg string)— showsmsgunder 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 intwithResultNone,ResultConfirm,ResultCancel.func (m *Model) View() string(implemented in Task 3)
-
Step 1: Write the failing test
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)
}
}
- 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:
// 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
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(appendView) - Test:
internal/dialog/dialog_test.go(append)
Interfaces:
-
Consumes:
Modelfrom 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:
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):
// 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
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 inModeEditor,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:
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).
- 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:
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(""))
}
- 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
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— replacesModeSaveAsin the const block.App.dialog *dialog.Model,App.dialogKind namingKind,App.dialogDir string,App.prevMode Mode.type namingKind intwithnamingNew,namingRename.func (a *App) openNamePrompt(kind namingKind, dir, prefill string)— setsprevMode,dialogKind,dialogDir, opens the dialog, switches mode.func (a *App) handleNameKey(msg tea.KeyMsg) (tea.Model, tea.Cmd)— routes into the dialog and dispatches onResult.func (a *App) confirmName() (tea.Model, tea.Cmd)— stubbed here, implemented in Task 6.func (a *App) cancelName() (tea.Model, tea.Cmd)— restoresprevMode, clears status.func (a *App) dirLabel(dir string) string—dirwith$HOMEreplaced by~, trailing separator added.
-
Step 1: Write the failing test
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())
}
}
- 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:
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 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:
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; extendshowHeader
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} {
- Step 5: Write
naming.go(open/cancel, confirm stubbed)
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).
- 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:
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
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 theconfirmNamestub) - 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:
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:
// 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
}
}
- 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:
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
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:
StartNewInopens the dialog whennameis empty (signature unchanged:func (a *App) StartNewIn(dir, name string) error). -
Step 1: Write the failing test
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"))
}
}
- 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:
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.
- 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:
mkdir -p /tmp/glint-smoke && go run . -n
Verify by eye:
- The centered dialog appears with title
New noteand the folder hint. - Typing
smokeand pressing Enter opens an empty buffer withsmokein the header bar at the top of the column. - Typing text turns the header into
smoke •. - Ctrl+S clears the bullet.
- F2 opens
Renameprefilled withsmoke; renaming tosmokedupdates the header andls /tmp/glint-smokeshowssmoked.mdonly. - Ctrl+P (preview) shows its own title bar, with no doubled header.
- Ctrl+F (picker) shows no header bar.
- Esc out of a fresh Ctrl+N leaves no new file behind.
- Step 8: Commit
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
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.