iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
📝

Note Management in the Terminal (Neovim, nb, zeno.zsh)

に公開
1

Why take notes in the terminal?

The biggest advantages of taking notes in the terminal are customizability and operation via CLI commands.
You can freely build your preferred keybindings, search functions, and workflows.

For example, the following is possible:

  • Create, search, and edit notes with a single CLI command
  • Search notes using regular expressions and select them while previewing with fzf
  • Automatically fetch titles and create notes just by passing a URL
  • Complete all searching, creating, and editing of notes from within Neovim

Furthermore, it has recently become possible to display images in Neovim, making note management in the CLI more practical.

What I look for in notes

When it comes to keeping records, I think the most important thing is to quickly cycle through "note-taking actions" like the following:

Note-taking actions

  • Create file → Edit → Save
  • Search → Append
  • Add references to other files

In order to cycle through these actions quickly, I felt the following requirements were necessary:

  • Don't want to think about directory management
  • Accessible from any path
  • Don't want to think about filenames or titles
  • Auto-save and manage via GitHub

I chose nb as the tool to meet these needs.
I also considered Obsidian, but since it didn't seem to offer CLI commands, I decided against it this time.

nb

nb is a CLI tool for managing notes.

You can add, edit, and search for notes using commands as shown below.

Basic nb operation commands
# Add a note (Alias: nb a)
nb add

# List notes (Alias: nb ls)
nb list

# Edit a note (Alias: nb e)
nb edit note_number

# Search notes (both title and content) (Alias: nb q)
nb search "keyword"

# Search by title/filename only
nb ls "keyword"

# Use grep/rg if you want to search only by content
rg "keyword" "$(nb notebooks current --path)"

Notes are automatically saved, so it's safe even for people who tend to forget to save. If you set it up to link with a GitHub repository, it will even handle pushing automatically.

Installing nb is easy using Homebrew.

# Install nb
brew install xwmx/taps/nb
# For the latest version
brew install xwmx/taps/nb --head

nb's notebook management

nb notes can be managed in units called notebooks.
The directory structure is as follows:

nb directory structure
~/.nb/
├── home/       # Default notebook
   ├── .git/
   ├── 20251031152222.md
   └── 20251101093045.md
└── work/       # Additional notebook
    ├── .git/
    ├── 20251105140030.md
    └── 20251106183012.md

For example, you can manage notes separately in notebooks, such as home for everyday notes and work for business notes.
Since you can link a Git repository to each notebook, you can use a private repository for your daily notes and manage work notes locally only.

Notebook directories can be easily created using commands.
Since operations are possible via commands, you can manage notes without worrying about the directory structure.

Notebook operation commands
# List notebooks
nb notebooks

# Create a notebook
nb notebooks add work

# Switch notebooks
nb use work

# Check current notebook
nb notebooks current

# Operate notes in another notebook (add notebook_name:)
nb ls work:
nb edit work:1

nb Settings

Setting the editor used by nb

The configuration file is ~/.nbrc.
This file is automatically generated upon installing nb.
The content is as follows:

~/.nbrc
#!/usr/bin/env bash
###############################################################################
# .nbrc
#
# Configuration file for `nb`, a command line note-taking, bookmarking,
# and knowledge base application with encryption, search, Git-backed syncing,
# and more in a single portable script.
#
# Edit this file manually or manage settings using the `nb settings`
# subcommand. Configuration options are set as environment variables, eg:
#   export NB_ENCRYPTION_TOOL=gpg
#
# https://github.com/xwmx/nb
###############################################################################

To set the editor, run the following command:

# Set the editor to use
nb set editor nvim

Then, the following line will be added to ~/.nbrc:

export EDITOR="nvim" # Set by `nb` • Thu Jan  9 12:52:05 JST 2025

In this way, when you configure settings via commands, it automatically appends them to the configuration file.
Of course, there is no problem with manually editing the configuration file.

Setting the directory for managing nb files

Since I move between projects using ghq, I decided to place the directory where nb notes are saved under ghq management as well.
The directory specification used in ghq is set as follows:

[ghq]
 root = ~/src

If it's under this directory, the nb repository will also be displayed in ghq list.
When you clone a repository from GitHub with ghq get, it is placed under ghq root + github.com/your_username/, so I set github.com/mozumasu/nb as the nb management directory.

nb set nb_dir
# Enter ~/src/github.com/mozumasu/nb and press Enter

Executing the command above adds the following line to ~/.nbrc:

export NB_DIR="${NB_DIR:-/Users/mozumasu/src/github.com/mozumasu/nb}" # Set by `nb` • Sat Jan 11 20:09:06 JST 2025

Let's add a note and check if the nb directory is actually created under ~/src/github.com/mozumasu/nb.

# Add a note
nb notebooks add example
# Check if the directory corresponding to the notebook is created
ls ~/src/github.com/mozumasu/nb
# example/ home/

Linking with a GitHub repository

Configure nb notebooks to be managed on GitHub so they can be used on other devices.
To make it clear which notebook the repository belongs to, create a remote repository named nb-notebook_name.

# Create a GitHub repository to manage the default notebook (home)
gh repo create nb-home --private

Link the prepared remote repository to the nb notebook.

# Specify the notebook to use
nb use home
# Set the remote repository to use
nb remote set git@github.com:mozumasu/nb-home.git

Now, changes to the home notebook will be automatically pushed to the GitHub repository.

Customizing emojis displayed in the list

When you list notes with nb ls, emojis are displayed for each type of note.
These emojis can be customized in the configuration file.

nb list emojis

~/.nbrc
export  NB_INDICATOR_AUDIO="🔉"
export  NB_INDICATOR_BOOKMARK="🔖"
export  NB_INDICATOR_DOCUMENT="📄"
export  NB_INDICATOR_EBOOK="📖"
export  NB_INDICATOR_ENCRYPTED="🔒"
export  NB_INDICATOR_FOLDER="📂"
export  NB_INDICATOR_IMAGE="🌄"
export  NB_INDICATOR_PINNED="📌"
export  NB_INDICATOR_TODO="✔️ "
export  NB_INDICATOR_TODO_DONE="✅"
export  NB_INDICATOR_VIDEO="📹"
Final example of ~/.nbrc
~/.nbrc
#!/usr/bin/env bash
###############################################################################
# .nbrc
#
# Configuration file for `nb`, a command line note-taking, bookmarking,
# and knowledge base application with encryption, search, Git-backed syncing,
# and more in a single portable script.
#
# Edit this file manually or manage settings using the `nb settings`
# subcommand. Configuration options are set as environment variables, eg:
#   export NB_ENCRYPTION_TOOL=gpg
#
# https://github.com/xwmx/nb
###############################################################################

export EDITOR="nvim"                                                  # Set by `nb` • Thu Jan  9 12:52:05 JST 2025
export NB_DIR="${NB_DIR:-/Users/mozumasu/src/github.com/mozumasu/nb}" # Set by `nb` • Sat Jan 11 20:09:06 JST 2025

export NB_INDICATOR_AUDIO="🔉"
export NB_INDICATOR_BOOKMARK="🔖"
export NB_INDICATOR_DOCUMENT="📄"
export NB_INDICATOR_EBOOK="📖"
export NB_INDICATOR_ENCRYPTED="🔒"
export NB_INDICATOR_FOLDER="📂"
export NB_INDICATOR_IMAGE="🌄"
export NB_INDICATOR_PINNED="📌"
export NB_INDICATOR_TODO="✔️ "
export NB_INDICATOR_TODO_DONE="✅"
export NB_INDICATOR_VIDEO="📹"

Be happy by combining zeno.zsh and nb

It's a hassle to check the note number with nb ls and run nb edit <number> every time. It would be ideal if you could get a preview like fzf with Tab completion. You can achieve exactly that with zeno.zsh.

alt using zeno.zsh completion with nb
Tab completing nb note numbers with zeno.zsh

zeno.zsh is a zsh/fish plugin with the following features:

  • Snippet configuration
  • fzf completion
  • Command history search

Installing zeno.zsh

I will install it using sheldon, which is a fast shell plugin manager.

# Generate the configuration file
sheldon init --shell zsh

# Initialize new config file `~/.config/sheldon/plugins.toml`? [y/N] y
# Initialized ~/.config/sheldon/plugins.toml

Executing this generates a sheldon configuration file like the one below.

~/.config/sheldon/plugins.toml
# `sheldon` configuration file
# ----------------------------
#
# You can modify this file directly or you can use one of the following
# `sheldon` commands which are provided to assist in editing the config file:
#
# - `sheldon add` to add a new plugin to the config file
# - `sheldon edit` to open up the config file in the default editor
# - `sheldon remove` to remove a plugin from the config file
#
# See the documentation for more https://github.com/rossmacarthur/sheldon#readme

shell = "zsh"

[plugins]

# For example:
#
# [plugins.base16]
# github = "chriskempson/base16-shell"

Additionally, append the following command to your zsh configuration file (~/.zshrc) to load the plugins.

~/.zshrc
eval "$(sheldon source)"

To install zeno.zsh, add it to the plugins section as follows:

~/.config/sheldon/plugins.toml
[plugins]
+ [plugins.zeno]
+ github = "yuki-yano/zeno.zsh"
+ [plugins.fast-syntax-highlighting]
+ github = "zdharma-continuum/fast-syntax-highlighting"

To use zeno.zsh, you need to append the following settings to your zsh configuration file (~/.zshrc).

~/.zshrc
export ZENO_HOME=~/.config/zeno

# git file preview with color
export ZENO_GIT_CAT="bat --color=always"

# git folder preview with color
# export ZENO_GIT_TREE="eza --tree"

if [[ -n $ZENO_LOADED ]]; then
  bindkey ' '  zeno-auto-snippet

  # if you use zsh's incremental search
  # bindkey -M isearch ' ' self-insert

  bindkey '^m' zeno-auto-snippet-and-accept-line

  bindkey '^i' zeno-completion

  bindkey '^xx' zeno-insert-snippet           # open snippet picker (fzf) and insert at cursor

  bindkey '^x '  zeno-insert-space
  bindkey '^x^m' accept-line
  bindkey '^x^z' zeno-toggle-auto-snippet

  # preprompt bindings
  bindkey '^xp' zeno-preprompt
  bindkey '^xs' zeno-preprompt-snippet
  # Outside ZLE you can run `zeno-preprompt git {{cmd}}` or `zeno-preprompt-snippet foo`
  # to set the next prompt prefix; invoking them with an empty argument resets the state.

  bindkey '^r' zeno-smart-history-selection # smart history widget

  # fallback if completion not matched
  # (default: fzf-completion if exists; otherwise expand-or-complete)
  # export ZENO_COMPLETION_FALLBACK=expand-or-complete
fi

zeno.zsh settings are written in ~/.config/zeno/config.yml.
If you want to use Tab completion for nb note numbers, add the following to the completions section.

~/.config/zeno/config.yml
completions:
  - name: nb edit
    patterns:
      - "^nb e( .*)? $"
      - "^nb edit( .*)? $"
    sourceCommand: "nb ls --no-color | grep -E '^\\[[0-9]+\\]'"
    options:
      --ansi: true  # Enable ANSI colors
      --prompt: "'nb edit >'"
      --preview: "echo {} | sed -E 's/^\\[([0-9]+)\\].*/\\1/' | xargs nb show"
    callback: "sed -E 's/^\\[([0-9]+)\\].*/\\1/'"

It is also recommended to set up completion for checking subcommand help as well.

~/.config/zeno/config.yml
completions:
  - name: nb subcommands
    patterns:
      - ^\s*nb\s*$
      - ^\s*nb\s+help\s*$
    sourceCommand: nb subcommands
    options:
      --prompt: "'nb subcommand >'"

To register snippets, add the following.

~/.config/zeno/config.yml
completions:
  ...

+ snippets:
+   - name: Edit Note
+     keyword: nbe
+     snippet: nb edit
+ 
+   - name: List Note
+     keyword: nbl
+     snippet: nb ls --limit 20
+ 
+   - name: List All Note
+     keyword: nbla
+     snippet: nb ls --all
+
+   - name: nb search
+     keyword: nbg
+     snippet: rg "{{keyword}}" "$(nb notebooks current --path)"

Snippets can be expanded with the space key as shown below.

Using zeno.zsh snippets in nb

Shell functions configured for nb

nba: Add an article from a URL to notes

This function automatically fetches the title of an article when given a URL and adds a note to nb.

Adding an article to nb from a URL

~/.zshrc
# nb add article - Add a note with article title and URL
# Usage: nba <url>              - Auto-fetch title from URL
#        nba <title> <url>      - Use specified title
function nba() {
  if [ $# -lt 1 ]; then
    echo "Usage: nba <url>           # Auto-fetch title"
    echo "       nba <title> <url>   # Manual title"
    return 1
  fi

  local title=""
  local url=""

  if [ $# -eq 1 ]; then
    url="$1"
    echo "Fetching title from: $url"

    title=$(curl -sL --max-redirs 3 --max-time 5 --compressed "$url" | head -c 512 | perl -0777 -ne 'print $1 if /<title[^>]*>([^<]+)<\/title>/i')
    title=$(echo "$title" | perl -pe 's/^\s+|\s+$//g; s/\s+/ /g')

    if [ -z "$title" ]; then
      echo "Error: Could not fetch title from URL"
      return 1
    fi
    echo "Title: $title"
  else
    title="$1"
    url="$2"
  fi

  local content="# ${title}

Reference: [${title}](${url})"

  nb add --filename "${title}.md" --content "$content"
  echo "Note created: [${title}](${url})"
}

nbq: Search results selected and edited with fzf

This function allows you to select from nb search results while previewing with fzf and edit the note immediately.
Previewing and editing nb search results with fzf

~/.zshrc
# nb query - Search notes and select with fzf preview
# Usage: nbq <search query>
function nbq() {
  if [ -z "$1" ]; then
    echo "Usage: nbq <search query>"
    return 1
  fi

  local query="$*"
  local results=$(nb q "$query" --no-color 2>/dev/null | grep -E '^\[[0-9]+\]')

  if [ -z "$results" ]; then
    echo "No results found for: $query"
    return 1
  fi

  export _NBQ_QUERY="$query"

  local selected=$(echo "$results" | fzf --ansi \
    --preview 'note_id=$(echo {} | sed -E "s/^\[([0-9]+)\].*/\1/")
               echo "=== Note [$note_id] ==="
               echo ""
               nb show "$note_id" | head -5
               echo ""
               echo "=== Matching lines ==="
               echo ""
               nb show "$note_id" | grep -i --color=always -C 2 "$_NBQ_QUERY" | head -30' \
    --preview-window=right:60%:wrap \
    --header "Search: $query")

  unset _NBQ_QUERY

  if [ -n "$selected" ]; then
    local note_id=$(echo "$selected" | sed -E 's/^\[([0-9]+)\].*/\1/')
    nb edit "$note_id"
  fi
}

Linking references in nb

To link to another page, use [[]] as follows:

[[Page Title]]

Managing images with nb

Images can be imported using nb import path_to_image.
When you paste an image into the terminal, its path is inserted, so you can manage images in nb simply by typing nb import and then pasting the image. I use Raycast's clipboard history for this.

# Paste the image path from Raycast clipboard history
nb import ../../../../../Documents/screenshot/Screenshot%202025-11-06%208.48.40.png

To rename a file, use nb rename note_number new_filename.

# Rename a file by specifying its number
$ nb rename 13 wezterm-doc.png
Moving:   [13] wezterm-doc
To:       wezterm-doc.png
Proceed?  [y/N] y
Moved to: [13] 🌄 wezterm-doc.png

It is also possible to specify a filename during import as follows:

# Specify the filename when importing an image
nb import path_to_image image_filename

To link an image in a note, write it as follows.
Note that the ./ prefix for the current path is not required.

![Image title](image_filename.png)

Todo functionality

nb has two types of task management features as follows:

  • task: No note functionality, only markdown checkboxes.
  • todo: A combination of note functionality and checkbox functionality. You can also add tasks.

task can be managed and displayed using the nb tasks command for - [ ] within the body.
However, since I use - [ ] for article drafts and such, I do not use task and only use todo.

todo can be added with the nb todo add command, which adds a file with the .todo.md extension.

# todo
nb todo add "task to do"
# Mark as done
nb todo do task_id
# Mark as undone
nb todo undo task_id

For listing todos, use the following commands:

# List all tasks
nb todos
# Closed tasks
nb todos closed
# Open tasks
nb todo open

In particular, I use commands to switch regular notes to tasks to manage things to do/not to do.
Since I usually write an overview of what needs to be done in a note, I convert that note into a todo for management.

# Convert note 7 ("demo.md") to todo "demo.todo.md"
nb rename 7 --to-todo
# Convert todo to note
nb rename 7 --to-note

Neovim

I use Neovim as the editor for editing notes for the following reasons:

  • Keybindings for the most efficient text editing
  • Both appearance and keybindings are customizable
  • External commands can be executed
  • Existing plugins can be used as they are

I will introduce the Neovim settings configured for nb.

Buffer Title Settings

Since nb's filenames are automatically generated as timestamps, it's impossible to tell what the content is from the filename alone.

Displaying nb notes in nvim

You can display the title from the first line of the note in the tabs or buffer lines instead of the filename.

First, define the nb helper functions.

~/.config/nvim/lua/config/nb.lua
local M = {}

-- nb command prefix
local NB_CMD = "NB_EDITOR=: NO_COLOR=1 nb"

-- Get nb note directory path
function M.get_nb_dir()
  -- Change this according to your nb directory path
  return vim.fn.expand("~/.nb")
end

-- Execute nb command
function M.run_cmd(args)
  local cmd = NB_CMD .. " " .. args
  local output = vim.fn.systemlist(cmd)
  if vim.v.shell_error ~= 0 then
    return nil
  end
  return output
end

-- Parse list line and return structured data
-- Example: "[1] 🌄 image.png" -> { note_id = "1", name = "image.png", is_image = true }
-- Example: "[2] Note Title" -> { note_id = "2", name = "Note Title", is_image = false }
function M.parse_list_item(line)
  local note_id = line:match("^%[(.-)%]")
  if not note_id then
    return nil
  end

  local is_image = line:match("🌄") ~= nil
  local name
  if is_image then
    name = line:match("%%[%d+%]%%s*🌄%%s*(.+)$")
  else
    name = line:match("%%[%d+%]%%s*(.+)$")
  end

  if not name then
    return nil
  end

  return {
    note_id = note_id,
    name = vim.trim(name),
    is_image = is_image,
    text = line,
  }
end

-- Get list of parsed items
function M.list_items()
  local output = M.run_cmd("list --no-color")
  if not output then
    return nil
  end

  local items = {}
  for _, line in ipairs(output) do
    local item = M.parse_list_item(line)
    if item then
      table.insert(items, item)
    end
  end
  return items
end

-- Function to get the title of an nb note (for bufferline)
function M.get_title(filepath)
  local nb_dir = M.get_nb_dir()
  if not filepath:match("^" .. nb_dir) then
    return nil
  end

  local file = io.open(filepath, "r")
  if not file then
    return nil
  end

  local first_line = file:read("*l")
  file:close()

  if first_line then
    return first_line:match("^#%s+(.+)")
  end
  return nil
end

-- Get file path from note ID
function M.get_note_path(note_id)
  local output = M.run_cmd("show --path " .. note_id)
  if output and output[1] then
    return vim.trim(output[1])
  end
  return ""
end

return M

In LazyVim, bufferline.nvim is used by default for tab display, so we extend the configuration as follows.

~/.config/nvim/lua/plugins/bufferline.lua
return {
  "akinsho/bufferline.nvim",
  opts = function(_, opts)
    local nb = require("config.nb")
    opts.options = opts.options or {}
    opts.options.name_formatter = function(buf)
      local title = nb.get_title(buf.path)
      return title or buf.name
    end
  end,
}

With this setting, the title from the first line will be displayed in the buffer line when you open an nb note.

Displaying nb title in the buffer line
Displaying nb titles in the bufferline

Search Settings

Fuzzy finder plugins are useful when you want to search for and open notes by their titles or by grep-searching their content. In LazyVim, snacks.nvim is used by default, so we will utilize that.

I have configured it so that searching can be done by the title of the memo rather than the filename.

Searching for nb notes with snacks.nvim
Searching for nb notes with snacks.nvim

Creating the following file allows you to search for nb notes with snacks.nvim.

~/.config/nvim/lua/plugins/nb.lua
-- Search and open notes from the title list using snacks.nvim
local function pick_notes()
  local nb = require("config.nb")
  local Snacks = require("snacks")
  local items = nb.list_items()

  if not items or #items == 0 then
    vim.notify("No notes found", vim.log.levels.WARN)
    return
  end

  Snacks.picker({
    title = "nb Notes",
    items = items,
    format = function(item)
      return { { item.text } }
    end,
    preview = function(ctx)
      local item = ctx.item
      if not item.file then
        item.file = nb.get_note_path(item.note_id)
      end
      return Snacks.picker.preview.file(ctx)
    end,
    confirm = function(picker, item)
      picker:close()
      if item then
        vim.cmd.edit(nb.get_note_path(item.note_id))
      end
    end,
  })
end

-- Grep search note contents using snacks.nvim
local function grep_notes()
  local nb = require("config.nb")
  local Snacks = require("snacks")
  Snacks.picker.grep({
    dirs = { nb.get_nb_dir() },
  })
end

return {
  "folke/snacks.nvim",
  keys = {
    { "<leader>np", pick_notes, desc = "nb picker" },
    { "<leader>ng", grep_notes, desc = "nb grep" },
  },
}

With this configuration, you can use the following keymaps:

  • <leader>np - Search and open from the note title list
  • <leader>ng - Grep search note content and open

Search and open by note title
Opening a note with title search

Grep and open note
Opening a note with grep search

Adding Deletion Functionality

I'll add functionality to delete notes directly from within the picker. Add a deletion function to config/nb.lua (add it before return M).

~/.config/nvim/lua/config/nb.lua
+ -- Delete a note
+ function M.delete_note(note_id)
+   local output = M.run_cmd("delete --force " .. note_id)
+   return output ~= nil
+ end

  return M

Also, add the deletion action and keybinding to pick_notes in plugins/nb.lua.

~/.config/nvim/lua/plugins/nb.lua
  Snacks.picker({
    title = "nb Notes",
    items = items,
    -- ... omitted ...
    confirm = function(picker, item)
      picker:close()
      if item then
        vim.cmd.edit(nb.get_note_path(item.note_id))
      end
    end,
+   actions = {
+     delete_note = function(picker)
+       local item = picker:current()
+       if item then
+         vim.ui.select({ "Yes", "No" }, {
+           prompt = "Delete: " .. item.name .. "?",
+         }, function(choice)
+           if choice == "Yes" then
+             if nb.delete_note(item.note_id) then
+               vim.notify("Deleted: " .. item.name, vim.log.levels.INFO)
+               picker:close()
+               pick_notes()
+             else
+               vim.notify("Failed to delete", vim.log.levels.ERROR)
+             end
+           end
+         end)
+       end
+     end,
+   },
+   win = {
+     input = {
+       keys = {
+         ["<C-d>"] = { "delete_note", mode = { "n", "i" }, desc = "Delete note" },
+       },
+     },
+   },
  })

Now, when you press <C-d> within the picker, a confirmation dialog appears, allowing you to delete the selected note.

Settings to Add Notes

Enable adding nb notes from Neovim.
Add a function to config/nb.lua for adding notes (add it before return M).

~/.config/nvim/lua/config/nb.lua
+ -- Add a note and return the ID
+ function M.add_note(title)
+   local timestamp = os.date("%Y%m%d%H%M%S")
+   local note_title = title and title ~= "" and title or os.date("%Y-%m-%d %H:%M:%S")
+   local escaped_title = note_title:gsub('"', '\\"')
+   local args = string.format('add --no-color --filename "%s.md" --title "%s"', timestamp, escaped_title)
+
+   local output = M.run_cmd(args)
+   if not output then
+     return nil
+   end
+
+   -- Get the ID of the added note
+   for _, line in ipairs(output) do
+     local note_id = line:match("%[(%d+)%]")
+     if note_id then
+       return note_id
+     end
+   end
+   return nil
+ end

  return M

Also, add a function and keymap for adding notes to plugins/nb.lua.

~/.config/nvim/lua/plugins/nb.lua
+ -- Add a note and open it
+ local function add_note()
+   local nb = require("config.nb")
+   vim.ui.input({ prompt = "Note title (empty for timestamp): " }, function(title)
+     local note_id = nb.add_note(title)
+     if note_id then
+       local path = nb.get_note_path(note_id)
+       if path and path ~= "" then
+         vim.cmd.edit(path)
+       end
+     else
+       vim.notify("Failed to add note", vim.log.levels.ERROR)
+     end
+   end)
+ end

  return {
    "folke/snacks.nvim",
    keys = {
+     { "\u003cleader\u003ena", add_note, desc = "nb add" },
      { "\u003cleader\u003enp", pick_notes, desc = "nb picker" },
      { "\u003cleader\u003eng", grep_notes, desc = "nb grep" },
    },
  }

Now you can add and open notes with \u003cleader\u003ena.

Adding nb notes from Neovim
Adding nb notes from Neovim

Settings to Import Images

Enable importing images into nb from Neovim and inserting Markdown links.
Add a function for importing images to config/nb.lua (add it before return M).

~/.config/nvim/lua/config/nb.lua
+ -- Import an image into nb
+ function M.import_image(image_path, new_filename)
+   if not image_path or image_path == "" then
+     return nil, "No path provided"
+   end
+
+   -- Remove leading/trailing whitespace and quotes, then expand the path
+   local cleaned_path = image_path:gsub("^%s*['\"]?", ""):gsub("['\"]?%s*$", "")
+   local expanded_path = vim.fn.expand(cleaned_path)
+
+   -- Check if file exists
+   if vim.fn.filereadable(expanded_path) == 0 then
+     return nil, "File not found: " .. expanded_path
+   end
+
+   -- Add new filename if specified
+   local final_filename
+   if new_filename and new_filename ~= "" then
+     -- Add original extension if missing
+     if not new_filename:match("%.%w+$") then
+       local ext = vim.fn.fnamemodify(expanded_path, ":e")
+       new_filename = new_filename .. "." .. ext
+     end
+     final_filename = new_filename
+   else
+     final_filename = vim.fn.fnamemodify(expanded_path, ":t")
+   end
+
+   -- Build and execute command
+   local escaped_path = vim.fn.shellescape(expanded_path)
+   local args = "import --no-color " .. escaped_path
+   if new_filename and new_filename ~= "" then
+     args = args .. " " .. vim.fn.shellescape(new_filename)
+   end
+
+   local output = M.run_cmd(args)
+   if not output then
+     return nil, "Import failed"
+   end
+
+   -- Get the ID of the imported file
+   for _, line in ipairs(output) do
+     local note_id = line:match("%[(%d+)%]")
+     if note_id then
+       return note_id, final_filename
+     end
+   end
+   return nil, "Could not parse import result"
+ end

  return M

Also, add the image import function and keymap to plugins/nb.lua.

~/.config/nvim/lua/plugins/nb.lua
+ -- Import image and insert Markdown link
+ local function import_image()
+   local nb = require("config.nb")
+   vim.ui.input({ prompt = "Image path: ", completion = "file" }, function(image_path)
+     if not image_path or image_path == "" then
+       return
+     end
+
+     -- Input new filename (leave empty for original)
+     vim.ui.input({ prompt = "New filename (empty to keep original): " }, function(new_filename)
+       local note_id, result = nb.import_image(image_path, new_filename)
+       if note_id then
+         local filename = result
+         local link = string.format("![%s](%s)", filename, filename)
+         vim.api.nvim_put({ link }, "c", true, true)
+         vim.notify("Imported: " .. filename, vim.log.levels.INFO)
+       else
+         vim.notify(result or "Failed to import image", vim.log.levels.ERROR)
+       end
+     end)
+   end)
+ end

  return {
    "folke/snacks.nvim",
    keys = {
      { "<leader>na", add_note, desc = "nb add" },
+     { "<leader>ni", import_image, desc = "nb import image" },
      { "<leader>np", pick_notes, desc = "nb picker" },
      { "<leader>ng", grep_notes, desc = "nb grep" },
    },
  }

Now you can import images with <leader>ni.

  1. Enter (or paste) the image path.
  2. Enter a new filename (press Enter while empty to use the original name).

The image will be imported into nb, and a Markdown image link will be inserted at the cursor position.

Using the snacks.nvim picker, you can select an nb image or note and insert a link.

Add the link insertion function and keymap to plugins/nb.lua.

~/.config/nvim/lua/plugins/nb.lua
+ -- Insert link
+ local function link_item()
+   local nb = require("config.nb")
+   local Snacks = require("snacks")
+   local items = nb.list_items()
+
+   if not items or #items == 0 then
+     vim.notify("No items found", vim.log.levels.WARN)
+     return
+   end
+
+   Snacks.picker({
+     title = "nb Link",
+     items = items,
+     format = function(item)
+       return { { item.text } }
+     end,
+     preview = function(ctx)
+       local item = ctx.item
+       if not item.file then
+         item.file = nb.get_note_path(item.note_id)
+       end
+       return Snacks.picker.preview.file(ctx)
+     end,
+     confirm = function(picker, item)
+       picker:close()
+       if item then
+         local link
+         if item.is_image then
+           link = string.format("![%s](%s)", item.name, item.name)
+         else
+           link = string.format("[[%s]]", item.name)
+         end
+         vim.api.nvim_put({ link }, "c", true, true)
+       end
+     end,
+   })
+ end

  return {
    "folke/snacks.nvim",
    keys = {
      { "<leader>na", add_note, desc = "nb add" },
      { "<leader>ni", import_image, desc = "nb import image" },
+     { "<leader>nl", link_item, desc = "nb link" },
      { "<leader>np", pick_notes, desc = "nb picker" },
      { "<leader>ng", grep_notes, desc = "nb grep" },
    },
  }

Now, <leader>nl will display a picker with a preview.
If you select an image (marked with 🌄), it will be inserted in ![filename](filename) format, and if you select a note, it will be inserted in [[title]] format.

Integration with Claude Code

Since nb notes are saved locally as Markdown files, they integrate well with Claude Code. I create notes for logs and use slash commands to append or summarize session contents.

# Create a log notebook
nb notebooks add log

It is also convenient to configure formatting using Hooks, as content edited by Claude Code will be automatically formatted. In the following example, rumdl is used to format Markdown files.

{
  "hooks": {
    "PostToolUse": [{
      "matcher": "Write|Edit|MultiEdit",
      "hooks": [{
        "type": "command",
         "command": "jq -r '.tool_input.file_path | select(endswith(\".md\"))' | xargs -r rumdl fmt"
      }]
    }]
  }
}
Introduction to rumdl

Installation

brew install rumdl

Usage

# Lint Markdown files in the current directory
rumdl check .

# Format files (returns exit code 0 on success even if unfixable violations remain)
rumdl fmt .

# Report violations that cannot be automatically fixed (returns exit code 1 if violations remain)
rumdl check --fix .

# Create a default configuration file
rumdl init

Configuration files can be placed in the following locations:

  • .rumdl.toml or rumdl.toml within the project directory or parent directory
  • .config/rumdl.toml (complies with the config-dir convention)

Since existing .markdownlint.json or .markdownlint.yaml can also be used, you can leverage your existing settings when introducing rumdl.

~/.config/rumdl.toml
# rumdl configuration file

# Global configuration options
[global]
# List of rules to disable (uncomment and modify as needed)
# disable = ["MD013", "MD033"]

# List of rules to enable exclusively (if specified, only these rules will be executed)
# enable = ["MD001", "MD003", "MD004"]

# List of file/directory patterns to include in linting (if specified, only these will be linted)
# include = [
#    "docs/*.md",
#    "src/**/*.md",
#    "README.md"
# ]

# List of file/directory patterns to exclude from linting
exclude = [
    # Common directories to exclude
    ".git",
    ".github",
    "node_modules",
    "vendor",
    "dist",
    "build",

    # Specific files or patterns
    "CHANGELOG.md",
    "LICENSE.md",
]

# Respect .gitignore files when scanning directories (default: true)
respect-gitignore = true

# Markdown flavor/dialect (uncomment to enable)
# Options: mkdocs, gfm, commonmark
# flavor = "mkdocs"

# Rule-specific settings (uncomment and modify as needed)

# [MD003]
# style = "atx"  # Heading style (atx, atx_closed, setext)

# [MD004]
# style = "asterisk"  # Unordered list style (asterisk, plus, dash, consistent)

# [MD007]
# indent = 4  # Unordered list indentation

# [MD013]
# line-length = 100  # Line length
# code-blocks = false  # Exclude code blocks from line length checks
# tables = false  # Exclude tables from line length checks
# headings = true  # Include headings in line length checks

# [MD044]
# names = ["rumdl", "Markdown", "GitHub"]  # Proper nouns that should use correct casing
# code-blocks = false  # Check proper nouns in code blocks (default: false, skips code blocks)

I use the following slash commands:

Make sure to add the nb note directory using the /add-dir command or in claude/settings.json so that Claude Code can access it.

~/.config/claude/settings.json
{
  "permissions": {
    "additionalDirectories": [
      "~/src/github.com/mozumasu/nb", # Add the nb note directory here
      "~/src/github.com/mozumasu/zenn/articles"
    ]
  },
}

Conclusion

In this article, I introduced a terminal-based note management environment combining nb, Neovim, and zeno.zsh.

  • nb: Create, search, and edit notes via the CLI.
  • Shell functions: Automatically fetch titles from URLs with nba, and search with fzf previews with nbq.
  • Neovim integration: Complete note searching and adding within the editor using snacks.nvim.
  • zeno.zsh: Comfortable note selection with fzf completion.

Terminal-based note management takes a little effort to set up initially, but once built, you can realize a workflow that is uniquely your own.

I hope this article provides a helpful reference for building your own note-taking environment.

GitHubで編集を提案

Discussion

EJEJ

On WSL2, this setup helps open images using msedge.exe instead of Linux browsers.

$ > cat ~/.local/bin/xdg-open
#!/bin/bash
file="$1"
if [[ ! -f "$file" ]]; then
  exit 1
fi
win_path="$(wslpath -w "$file" 2>/dev/null)" || {
  exit 1
}
cd /mnt/c        # change dir to avoid cmd path warning
cmd.exe /c "start msedge ${win_path}"
cd - > /dev/null # return to dir silently
exit 0