Why I started writing pr.nvim

Why I started writing pr.nvim

I've been a happy Neovim user for a while, and I review a lot of pull requests. The two activities don't combine very well.

The usual flow looks like this:

  1. PR notification → open the browser.
  2. Read the diff in the browser, scroll up and down trying to remember the file structure.
  3. Want to check something three files away from the diff → switch to terminal, nvim, find the file, look at it, switch back to browser.
  4. Want to comment → click in the diff line, type, submit.
  5. Want to draft several comments and review them together → start a review on GitHub. Now the comments are in a third state ("pending"), and I can only see them by going back to a specific tab in the UI.
  6. CI failed → click the checks tab. Read the log. Curse a little.

Multiply by ten PRs and a day is gone.

I wanted the whole thing to live in the editor. So I started a plugin.

What I actually wanted

Before I wrote any plugin code, I wrote down what I was solving for:

  1. Inline review threads, in the buffer. I want to open a file that has unresolved review comments and see them as virtual text or signs on the relevant lines. Same UX as LSP diagnostics, because that's what they are, conceptually.
  2. A floating window to read and reply to threads. A real, focusable, scrollable window I can navigate with normal Vim motions, and one that doesn't disappear the moment you move the cursor.
  3. Drafts that survive. If I open a thread, type half a reply and accidentally close it, the draft is still there next time.
  4. One picker to find PRs. I shouldn't have to leave the editor to switch to a different PR.
  5. Provider-agnostic. I work in GitHub mostly, but not exclusively. I don't want this rewritten if I ever land on a GitLab project.

That last one turned out to drive most of the architecture.

The "shell out to a CLI" decision

I decided this very early: do not call provider APIs directly from Lua.

Reasons:

So the plugin shells out. Provider abstraction becomes a tiny strategy interface:

-- lua/pr/providers/init.lua
local M = {}

local providers = {
  github    = require("pr.providers.github"),
  gitlab    = require("pr.providers.gitlab"),
  bitbucket = require("pr.providers.bitbucket"),
}

function M.get(name)
  return providers[name] or error("unknown provider: " .. tostring(name))
end

return M

Each provider implements a small set of operations:

list_prs(opts, cb)
get_pr(id, cb)
get_threads(pr_id, cb)
post_comment(pr_id, body, line, cb)
submit_review(pr_id, event, cb)
... and a few more

The implementation of each is "build the right CLI arg list, run it async via plenary, parse the JSON output". That's it.

This buys me a few things I didn't think about at first:

Inline threads as vim.diagnostic

Review comments are conceptually just diagnostics. They have a location (file + line), a severity, and a message. Neovim already has an API for displaying those: vim.diagnostic.

So pr.nvim publishes review threads as diagnostics in a dedicated namespace:

local ns = vim.api.nvim_create_namespace("pr.nvim/threads")

vim.diagnostic.set(ns, bufnr, {
  {
    lnum = thread.line - 1,
    col = 0,
    message = thread.body,
    severity = severity_for(thread),
    source = "pr.nvim",
  },
})

What that gives me:

I get most of an inline review UX for free by building on top of vim.diagnostic.

Outdated and resolved threads: what to show by default?

This one was tricky.

A review thread can be:

Showing all three by default makes the buffer noisy. Showing only active hides important context, and sometimes you want to see what was said on a line that has since been refactored.

The compromise I landed on:

opts = {
  show_outdated_inline = false,
  show_resolved_inline = false,
  diagnostics = {
    severity_resolved = vim.diagnostic.severity.INFO,
  },
}

Both toggles are reachable from the picker via <C-f> to cycle filters in place, so a user who does want to see all the context can flip them on without leaving the workflow.

The popup is its own headache

The other piece is the floating window, the one that opens when you hit <CR> on a thread.

I tried implementing it from scratch with nvim_open_win. Bad time. There's the popup itself, but then there's a header, a body, a reply input, scrolling, focus management between header and body, key dispatch, layout on resize, layout on small screens, layout on multiple monitors…

The thing that saved me was nui.nvim. It's a UI-primitives library (popups, layouts, menus) and it does the chore work I really did not want to do. After switching to it, the popup code roughly halved and the bug count fell off a cliff. Async subprocess handling is via plenary.nvim.

Picker as control surface

The other thing that has scaled well: pickers (snacks.nvim / telescope.nvim / fzf-lua) as the central control surface.

Rather than have a custom UI for "list PRs", "list threads" and "switch filters", every list-shaped thing is a picker entry. Users pick the picker they already use. The plugin defines actions on entries; the picker handles fuzzy matching, preview, multi-select, the works.

:PR
   ├── list PRs in this repo
   ├── filter: status (open/closed/all)
   ├── filter: author
   ├── action: checkout
   ├── action: view info
   └── action: pick a thread

If I add a feature, it usually appears as a new action on an existing picker, not a new command. Fewer entry points to remember.

Where it stands today

Today, pr.nvim:

There's a lot more I want to do: drafts that survive across sessions, full review submission with pending comments, suggestion blocks rendered as applyable diffs, status counters, CI checks. Some of that is already in progress. The next post will cover those pieces once they've shipped.

For now, I haven't switched tabs to read a review comment in six weeks.

Happy reviewing!

References

Plugin runtime

Provider CLIs

Pickers

Neovim APIs

Jeffrey Cheung

Backend Software Engineer at Stransa. Obsessed to learn and build things. Occasionally, I write blogs about neovim related stuffs and articles I read lately.