Markdown and diffs in the terminal, without opening an editor.
Birds preen to tidy their feathers. You preen your diff before you commit.
Markdown mode, same keys:
Thin glue over three tools that already do the hard part:
| job | tool |
|---|---|
| render markdown | glow (3.0 or newer) |
| render diffs | delta |
| file navigation | fzf |
curl -fsSL https://raw.githubusercontent.com/beatzball/preen/main/install.sh | bashThat clones preen to ~/.local/share/preen and links preen into
~/.local/bin. Or clone it first and run the installer out of the checkout:
gh repo clone beatzball/preen && ./preen/install.shThe installer is safe to run again. It:
- clones the repo if it is not already running from a checkout,
- installs
glow,deltaandfzfif they are missing, - copies
themes/glow-roost.jsonto~/.config/glow/roost.json, - writes the delta theme into
~/.gitconfig(backed up first, once), - registers the
git preenalias and thepreendifftool, - links
bin/preeninto~/.local/bin, - tells you if that directory is not on your PATH.
| flag | effect |
|---|---|
--dry-run |
print every step, change nothing |
--no-deps |
config only, install no packages |
--user |
always fetch tools into ~/.local/bin, never use sudo |
--dir DIR |
clone preen somewhere else (default ~/.local/share/preen) |
--prefix DIR |
link preen somewhere else |
--uninstall |
undo the theme, the git alias, the difftool, the link and the config |
macOS and Linux. It uses whichever of brew, apt-get, dnf, pacman,
zypper or apk it finds. If the package manager has no package for a tool,
it falls back to that tool's official GitHub release tarball and drops the
binary in ~/.local/bin. No sudo is needed for the fallback path.
--uninstall removes every delta.* theme key plus alias.preen, diff.tool,
difftool.preen.cmd and difftool.prompt. It keeps core.pager,
interactive.diffFilter, delta.navigate and delta.dark, because those are a
plain delta setup rather than this theme. It never removes packages.
preen # auto: diffs in a git repo, else markdown
preen md [DIR] # browse markdown files under DIR (default .)
preen diff [REV] # browse files changed vs REV (default: working tree)
preen pr [NUMBER] # browse the files in a GitHub PR (default: this branch)
preen worktrees # pick a git worktree, then browse what it changed
preen wt # short for the same
preen FILE.md # render one file, paged, and quit
preen --helppreen pr 42 # review PR 42
preen pr # the PR opened from the current branchSame picker as preen diff: file list on the left, delta diff on the right,
ctrl-s to flip layout, enter for the full file in a pager.
It needs the gh CLI and a logged-in account
(gh auth login). It is read-only: the diff comes over the API, so there is
no checkout, no fetch, no branch switch and no stash. Your working tree is left
exactly as it was.
gh pr diff is called once for the whole PR and cached for the life of the
run. Each preview slices its own file out of that cache, so a 90-file PR still
costs one network call, not ninety.
preen worktrees # or: preen wtAgents work in git worktrees, and when one finishes the question is always "what did it actually change?". This mode answers it in two levels.
First a picker of every worktree except the main checkout, one line each:
1 file worktree-cutoff .claude/worktrees/cutoff
5 files worktree-read-render .claude/worktrees/read-render
6 files worktree-rewire .claude/worktrees/rewire
The preview shows the branch, the base it is measured against, the commits
made on it, and the whole diff. Pick one and the normal file picker opens on
that worktree: file list on the left, delta diff on the right, ctrl-s to flip
layout, enter for the full file in a pager. esc goes back to the worktree
list; ctrl-c quits.
The count is files changed against the merge-base with the default branch,
not against its tip, so commits landed on the default branch since the worktree
branched do not show up. The default branch is read from origin/HEAD, falling
back to main and then master. Uncommitted and untracked files are counted
and browsable too.
Like pr mode it is read-only: it runs git worktree list, merge-base,
log, diff and ls-files and nothing else. No checkout, no fetch, no stash,
no commit, in any worktree.
A repo with no worktrees besides the main checkout says so and exits.
With something on stdin and no file argument, preen renders it and quits. No
picker, no fzf. A single - asks for the same thing out loud.
git diff | preen # delta renders it
gh pr diff 42 | preen # so does a PR diff
cat NOTES.md | preen # glow renders it
preen - < NOTES.md # the same, said out loud
git diff | preen > out.txt # color is kept in the file tooIt picks the renderer by sniffing the content: diff --git, --- / +++ or
@@ headers mean delta, anything else means glow. The sniff strips ANSI
escapes first, so git diff --color=always | preen is still read as a diff.
On a terminal the output is paged through less -R, with --mouse added when
your less is new enough to know it (551 and up), so the wheel scrolls the
page. That is worth one warning: while less holds the mouse a plain drag no
longer selects text — to select and copy, hold whichever modifier your terminal
uses to bypass mouse reporting, which is shift in Ghostty and xterm and option
in iTerm2. Under tmux, prefer prefix + [: that modifier takes the mouse from
tmux as well, so the terminal selects across the whole screen and splices in
whatever is in the pane beside it, while copy mode selects within the pane.
Into a pipe or a file there is no pager, and the output keeps its color codes:
less -R out.txt shows them as color, but a paste shows them as text. To copy
a file's text, copy the file itself: pbcopy < NOTES.md.
PREEN_LAYOUT=inline applies here too.
install.sh adds two entry points to your global git config:
git preen # same as: preen diff
git difftool -t preen # render one file's diff at a time
git difftool # the same; preen is the default diff.toolgit preen is a ! alias, so it runs preen diff from the repo root. The
difftool builds a plain unified diff and pipes it into preen -, which sends it
to delta and pages it. difftool.prompt is set to false so git does not ask
before each file.
| key | action |
|---|---|
ctrl-s |
toggle side-by-side / inline (diff, pr and worktrees modes) |
enter |
open the full render in a pager |
ctrl-e |
open in $EDITOR |
ctrl-d / ctrl-u |
scroll the preview 8 lines |
pgdn / pgup |
scroll the preview half a page |
shift-down / shift-up |
scroll the preview one line |
home / end |
jump the preview to the top or bottom |
ctrl-/ |
hide or show the preview |
esc |
quit, or in worktrees mode go back one level |
ctrl-c |
quit |
Both renderers use the roost palette (#7c6ff0 purple, #c8c3e0 text, #8a84b0 dim).
- glow:
~/.config/glow/roost.json. Override withPREEN_GLOW_STYLE=/path/to/style.json. - delta: the
[delta]section of~/.gitconfig, syntax themeDracula. - Start in inline layout with
PREEN_LAYOUT=inline preen diff. This is the only way to do it today. A per-repogit config preen.layout inlineis a planned follow-up and is not implemented yet, so setting that key does nothing.
The diff layout lives in two named delta features so preen can flip between them:
[delta]
features = sbs
[delta "sbs"]
side-by-side = true
[delta "inline"]
side-by-side = falseKeep side-by-side out of the main [delta] section. Options there beat feature options, and the toggle stops working.
tests/run.sh # everything
tests/run.sh worktrees # one areaPlain bash, no framework. Needs git, delta, fzf and python3. Every test
builds a throwaway repository under $TMPDIR, so nothing touches the checkout
you run it from. tests/README.md explains how preen is driven without a
terminal, and why reaching that code needs a pty.
brew install vhs
./demo/seed-repo.sh /tmp/preen-demo-repo
vhs demo/preen.tape
vhs demo/preen-pipes.tape
vhs demo/preen-pr.tape
vhs demo/preen-md.tapeOne tape per thing worth watching, so none of them runs long enough to lose you:
| tape | writes | shows |
|---|---|---|
demo/preen.tape |
demo/preen.gif |
preen worktrees: pick a worktree, open its files, ctrl-s, esc back |
demo/preen-pipes.tape |
demo/preen-pipes.gif |
git diff | preen, then cat docs/api.md | preen |
demo/preen-pr.tape |
demo/preen-pr.gif |
preen pr 673 against a real public PR |
demo/preen-md.tape |
demo/preen-md.png |
the markdown picker |
seed-repo.sh builds the whole stage in one directory: a dirty working tree, three
git worktrees under .worktrees/ with real commits on them, and a .pr/ sandbox
whose only content is a remote for PR numbers to resolve against. .worktrees/
and .pr/ are git-ignored, so they stay out of preen diff.
preen-pr.tape needs gh and a logged-in account. It reads
charmbracelet/vhs#673 over the
API and checks nothing out.
Two things to keep in mind when editing a tape. Every cd and every export
belongs between Hide and Show, so no real path reaches a frame. And
Screenshot wants a path relative to the repo root plus a Sleep after it, or
vhs writes nothing at all.
preen-pipes.tape records a narrower frame than the rest. When it was
recorded, piped output was always rendered 80 columns wide, so a wider frame
would only have been empty on the right. Piped output now takes the terminal's
width like everything else, so the frame could match the others the next time
the tape is re-recorded.
- glow 3.0 or newer is required. glow 2 throws away all color when its
output is not a terminal, and fzf hands previews a pipe, so every preview came
out gray. Wrapping glow in a pseudo-tty (
script) fixed it under tmux but hung forever inside fzf elsewhere. glow 3 keeps its color in a pipe, sopreencalls it directly andinstall.shupgrades anything older. - Preview scrolling jumps; it is not animated. fzf has no timer or delay action,
so a
--bindchain ofpreview-downsteps still paints one frame. Smooth scrolling would need an app that redraws on its own tick, the waysnacks.nvimdoes it in Neovim. - Inline images do not work. tmux swallows the kitty graphics escapes and dumps
the payload into the pane title.
mdcatwas tested and dropped for this reason. - An odd filename is listed and opened as itself. A quote, a backslash, a
tab or a newline in a name is escaped by git on output whatever
core.quotePathsays, and a name that comes back escaped is listed but cannot then be opened. So every file list is asked for with-zand handed to fzf with--read0, andprmode decodes the C-quoted path git writes into adiff --githeader — which is how an accented name arrives from the GitHub API. The one nameprmode still cannot spell is one containing a newline: its list comes fromgh pr diff --name-only, which has no NUL form and separates its output with a newline. Every other mode carries that too. - fzf 0.53 or newer draws a name containing a newline on more than one line.
--read0itself has been in fzf since 0.15, so an older fzf still treats such a name as one entry and still opens it; it just draws it on a single line. demo/kitchen-sink.mdexercises every markdown feature. Use it to check a theme:preen demo/kitchen-sink.md.
CONTRIBUTING.md covers what to run before a pull request,
where documentation goes, and what the P0-P3 labels mean.
MIT. See LICENSE.

