A personal, offline-only markdown notes app for macOS. Notes are plain markdown files with YAML frontmatter, stored in a configurable directory.
leaf has three ways to work with the notes:
- TUI: a keyboard-driven terminal UI for full note management
- Menu bar (leaflet): a menu bar companion for quick capture and lookup
- API: local HTTP API for scripting
leaf TUI
leaflet menu bar
Requires Go 1.26+. leaflet (the menu bar app, below) requires macOS.
make build # builds bin/leaf and bin/leaflet.app
make install # installs leaf into ~/.local/bin, leaflet.app into ~/Applications
Run leaf. Notes live in ~/.local/share/leaf by default (or
$XDG_DATA_HOME/leaf). leaf creates the directory on first launch.
All rebindable via config. Defaults:
| Key | Action |
|---|---|
n |
New note (prompts for title, then opens edit) |
e / enter |
Edit note body |
R |
Rename note (title only, never the file) |
p |
Pin / unpin |
t |
Edit tags (comma-separated) |
/ |
Search |
b |
Backlinks: notes linking to the selected note |
r |
Reload from disk |
d |
Delete (to trash by default) |
j/k |
Move down / up |
g/G |
Jump to top / bottom of the list |
ctrl+d/ctrl+u |
Half page down / up |
tab |
Toggle focus: tags ↔ notes |
T |
Theme picker (live preview) |
, |
Open config file in TextEdit |
esc |
Clear active filter / cancel input |
q |
Quit |
? |
Show all keybindings |
The status bar shows only the essential hints. ? opens the full
reference, reflecting any rebindings from your config.
In the builtin editor, esc saves and returns to the list, and
ctrl+s saves while you keep editing. leaf writes nothing unless the
body changed.
Case-insensitive substring matching:
tag:workmatches notes with exactly that tagtitle:milkmatches the title onlylinksto:<filename>matches notes linking to that note- anything else searches the full text across title, tags, and body
Selecting a tag in the tags pane applies a tag: filter, and esc
clears it.
Write [[Title]] in a note body to link to the note with that title
(case-insensitive, and links inside code blocks and code spans don't
count). Press b on a note to see everything linking to it. Links
point at titles, so renaming a note can leave links dangling.
Dangling links are inert text, never an error.
d moves a note to .trash/ inside the save directory. It is a
folder, so restore by moving the file back, and empty it whenever you
like. With permanent_delete = true in config, d deletes
immediately after a y/n confirmation.
Each note is one markdown file with YAML frontmatter:
---
title: Meeting notes
tags: [work, planning]
pinned: true
created: 2026-07-29T14:30:00-07:00
modified: 2026-07-29T15:02:00-07:00
---
## Actual note content starts here...Plain markdown files without frontmatter work too. They show up
titled by filename. leaf generates filenames once, at creation
(2026-07-29-1430-meeting-notes.md), and they never change, even
when you rename a note.
~/.config/leaf/config.toml (or $XDG_CONFIG_HOME/leaf/config.toml).
Press , inside leaf to open it in TextEdit. On first use this
creates a fully commented starter file. leaf reads config at startup,
so restart after editing.
Every field is optional. Defaults are shown:
# Appearance
appearance = "system" # "light" | "dark" | "system"
theme_light = "go-light"
theme_dark = "go-dark"
# Storage
default_save_dir = "~/.local/share/leaf"
# Filenames
datetime_prefix = true
datetime_format = "2006-01-02-1504" # Go time layout
# Editing
editor_mode = "builtin" # "builtin" | "external" ($EDITOR)
# Deletion
permanent_delete = false
# Sorting: pinned notes always float to the top
sort_order = "recency" # recency | alpha
# Panes
show_tags_pane = true
show_status_bar = true
[keybindings]
new = "n"
edit = "e"
rename = "R"
pin = "p"
tag = "t"
search = "/"
reload = "r"
delete = "d"
quit = "q"
up = "k"
down = "j"
top = "g"
bottom = "G"
half_down = "ctrl+d"
half_up = "ctrl+u"
next_pane = "tab"
config = ","
theme = "T"
backlinks = "b"
help = "?"With editor_mode = "external", e opens the whole file (frontmatter
included) in $EDITOR and re-reads it when you return.
Built-in: dark and light variants of atom-one, dracula, github, go
(the default, inspired by pkg.go.dev), and
gruvbox (atom-one-dark, atom-one-light, dracula-dark,
dracula-light, github-dark, github-light, go-dark, go-light,
gruvbox-dark, gruvbox-light), plus the four
Catppuccin flavors:
catppuccin-latte (light), catppuccin-frappe,
catppuccin-macchiato, and catppuccin-mocha (dark). With
appearance = "system", leaf follows macOS light/dark mode (checked
at launch and on reload) and picks theme_light or theme_dark
accordingly. Reload (r) also re-reads the active theme file, so you
can iterate on a custom theme without restarting.
Press T to open the theme picker: ↑/↓ (or j/k) previews
each theme live across the whole UI, enter keeps it for this
session, esc reverts. leaf never writes the choice to config. After
enter the status bar shows the exact theme_light/theme_dark
line to add to config.toml to make it stick.
While running, leaf sets the terminal's default background and foreground to match the theme, and restores them on quit, so themes look right regardless of your terminal's own color scheme.
Custom themes go in ~/.config/leaf/themes/<name>.toml and are
referenced by name in config (theme_dark = "mytheme"). A user theme
with the same name as a built-in shadows it. Every key is optional.
leaf derives missing markdown colors from the [ui] block:
[ui]
background = "#282a36"
foreground = "#f8f8f2"
border = "#44475a"
selected_bg = "#44475a"
selected_fg = "#f8f8f2"
accent = "#bd93f9"
pinned = "#ffb86c"
tag = "#8be9fd"
status_bar_bg = "#191a21"
status_bar_fg = "#6272a4"
[markdown]
heading = "#ff79c6"
bold = "#ffb86c"
italic = "#f1fa8c"
link = "#8be9fd"
code = "#50fa7b"
code_block_theme = "dracula" # Chroma syntax-highlighting style
blockquote = "#6272a4"
# ... text, h1–h6, strikethrough, link_text, code_block_bg,
# list_bullet, list_enumeration, table_border, hr, imageTwo optional layers over the same internal/core engine as the TUI,
for scripting your notes or capturing one without opening leaf.
leaf serve runs a local JSON API over the same notes. No TUI starts:
leaf serve [--addr 127.0.0.1:7337]
It binds loopback only (non-loopback needs --allow-remote) and has
no authentication, so keep it local.
| Method & path | Action |
|---|---|
GET /api/health |
is it running: {"status", "notes", "dir"} |
GET /api/notes?q=&limit= |
list notes, where q takes the same search syntax as the TUI and limit caps the count |
POST /api/notes |
create: {"title", "body", "tags"}, only title required |
GET /api/notes/{filename} |
one note, body included |
PATCH /api/notes/{filename} |
update any of title, tags, pinned |
PUT /api/notes/{filename}/body |
replace the body |
DELETE /api/notes/{filename} |
trash (or permanent per config) |
GET /api/notes/{filename}/links |
outgoing links, resolved |
GET /api/notes/{filename}/backlinks |
notes linking here |
GET /api/tags |
tag names with counts |
POST /api/reload |
re-scan the save directory |
curl -s localhost:7337/api/notes -d '{"title":"From the CLI","tags":["api"]}'
Every note carries an absolute path, so scripts can hand a note
straight to open or $EDITOR without redoing the save-dir lookup:
curl -s 'localhost:7337/api/health'
{"status":"ok","notes":42,"dir":"/Users/you/.local/share/leaf"}
curl -s 'localhost:7337/api/notes?limit=1'
[{"filename":"groceries.md","path":"/Users/you/.local/share/leaf/groceries.md", ...}]
leaflet is a small macOS menu bar companion for quick capture and
lookup (no terminal needed):
make build # produces bin/leaf and bin/leaflet.app
open bin/leaflet.app # or: make run-leaflet
It must run as a .app bundle (make build produces
bin/leaflet.app, not a plain binary). The menu bar framework
initializes macOS notifications at startup, which requires a real
bundle identity. make install puts the bundle in ~/Applications
alongside installing leaf to ~/.local/bin.
New Noteprompts for a title (same convention asnin the TUI), creates the note, and opens it in your default.mdapp.Open Noteis a submenu whose live filter box narrows the note list as you type, using the same search syntax as the TUI (tag:,title:,linksto:, or plain text). Clear it to see the normal pinned/recent list. Clicking a note opens it in your default.mdapp.Reloadre-scans the save directory, andOpen leaf folderreveals it in Finder.Start at LoginandQuitare provided automatically by the menu bar framework.- The status bar icon (
cmd/leaflet/assets/icon.svg, FontAwesome'senviraglyph) is a template image, so it adapts to light/dark menu bars automatically from the one asset.
It reads your save directory directly via the same internal/core
engine as the TUI (no leaf serve process or separate config
file required). One new table in config.toml:
[menubar]
notes_limit = 10 # max items in the live list
show_icon_text = false # true: note count appears as text next to the iconmake test # run all tests
make lint # go vet (+ staticcheck when installed)
make fmt # gofmt
make modernize # apply go fix modernizations (any, minmax, rangeint, etc.)
Architecture: internal/core holds all note logic (CRUD, tags, pins,
links, search) with zero UI dependencies. internal/store does file
IO and frontmatter. internal/config and internal/theme handle TOML
loading. cmd/leaf is the Bubble Tea TUI on top, internal/api is the
thin HTTP layer behind leaf serve, and cmd/leaflet is a thin macOS
menu bar layer over the same internal/core, using
menuet.
See docs/design-spec.md for the full design spec.

