▍ humdrum codex / glint v1.0.2

feat: glint new — fresh scratch doc + save-as to a configurable inbox

39bd908355006ec750b0191e611b6d668dee0bd1
humdrum <me@humdrum.me> · 2026-06-28 15:50

parent d919368d

feat: glint new — fresh scratch doc + save-as to a configurable inbox

Adds a way to start a blank markdown document from anywhere:
- `glint new` opens a blank unnamed buffer; `glint new <name>` creates and
  opens <inbox>/<name>.md.
- Ctrl+N inside the editor starts a fresh blank doc (confirm-then-discard if
  dirty, like Ctrl+P/Ctrl+D); Ctrl+N in the picker still makes a note from
  the query.
- Ctrl+S on an unnamed buffer opens a one-line save-as prompt; the typed name
  resolves under the inbox (Folder/Name supported, .md appended), writes, and
  binds the buffer.
- New config key inbox_dir (default "" = vault root) sets where new notes
  land; relative paths resolve under the vault, absolute paths as-is.

Tests cover the inbox resolver, the save-as flow, editor Ctrl+N dirty-confirm,
and both StartNew paths.

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

5 files changed

internal/app/app.go +122 −16
@@ -16,6 +16,7 @@ 	"glint/internal/picker"
 	"glint/internal/preview"
 	"glint/internal/theme"
 
+	"github.com/charmbracelet/bubbles/textinput"
 	tea "github.com/charmbracelet/bubbletea"
 	"github.com/charmbracelet/lipgloss"
 )
@@ -27,6 +28,7 @@ const (
 	ModeEditor Mode = iota
 	ModePicker
 	ModePreview
+	ModeSaveAs
 )
 
 // pendingDiscard tracks which open-while-dirty action is awaiting confirmation.
@@ -36,6 +38,7 @@ const (
 	discardNone   pendingDiscard = iota
 	discardPicker
 	discardDaily
+	discardNew
 )
 
 // Canvas layout: a centered, percentage-width text column with a little top air.
@@ -51,13 +54,14 @@ type App struct {
 	mode    Mode
 	cfg     config.Config
 	theme   theme.Theme
-	editor  *editor.Editor
-	preview *preview.Model
-	picker  *picker.Model
-	path    string
-	status  string
-	width   int
-	height  int
+	editor    *editor.Editor
+	preview   *preview.Model
+	picker    *picker.Model
+	saveInput textinput.Model // one-line "save as" prompt for unnamed buffers
+	path      string
+	status    string
+	width     int
+	height    int
 
 	quitArmed bool           // true after a dirty Ctrl+Q, awaiting confirm
 	pending   pendingDiscard // armed open-while-dirty confirmation
@@ -68,11 +72,15 @@ func New(cfg config.Config) *App {
 	th := theme.Resolve(cfg.Theme)
 	ed := editor.New()
 	ed.SetTheme(th)
+	ti := textinput.New()
+	ti.Prompt = "save as › "
+	ti.Placeholder = "name…"
 	a := &App{
-		mode:   ModeEditor,
-		cfg:    cfg,
-		theme:  th,
-		editor: ed,
+		mode:      ModeEditor,
+		cfg:       cfg,
+		theme:     th,
+		editor:    ed,
+		saveInput: ti,
 	}
 	a.preview = preview.New(a.glamourStyle())
 	return a
@@ -129,7 +137,8 @@ 		a.quitArmed = false
 	}
 	// Disarm pending-discard unless the same action is being re-pressed.
 	if !(msg.Type == tea.KeyCtrlP && a.pending == discardPicker) &&
-		!(msg.Type == tea.KeyCtrlD && a.pending == discardDaily) {
+		!(msg.Type == tea.KeyCtrlD && a.pending == discardDaily) &&
+		!(msg.Type == tea.KeyCtrlN && a.pending == discardNew) {
 		a.pending = discardNone
 	}
 
@@ -170,7 +179,17 @@ 	}
 
 	switch a.mode {
 	case ModeEditor:
+		if msg.Type == tea.KeyCtrlN {
+			return a.newBlank()
+		}
 		a.editor.HandleKey(msg)
+	case ModeSaveAs:
+		if msg.Type == tea.KeyEnter {
+			return a.saveAs()
+		}
+		var cmd tea.Cmd
+		a.saveInput, cmd = a.saveInput.Update(msg)
+		return a, cmd
 	case ModePreview:
 		return a, a.preview.Update(msg)
 	case ModePicker:
@@ -191,9 +210,11 @@ 	return a, nil
 }
 
 func (a *App) save() (tea.Model, tea.Cmd) {
+	if a.mode == ModeSaveAs {
+		return a.saveAs() // Ctrl+S confirms an open save-as prompt
+	}
 	if a.path == "" {
-		a.status = "No file to save"
-		return a, nil
+		return a.promptSaveAs() // unnamed buffer → ask for a name
 	}
 	if err := os.WriteFile(a.path, a.editor.Bytes(), 0o644); err != nil {
 		a.status = "Save failed: " + err.Error()
@@ -204,6 +225,78 @@ 	a.status = "Saved " + a.path
 	return a, nil
 }
 
+// promptSaveAs opens the one-line save-as prompt for an unnamed buffer.
+func (a *App) promptSaveAs() (tea.Model, tea.Cmd) {
+	a.saveInput.SetValue("")
+	a.saveInput.Focus()
+	a.mode = ModeSaveAs
+	a.status = "Save as — type a name, Enter to save, Esc to cancel"
+	return a, nil
+}
+
+// saveAs writes the unnamed buffer to a name typed at the prompt, resolved under
+// the inbox directory, then binds the buffer to that path.
+func (a *App) saveAs() (tea.Model, tea.Cmd) {
+	p := picker.NewNotePath(a.cfg.InboxRoot(), a.saveInput.Value())
+	if p == "" {
+		a.status = "Type a name first"
+		return a, nil
+	}
+	if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+		a.status = "Save dir failed: " + err.Error()
+		return a, nil
+	}
+	if err := os.WriteFile(p, a.editor.Bytes(), 0o644); err != nil {
+		a.status = "Save failed: " + err.Error()
+		return a, nil
+	}
+	a.path = p
+	a.editor.Dirty = false
+	a.mode = ModeEditor
+	a.status = "Saved " + p
+	return a, nil
+}
+
+// newBlank starts a fresh unnamed buffer, confirming discard if the current
+// buffer is dirty (a second Ctrl+N discards).
+func (a *App) newBlank() (tea.Model, tea.Cmd) {
+	if a.editor.Dirty && a.pending != discardNew {
+		a.pending = discardNew
+		a.status = "Unsaved changes — Ctrl+N again to discard"
+		return a, nil
+	}
+	a.pending = discardNone
+	a.startBlank()
+	return a, nil
+}
+
+// startBlank resets to an empty, unnamed editor buffer.
+func (a *App) startBlank() {
+	a.editor.SetContent(nil)
+	a.path = ""
+	a.mode = ModeEditor
+	a.status = "New note"
+}
+
+// StartNew is the entry point for `glint new [name]`: a blank buffer when name
+// is empty, or a new note created under the inbox when a name is given.
+func (a *App) StartNew(name string) error {
+	if strings.TrimSpace(name) == "" {
+		a.startBlank()
+		return nil
+	}
+	p := picker.NewNotePath(a.cfg.InboxRoot(), name)
+	if _, err := os.Stat(p); os.IsNotExist(err) {
+		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+			return err
+		}
+		if err := os.WriteFile(p, []byte{}, 0o644); err != nil {
+			return err
+		}
+	}
+	return a.Load(p)
+}
+
 // glamourStyle is the explicit config override if set, else the theme's style.
 func (a *App) glamourStyle() string {
 	if a.cfg.GlamourStyle != "" {
@@ -352,9 +445,22 @@ 	var body string
 	if a.mode == ModePreview {
 		body = a.preview.View()
 	} else {
-		body = a.editor.View()
+		body = a.editor.View() // editor stays visible beneath the save-as prompt
+	}
+	bottom := a.statusBar()
+	if a.mode == ModeSaveAs {
+		bottom = a.saveBar()
 	}
-	return a.paintCanvas(body) + a.statusBar()
+	return a.paintCanvas(body) + bottom
+}
+
+// saveBar renders the save-as prompt as a themed full-width bottom bar.
+func (a *App) saveBar() string {
+	bar := lipgloss.NewStyle().
+		Foreground(a.theme.StatusFg).
+		Background(a.theme.StatusBg).
+		Width(maxInt(a.width, 1))
+	return bar.Render(" " + a.saveInput.View() + " ")
 }
 
 // paintCanvas centers body in the theme's paper: a top pad, then each body line
internal/app/app_test.go +98 −3
@@ -128,11 +128,11 @@ 		t.Error("Ctrl+Q after disarm should re-arm, not quit")
 	}
 }
 
-func TestCtrlSNoPathSetsStatus(t *testing.T) {
+func TestCtrlSNoPathOpensSaveAsPrompt(t *testing.T) {
 	a := newApp()
 	a.Update(tea.KeyMsg{Type: tea.KeyCtrlS})
-	if a.status != "No file to save" {
-		t.Errorf("status = %q, want %q", a.status, "No file to save")
+	if a.mode != ModeSaveAs {
+		t.Errorf("Ctrl+S on unnamed buffer: mode = %d, want ModeSaveAs", a.mode)
 	}
 }
 
@@ -396,3 +396,98 @@ 	if !found {
 		t.Errorf("editor content not indented by left margin; view:\n%s", view)
 	}
 }
+
+func TestStartNewBlankBuffer(t *testing.T) {
+	cfg := config.Default()
+	a := New(cfg)
+	if err := a.StartNew(""); err != nil {
+		t.Fatal(err)
+	}
+	if a.mode != ModeEditor {
+		t.Errorf("mode = %d, want ModeEditor", a.mode)
+	}
+	if a.path != "" {
+		t.Errorf("path = %q, want empty (unnamed)", a.path)
+	}
+	if got := string(a.editor.Bytes()); got != "" {
+		t.Errorf("editor content = %q, want empty", got)
+	}
+}
+
+func TestStartNewWithNameLandsInInbox(t *testing.T) {
+	dir := t.TempDir()
+	cfg := config.Default()
+	cfg.VaultDir = dir
+	cfg.InboxDir = "Inbox"
+	a := New(cfg)
+	if err := a.StartNew("foo"); err != nil {
+		t.Fatal(err)
+	}
+	want := filepath.Join(dir, "Inbox", "foo.md")
+	if a.path != want {
+		t.Errorf("path = %q, want %q", a.path, want)
+	}
+	if _, err := os.Stat(want); err != nil {
+		t.Errorf("inbox note not created: %v", err)
+	}
+}
+
+func TestSaveAsPromptOnPathlessBuffer(t *testing.T) {
+	dir := t.TempDir()
+	cfg := config.Default()
+	cfg.VaultDir = dir
+	cfg.InboxDir = "" // vault root
+	a := New(cfg)
+	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+	a.StartNew("") // blank unnamed buffer
+	a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hello")})
+
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) // pathless → save-as prompt
+	if a.mode != ModeSaveAs {
+		t.Fatalf("Ctrl+S on pathless buffer: mode = %d, want ModeSaveAs", a.mode)
+	}
+	a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("idea")})
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter}) // confirm
+
+	want := filepath.Join(dir, "idea.md")
+	if a.path != want {
+		t.Errorf("path = %q, want %q", a.path, want)
+	}
+	data, err := os.ReadFile(want)
+	if err != nil {
+		t.Fatalf("file not written: %v", err)
+	}
+	if string(data) != "hello" {
+		t.Errorf("saved content = %q, want hello", string(data))
+	}
+	if a.editor.Dirty {
+		t.Error("buffer should be clean after save")
+	}
+	if a.mode != ModeEditor {
+		t.Errorf("after save mode = %d, want ModeEditor", a.mode)
+	}
+}
+
+func TestEditorCtrlNNewBlankWithDirtyConfirm(t *testing.T) {
+	dir := t.TempDir()
+	src := filepath.Join(dir, "a.md")
+	os.WriteFile(src, []byte("body"), 0o644)
+	cfg := config.Default()
+	cfg.VaultDir = dir
+	a := New(cfg)
+	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+	a.Load(src)
+	a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")}) // dirty
+
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) // first press: arm, do not discard
+	if a.path != src {
+		t.Errorf("first Ctrl+N discarded without confirm; path = %q", a.path)
+	}
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) // second press: new blank
+	if a.path != "" {
+		t.Errorf("after confirm, path = %q, want empty", a.path)
+	}
+	if got := string(a.editor.Bytes()); got != "" {
+		t.Errorf("editor not blank: %q", got)
+	}
+}
internal/config/config.go +17 −0
@@ -18,6 +18,7 @@ 	DailySubdir  string `toml:"daily_subdir"`
 	DailyFormat  string `toml:"daily_format"`
 	GlamourStyle string `toml:"glamour_style"`
 	Theme        string `toml:"theme"`
+	InboxDir     string `toml:"inbox_dir"`
 }
 
 // Default returns the built-in configuration used when no file is present.
@@ -70,7 +71,23 @@ 	}
 	if fileCfg.Theme != "" {
 		cfg.Theme = fileCfg.Theme
 	}
+	if fileCfg.InboxDir != "" {
+		cfg.InboxDir = fileCfg.InboxDir
+	}
 	return cfg, nil
+}
+
+// InboxRoot is the directory new notes default into: the vault root when
+// InboxDir is empty, an absolute InboxDir as-is, or InboxDir resolved under the
+// vault when relative.
+func (c Config) InboxRoot() string {
+	if c.InboxDir == "" {
+		return c.VaultDir
+	}
+	if filepath.IsAbs(c.InboxDir) {
+		return c.InboxDir
+	}
+	return filepath.Join(c.VaultDir, c.InboxDir)
 }
 
 // DailyPath builds the absolute path to the daily note for time t.
internal/config/config_test.go +33 −0
@@ -99,3 +99,36 @@ 	if got != want {
 		t.Errorf("DailyPath = %q, want %q", got, want)
 	}
 }
+
+func TestInboxDirDefaultsEmptyAndOverlays(t *testing.T) {
+	if d := Default(); d.InboxDir != "" {
+		t.Errorf("InboxDir default = %q, want empty (vault root)", d.InboxDir)
+	}
+	dir := t.TempDir()
+	path := filepath.Join(dir, "config.toml")
+	if err := os.WriteFile(path, []byte("inbox_dir = \"Inbox\"\n"), 0o644); err != nil {
+		t.Fatal(err)
+	}
+	cfg, err := loadFromFile(path)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if cfg.InboxDir != "Inbox" {
+		t.Errorf("InboxDir = %q, want Inbox", cfg.InboxDir)
+	}
+}
+
+func TestInboxRootResolves(t *testing.T) {
+	c := Config{VaultDir: "/v"}
+	if got := c.InboxRoot(); got != "/v" {
+		t.Errorf("empty InboxDir → %q, want /v (vault root)", got)
+	}
+	c.InboxDir = "Inbox"
+	if got := c.InboxRoot(); got != filepath.Join("/v", "Inbox") {
+		t.Errorf("relative InboxDir → %q, want /v/Inbox", got)
+	}
+	c.InboxDir = "/abs/inbox"
+	if got := c.InboxRoot(); got != "/abs/inbox" {
+		t.Errorf("absolute InboxDir → %q, want /abs/inbox", got)
+	}
+}
main.go +23 −5
@@ -13,25 +13,43 @@ 	tea "github.com/charmbracelet/bubbletea"
 )
 
 func main() {
-	daily := flag.Bool("daily", false, "open today's daily note")
-	flag.Parse()
-
 	cfg, err := config.Load()
 	if err != nil {
 		fmt.Fprintln(os.Stderr, "glint: config:", err)
 	}
+	a := app.New(cfg)
+
+	// `glint new [name]` — start a fresh document: a blank unnamed buffer, or a
+	// new note created under the inbox when a name is given.
+	if args := os.Args[1:]; len(args) > 0 && args[0] == "new" {
+		name := ""
+		if len(args) > 1 {
+			name = args[1]
+		}
+		if err := a.StartNew(name); err != nil {
+			fmt.Fprintln(os.Stderr, "glint:", err)
+			os.Exit(1)
+		}
+		run(a)
+		return
+	}
+
+	daily := flag.Bool("daily", false, "open today's daily note")
+	flag.Parse()
 
 	var path string
 	if args := flag.Args(); len(args) > 0 {
 		path = args[0]
 	}
-
-	a := app.New(cfg)
 	if err := a.Start(path, *daily); err != nil {
 		fmt.Fprintln(os.Stderr, "glint:", err)
 		os.Exit(1)
 	}
+	run(a)
+}
 
+// run drives the Bubbletea program in the alternate screen.
+func run(a *app.App) {
 	if _, err := tea.NewProgram(a, tea.WithAltScreen()).Run(); err != nil {
 		fmt.Fprintln(os.Stderr, "glint:", err)
 		os.Exit(1)