Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

annotations.nvim

A convention, and a small neovim plugin that helps you keep it.

The idea

Two parties — realistically one human and one AI — work on a markdown document without ever editing each other's text.

One of us writes possible-topics.md. The other doesn't touch it. Instead, they add annotations anchored to spans of it, and those annotations live in a sidecar file: possible-topics.notes.md.

It's a way to have a conversation about a document, asynchronously, without the document turning into a thread. Good things come up in chat that need to be talked through later; they get parked in a markdown file, and the reply happens in annotations whenever there's time for it.

The protocol

For any {file}.md, its annotations live in {file}.notes.md.

The lifecycle is a two-state machine:

  1. No notes file. The source document is live. Claude writes and edits it freely.
  2. Notes file exists. The source document is frozen. Jeff adds annotations. Nobody edits {file}.md — not a typo, not a reformat.

To get from state 2 back to state 1, Claude dissolves the annotations: reads them, rewrites {file}.md to incorporate what they say, and deletes {file}.notes.md. That reopens the document for a new round.

Dissolve is all-or-nothing. There is no per-annotation resolved/open state, which is what keeps this from growing into a threaded review system. Commit before dissolving; git is the archive.

Why the freeze matters

The freeze is the whole reason this is cheap to build. Annotations never have to survive an edit to the text they point at, so anchoring is just line numbers — no fuzzy re-matching, no orphan handling, none of the machinery a real review tool needs.

The plugin enforces the freeze by setting the source buffer nomodifiable while a notes file exists. This is a Ulysses mast, not a security boundary — Jeff can always :AnnotateUnfreeze, or just use a different editor. It only has to make breaking the rule slightly harder than following it.

The escape hatch is load-bearing, not a concession. A mast with no release gets cut: the first time the plugin refuses an edit you actually need, you leave neovim to make it, and then the plugin is not holding anything at all.

The mast only binds one of us. Nothing in the editor stops Claude from editing a frozen document. That half of the convention is enforced by stating it in the project's CLAUDE.md.

The notes format

# Notes on possible-topics.md
<!-- annotates: possible-topics.md sha256:a3f91c2b8e04 -->
<!-- Protocol: do not edit possible-topics.md while this file exists; these
     annotations are anchored to its line numbers. To respond, rewrite
     possible-topics.md to incorporate them, then delete this file.
     All or nothing.
     https://github.com/JeffryGonzalez/annotations.nvim -->

## Whole file

Feels like three separate docs. Maybe split before we go further.

## L12-14

> Annotations should be anchored to a span (selection) in the markdown
> it is annotating.

Yes — and the freeze invariant makes this way cheaper than it sounds.

## L31

> I've never created a neovim plugin

Well. I have now.

The format is designed to be legible to its primary reader, which is an LLM picking this up asynchronously with no UI, no hover, and no ability to jump between files. Everything follows from one property:

The notes file must be readable without opening the source file.

Hence:

  • Verbatim quote, blockquoted. Not a locator — the actual text, so the whole conversation reads in one pass. Very long spans may elide the middle with ; the line range is still exact.
  • Source order, always. Annotations are sorted by line number so reading the file top to bottom feels like a review pass. New annotations are inserted in position, never appended.
  • ## Whole file for thoughts that aren't anchored to a span, because "this doc is too long" is a real annotation and needs a home. :Annotate! writes one.
  • A sha256 of the source in the header comment. If it doesn't match, the freeze was violated somewhere and the line numbers may have shifted — the plugin says so loudly rather than rendering annotations against drifted text.
  • The protocol itself, stated rather than linked. A notes file may be read on a machine that has never seen this repo, by a collaborator, or by whatever LLM that machine happens to have. Stating the rule inline means it holds without any local configuration; the link is there for the full story, not as a precondition for following the rule.

Deliberately absent: ids (the heading is the id), timestamps (git has them, and source order beats chronological order), author (it's Jeff; ## L31 — claude would add it later with no format change), and status (dissolve is all-or-nothing).

The format is plain markdown and hand-writable. The plugin is convenience over a convention that works without it — a broken plugin, an SSH session without your config, or Claude with nothing but a file-write tool never blocks the workflow.

What the plugin does

Two things, really: capture a quote and line range from a visual selection, and paint annotations back into the source buffer.

Command
:Annotate / :'<,'>Annotate Annotate the current line or selection. Opens a scratch split; :w commits, :q discards.
:Annotate! Annotate the whole file — a note with no anchor, for "this doc is too long".
:AnnotateShow Float the annotation under the cursor.
:AnnotateList All annotations for the buffer, in source order, in the quickfix list.
:AnnotateDelete Delete the annotation under the cursor. Deleting the last one removes the notes file and unfreezes.
:AnnotateUnfreeze Make the source writable anyway. Deliberate friction, not a wall.
:AnnotateOpen Open the notes file in a split.

Annotated spans get a highlight and a sign in the gutter. The float shows only the annotation body — the quote is essential for Claude and redundant for you, since your cursor is already sitting on the text.

Whole-file notes have no span, so they get no highlight and no sign. They sort to the top of the notes file, and :AnnotateShow falls back to them when the cursor isn't inside any anchored span.

The notes file is just a markdown buffer. Editing it directly and saving re-renders the source buffer.

The plugin sets no keymaps; see Install below.

Install

No setup() call required.

{ "JeffryGonzalez/annotations.nvim", ft = "markdown" }  -- lazy.nvim

LazyVim

Drop a file in ~/.config/nvim/lua/plugins/; it gets auto-imported.

return {
  {
    "JeffryGonzalez/annotations.nvim",
    ft = "markdown",
    cmd = { "Annotate", "AnnotateShow", "AnnotateList",
            "AnnotateDelete", "AnnotateUnfreeze", "AnnotateOpen" },
    keys = {
      { "<leader>aa", ":Annotate<CR>", mode = { "n", "x" }, silent = true, desc = "Annotate line/selection" },
      { "<leader>aA", "<Cmd>Annotate!<CR>", desc = "Annotate whole file" },
      { "<leader>as", "<Cmd>AnnotateShow<CR>", desc = "Show annotation" },
      { "<leader>al", "<Cmd>AnnotateList<CR>", desc = "List annotations (quickfix)" },
      { "<leader>ad", "<Cmd>AnnotateDelete<CR>", desc = "Delete annotation" },
      { "<leader>ao", "<Cmd>AnnotateOpen<CR>", desc = "Open notes file" },
      { "<leader>au", "<Cmd>AnnotateUnfreeze<CR>", desc = "Unfreeze source" },
    },
  },
  {
    "folke/which-key.nvim",
    opts = { spec = { { "<leader>a", group = "annotations", icon = "󰏫" } } },
  },
}

LazyVim claims <leader>n (Notification History), and <leader>a if you have the ai.copilot* extras enabled. Check yours before picking a prefix.

Hacking on it from one config

If you work on the plugin locally but also want it on machines you only SSH into, let lazy.nvim pick. In your require("lazy").setup({...}) opts:

dev = {
  path = "~/Projects",
  patterns = { "JeffryGonzalez" },
  fallback = true,
},

lazy resolves a dev plugin to dev.path .. "/" .. plugin.name, and with fallback = true it clones from GitHub when that directory doesn't exist. So the same spec loads your working copy at ~/Projects/annotations.nvim on the machine you develop on, and a normal clone everywhere else.

plugin.name is the repo basename, so the local directory has to be named annotations.nvim too. If it isn't, lazy silently clones instead and you end up editing one copy while running another.

Layout

lua/annotations/notes.lua   -- headless: parse, serialize, insert in order
lua/annotations/ui.lua      -- extmarks, floats, quickfix
lua/annotations/init.lua    -- commands, freeze, file watching
tests/notes_spec.lua        -- nvim -l tests/notes_spec.lua

notes.lua touches no editor API, so the part with actual logic in it is testable without an editor — and reusable by anything else that wants to read or write this format.

About

Annotate a markdown file from a sidecar notes file, without ever editing the original.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages