A set of small, composable tools that let you browse, read, check out, run, review, and comment on GitHub pull requests entirely from the terminal — no browser, no mouse. Built for a fish + tmux + Neovim(LazyVim) + gh + git-delta setup, but every piece is optional and swappable (see For the implementing agent).


1. Quick reference — the workflow & commands

Once installed, this is the whole day-to-day surface.

From the shell / tmux

You want to…Do this
GitHub hubtmux prefix + gPull requests · Issues · New issue. Every menu has a ?-toggled panel (context help, or the rendered issue).
Pull requestspick a PR → View (description + comments, paged) · Review (worktree) · Comment (plain, not a review) · Approve · Merge… (squash / merge commit / rebase / auto) · Close…. ? previews the diff. View returns to the menu; merge/close/comment ask an explicit y/N.
Issuesfuzzy-search open issues → the rendered issue (body + comments) is shown while you choose → View (whole thread, paged; q returns to the menu) · Comment (write in nvim, read it back, confirm) · Close… (completed / not planned)
New issuetitle at the prompt → body in nvim → read it back → confirm. Nothing is created until you confirm.
List open PRsprs (alias for gh pr list)
Read a PR’s diffprdiff <n> (add -s for side-by-side), or ? in the popup
Open a PR to work onprco <n> → puts you in a tmux session on that PR’s code. If the PR’s branch is already checked out in one of your own worktrees it reuses that; otherwise it creates a managed worktree at ~/.local/share/pr-worktrees/<repo>/pr-<n>. Commit + push works either way.
Write inline review commentsprreview <n> → annotate the review file in nvim → prsubmit <n>
Quick approvegh pr review <n> --approve
Tear down a PR checkoutprclean <n> (or bare prclean to fuzzy-pick). Refuses if there’s uncommitted or unpushed work (-f forces). Only ever removes worktrees it created — your own worktrees are never touched.

In Neovim — review mode

Review mode pins gitsigns’ baseline to the PR’s target branch, so the whole codebase shows the PR’s changes as you browse normally (not just your uncommitted edits). It auto-activates in a managed prco worktree; in a reused worktree just press <leader>gq or <leader>gp — they detect the PR live.

KeyDoes
<leader>gqEvery changed hunk in the repo → quickfix. The main entry point: step through the PR’s changes in the real files, editing normally.
<leader>ghpPreview this hunk inline — shows exactly what was added/removed at the cursor (LazyVim default)
]h / [hNext / previous hunk (LazyVim default)
<leader>gwToggle inline diff for the file — deleted lines shown inline + word-level highlighting; reads like a diff but stays editable
<leader>gpDiffview panel — changed-files list + side-by-side. The right pane is the working tree, so you can fix a mistake and :w without leaving the diff
<leader>gvOpen this PR’s prr review file as a normal buffer in this nvim (downloads it if needed) — write comments next to the code you’re reviewing
<leader>gVSubmit the prr review (writes the buffer, then confirms before posting)
<leader>gPReview mode off (gutters back to normal)

Typical loops

Fast review (no checkout): prefix + g → pick PR → Diff to read → Review (comments in nvim) or Approve. Done.

Deep review (read + fix the real code):

prco 142                 # tmux session on the PR's code (reused or managed worktree)
nvim .
  <leader>gq             # all of the PR's changes -> quickfix
  <leader>ghp            # on a hunk: see exactly what changed here
  …fix it in place, :w
  ]h                     # next change
  <leader>gp             # side-by-side for a tricky file (right pane is editable)
  <leader>gv             # open the prr review file right here; write comments
  <leader>gV             # submit the review (confirms first)
git commit && git push   # pushes back to the PR branch
prclean 142              # only if prco created the worktree; safe by default

prreview/prsubmit (Module A) do the same from a shell, but they spawn a second $EDITOR. If you’re already in nvim in the PR’s worktree, <leader>gv/<leader>gV keep everything in one place.


2. What this is and why (for anyone with no prior context)

The problem. If you live in the terminal (tmux, a TUI editor, gh, lazygit), reviewing GitHub PRs is the one task that keeps yanking you back to a browser: reading diffs, checking out branches to try them, leaving line comments. That context-switch is slow and breaks flow.

The goal. Make the everyday GitHub loop native to the terminal:

  • Browse/triage open PRs and issues from a fuzzy picker.
  • Read diffs with good syntax highlighting, and issues rendered properly.
  • Manage issues — search, read, comment, close, create — without opening a browser.
  • Try locally in a way that never disturbs your current work — each PR gets its own git worktree and tmux session, cleanly isolated and trivially torn down (and if you already keep a worktree for that branch, it’s reused rather than duplicated).
  • Review in your editor by navigating the actual checked-out codebase, with the PR’s changed lines marked relative to its target branch (not just “what I edited”).
  • Comment with real inline GitHub review comments, written in your editor.

The design ethos: a handful of tiny scripts + your existing tools, one source of truth, nothing bespoke where a standard tool already fits. Each part below is independently useful — adopt only what you want.


3. For the implementing agent (read this first)

⚠️ Default: build this as specified — then check in about variations

The reader has most likely seen this workflow demonstrated and wants the same thing on their machine. So reproduce it as described by default. It’s a coherent whole: the modules were designed to fit together, and the defaults encode decisions arrived at by actually hitting the problems listed in Known gotchas. Treat those defaults as load-bearing.

Do not open with “so which parts would you like?” — that dumps design work on someone who just wants the setup working. Instead:

  1. State the plan and confirm it. e.g. “I’ll set up the full workflow: PR checkout into isolated worktrees + tmux sessions, in-editor review in Neovim, inline review comments via prr, and the prefix + g GitHub popup for PRs and issues. Want all of that, or shall I leave anything out?” One question, easy to say “yes, all of it” to.
  2. Interview where their environment forces a decision — different shell, editor, multiplexer, OS, or a keybinding that’s already taken. These are compatibility questions, not preference questions; every module lists alternatives for exactly this. Don’t silently substitute — ask, then adapt.
  3. Call out the genuinely optional pieces so opting out is cheap and informed: prr (Module B — only needed for inline, line-level review comments; gh pr review already covers approve and top-level comments) and the shell abbreviation (Module E).
  4. Then invite variations — different keymaps, a different worktree location, extra top-level popup entries (releases, Actions runs, notifications, “review requested of me”). Build those if asked. Don’t propose a redesign unprompted.

Deviate when the user asks, or when their environment requires it. Don’t deviate because something looks tidier — that’s how the gotchas get reintroduced.

That said, nothing here requires all-or-nothing: the modules are independent, so a user who only wants one part can have it, and more can be added later. Just let that be their call.

You are setting this up on someone else’s machine, which will differ from the author’s. Before installing anything:

  1. Inventory what’s already installed (§4) — so you know what actually needs adding, and where their stack differs from the author’s. This is reconnaissance, not a prompt to re-litigate whether each module is wanted; that’s settled by the single confirmation above.
  2. Then walk the dependencies module by module. For each missing one, say briefly what it’s for and install it. Pause only where their environment points at an alternative (no Neovim, no tmux, a different pager) — surface the choice, take their answer, move on.
  3. Adapt to their environment. Paths, shell (fish vs zsh/bash), editor, terminal multiplexer, and leader keys will differ. Genericize accordingly.
  4. Preserve modularity. Keep the scripts as separate files so the user can delete/replace any one without breaking the others.

The author’s baseline (for reference; the user’s may differ): macOS, fish shell, tmux, Neovim (LazyVim), Ghostty, gh authenticated over SSH, git-delta as the git pager.

Known gotchas — every one of these was hit in practice

Don’t “simplify” these away; each is load-bearing. Details in the module sections.

  1. A PR’s branch may already be checked out in another worktree. Git refuses to check a branch out twice, so gh pr checkout fails hard. Extremely common if the user keeps a worktree per feature branch and opens PRs from them. prco detects and reuses. (Module A)
  2. Never delete a worktree you didn’t create. Reused worktrees are the user’s real work; prclean must only touch its own managed paths. (Module A)
  3. prclean must not blindly --force. Check for uncommitted changes and commits not on any remote before removing, or you silently destroy work. (Module A)
  4. set -e inside a tmux popup hides all errors — the popup tears down before anything can be read, so failures look like “nothing happened.” Wrap actions so failures pause. (Module D)
  5. diffview: diff a single rev, not a base...HEAD range. A range diffs two historical commits → both panes read-only. A single rev puts the working tree on the right → editable. (Module C)
  6. gitsigns’ global change_base doesn’t reach buffers that attach later. They keep base = nil and diff against HEAD, so no gutter signs on anything you open afterwards — the feature silently appears to do nothing. Re-apply per buffer on attach. (Module C)
  7. LazyVim’s gitsigns keymaps are buffer-local (set in on_attach), so they’re invisible to nvim_get_keymap. Check nvim_buf_get_keymap(0,'n') in an attached buffer before concluding a key is missing or free. (Module C)
  8. fzf runs --preview/execute strings through $SHELL -c. If the user’s $SHELL is fish (or any non-POSIX shell), bash-isms in a preview string break at runtime with something like fish: ${ is not a valid variable in fish. Ours hit this with a VAR=value cmd prefix and ${VAR:-default}. Keep preview strings to plain command invocations — put any real logic behind a flag on your own script (pr-popup --issue {1}) where you control the interpreter. (Module D)
  9. gh prints raw key: value dumps when piped. gh issue view in a preview pane looks nothing like the terminal output. Set GH_FORCE_TTY (fzf exports FZF_PREVIEW_COLUMNS, so GH_FORCE_TTY=$FZF_PREVIEW_COLUMNS renders it properly and wraps to the pane). (Module D)
  10. Don’t let gh open the editor for you. gh issue comment --editor and gh issue create’s interactive body step errored inside the popup. Owning that step — open $EDITOR on a temp file yourself, then pass --body-file — is predictable, works everywhere, and lets you show the text back and confirm before anything posts. (Module D)
  11. gh <kind> view omits comments unless you pass --comments. Easy to miss, because the output looks complete — you get the description and assume that’s the whole thing, when the entire discussion is silently absent. Applies to both gh pr view and gh issue view, in previews and full views. (Module D)
  12. Tooling that opens the user’s editor will surface their pre-existing config errors. Our temp files are .md, which exposed a broken FileType markdown autocmd in the author’s nvim (it required a plugin removed in a migration) — it looked like our bug but fired on every markdown buffer. If the editor errors on open, reproduce with a bare nvim --headless <file> -c 'messages' before blaming the tooling. (Module D)

4. Dependencies at a glance

ModuleHard depsOptional / alternatives
A. Core scripts (browse/diff/checkout/cleanup)git, gh (authenticated), fzf, tmuxgit-delta for pretty diffs → else use gh pr diff | less -R, difftastic, or bat
B. Review authoring (prr)prr, a GitHub tokenAlternatives: gh pr review (no inline comments), octo.nvim, GitHub web
C. In-editor review (Neovim)Neovim, gitsigns.nvim (with change_base), diffview.nvim, a plugin managerAlternatives: octo.nvim, manual :DiffviewOpen, VS Code + GitLens, JetBrains
D. GitHub popup (prefix + g) — PRs, issues, new issuetmux ≥ 3.2 (display-popup), fzf, ghOr run the script as a normal command (no tmux); or use gh-dash / lazygit custom commands instead. Easy to extend to releases, Actions, notifications…
E. Shell abbreviationfishzsh/bash alias equivalent

Common setup for the scripts: put them in a directory on your PATH (e.g. ~/bin or ~/.local/bin), chmod +x them. They are POSIX-ish bash and avoid Bash-4 features so they run on macOS’s stock bash 3.2. gh must be logged in (gh auth login).


Module A — Core scripts (~/bin/pr*)

The foundation: browse, diff, check-out-to-worktree, and clean up. Depends on git, gh, fzf, tmux (and delta for prdiff, easily swapped).

Key design points / gotchas:

  • Worktrees are stored at ~/.local/share/pr-worktrees/<repo>/pr-<n>outside the repo, so nothing needs .gitignore, and one repo can have many PRs checked out at once.
  • If you already run a worktree-per-branch workflow, this matters: git refuses to check out a branch that’s already checked out in another worktree, so gh pr checkout fails hard for any PR whose branch you already have locally (very common when the PRs are your own feature branches). prco therefore checks first — if the PR’s head branch is already in a worktree, it reuses that worktree and just opens a session there (named after the directory), rather than creating a competing checkout. Those reused worktrees are not managed: prclean will never delete them, and no pr-review-base marker is written into them (use <leader>gp in nvim there, which detects the PR live).
  • prco uses gh pr checkout (not a bare fetch) so you can commit and push back to the PR branch. Pushing back to a fork PR still requires the PR author enabled “allow maintainer edits.”
  • prclean is safe by default: it refuses to delete a worktree with uncommitted changes or commits not pushed to any remote. -f overrides. It also removes the local branch and the tmux session, and works even when run from inside the PR’s own session.
  • tmux tip: set set -g detach-on-destroy off so killing a PR session lands you on another session instead of detaching.

prdiff — view a PR’s diff through delta

#!/usr/bin/env bash
# prdiff <pr-number> [-s]  — view a GitHub PR's diff through delta
#   -s / --side-by-side : force side-by-side (overrides your delta config)
# Runs inside a GitHub repo. delta pages with less; navigate=true → n/N jumps files.
set -eu

n="${1:-}"
if [ -z "$n" ]; then echo "usage: prdiff <pr-number> [-s]" >&2; exit 2; fi

case "${2:-}" in
  -s|--side-by-side) gh pr diff "$n" | delta --side-by-side ;;
  *)                 gh pr diff "$n" | delta ;;
esac

No delta? Replace the two delta lines with gh pr diff "$n" | less -R (uses gh’s own coloring), or pipe to difftastic/bat.

If delta warns Unknown theme '<name>', using default: the theme was configured but never installed, so you’ve been silently getting the fallback. delta uses bat’s theme machinery — drop the .tmTheme into $(bat --config-dir)/themes/ and run bat cache --build. (Many colourschemes ship one; e.g. tokyonight.nvim has them under extras/sublime/.) Rebuild again after a bat upgrade.

prco — check a PR out into an isolated worktree + tmux session

#!/usr/bin/env bash
# prco [--no-switch] <pr-number>
#   Open a PR in a dedicated tmux session.
#
#   If the PR's head branch is ALREADY checked out in one of your existing worktrees, that
#   worktree is reused (git can't check a branch out twice). prco does not own it and
#   `prclean` will never delete it — you just get a session there.
#
#   Otherwise the PR is checked out into a managed worktree under
#   ~/.local/share/pr-worktrees/<repo>/pr-<n> (never inside the repo), via `gh pr checkout`
#   so committing + pushing back to the PR branch works (fork push-back still requires the
#   PR's "allow maintainer edits"). Managed worktrees also record the PR's base branch in
#   $gitdir/pr-review-base so nvim auto-enters review mode (gitsigns + diffview).
#   In a reused worktree, use <leader>gp in nvim instead — it detects the PR live.
#
#   --no-switch : create the session but don't switch to it (prints how to jump instead).
set -eu

switch=1
if [ "${1:-}" = "--no-switch" ]; then switch=0; shift; fi

n="${1:-}"
if [ -z "$n" ]; then echo "usage: prco [--no-switch] <pr-number>" >&2; exit 2; fi

# Resolve the MAIN worktree so repo name/paths are stable even when invoked from a linked one.
main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
if [ -z "$main" ]; then echo "prco: not inside a git repository" >&2; exit 1; fi
name=$(basename "$main")

# Is this PR's branch already checked out in a worktree? If so, reuse it.
head=$(gh pr view "$n" --json headRefName -q .headRefName 2>/dev/null || true)
existing=""
if [ -n "$head" ]; then
  existing=$(git -C "$main" worktree list --porcelain 2>/dev/null \
    | awk -v b="refs/heads/$head" '/^worktree /{p=$2} $1=="branch" && $2==b {print p; exit}')
fi

if [ -n "$existing" ]; then
  # --- Reuse an existing worktree (yours). Session named after the dir, matching `t`. ---
  wt="$existing"
  # printf (not echo/basename alone) so the trailing newline isn't turned into a stray '_'.
  session=$(printf '%s' "$(basename "$wt")" | tr -c 'A-Za-z0-9_-' '_')
  echo "PR #$n is on '$head', already checked out at:"
  echo "  $wt"
  echo "Using that worktree (prco doesn't manage it; prclean won't touch it)."
else
  # --- Create a managed worktree for this PR. ---
  wt="$HOME/.local/share/pr-worktrees/$name/pr-$n"
  session="pr-$n"

  if [ ! -d "$wt" ]; then
    mkdir -p "$(dirname "$wt")"
    git -C "$main" worktree add --detach "$wt" HEAD >/dev/null
    if ! ( cd "$wt" && gh pr checkout "$n" ); then
      echo "prco: 'gh pr checkout $n' failed" >&2
      git -C "$main" worktree remove --force "$wt" 2>/dev/null || true
      rm -rf "$wt"
      rmdir "$(dirname "$wt")" 2>/dev/null || true
      exit 1
    fi
  fi

  # Record the PR's base branch for nvim review mode, and fetch it so gitsigns/diffview
  # can diff against it offline. Managed worktrees only — never touch your own worktrees.
  gitdir=$(git -C "$wt" rev-parse --absolute-git-dir 2>/dev/null || true)
  if [ -n "$gitdir" ] && [ ! -f "$gitdir/pr-review-base" ]; then
    base=$(gh pr view "$n" --json baseRefName -q .baseRefName 2>/dev/null || true)
    if [ -n "$base" ]; then
      git -C "$wt" fetch -q origin "$base" 2>/dev/null || true
      printf 'origin/%s\n' "$base" > "$gitdir/pr-review-base" 2>/dev/null || true
    fi
  fi
fi

# Create the session (detached) only if it doesn't already exist — no attach side effect.
tmux has-session -t "=$session" 2>/dev/null || tmux new-session -d -s "$session" -c "$wt"

if [ "$switch" = 1 ]; then
  if [ -n "${TMUX:-}" ]; then tmux switch-client -t "$session"; else tmux attach -t "$session"; fi
else
  echo "Session '$session' ready at $wt"
  echo "  jump with: prefix+T  (or: tmux switch-client -t $session)"
fi

Not using Module C (nvim)? The pr-review-base block is harmless but unnecessary — you can delete it. No tmux? Replace the session lines with a plain cd "$wt" and open a shell/editor there yourself.

prreview / prsubmit — write & submit inline review comments (needs Module B)

#!/usr/bin/env bash
# prreview <pr-number>
#   Download the current repo's PR into a prr review file and open it in $EDITOR (nvim).
#   Add inline comments in the review file, save/quit, then run `prsubmit <pr-number>`.
#   Re-run to reopen an in-progress review without re-downloading.
set -eu

n="${1:-}"
if [ -z "$n" ]; then echo "usage: prreview <pr-number>" >&2; exit 2; fi

slug=$(gh repo view --json nameWithOwner -q .nameWithOwner)

# First time: fetch + open. If a review already exists, just reopen it.
prr get "$slug/$n" --open 2>/dev/null || prr edit "$slug/$n"
#!/usr/bin/env bash
# prsubmit <pr-number>
#   Submit the prr review for the current repo's PR (posts inline comments as a GitHub review).
set -eu

n="${1:-}"
if [ -z "$n" ]; then echo "usage: prsubmit <pr-number>" >&2; exit 2; fi

slug=$(gh repo view --json nameWithOwner -q .nameWithOwner)
prr submit "$slug/$n"
echo "Submitted review for $slug#$n"

prclean — safe, complete teardown

#!/usr/bin/env bash
# prclean [-f] [<pr-number>]
#   Tear a PR checkout all the way down: worktree + its tmux session + local branch + prr review.
#   No <pr-number> → fzf-pick from the PR checkouts that exist for this repo.
#   SAFE by default: refuses if the worktree has uncommitted changes or commits not on any
#   remote. Pass -f to discard anyway. Works even when run from inside the PR's own session.
set -eu

force=0
case "${1:-}" in -f|--force) force=1; shift ;; esac

main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
if [ -z "$main" ]; then echo "prclean: not inside a git repository" >&2; exit 1; fi
name=$(basename "$main")
base="$HOME/.local/share/pr-worktrees/$name"

n="${1:-}"
if [ -z "$n" ]; then
  if [ ! -d "$base" ] || [ -z "$(ls -A "$base" 2>/dev/null)" ]; then
    echo "No PR checkouts for $name."; exit 0
  fi
  n=$(ls -1 "$base" 2>/dev/null | sed -n 's/^pr-//p' \
      | fzf --prompt="clean PR ❯ " --header="$name — checked-out PRs" \
            --preview="git -C '$base/pr-{}' status -sb 2>/dev/null") || exit 0
  [ -n "$n" ] || exit 0
fi

wt="$base/pr-$n"
session="pr-$n"

if [ ! -d "$wt" ]; then
  echo "prclean: no worktree at $wt"
  tmux kill-session -t "=$session" 2>/dev/null && echo "(killed stray session $session)" || true
  exit 0
fi

# --- Safety gate: don't discard work unless forced ---
if [ "$force" != 1 ]; then
  dirty=$(git -C "$wt" status --porcelain 2>/dev/null || true)
  unpushed=$(git -C "$wt" log --oneline HEAD --not --remotes 2>/dev/null | head -1 || true)
  if [ -n "$dirty" ] || [ -n "$unpushed" ]; then
    echo "prclean: pr-$n still has work:" >&2
    [ -n "$dirty" ]    && echo "  - uncommitted changes" >&2
    [ -n "$unpushed" ] && echo "  - commits not pushed to any remote" >&2
    echo "  Push/commit first, or discard with:  prclean -f $n" >&2
    exit 1
  fi
fi

branch=$(git -C "$wt" rev-parse --abbrev-ref HEAD 2>/dev/null || true)
slug=$( (cd "$main" && gh repo view --json nameWithOwner -q .nameWithOwner) 2>/dev/null || true)

# --- Do all the filesystem/git/review teardown BEFORE killing the session, so this still
#     completes cleanly even when prclean is running inside the session being removed. ---
git -C "$main" worktree remove --force "$wt" 2>/dev/null || rm -rf "$wt"
git -C "$main" worktree prune 2>/dev/null || true

if [ -n "$branch" ] && [ "$branch" != "HEAD" ]; then
  if [ "$force" = 1 ]; then
    git -C "$main" branch -D "$branch" 2>/dev/null || true
  else
    git -C "$main" branch -d "$branch" 2>/dev/null \
      || echo "note: kept branch '$branch' (unmerged) — remove with: git -C '$main' branch -D '$branch'"
  fi
fi

[ -n "$slug" ] && prr remove -f "$slug/$n" 2>/dev/null || true
rmdir "$base" 2>/dev/null || true
echo "Cleaned pr-$n (worktree${branch:+ + branch $branch} + session + review)."

# --- Session last: move the client off it first (detach-on-destroy off also covers this),
#     then kill. If we were running inside it, everything above already finished. ---
if [ -n "${TMUX:-}" ] && [ "$(tmux display-message -p '#{session_name}' 2>/dev/null || true)" = "$session" ]; then
  other=$(tmux list-sessions -F '#{session_name}' 2>/dev/null | grep -vx "$session" | head -1 || true)
  [ -n "$other" ] && tmux switch-client -t "$other" 2>/dev/null || true
fi
tmux kill-session -t "=$session" 2>/dev/null || true

The prr remove line is a no-op if you skip Module B.

Install Module A: save the files above into a PATH dir (author used ~/bin) with those exact names (no extension), then chmod +x ~/bin/prdiff ~/bin/prco ~/bin/prreview ~/bin/prsubmit ~/bin/prclean.


Module B — Review authoring with prr

prr (github.com/danobi/prr) brings mailing-list-style review: it downloads a PR as a local file, you annotate lines inline in your editor, then submit — producing a real GitHub review with inline comments. Used by prreview/prsubmit above and the popup’s Review/Submit actions.

Install: brew install prr (it’s in Homebrew core) or cargo install prr.

Configure ~/.config/prr/config.toml (chmod 600 — it holds a token):

[prr]
token   = "<YOUR_GITHUB_TOKEN>"   # a GitHub token with 'repo' scope (see below)
workdir = "~/.cache/prr"          # any writable dir; where review files live

Token options (pick one, discuss with the user):

  • Reuse the gh CLI token: gh auth token prints it; it typically has repo scope, which is what prr needs to post reviews. Convenient, but if gh rotates it, prr breaks — then regenerate.
  • A dedicated fine-grained/classic Personal Access Token with repo (or public_repo) scope. More stable; recommended if this is a shared/long-lived setup.

    ⚠️ The token is a secret. Never commit config.toml; keep it chmod 600. It is redacted here.

Commands: prr get owner/repo/<n> --open → annotate in $EDITORprr submit owner/repo/<n>. prr edit reopens; prr apply checks a PR out into the working dir; prr remove deletes a local review.

The review-file format (the non-obvious part)

This is where people get stuck — the commands are easy, the file format is unfamiliar. The model is email/mailing-list: the entire PR diff is quoted with > , and anything you type that isn’t quoted becomes a review comment.

Three things you can write:

  1. Inline comment — unquoted text on a new line immediately after the quoted diff line you’re commenting on. It posts as a line comment anchored there:

    > -	{
    > -		id: 'ram',
    > -		cooldownMs: 3000,
    > -	},
    
    Was removing `ram` intentional? It's still referenced in the intel tree.
    
    >  	{
    >  		id: 'penetrating-laser',
  2. PR-level comment — unquoted text at the very top, before the first > diff --git line. Only one allowed per review.

  3. Verdict directive — a standalone line anywhere: @prr approve, @prr reject (= request changes), or @prr comment (comment-only).

Rules and gotchas:

  • Don’t edit the > lines — they’re the diff; only add unquoted lines between them.
  • The comment must directly follow the line it refers to; that’s how prr computes the anchor.
  • [...] on its own line elides a chunk of quoted diff you don’t care about.
  • You cannot approve your own PR — GitHub rejects self-approval with a 422, so @prr approve fails when testing on your own PRs. Use @prr comment.
  • Nothing is sent until prr submit, so it’s safe to open, scribble, and walk away.

Docs (the docs home page is only a short intro — the detail is in these chapters):

Without it a review file is undifferentiated plain text — the quoted diff and your own comments look identical, and a real review is thousands of lines (one measured example: 3,139 lines across 42 files). The prr repo ships a plugin under its vim/ directory giving syntax colouring, filetype detection and folding (level 1 = per file, level 2 = per hunk), so zM collapses a huge review to a file list. Your comment lines are then the only unhighlighted text, which makes “where do I type” obvious.

Gotcha: the plugin is in the repo’s vim/ subdirectory, so a plugin manager that adds the repo root to runtimepath won’t find ftdetect/ftplugin/syntax. Register the filetype yourself and append the subdirectory. lazy.nvim spec (lua/plugins/prr.lua):

return {
  "danobi/prr",
  lazy = false, -- three tiny vim files; must be on the rtp before any .prr file is opened
  init = function()
    vim.filetype.add({ extension = { prr = "prr" } })
  end,
  config = function(plugin)
    vim.opt.runtimepath:append(plugin.dir .. "/vim")
    -- open reviews collapsed to the file list; delete if you prefer fully expanded
    vim.api.nvim_create_autocmd("FileType", {
      pattern = "prr",
      group = vim.api.nvim_create_augroup("prr_fold", { clear = true }),
      callback = function() vim.opt_local.foldlevel = 0 end,
    })
  end,
}

Vundle equivalent (from the docs): Plugin 'danobi/prr', {'rtp': 'vim/'}. Verify with: :set ft?prr, :echo b:current_syntaxprr, :set foldmethod?expr.

Alternatives to Module B: gh pr review <n> --approve|--comment|--request-changes (top-level only, no per-line comments); octo.nvim (full review UI in Neovim); the GitHub web UI.


Module C — In-editor PR review (Neovim: gitsigns + diffview)

Turns a checked-out PR worktree into a navigable review: as you open any file the normal way, the gutter marks exactly the lines this PR changes relative to its target branch (merge-base of origin/<base>...HEAD, i.e. GitHub “Files changed” semantics).

Three keymaps:

  • <leader>gp — diffview panel (changed files + side-by-side). Diff a single rev, not a base...HEAD range: a range diffs two historical commits so both panes are read-only, whereas a single rev puts the working tree on the right — so you can fix a mistake and :w without leaving the diff. (Verified: range → 0 editable panes; single rev → the real file is editable.)

  • <leader>gq — dump every changed hunk in the repo into the quickfix list. This is the “just browse the codebase and see where the changes are” answer: jump between hunks in the real files, with gutters on, editing normally — no diff view involved.

  • <leader>gP — turn review mode off (gutters back to uncommitted-vs-HEAD).

  • <leader>gw — toggle inline diff: deleted/old lines rendered inline plus word-level highlighting, so a file reads as a diff while staying editable. Good for reading a whole file.

The gitsigns baseline persists after you close diffview, so once review mode is on you can browse and edit the whole codebase with PR gutters showing.

Reading an individual change: a gutter sign tells you where but not what. gitsigns already covers this and LazyVim binds it buffer-locally (so it won’t show up in a global keymap dump — check nvim_buf_get_keymap inside an attached buffer): <leader>ghp = preview hunk inline (expands the old lines in place — the main one), ]h/[h next/prev hunk, ]H/[H first/last, <leader>ghd/<leader>ghD diff this file, <leader>ghb/<leader>ghB blame. If a distro doesn’t provide these, map require("gitsigns").preview_hunk_inline (or preview_hunk for a float) yourself.

How it works: prco writes the PR’s base branch into the worktree’s private gitdir ($gitdir/pr-review-base). On nvim startup in that worktree, an autocmd reads it and calls gitsigns’ change_base. Because each PR gets its own throwaway nvim (dedicated tmux session), the global base change is naturally scoped and never affects your main editor.

Depends on: Neovim; gitsigns.nvim (must expose change_base — mainline does); diffview.nvim (sindrets/diffview.nvim); a plugin manager. Author used LazyVim + lazy.nvim; the files below are lazy.nvim plugin specs. If the user uses a different manager/distro, adapt the spec wrapper — the logic in pr_review.lua is manager-agnostic.

File 1 — the logic: ~/.config/nvim/lua/util/pr_review.lua

-- PR review helpers. See lua/plugins/pr-review.lua for the wiring.
--
-- Points gitsigns' diff baseline at the PR's target branch (the merge-base of
-- origin/<base>...HEAD — GitHub "Files changed" semantics) so the whole codebase reads normally
-- but every gutter marks exactly the PR's changed lines.
--
-- Entry points:
--   <leader>gp  panel     — diffview against the merge-base. Diffing a SINGLE rev (not
--                           `base...HEAD`) makes the right-hand side the WORKING TREE, so you can
--                           edit and :w fixes straight from the diff. A `base...HEAD` range would
--                           be two historical blobs = read-only.
--   <leader>gq  hunks     — every changed hunk in the repo -> quickfix, for normal browsing/editing.
--   <leader>gP  off       — reset the baseline back to normal (uncommitted-vs-HEAD).

local M = {}

function M.absolute_git_dir()
  local out = vim.fn.systemlist({ "git", "rev-parse", "--absolute-git-dir" })
  if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end
  return out[1]
end

-- Base ref recorded by prco, e.g. "origin/main". nil when not a prco worktree.
function M.marker_base()
  local gd = M.absolute_git_dir()
  if not gd then return nil end
  local f = gd .. "/pr-review-base"
  if vim.fn.filereadable(f) == 0 then return nil end
  local line = (vim.fn.readfile(f) or {})[1]
  if line and line ~= "" then return vim.trim(line) end
  return nil
end

-- Live fallback: ask gh for the current branch's PR base (network).
function M.live_base()
  local out = vim.fn.systemlist({ "gh", "pr", "view", "--json", "baseRefName", "-q", ".baseRefName" })
  if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end
  return "origin/" .. vim.trim(out[1])
end

function M.ensure_ref(base_ref)
  vim.fn.system({ "git", "rev-parse", "--verify", "--quiet", base_ref })
  if vim.v.shell_error ~= 0 then
    vim.fn.system({ "git", "fetch", "origin", (base_ref:gsub("^origin/", "")) })
  end
end

-- The fork point: what the PR actually branched from.
function M.merge_base(base_ref)
  local mb = vim.fn.systemlist({ "git", "merge-base", base_ref, "HEAD" })
  if vim.v.shell_error == 0 and mb[1] and mb[1] ~= "" then return vim.trim(mb[1]) end
  return base_ref
end

local function gitsigns()
  -- gitsigns is lazy-loaded on file events; force it so change_base has actually run setup.
  pcall(function() require("lazy").load({ plugins = { "gitsigns.nvim" } }) end)
  local ok, gs = pcall(require, "gitsigns")
  return ok and gs or nil
end

-- The active review revision (merge-base sha), or nil when review mode is off.
M._rev = nil

local AUG = vim.api.nvim_create_augroup("pr_review_base", { clear = true })

-- gitsigns' global change_base only updates buffers that are ALREADY attached — a buffer that
-- attaches later keeps bcache.base = nil and diffs against HEAD (no gutter signs). Since review
-- mode is enabled before you browse, that's every file you open. So re-apply per buffer on attach.
-- gitsigns attaches asynchronously, hence the short retry.
function M.apply(bufnr, tries)
  if not M._rev then return end
  tries = tries or 8
  if not vim.api.nvim_buf_is_valid(bufnr) or vim.bo[bufnr].buftype ~= "" then return end
  if vim.b[bufnr].pr_review_based == M._rev then return end
  local gs = gitsigns()
  if not gs then return end
  vim.api.nvim_buf_call(bufnr, function() pcall(gs.change_base, M._rev, false) end)
  if gs.get_hunks(bufnr) ~= nil then
    vim.b[bufnr].pr_review_based = M._rev
  elseif tries > 1 then
    vim.defer_fn(function() M.apply(bufnr, tries - 1) end, 60)
  end
end

function M.set_base(base_ref, notify)
  if not base_ref then return false end
  local gs = gitsigns()
  if not gs then return false end
  M._rev = M.merge_base(base_ref)
  pcall(gs.change_base, M._rev, true) -- global default + already-attached buffers

  -- Catch every buffer opened from here on (quickfix jumps, telescope, neo-tree, …).
  vim.api.nvim_clear_autocmds({ group = AUG })
  vim.api.nvim_create_autocmd({ "BufReadPost", "BufWinEnter" }, {
    group = AUG,
    callback = function(ev) vim.schedule(function() M.apply(ev.buf) end) end,
  })
  -- …and any already loaded.
  for _, b in ipairs(vim.api.nvim_list_bufs()) do
    if vim.api.nvim_buf_is_loaded(b) then M.apply(b) end
  end

  if notify then
    vim.notify("PR review ON — gutters vs " .. base_ref, vim.log.levels.INFO, { title = "PR review" })
  end
  return true
end

-- Resolve the base (marker first, then live gh) and turn review mode on. Returns the base ref.
function M.activate(notify)
  local base = M.marker_base() or M.live_base()
  if not base then
    vim.notify("PR review: couldn't determine the PR base branch", vim.log.levels.WARN, { title = "PR review" })
    return nil
  end
  M.ensure_ref(base)
  M.set_base(base, notify)
  return base
end

-- Auto-activate from the marker (offline, managed prco worktrees only). No-op elsewhere.
function M.auto()
  local base = M.marker_base()
  if base then M.set_base(base, true) end
end

-- <leader>gp — diffview against the merge-base; right-hand side is the working tree (EDITABLE).
function M.open_panel()
  local base = M.activate(false)
  if not base then return end
  vim.cmd("DiffviewOpen " .. M.merge_base(base))
end

-- <leader>gq — every changed hunk in the repo into the quickfix list, so you can browse and edit
-- the real files normally (gutters stay on) instead of sitting inside a diff.
function M.changed_hunks()
  local base = M.activate(false)
  if not base then return end
  local gs = gitsigns()
  if not gs then return end
  -- 'all' scans the whole repo against the current base.
  pcall(gs.setqflist, "all", { open = true })
end

-- ── prr review file, opened as a normal buffer in THIS nvim ──────────────────────────────────
-- (`prreview` in the shell spawns a second $EDITOR; in a PR worktree you're already in nvim, so
--  just open the .prr file here alongside the code you're reviewing.)

local function sh(cmd)
  local out = vim.fn.systemlist(cmd)
  if vim.v.shell_error ~= 0 or not out[1] or out[1] == "" then return nil end
  return vim.trim(out[1])
end

-- owner/repo and the PR number for the branch checked out here.
function M.pr_target()
  local slug = sh({ "gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner" })
  local num = sh({ "gh", "pr", "view", "--json", "number", "-q", ".number" })
  if not slug or not num then return nil end
  return slug, num
end

-- Where prr keeps review files, from ~/.config/prr/config.toml.
function M.prr_workdir()
  local cfg = vim.fn.expand("~/.config/prr/config.toml")
  if vim.fn.filereadable(cfg) == 0 then return nil end
  for _, line in ipairs(vim.fn.readfile(cfg)) do
    local w = line:match('^%s*workdir%s*=%s*"([^"]+)"')
    if w then return vim.fn.expand(w) end
  end
  return nil
end

-- <leader>gv — open (downloading first if needed) this PR's prr review file in the current nvim.
function M.review()
  local slug, num = M.pr_target()
  if not slug then
    vim.notify("prr: no PR found for this branch", vim.log.levels.WARN, { title = "PR review" })
    return
  end
  local wd = M.prr_workdir()
  if not wd then
    vim.notify("prr: no workdir in ~/.config/prr/config.toml", vim.log.levels.ERROR, { title = "PR review" })
    return
  end
  local path = ("%s/%s/%s.prr"):format(wd, slug, num)
  if vim.fn.filereadable(path) == 0 then
    vim.fn.system({ "prr", "get", ("%s/%s"):format(slug, num) })
    if vim.fn.filereadable(path) == 0 then
      vim.notify("prr get failed for " .. slug .. "/" .. num, vim.log.levels.ERROR, { title = "PR review" })
      return
    end
  end
  vim.cmd("edit " .. vim.fn.fnameescape(path))
  vim.notify(("Review %s#%s — comment on UNquoted lines, then <leader>gV to submit"):format(slug, num),
    vim.log.levels.INFO, { title = "PR review" })
end

-- <leader>gV — submit the review. Posts to GitHub, so confirm first.
function M.submit()
  local slug, num = M.pr_target()
  if not slug then
    vim.notify("prr: no PR found for this branch", vim.log.levels.WARN, { title = "PR review" })
    return
  end
  if vim.bo.modified and vim.api.nvim_buf_get_name(0):match("%.prr$") then vim.cmd("write") end
  if vim.fn.confirm(("Submit prr review to GitHub for %s#%s?"):format(slug, num), "&No\n&Yes", 1) ~= 2 then
    vim.notify("Submit cancelled", vim.log.levels.INFO, { title = "PR review" })
    return
  end
  local out = vim.fn.systemlist({ "prr", "submit", ("%s/%s"):format(slug, num) })
  if vim.v.shell_error ~= 0 then
    vim.notify("prr submit failed:\n" .. table.concat(out, "\n"), vim.log.levels.ERROR, { title = "PR review" })
  else
    vim.notify(("Submitted review for %s#%s"):format(slug, num), vim.log.levels.INFO, { title = "PR review" })
  end
end

-- <leader>gw — read the whole file as a diff without pressing anything per-hunk:
-- deleted/old lines shown inline, plus word-level highlighting of what changed within a line.
-- (Per-hunk on demand is LazyVim's <leader>ghp = preview_hunk_inline.)
M._inline = false
function M.toggle_inline()
  local gs = gitsigns()
  if not gs then return end
  pcall(gs.toggle_deleted)
  pcall(gs.toggle_word_diff)
  M._inline = not M._inline
  vim.notify("Inline diff " .. (M._inline and "ON — deleted lines + word diff" or "OFF"),
    vim.log.levels.INFO, { title = "PR review" })
end

-- <leader>gP — back to normal (uncommitted-vs-HEAD gutters).
function M.off()
  local gs = gitsigns()
  if not gs then return end
  M._rev = nil
  vim.api.nvim_clear_autocmds({ group = AUG })
  pcall(gs.change_base, nil, true)
  for _, b in ipairs(vim.api.nvim_list_bufs()) do
    if vim.api.nvim_buf_is_loaded(b) and vim.bo[b].buftype == "" then
      vim.api.nvim_buf_call(b, function() pcall(gs.change_base, nil, false) end)
      vim.b[b].pr_review_based = nil
    end
  end
  vim.notify("PR review OFF — gutters back to normal", vim.log.levels.INFO, { title = "PR review" })
end

return M

File 2 — the plugin spec / wiring: ~/.config/nvim/lua/plugins/pr-review.lua

-- PR review mode: diffview panel + gitsigns baseline pinned to the PR's target branch.
-- Auto-activates in `prco` worktrees (via the $gitdir/pr-review-base marker prco writes).
-- Logic lives in lua/util/pr_review.lua.
return {
  "sindrets/diffview.nvim",
  cmd = { "DiffviewOpen", "DiffviewClose", "DiffviewFileHistory", "DiffviewToggleFiles", "DiffviewFocusFiles" },
  opts = {},
  keys = {
    { "<leader>gp", function() require("util.pr_review").open_panel() end,    desc = "PR review: diffview panel (editable)" },
    { "<leader>gq", function() require("util.pr_review").changed_hunks() end, desc = "PR review: changed hunks -> quickfix" },
    { "<leader>gw", function() require("util.pr_review").toggle_inline() end,  desc = "PR review: inline diff (deleted + word diff)" },
    { "<leader>gv", function() require("util.pr_review").review() end,         desc = "PR review: open prr review file here" },
    { "<leader>gV", function() require("util.pr_review").submit() end,         desc = "PR review: submit prr review" },
    { "<leader>gP", function() require("util.pr_review").off() end,           desc = "PR review: off (normal gutters)" },
  },
  init = function()
    vim.api.nvim_create_autocmd("User", {
      pattern = "VeryLazy",
      group = vim.api.nvim_create_augroup("pr_review_auto", { clear = true }),
      callback = function() require("util.pr_review").auto() end,
    })
  end,
}

Notes / gotchas:

  • The big one — gitsigns’ global change_base does NOT apply to buffers that attach later. change_base(rev, true) sets the global default and refreshes already-attached buffers, but a buffer opened afterwards keeps bcache.base = nil and silently diffs against HEAD → no gutter signs. Because review mode is switched on before you start browsing, that’s every file you open — the feature appears to do nothing. The module works around it by re-applying change_base(rev, false) per buffer from a BufReadPost/BufWinEnter autocmd (with a short retry, since gitsigns attaches asynchronously). Measured in a real repo: without the workaround a modified file reported 0 hunks; with it, 19. Don’t remove M.apply/the autocmd.
  • <leader>gp is the conventional “git PR” key in LazyVim’s octo/gh extras. If the user has either extra enabled, pick a different key to avoid a clash.
  • require("util.pr_review") assumes the logic file is at lua/util/pr_review.lua on the nvim runtimepath (standard for LazyVim). Adjust the module path if their layout differs.
  • The User VeryLazy autocmd + require("lazy")...load calls are lazy.nvim-specific. On another manager, trigger M.auto() from a VimEnter/BufReadPre autocmd and drop the lazy.load line (ensure gitsigns is loaded some other way).
  • To leave review mode manually: :Gitsigns change_base (reset). In this workflow you usually just close the ephemeral PR-worktree nvim.

Alternatives to Module C: octo.nvim (browse/comment/approve PRs in-editor); :DiffviewOpen <base>...HEAD by hand without the auto-marker; VS Code + GitLens; JetBrains.


Module D — GitHub popup (prefix + g): PRs, issues, new issue

A fuzzy GitHub menu you can summon from anywhere without opening another TUI. Top level is Pull requests · Issues · New issue; each branch is a small function around gh.

Depends on: tmux ≥ 3.2 (display-popup), fzf, gh, and Module A for the PR actions.

Division of labour: this popup handles the conversation and administrative side (read the description and comments, comment, approve, merge, close, create). Reading a PR’s code and writing line-level review comments happens in nvim after checking out (Module C). Keeping those separate is what stopped the menu sprawling.

Two distinctions the menus deliberately make explicit, because they confuse people:

  • Comment on PR (a plain conversation comment, gh pr comment) vs. review comments (line-anchored, part of an approve/request-changes verdict — Module B via <leader>gv). The cheatsheet panel for each points at the other.
  • View (PRs and issues) is read-only and returns to the action menu when you quit the pager, so the natural loop is read the discussion → decide → act. That’s why both action menus sit in a while loop with continue for view and break for everything else. A preview pane is fine for glancing; it’s the wrong place to read a long thread.

Two functions serve both PRs and issuesgh pr and gh issue take identical flags for these, so don’t fork them or the two flows will drift apart: view_item <kind> <n> (paged gh <kind> view --comments) and comment_on <kind> <n> (edit → confirm → gh <kind> comment --body-file).

Extending it: the shape is deliberately boring — one fzf menu → one function → one gh call. Adding gh release, gh run, gh api for notifications, “PRs awaiting my review”, or a multi-repo picker is a new entry in the top-level menu plus a function. Do that rather than cramming more into the existing branches.

Design notes worth keeping

  • Preview strings must be shell-agnostic. fzf runs them via $SHELL -c; on a fish machine bash syntax dies at runtime. Both the cheatsheet and the issue detail are rendered by re-invoking the script itself (pr-popup --cheat <key> / pr-popup --issue <n>), so the preview string is a plain command and all real logic stays in bash. Do the same for anything you add.
  • GH_FORCE_TTY makes gh render properly instead of dumping key: value when piped, and wraps to the pane when given $FZF_PREVIEW_COLUMNS.
  • Own the editor step. Don’t use gh ... --editor or gh’s interactive body prompt; open $EDITOR on a temp file and pass --body-file. Predictable, and you can show the text back and confirm before posting. gh issue comment and gh pr comment take identical flags, so one comment_on <kind> <n> serves both — don’t fork it, or issues and PRs will drift apart.
  • Confirm outward-facing actions. Merge, close (PR and issue), comment, and create all take an explicit y/N. An fzf pick is too easy to fat-finger for something irreversible. Approve is deliberately one-step (reversible, and wanted to be quick).
  • Never let a failure kill the popup silently. With set -e the popup tears down before the error can be read; the run() wrapper pauses instead.

Script — ~/bin/pr-popup

#!/usr/bin/env bash
# pr-popup — fzf-driven GitHub manager for a tmux display-popup (bound to prefix+g).
#
# Top level:  Pull requests · Issues · New issue
#   Pull requests → browse, then View / Review (worktree) / Comment / Approve / Merge… / Close…
#   Issues        → browse + search, then View / Comment / Close…
#   New issue     → title prompt → body in nvim → confirm
#
# Runs inside the popup with cwd = the pane's repo (display-popup -d #{pane_current_path}).
# Reading a PR and writing review comments happens in nvim once it's checked out
# (<leader>gq / gv / gp) — this menu is for acting on things. `?` toggles the preview pane.
set -eu

# The popup inherits tmux's server env, which may lack these dirs — make sure gh, delta,
# fzf, and the pr* scripts all resolve.
export PATH="$HOME/bin:/opt/homebrew/bin:$PATH"

FZF=$(command -v fzf 2>/dev/null || echo /opt/homebrew/bin/fzf)

pause() { printf '\n'; read -r -p "Press Enter to close… " _ || true; }

# Run an action, but never let a failure silently kill the popup — without this, `set -e`
# tears the popup down before you can read the error.
run() {
  if ! "$@"; then
    rc=$?
    printf '\n\033[31m✖ %s failed (exit %s)\033[0m\n' "$1" "$rc" >&2
    pause
    exit 1
  fi
}

# Outward-facing / awkward-to-undo actions shouldn't fire on an fzf pick alone.
confirm() {
  local ans
  printf '\n%s [y/N] ' "$1"
  read -r ans || return 1
  case "$ans" in [yY] | [yY][eE][sS]) return 0 ;; *) return 1 ;; esac
}

# ── shared: composing a comment ──────────────────────────────────────────────────────────────

# Open $EDITOR on a scratch file and hand the result to gh as --body-file, rather than letting
# gh launch the editor itself (`--editor` / its interactive body prompt). gh's own editor path
# errored inside the popup; owning it is predictable and lets you confirm before posting.
edit_body() { # $1 = file to edit; returns 0 if it ended up non-empty
  "${EDITOR:-nvim}" "$1" || true
  grep -q '[^[:space:]]' "$1" 2>/dev/null
}

# `gh issue comment` and `gh pr comment` take the same flags, so one function serves both.
comment_on() { # $1 = issue|pr, $2 = number
  local kind="$1" n="$2" dir file
  dir=$(mktemp -d); file="$dir/comment.md"; : > "$file"
  printf 'Opening %s for your comment on %s #%s (save & quit when done)…\n' "${EDITOR:-nvim}" "$kind" "$n"
  if ! edit_body "$file"; then
    printf '\nEmpty comment — nothing posted.\n'; rm -rf "$dir"; pause; return 0
  fi
  printf '\n\033[2m--- your comment ---\033[0m\n'; cat "$file"; printf '\033[2m--------------------\033[0m\n'
  if ! confirm "Post this comment on $kind #$n?"; then
    printf '\nCancelled.\n'; rm -rf "$dir"; pause; return 0
  fi
  if gh "$kind" comment "$n" --body-file "$file"; then
    printf '\nCommented on #%s.\n' "$n"
  else
    printf '\n\033[31m✖ comment failed\033[0m\n' >&2
  fi
  rm -rf "$dir"; pause
}

# The description + full comment chain, rendered and paged. Read-only. `gh pr view` and
# `gh issue view` both take --comments, so one function serves both.
view_item() { # $1 = pr|issue, $2 = number
  local kind="$1" n="$2" w
  w=$(tput cols 2>/dev/null || echo 100)
  GH_FORCE_TTY="$w" gh "$kind" view "$n" --comments | ${PAGER:-less} -R
}

# ── pull requests ────────────────────────────────────────────────────────────────────────────

# What (if anything) is there to clean up locally for this PR? `prclean` only manages worktrees
# prco created — if prco reused one of YOUR worktrees, prclean is deliberately a no-op, so
# suggesting it would be misleading.
cleanup_hint() {
  local n="$1" main name managed head existing
  main=$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')
  [ -n "${main:-}" ] || return 0
  name=$(basename "$main")
  managed="$HOME/.local/share/pr-worktrees/$name/pr-$n"

  if [ -d "$managed" ]; then
    printf 'Local cleanup: prclean %s\n' "$n"
    return 0
  fi

  head=$(gh pr view "$n" --json headRefName -q .headRefName 2>/dev/null || true)
  if [ -n "${head:-}" ]; then
    existing=$(git -C "$main" worktree list --porcelain 2>/dev/null \
      | awk -v b="refs/heads/$head" '/^worktree /{p=$2} $1=="branch" && $2==b {print p; exit}')
    if [ -n "${existing:-}" ]; then
      printf 'Your worktree is untouched: %s\n' "$existing"
      printf '  branch %s — remove it yourself when done (prclean only manages its own).\n' "$head"
      return 0
    fi
  fi
  printf 'No local checkout for #%s.\n' "$n"
}

merge_pr() {
  local n="$1" method label
  local flags
  method=$(printf '%s\t%s\n' \
      squash "Squash and merge" \
      merge  "Create a merge commit" \
      rebase "Rebase and merge" \
      auto   "Auto-merge when checks pass (squash)" \
    | "$FZF" --delimiter='\t' --with-nth=2 --prompt="Merge #$n how? ❯ " \
    | cut -f1) || return 0
  [ -n "${method:-}" ] || return 0

  case "$method" in
    squash) flags=(--squash);         label="squash and merge" ;;
    merge)  flags=(--merge);          label="merge commit" ;;
    rebase) flags=(--rebase);         label="rebase and merge" ;;
    auto)   flags=(--squash --auto);  label="auto-merge when checks pass (squash)" ;;
    *)      return 0 ;;
  esac

  if ! confirm "Merge #$n via $label?"; then printf '\nCancelled.\n'; pause; return 0; fi

  # Deliberately no --delete-branch: with a worktree-per-branch workflow the local branch is
  # usually checked out somewhere, which makes the delete fail after the merge has landed.
  if gh pr merge "$n" "${flags[@]}"; then
    printf '\nMerged #%s (%s).\n' "$n" "$label"
    cleanup_hint "$n"
  else
    printf '\n\033[31m✖ merge failed\033[0m\n' >&2
  fi
  pause
}

close_pr() {
  local n="$1"
  if ! confirm "Close #$n WITHOUT merging?"; then printf '\nCancelled.\n'; pause; return 0; fi
  if gh pr close "$n"; then
    printf '\nClosed #%s.\n' "$n"
    cleanup_hint "$n"
  else
    printf '\n\033[31m✖ close failed\033[0m\n' >&2
  fi
  pause
}

pr_flow() {
  local list pr action
  list=$(gh pr list --limit 50 --json number,title,author,headRefName \
          --jq '.[] | "\(.number)\t\(.title)\t@\(.author.login)\t\(.headRefName)"' 2>/dev/null || true)
  if [ -z "$list" ]; then
    echo "No open PRs in $slug."; pause; return 0
  fi

  # '?' toggles a delta diff preview (hidden by default to stay snappy — it's a network call).
  pr=$(printf '%s\n' "$list" \
    | "$FZF" --delimiter='\t' --with-nth=1,2,3 \
          --prompt="PR ❯ " --header="$slug — open PRs   (? = diff preview)" \
          --preview='gh pr diff {1} | delta' \
          --preview-window='right,60%,hidden,wrap' \
          --bind='?:toggle-preview' \
    | cut -f1) || return 0
  [ -n "${pr:-}" ] || return 0

  # Loop so "View PR" returns here — read the conversation, then decide what to do about it.
  while true; do
    action=$(printf '%s\t%s\n' \
        view    "View PR — description + comments" \
        review  "Review PR — worktree + tmux session" \
        comment "Comment on PR (not a review)" \
        approve "Approve PR (no comment)" \
        merge   "Merge PR…" \
        close   "Close PR (without merging)…" \
      | "$FZF" --delimiter='\t' --with-nth=2 --prompt="Action for #$pr ❯ " \
            --header="$slug #$pr   (? toggles this panel)" \
            --preview="'$0' --cheat {1}" \
            --preview-window='right,62%,wrap' \
            --bind='?:toggle-preview' \
      | cut -f1) || return 0
    [ -n "${action:-}" ] || return 0

    case "$action" in
      view)    view_item pr "$pr"; continue ;;                       # back to the menu when you quit the pager
      review)  run prco "$pr"; break ;;                         # switches the client to the PR session
      comment) comment_on pr "$pr"; break ;;
      approve) run gh pr review "$pr" --approve; printf '\nApproved #%s.\n' "$pr"; pause; break ;;
      merge)   merge_pr "$pr"; break ;;
      close)   close_pr "$pr"; break ;;
    esac
  done
}

# ── issues ───────────────────────────────────────────────────────────────────────────────────

close_issue() {
  local n="$1" reason
  reason=$(printf '%s\t%s\n' \
      completed     "Close as completed" \
      "not planned" "Close as not planned" \
    | "$FZF" --delimiter='\t' --with-nth=2 --prompt="Close #$n how? ❯ " \
    | cut -f1) || return 0
  [ -n "${reason:-}" ] || return 0

  if ! confirm "Close issue #$n as \"$reason\"?"; then printf '\nCancelled.\n'; pause; return 0; fi
  if gh issue close "$n" --reason "$reason"; then
    printf '\nClosed issue #%s (%s).\n' "$n" "$reason"
  else
    printf '\n\033[31m✖ close failed\033[0m\n' >&2
  fi
  pause
}

issue_flow() {
  local list issue action
  list=$(gh issue list --limit 100 --json number,title,author \
          --jq '.[] | "\(.number)\t\(.title)\t@\(.author.login)"' 2>/dev/null || true)
  if [ -z "$list" ]; then
    echo "No open issues in $slug."; pause; return 0
  fi

  # Type to fuzzy-search titles. '?' toggles the rendered issue detail.
  issue=$(printf '%s\n' "$list" \
    | "$FZF" --delimiter='\t' --with-nth=1,2,3 \
          --prompt="Issue ❯ " --header="$slug — open issues   (type to search · ? = detail)" \
          --preview="'$0' --issue {1}" \
          --preview-window='right,60%,hidden,wrap' \
          --bind='?:toggle-preview' \
    | cut -f1) || return 0
  [ -n "${issue:-}" ] || return 0

  # Detail is shown by default here, so you decide with the issue in front of you. That's a pane
  # though — "View issue" pages the whole thread properly and returns here afterwards.
  while true; do
    action=$(printf '%s\t%s\n' \
        view    "View issue — description + comments" \
        comment "Comment on this issue" \
        close   "Close issue…" \
      | "$FZF" --delimiter='\t' --with-nth=2 --prompt="Issue #$issue ❯ " \
            --header="$slug #$issue   (? toggles detail)" \
            --preview="'$0' --issue $issue" \
            --preview-window='right,62%,wrap' \
            --bind='?:toggle-preview' \
      | cut -f1) || return 0
    [ -n "${action:-}" ] || return 0

    case "$action" in
      view)    view_item issue "$issue"; continue ;;  # back to the menu when you quit the pager
      comment) comment_on issue "$issue"; break ;;
      close)   close_issue "$issue"; break ;;
    esac
  done
}

new_issue() {
  local title dir file
  printf 'New issue in %s\n\n' "$slug"
  printf 'Title: '
  read -r title || return 0
  if [ -z "${title:-}" ]; then printf '\nNo title — cancelled.\n'; pause; return 0; fi

  dir=$(mktemp -d); file="$dir/issue.md"; : > "$file"
  printf '\nOpening %s for the body — leave it empty for a title-only issue…\n' "${EDITOR:-nvim}"
  edit_body "$file" || true

  printf '\n\033[1mTitle:\033[0m %s\n' "$title"
  if grep -q '[^[:space:]]' "$file" 2>/dev/null; then
    printf '\033[2m--- body ---\033[0m\n'; cat "$file"; printf '\033[2m------------\033[0m\n'
  else
    printf '\033[2m(no body)\033[0m\n'
  fi
  if ! confirm "Create this issue in $slug?"; then
    printf '\nCancelled — nothing created.\n'; rm -rf "$dir"; pause; return 0
  fi
  if gh issue create --title "$title" --body-file "$file"; then
    printf '\nIssue created.\n'
  else
    printf '\n\033[31m✖ issue creation failed\033[0m\n' >&2
  fi
  rm -rf "$dir"; pause
}

# ── cheatsheet (rendered into fzf's preview pane) ────────────────────────────────────────────
# Abridged here — in the real script each branch is a `cat <<EOF` block using $B/$D/$C/$Y/$R ANSI
# vars for bold/dim/cyan/yellow/reset. Keep lines <= 55 columns so they fit the pane. Write these
# for YOUR keys and workflow; they are the in-context documentation for the whole thing.
# NOTE: the issue *action* menu previews the issue itself, not this cheatsheet — so issue actions
# don't need panels here; document them in the `issues` panel instead.
cheat() {
  local B D C Y R
  B=$'\033[1m'; D=$'\033[2m'; C=$'\033[36m'; Y=$'\033[33m'; R=$'\033[0m'
  case "${1:-}" in
    prs)      ;; # top level: what the PR actions do; "? previews the diff"
    issues)   ;; # top level: search, detail, View/Comment/Close semantics
    newissue) ;; # top level: title -> body -> confirm; nothing created until confirmed
    view)     ;; # gh pr view --comments; read-only, paged, returns to the menu
    review)   ;; # what prco does + the nvim keys (,gq ,ghp ]h ,gw ,gp ,gv ,gV ,gP)
                 # + prr syntax (quoted "> " diff, unquoted = comment, @prr verdicts, zM/za/zR)
    comment)  ;; # plain conversation comment vs. line-level review — point at Review PR + ,gv/,gV
    approve)  ;; # gh pr review --approve; runs immediately; self-approval 422
    merge)    ;; # the four methods + "asks y/N" + why no --delete-branch + prclean
    close)    ;; # gh pr close; asks y/N; reversible; prclean
  esac
}

# Preview hooks. IMPORTANT: fzf runs preview/execute strings via `$SHELL -c`, and $SHELL here is
# fish — so a preview string must contain NO bash syntax (no `VAR=val cmd` prefix, no
# `${VAR:-default}`). Keep them plain command invocations and do the real work back in this
# script, where we know we're in bash.
if [ "${1:-}" = "--cheat" ]; then cheat "${2:-}"; exit 0; fi
if [ "${1:-}" = "--issue" ]; then
  # GH_FORCE_TTY makes gh render the pretty view instead of a key:value dump when piped.
  GH_FORCE_TTY="${FZF_PREVIEW_COLUMNS:-80}" exec gh issue view "${2:-}" --comments
fi

# Must be inside a GitHub repo.
if ! slug=$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null); then
  echo "Not inside a GitHub repository."; pause; exit 0
fi

# ── top level ────────────────────────────────────────────────────────────────────────────────
top=$(printf '%s\t%s\n' \
    prs      "Pull requests" \
    issues   "Issues" \
    newissue "New issue…" \
  | "$FZF" --delimiter='\t' --with-nth=2 --prompt="GitHub ❯ " \
        --header="$slug   (? toggles this panel)" \
        --preview="'$0' --cheat {1}" \
        --preview-window='right,62%,wrap' \
        --bind='?:toggle-preview' \
  | cut -f1) || exit 0
[ -n "${top:-}" ] || exit 0

case "$top" in
  prs)      pr_flow ;;
  issues)   issue_flow ;;
  newissue) new_issue ;;
esac

chmod +x ~/bin/pr-popup. (The name is historical — it manages issues too. Rename it if you like; just update the tmux binding.)

tmux binding (add to ~/.tmux.conf; g is unbound by default — check the user’s config):

bind-key g display-popup -E -d "#{pane_current_path}" -w 90% -h 85% "$HOME/bin/pr-popup"

Reload with your reload binding or tmux source-file ~/.tmux.conf. If your tmux doesn’t expand $HOME there, use an absolute path. Pick a different key if prefix + g is taken.

Alternatives to Module D: run the script directly as a shell command (no tmux needed); or use gh-dash for a richer standalone dashboard; or drive it from lazygit custom commands.

Module E — shell convenience (fish)

One abbreviation for listing PRs. Author uses fish:

abbr -a prs "gh pr list"

bash/zsh equivalent: alias prs='gh pr list' in ~/.bashrc / ~/.zshrc.

The pr* scripts themselves need no shell integration — they’re on PATH and callable from any shell, tmux, or editor.


5. Install order (suggested)

Install all of it unless the user said otherwise (§3). This order builds the foundation first, then the entry point, so you can demo progress as you go.

  1. Ensure git, gh (gh auth login), fzf, tmux are present. Add ~/bin to PATH.
  2. Module A scripts → chmod +x. Test prdiff <n>, prco <n>, prclean <n> on a repo with an open PR. Everything else builds on these.
  3. Module D popup + tmux binding — the everyday entry point; prefix + g should now work.
  4. Module C (Neovim) → add both lua files, install diffview.nvim, restart nvim.
  5. Module B (prr) → install + configure token (redacted). The most reasonable one to skip: it exists purely for inline, line-level review comments. If skipped, drop prreview/prsubmit from Module A and the <leader>gv/<leader>gV keymaps from Module C.
  6. Module E shell abbr/alias.

Each step is independently verifiable, so check in as you go rather than at the end.

6. Verification quick-checks

  • prdiff <n> renders a colored diff.
  • prco <n> on a PR whose branch you don’t have locally → creates ~/.local/share/pr-worktrees/<repo>/pr-<n> + tmux session pr-<n>.
  • prco <n> on a PR whose branch you do already have checked out → prints “already checked out at …” and opens a session on that worktree, creating nothing. (This is the case that used to fail outright.)
  • In the PR’s worktree, nvim .<leader>gq lists every changed hunk in the quickfix; jumping to one shows gutter signs in that file (if the quickfix has entries but the file shows no signs, the M.apply/attach workaround is missing — see Module C gotchas); <leader>ghp previews the hunk inline; <leader>gp opens diffview with an editable right-hand pane.
  • prreview <n> opens a review file; prsubmit <n> posts it (Module B).
  • prclean <n> refuses if there’s uncommitted/unpushed work; otherwise removes worktree + branch + session. On a reused worktree it must report “no worktree at …” and leave your worktree intact.
  • prefix + g opens the popup showing Pull requests / Issues / New issue, each with a ?-toggled panel; a failing action prints the error and waits for Enter rather than closing the popup instantly (Module D).
  • PR → View shows the description and comments rendered (not a raw dump), and quitting the pager returns you to the action menu rather than closing the popup.
  • PR → Comment posts a plain conversation comment; confirm it lands on the PR’s Conversation tab, not as a line-level review comment.
  • Issues: the list is fuzzy-searchable; ? shows the issue rendered (title, state, author, markdown body, and its comments) — if you instead see a raw key: value dump, GH_FORCE_TTY isn’t being set; if the body appears but the discussion doesn’t, --comments is missing.
  • Issue → View pages the whole thread and returns to the action menu on q, same as PR → View.
  • Issue previews on a fish/non-POSIX $SHELL: if the preview pane shows something like fish: ${ is not a valid variable, a preview string still contains bash syntax — move it behind a flag on the script (gotcha 8).
  • Comment / New issue: your editor opens on a temp file, the text is echoed back, and nothing posts until you answer y. An empty comment or empty title cancels. If the editor itself throws autocmd errors, check the user’s own editor config first (gotcha 11).
  • Nothing destructive fires from a menu pick alone — merge, close (PR and issue), comment and create all require an explicit y/N.