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

feat: flag-based commands; Ctrl+N new in current dir; more top padding

03cf4998a4fe25e8a85cd04101df5b0c715fe9c5
humdrum <me@humdrum.me> · 2026-06-29 08:14

parent 99f1a50d

feat: flag-based commands; Ctrl+N new in current dir; more top padding

- Commands are now flags accepting a short letter or full word with one or two
  dashes: -n/--new (new note), -t/--today (daily note), -d/--daily (browse the
  daily folder), -v/--vault (vault picker), -i/--inbox (inbox picker),
  -c/--config (walkthrough). -n combines with -i/-v to target the inbox/vault.
  Replaces the new/vault/config subcommands. --version stays long-only (-v is
  now vault).
- Ctrl+N starts a new note in the current directory (the open file's folder, the
  picker root, or the working dir); a typed picker query becomes the filename.
  Unnamed buffers save-as into that directory.
- Top pad raised to 3 rows so notes aren't crowded against the top.
- Adds config.DailyDir(); generalizes app new/picker helpers (StartNewIn,
  StartPickerIn, currentDir).

7 files changed

README.md +10 −6
@@ -26,13 +26,17 @@
 ```bash
 glint                 # fuzzy picker over the current directory
 glint notes.md        # open a file
-glint vault           # fuzzy picker over your configured vault, from anywhere
-glint --daily         # open (and create) today's daily note
-glint new             # start a blank scratch document
-glint new ideas       # create and open <inbox>/ideas.md
-glint config          # interactive setup walkthrough (writes the config file)
+glint -n [name]       # new note in the current dir (-n -i → inbox, -n -v → vault)
+glint -t              # today's daily note
+glint -d              # browse the daily-notes folder
+glint -v              # fuzzy picker over your vault, from anywhere
+glint -i              # fuzzy picker over your inbox
+glint -c              # interactive config walkthrough
 glint --version       # print version
 ```
+
+Every command flag accepts a short letter or the full word, with one or two
+dashes: `-n` / `--n` / `-new` / `--new`, `-t` / `--today`, `-v` / `--vault`, etc.
 
 ### Keys
 
@@ -48,7 +52,7 @@ | `Ctrl+S` | save (an unnamed buffer prompts for a name → inbox) |
 | `Ctrl+P` | toggle the Glamour read preview |
 | `Ctrl+F` | fuzzy file picker (with live preview) |
 | `Ctrl+D` | today's daily note |
-| `Ctrl+N` | new document (in the editor) / new note from the query (in the picker) |
+| `Ctrl+N` | new note in the current directory (a typed picker query becomes its name) |
 | `Ctrl+T` | cycle theme (flexoki-light → flexoki-dark → charm) |
 | `Ctrl+Q` | quit (press twice if there are unsaved changes) |
 | `Esc` | back to the editor |
Users/kortum/Humdrum/Daily/2026-06-29.md +0 −0

Binary file.

internal/app/app.go +85 −61
@@ -46,7 +46,7 @@ const (
 	canvasRatio  = 0.75
 	canvasMax    = 120
 	canvasMin    = 24
-	canvasTopPad = 1
+	canvasTopPad = 3
 )
 
 // App is the root model.
@@ -57,11 +57,13 @@ 	theme     theme.Theme
 	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
+	saveInput  textinput.Model // one-line "save as" prompt for unnamed buffers
+	path       string
+	pickerRoot string // directory the current picker is browsing
+	saveDir    string // where an unnamed buffer's save-as lands ("" → inbox)
+	status     string
+	width      int
+	height     int
 
 	quitArmed bool           // true after a dirty Ctrl+Q, awaiting confirm
 	pending   pendingDiscard // armed open-while-dirty confirmation
@@ -215,6 +217,8 @@ 			return a, nil
 		}
 		a.pending = discardNone
 		return a.openDaily()
+	case tea.KeyCtrlN:
+		return a.newFile(a.currentDir(), discardNew)
 	case tea.KeyEsc:
 		a.mode = ModeEditor
 		return a, nil
@@ -222,9 +226,6 @@ 	}
 
 	switch a.mode {
 	case ModeEditor:
-		if msg.Type == tea.KeyCtrlN {
-			return a.newBlank()
-		}
 		a.editor.HandleKey(msg)
 	case ModeSaveAs:
 		if msg.Type == tea.KeyEnter {
@@ -236,16 +237,13 @@ 		return a, cmd
 	case ModePreview:
 		return a, a.preview.Update(msg)
 	case ModePicker:
-		switch msg.Type {
-		case tea.KeyEnter:
+		if msg.Type == tea.KeyEnter {
 			if sel := a.picker.Selected(); sel != "" {
 				if err := a.Load(sel); err != nil {
 					a.status = "Open failed: " + err.Error()
 				}
 			}
 			return a, nil
-		case tea.KeyCtrlN:
-			return a.newNote()
 		}
 		return a, a.picker.Update(msg)
 	}
@@ -280,7 +278,11 @@
 // 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())
+	root := a.saveDir
+	if root == "" {
+		root = a.cfg.InboxRoot()
+	}
+	p := picker.NewNotePath(root, a.saveInput.Value())
 	if p == "" {
 		a.status = "Type a name first"
 		return a, nil
@@ -300,35 +302,79 @@ 	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"
+// newFile is the Ctrl+N / Ctrl+I handler: start a new note in dir. From the
+// picker with a typed query it creates dir/<query>.md; otherwise it opens a
+// blank buffer whose save-as targets dir (confirming discard if the editor is
+// dirty, keyed by pend so re-pressing the same key confirms).
+func (a *App) newFile(dir string, pend pendingDiscard) (tea.Model, tea.Cmd) {
+	if a.mode == ModePicker {
+		if q := strings.TrimSpace(a.picker.Query()); q != "" {
+			return a.openNoteAt(picker.NewNotePath(dir, q))
+		}
+		a.startBlankIn(dir)
+		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.startBlank()
+	a.startBlankIn(dir)
 	return a, nil
 }
 
-// startBlank resets to an empty, unnamed editor buffer.
-func (a *App) startBlank() {
+// startBlankIn opens an empty, unnamed buffer whose save-as targets dir.
+func (a *App) startBlankIn(dir string) {
+	a.saveDir = dir
 	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 {
+// openNoteAt creates the note at p (if absent) and opens it.
+func (a *App) openNoteAt(p string) (tea.Model, tea.Cmd) {
+	if p == "" {
+		a.status = "Type a name first"
+		return a, nil
+	}
+	if _, err := os.Stat(p); os.IsNotExist(err) {
+		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
+			a.status = "New note dir failed: " + err.Error()
+			return a, nil
+		}
+		if err := os.WriteFile(p, []byte{}, 0o644); err != nil {
+			a.status = "New note failed: " + err.Error()
+			return a, nil
+		}
+	}
+	if err := a.Load(p); err != nil {
+		a.status = "Open failed: " + err.Error()
+	}
+	return a, nil
+}
+
+// currentDir is the "same directory" for Ctrl+N: the picker root in the picker,
+// the open file's folder in the editor, else the working directory.
+func (a *App) currentDir() string {
+	if a.mode == ModePicker {
+		return a.pickerRoot
+	}
+	if a.path != "" {
+		return filepath.Dir(a.path)
+	}
+	return a.cfg.WorkingDir()
+}
+
+// StartNewIn is the `glint -n` entry point: a blank buffer targeting dir when
+// name is empty, or a new note created at dir/<name>.md.
+func (a *App) StartNewIn(dir, name string) error {
 	if strings.TrimSpace(name) == "" {
-		a.startBlank()
+		a.startBlankIn(dir)
 		return nil
 	}
-	p := picker.NewNotePath(a.cfg.InboxRoot(), name)
+	p := picker.NewNotePath(dir, name)
 	if _, err := os.Stat(p); os.IsNotExist(err) {
 		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
 			return err
@@ -339,6 +385,9 @@ 		}
 	}
 	return a.Load(p)
 }
+
+// StartNew creates a new note in the inbox (kept for callers/tests).
+func (a *App) StartNew(name string) error { return a.StartNewIn(a.cfg.InboxRoot(), name) }
 
 // glamourStyle is the explicit config override if set, else the theme's style.
 func (a *App) glamourStyle() string {
@@ -429,12 +478,7 @@ func (a *App) openPicker() (tea.Model, tea.Cmd) {
 	return a.openPickerAt(a.cfg.WorkingDir())
 }
 
-// openVault opens the picker over the configured vault, from anywhere.
-func (a *App) openVault() (tea.Model, tea.Cmd) {
-	return a.openPickerAt(a.cfg.Vault())
-}
-
-// openPickerAt opens the picker rooted at dir.
+// openPickerAt opens the picker rooted at dir and records the root.
 func (a *App) openPickerAt(dir string) (tea.Model, tea.Cmd) {
 	p, err := picker.New(dir, a.theme, a.cfg.DailyPath(time.Now()), a.glamourStyle())
 	if err != nil {
@@ -443,16 +487,19 @@ 		return a, nil
 	}
 	p.SetSize(a.width, a.height-1)
 	a.picker = p
+	a.pickerRoot = dir
 	a.mode = ModePicker
 	return a, nil
 }
 
-// StartVault is the entry point for `glint vault`: open the picker over the
-// configured vault directory.
-func (a *App) StartVault() error {
-	_, _ = a.openVault()
+// StartPickerIn opens the picker over dir (used by -v/-i/-d and the default).
+func (a *App) StartPickerIn(dir string) error {
+	_, _ = a.openPickerAt(dir)
 	return nil
 }
+
+// StartVault opens the picker over the configured vault.
+func (a *App) StartVault() error { return a.StartPickerIn(a.cfg.Vault()) }
 
 // openDaily opens today's daily note, creating the file and directory if needed.
 func (a *App) openDaily() (tea.Model, tea.Cmd) {
@@ -469,29 +516,6 @@ 		}
 	}
 	if err := a.Load(path); err != nil {
 		a.status = "Daily open failed: " + err.Error()
-	}
-	return a, nil
-}
-
-// newNote creates a note named after the picker query and opens it.
-func (a *App) newNote() (tea.Model, tea.Cmd) {
-	p := picker.NewNotePath(a.cfg.InboxRoot(), a.picker.Query())
-	if p == "" {
-		a.status = "Type a name first"
-		return a, nil
-	}
-	if _, err := os.Stat(p); os.IsNotExist(err) {
-		if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
-			a.status = "New note dir failed: " + err.Error()
-			return a, nil
-		}
-		if err := os.WriteFile(p, []byte{}, 0o644); err != nil {
-			a.status = "New note failed: " + err.Error()
-			return a, nil
-		}
-	}
-	if err := a.Load(p); err != nil {
-		a.status = "Open failed: " + err.Error()
 	}
 	return a, nil
 }
internal/app/app_test.go +36 −2
@@ -105,8 +105,8 @@ 	a.Update(tea.WindowSizeMsg{Width: 100, Height: 30})
 	if a.editor.Width != 75 { // contentWidth = round(100 * 0.75) = 75
 		t.Errorf("editor width = %d, want 75", a.editor.Width)
 	}
-	if a.editor.Height != 28 { // minus status bar and top pad
-		t.Errorf("editor height = %d, want 28", a.editor.Height)
+	if a.editor.Height != 26 { // minus status bar and top pad (3)
+		t.Errorf("editor height = %d, want 26", a.editor.Height)
 	}
 }
 
@@ -566,3 +566,37 @@ 	if a.editor.Cursor.Col != 3 {
 		t.Errorf("click col → cursor col %d, want 3", a.editor.Cursor.Col)
 	}
 }
+
+func TestStartNewInCreatesInDir(t *testing.T) {
+	dir := t.TempDir()
+	a := newApp()
+	if err := a.StartNewIn(dir, "scratch"); err != nil {
+		t.Fatal(err)
+	}
+	want := filepath.Join(dir, "scratch.md")
+	if a.path != want {
+		t.Errorf("path = %q, want %q", a.path, want)
+	}
+	if _, err := os.Stat(want); err != nil {
+		t.Errorf("note not created: %v", err)
+	}
+}
+
+func TestCtrlNNewInSameDirSaveAs(t *testing.T) {
+	dir := t.TempDir()
+	src := filepath.Join(dir, "sub", "a.md")
+	os.MkdirAll(filepath.Dir(src), 0o755)
+	os.WriteFile(src, []byte("x"), 0o644)
+	a := newApp()
+	a.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
+	a.Load(src)
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlN}) // new in the source's directory
+	a.editor.HandleKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("hi")})
+	a.Update(tea.KeyMsg{Type: tea.KeyCtrlS}) // unnamed → save-as prompt
+	a.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("new")})
+	a.Update(tea.KeyMsg{Type: tea.KeyEnter})
+	want := filepath.Join(dir, "sub", "new.md")
+	if a.path != want {
+		t.Errorf("Ctrl+N note saved to %q, want %q (same dir as source)", a.path, want)
+	}
+}
internal/config/config.go +9 −0
@@ -132,6 +132,15 @@ 	}
 	return filepath.Join(c.WorkingDir(), c.InboxDir)
 }
 
+// DailyDir is the folder daily notes live in (for the daily-folder picker): an
+// absolute daily_subdir as-is, else daily_subdir under the vault.
+func (c Config) DailyDir() string {
+	if filepath.IsAbs(c.DailySubdir) {
+		return c.DailySubdir
+	}
+	return filepath.Join(c.Vault(), c.DailySubdir)
+}
+
 // DailyPath builds the path to the daily note for time t. Daily notes belong to
 // the vault (Vault()), not the working directory, so `glint --daily` lands in
 // the same place from anywhere. An absolute daily_subdir is used as-is.
internal/config/config_test.go +11 −0
@@ -203,3 +203,14 @@ 	if got != want {
 		t.Errorf("absolute DailyPath = %q, want %q", got, want)
 	}
 }
+
+func TestDailyDir(t *testing.T) {
+	c := Config{VaultDir: "/vault", DailySubdir: "Daily"}
+	if got := c.DailyDir(); got != filepath.Join("/vault", "Daily") {
+		t.Errorf("DailyDir = %q, want /vault/Daily", got)
+	}
+	c.DailySubdir = "/abs/Journal"
+	if got := c.DailyDir(); got != "/abs/Journal" {
+		t.Errorf("absolute DailyDir = %q, want /abs/Journal", got)
+	}
+}
main.go +75 −42
@@ -19,7 +19,8 @@ // -ldflags "-X main.version=<v>" (the Homebrew formula sets it).
 var version = "dev"
 
 func main() {
-	if a := os.Args[1:]; len(a) > 0 && (a[0] == "version" || a[0] == "--version" || a[0] == "-v") {
+	// Version is the one long-only flag (-v means --vault).
+	if len(os.Args) > 1 && (os.Args[1] == "--version" || os.Args[1] == "-version") {
 		fmt.Println("glint", version)
 		return
 	}
@@ -28,56 +29,88 @@ 	cfg, err := config.Load()
 	if err != nil {
 		fmt.Fprintln(os.Stderr, "glint: config:", err)
 	}
+
+	// Every command is a flag; both -x and --x (letter or word) work.
+	flagNew := boolFlag("n", "new")
+	flagToday := boolFlag("t", "today")
+	flagDaily := boolFlag("d", "daily")
+	flagVault := boolFlag("v", "vault")
+	flagConfig := boolFlag("c", "config")
+	flagInbox := boolFlag("i", "inbox")
+	flagKeys := flag.Bool("keys", false, "show what the terminal sends for each key")
+	flag.Parse()
+
+	isNew := *flagNew[0] || *flagNew[1]
+	isToday := *flagToday[0] || *flagToday[1]
+	isDaily := *flagDaily[0] || *flagDaily[1]
+	isVault := *flagVault[0] || *flagVault[1]
+	isConfig := *flagConfig[0] || *flagConfig[1]
+	isInbox := *flagInbox[0] || *flagInbox[1]
+
+	// Standalone commands (no editor TUI).
+	if isConfig {
+		runOrDie(configui.Run())
+		return
+	}
+	if *flagKeys {
+		runOrDie(keyprobe.Run())
+		return
+	}
+
+	name := ""
+	if args := flag.Args(); len(args) > 0 {
+		name = args[0]
+	}
+
 	a := app.New(cfg)
-
-	// Subcommands run before flag parsing.
-	if args := os.Args[1:]; len(args) > 0 {
-		switch args[0] {
-		case "new": // `glint new [name]` — blank buffer, or a named note under the inbox
-			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
-		case "vault": // `glint vault` — open the picker over the configured vault, from anywhere
-			if err := a.StartVault(); err != nil {
-				fmt.Fprintln(os.Stderr, "glint:", err)
-				os.Exit(1)
-			}
-			run(a)
-			return
-		case "config": // `glint config` — interactive setup walkthrough
-			if err := configui.Run(); err != nil {
-				fmt.Fprintln(os.Stderr, "glint:", err)
-				os.Exit(1)
-			}
-			return
-		case "keys": // `glint keys` — show what the terminal sends for each key
-			if err := keyprobe.Run(); err != nil {
-				fmt.Fprintln(os.Stderr, "glint:", err)
-				os.Exit(1)
-			}
-			return
+	var startErr error
+	switch {
+	case isNew:
+		// New note in the current dir, or the inbox/vault when combined.
+		dir := cfg.WorkingDir()
+		if isInbox {
+			dir = cfg.InboxRoot()
+		}
+		if isVault {
+			dir = cfg.Vault()
 		}
+		startErr = a.StartNewIn(dir, name)
+	case isToday:
+		startErr = a.Start("", true) // today's daily note
+	case isVault:
+		startErr = a.StartPickerIn(cfg.Vault())
+	case isInbox:
+		startErr = a.StartPickerIn(cfg.InboxRoot())
+	case isDaily:
+		startErr = a.StartPickerIn(cfg.DailyDir()) // browse the daily folder
+	case name != "":
+		startErr = a.Start(name, false) // open a file
+	default:
+		startErr = a.Start("", false) // bare → fuzzy picker over the current dir
+	}
+	if startErr != nil {
+		fmt.Fprintln(os.Stderr, "glint:", startErr)
+		os.Exit(1)
 	}
 
-	daily := flag.Bool("daily", false, "open today's daily note")
-	flag.Parse()
+	run(a)
+}
 
-	var path string
-	if args := flag.Args(); len(args) > 0 {
-		path = args[0]
+// boolFlag registers a short and long name for the same command and returns both
+// pointers; either being set means the command was given (e.g. -n / --n / -new /
+// --new). Go's flag package accepts both single- and double-dash for each name.
+func boolFlag(short, long string) [2]*bool {
+	return [2]*bool{
+		flag.Bool(short, false, "command: -"+short+" / --"+long),
+		flag.Bool(long, false, ""),
 	}
-	if err := a.Start(path, *daily); err != nil {
+}
+
+func runOrDie(err error) {
+	if err != nil {
 		fmt.Fprintln(os.Stderr, "glint:", err)
 		os.Exit(1)
 	}
-	run(a)
 }
 
 // run drives the Bubbletea program in the alternate screen.