A GitHub-style git diff viewer for the terminal, built with Ink (React for CLIs). A prototype exploring how well a "web GUI mental model" — components, flexbox layout — holds up rendered to terminal cells.
cd orbit-diff
bun install
bun index.jsx # uncommitted work vs HEAD
bun index.jsx --staged # staged changes only
bun index.jsx main..feature # a branch range, PR-style
bun index.jsx HEAD~3 HEAD # any args pass straight through to `git diff`Fastest — the installer picks the right binary for your platform from the latest
Release and drops it in ~/.local/bin:
curl -fsSL https://raw.githubusercontent.com/Diagonal-HQ/orbit-diff/main/install.sh | shSet ORBIT_DIFF_BIN_DIR to install elsewhere. Once installed, upgrade in place
anytime:
orbit-diff update # self-replaces with the latest release
orbit-diff --version # show the installed versionBinaries are published for macOS arm64, Linux x64, and Linux arm64 (built on
every push to main, tagged v0.0.<run>). You can also grab one straight from
the Releases page, chmod +x, and drop it on your PATH.
Two options.
1. Standalone binary (no Bun needed to run it). bun build --compile bundles
the app and the Bun runtime into a single ~62 MB executable — copy it anywhere on
your PATH and it runs on machines without Bun installed.
bun install
bun run install:local # builds dist/orbit-diff and copies it to ~/.local/bin/orbit-diffinstall:local targets ~/.local/bin (already on most PATHs). To place it
elsewhere, build and copy yourself:
bun run build # -> dist/orbit-diff
cp dist/orbit-diff /usr/local/bin/ # or any dir on your PATH2. bun link (dev symlink; needs Bun + this repo to stay put). Uses the
bin entry in package.json to symlink orbit-diff into Bun's global bin. Fast,
always tracks your working copy, but breaks if you move/delete the repo.
bun install
bun link # registers orbit-diff -> index.jsx
# ensure Bun's global bin is on PATH: export PATH="$HOME/.bun/bin:$PATH"Either way:
orbit-diff # uncommitted work vs HEAD
orbit-diff main..feature # a branch range, PR-style- Left rail — files changed, with
A/M/D/Rstatus and+/-counts (GitHub's "Files changed" list). - Right panel — the unified diff of the selected file, syntax-highlighted, with old/new line-number gutters. A current-line cursor (
▸+ brightened gutter, and anL n/Nreadout in the status bar) tracks where you are as you scroll; the viewport follows it.
| Key | Action |
|---|---|
Tab |
switch focus between the file rail and the diff |
s |
show / hide the file rail (diff goes full-width when hidden) |
[ / ] |
narrow / widen the file rail (responsive default until adjusted) |
↑↓ / j k |
move file (rail) · move the line cursor (diff) |
PgUp/PgDn / Ctrl-u Ctrl-d |
move the cursor a page — works from either pane |
g / G |
jump the cursor to top / bottom — works from either pane |
/ |
filter files (fuzzy subsequence on path) |
f |
find in diff contents — matches every line, context included |
Tab (while finding) |
toggle search scope: whole diff ⇄ focused file |
n / N |
next / previous match (jumps across files in whole-diff scope) |
v |
start / cancel a multi-line selection (anchor at the cursor, extend with the cursor) |
c |
comment on the selection (or the cursor line); on an already-annotated line, edit it |
x |
delete the annotation on the cursor line (or the highlighted one in the rail's annotations list) |
a |
jump the rail cursor to the annotations list (then ↑↓/j k navigate, Enter jumps to it in the diff) |
y |
copy all annotations to the clipboard as a change-request prompt for Claude Code |
r |
open the submit picker: apply via Claude Code, post to the GitHub PR (when one exists), or copy |
Enter |
rail → focus diff · find → jump to first match |
Esc |
while typing: cancel · selecting: cancel the selection · normal: clear an applied filter/search |
q / Ctrl-c |
quit |
Only the matched substring is highlighted (the rest of the line keeps its
add/del color): cyan on the focused match (the one n/N points at), yellow on
the others.
Syntax highlighting is by file extension via cli-highlight (highlight.js),
emitted as ANSI that Ink renders directly. It's per-line, so multi-line
constructs (block comments, template strings) may not carry state across lines.
Review a diff, leave comments on the lines you want changed, then press r to
submit them — hand the whole set to Claude Code
as a change-request prompt and watch the diff reload with Claude's edits, post
them as inline comments on the branch's GitHub PR, or copy them out.
-
Comment — put the cursor on a line and press
c, or select a block first (v, move the cursor to extend, thenc). Type your request andEnter. Annotated lines carry a green●in the gutter. Presscagain on an annotated line to edit it (saving it empty deletes it), orxto delete. -
Review — annotations are always listed beneath the file rail on the left. Navigate down out of the file list (or press
a) to move the rail cursor into the annotations;↑↓/jkmove between them,Enterjumps to one in the diff, andxdeletes the highlighted one. -
Submit — press
rfor a small picker with up to three targets (↑↓to move,Enterto choose,Escto cancel):-
Apply via Claude Code — orbit-diff steps aside and hands the terminal to a real, interactive Claude Code session seeded with your change-request prompt — you see its full window, watch it work, answer any questions, and approve tools exactly as you normally would. When you exit Claude (
/exitor Ctrl-D), orbit-diff re-reads the working tree and relaunches on the updated diff — review → request → re-review in one flow. The annotations don't survive the round-trip (their line anchors no longer point at the same code once files change), so you land on a fresh diff to comment on again. Requires theclaudeCLI on yourPATH. -
Post to GitHub PR — shown only when the branch has an open PR (detected via
gh). Each annotation becomes an inline review comment anchored to its file and line(s) on the PR head. Comments post independently, so an annotation on a line that isn't part of the pushed PR diff (e.g. an uncommitted local edit) is skipped and reported rather than sinking the rest. Requires theghCLI, authenticated, on yourPATH. -
Copy to clipboard — every comment is assembled into a markdown prompt (each request anchored to its real file line numbers, with the code snippet inline) and copied to your clipboard, plus a copy at
.orbit/change-request.md. Paste it into Claude Code:claude # then paste, or: claude -p "$(cat .orbit/change-request.md)"
yremains a direct shortcut for that last copy step, skipping the picker. -
Annotations are in-memory for the session — they're gone when you quit, so copy (or run) before you leave.
The copy uses OSC 52, a terminal escape sequence that sets the clipboard on
the machine your terminal emulator runs on — so it works from a tmux session
over SSH, where pbcopy/xclip would only reach the remote host. Two things to
know:
- tmux must allow it: add
set -g set-clipboard onto your~/.tmux.conf. orbit-diff wraps the sequence in tmux's (and GNU screen's) passthrough form automatically. - Terminal support varies. iTerm2, kitty, WezTerm, Alacritty, and Windows
Terminal honor OSC 52; macOS Terminal.app does not, and there's no reply
to confirm success either way. So
yalso writes the prompt to.orbit/change-request.md(gitignored) as a recoverable fallback —cat .orbit/change-request.md | claude -pworks regardless.
bun smoke.jsx renders the UI headlessly through a fake TTY and drives a scripted
sequence of keystrokes — useful as a smoke test since Ink needs a real terminal
to run interactively.
- Find respects the
/file filter — whole-diff scope means all visible files. - A matched line drops syntax highlighting (its unmatched parts fall back to the flat add/del color) since ANSI resets would fight the match background.
- No word-level intra-line diff highlighting yet (GitHub's red/green spans marking what changed within a line — distinct from search highlighting).
- Horizontal scroll isn't implemented; long lines truncate with
…, which can also truncate the scroll-position indicator in the panel title for long paths. - The current-line / selection highlight is a subtle off-shade of your terminal background, detected at startup via an OSC 11 query so it adapts to light and dark themes. Terminals that don't answer (some over multiplexers) fall back to a dark-tuned default after a brief timeout.