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.