terminal PR review for humans and agents.
module github.com/wvvb/hunkr - Go 1.25.
hunkr 123 # TUI for PR #123 (repo from git remote origin)
hunkr 123 --json # PR meta + structured hunks as JSON
hunkr 123 --json --file pkg/foo.go # one file only
hunkr diff 123 # unified patch to stdout
hunkr diff 123 --json --file pkg/foo.go
hunkr mcp # MCP server on stdio
hunkr cache # list cached PRs (cache ls)
hunkr cache rm 123 -R owner/repo # drop one PR
hunkr cache clear # drop all- Web PR UIs and pasting a million-line diff into an LLM are both bad.
- You need to read hunks; agents need structured hunks (
get_diffJSON), not a blob. - One process, one cache, two interfaces: TUI for you, MCP/JSON for agents.
Built because the author wanted a diff hub shared by their coding agents and themselves: same PR, same cache, no copy-paste through the browser. One fetch, many consumers.
Go CLI at cmd/hunkr. Packages:
internal/gh: thin GitHub REST (nogo-github).Accept: application/vnd.github+json,X-GitHub-Api-Version: 2022-11-28. Token:HUNKR_TOKEN→GH_TOKEN→GITHUB_TOKEN→gh auth token. Host:GH_HOST/GITHUB_HOST(defaultgithub.com, enterprise becomeshttps://<host>/api/v3).internal/cache:$HOME/.hunkr(overrideHUNKR_DIR). Patches on disk, metadata in RAM. Concurrent file-page fetch (pool of 6,per_page=100).--refresh/--forcedeletes the PR dir and refetches.complete=trueonly after every page succeeds; otherwise the cache is left incomplete and the next run refetches.internal/diff: unified-diff parse + index.BuildIndexrecords hunk spans (HeaderRow,LineCount,BodyOff/BodyEnd) without building a full[]Line. A window is decoded later viaDecodeRange.internal/tui: Bubble Tea camera over that index. Virtual list, not a buffer of all lines.internal/mcp:modelcontextprotocol/go-sdk, stdio, samecache.Loadpath as the CLI.internal/cli: argument parsing,runDiff/runPRJSON/runTUI/runCache, help rendering.
Cache layout (as written on disk):
$HUNKR_DIR/cache/<owner>/<repo>/<pr>/
meta.json # { version, fetched_at RFC3339, complete, pr{number,title,state,draft,author,body,base,head,url,additions,deletions,changed_files,head_sha,base_sha}, file_count }
index.json # []FileMeta{ filename, previous_filename, status, additions, deletions }
patches/<sha256(path)>.patch # one file per changed path, truncated/binary => empty or missing
$HUNKR_DIR defaults to $HOME/.hunkr; CacheDir() is $HUNKR_DIR/cache. PRDir sanitizes owner/repo (rejects .., \, bad chars) and caps at 3000 files (GitHub limit) with a warning on stderr. Patches are written per page as it arrives; only FileMeta is kept in memory.
- Go 1.25
charmbracelet/bubbletea+charmbracelet/lipgloss(+charmbracelet/x/ansi)alecthomas/chroma/v2: visible-line syntax color only, not whole-file highlightingmodelcontextprotocol/go-sdk: MCP server (stdio)- GitHub REST (
application/vnd.github+json) - Inspired by diffs.com CodeView (virtualized mixed file/diff list) and game-style render culling
The TUI treats a PR diff like a large map. It never holds all patches decoded in RAM and never paints off-screen rows.
- Frustum cull: paint only on-screen rows.
renderDiffPaneiterates[offset, offset+height), not[0, Total). - Chunk streaming: decode ~3 viewports around the cursor (
[offset-height, offset+2*height)clamped to[0, Total)). Rest is an index (Spans) + raw patch on disk. Scrolling triggersCullon demand. - LOD: unfocused tabs drop patch text and the decoded window (
Tab.Sleep()clearsPatchanddecoded). Focusing reloads from cache (Tab.Wake()readspatches/<hash>.patch). No tab keeps its full diff decoded while hidden. - Spatial index: hunk header rows are indexed and looked up with
sort.Search(binary search onSpans[].HeaderRow), not a linear scan per line.Lookup,NextHunkRow,PrevHunkRoware all binary searches. - Dirty frames: if the camera didn't move, return the last frame.
Model.View()hasheswidth|height|repo|pr|loading|files|cursor|offset|tab|…intocameraKey(); a hit returnsframe.outwithout re-rendering. - First GitHub fetch of a huge PR is still a download; culling stops the TUI from simulating the whole map every frame after that. This was forced by real loads like oven-sh/bun#30412 (~1M additions, 2188 files, 3000-file API cap) where decoding everything would OOM or hang the render loop.
- Resolve repo:
-R owner/repoorgit remote get-url originparsed viainternal/repo(handleshttps://,ssh://,git@host:owner/repo.git). GetPR→ checkHasCompleteCache(meta.complete==true+index.json). Hit with no--refreshreturns cachedMeta+index.jsonwith no network.- Miss:
GET /repos/<repo>/pulls/<pr>/files?per_page=100&page=1, parseLink: <...>; rel="last"for total pages, then fetch remaining pages with a worker pool of 6. Each page's patches are written topatches/*.patchimmediately; onlyFileMetaaccumulates in memory.StreamPRCacheemitsfilesPageMsgper page so the TUI showsfiles 400/2188progressively; opening a file whose patch is already on disk works before the rest finish. - TUI:
Enteron a file →openTab→cache.LoadPatch→diff.BuildIndex→Cull(offset, viewportHeight)→DecodeRangefor the window →chromahighlight visible lines only.tab / shift+tabswitches tabs (sleeping the previous one),wcloses. --json/hunkr diff --jsonstream file-by-file from cache: for eachFileMeta, load patch, checkdiff.PatchOmitted,diff.ParseFile,json.MarshalIndent, write directly to stdout. Never builds a giant in-memory[]diff.File.
Via Homebrew:
brew tap wvvb/hunkr
brew install hunkrFrom GitHub Releases (tagged releases and nightly pre-release nightly):
Download the archive for your OS/arch from Releases: hunkr_<version>_<os>_<arch>.tar.gz (zip on Windows) plus checksums.txt with os=linux|darwin and arch=amd64|arm64 (not raw uname output). The rolling pre-release nightly is rebuilt nightly from main.
curl -fsSL https://raw.githubusercontent.com/wvvb/hunkr/main/install.sh | shManual download archives are named hunkr_<version>_<os>_<arch>.tar.gz with os=linux|darwin and arch=amd64|arm64 (not raw uname).
Or via Go:
go install github.com/wvvb/hunkr/cmd/hunkr@latest
go build -o ./hunkr ./cmd/hunkr
hunkr version # prints hunkr <version>
hunkr --version # same
hunkr -v # sameexport HUNKR_TOKEN=ghp_... # highest priority
export GH_TOKEN=ghp_... # fallback 1
export GITHUB_TOKEN=ghp_... # fallback 2
gh auth login # fallback 3: gh auth token
export GH_HOST=github.example.com # or GITHUB_HOST; default github.comToken order is HUNKR_TOKEN → GH_TOKEN → GITHUB_TOKEN → gh auth token. GH_HOST/GITHUB_HOST is trimmed of scheme and trailing slash; github.com maps to https://api.github.com.
hunkr <pr> [--json] [--file path] [-R owner/repo] [--refresh] [--ascii]
hunkr diff <pr> [--json] [--file path] [-R owner/repo] [--refresh]
hunkr mcp MCP server on stdio
hunkr cache list cached PRs (alias: cache ls)
hunkr cache ls
hunkr cache rm <pr> [-R owner/repo] remove PR cache
hunkr cache clear clear all caches (with -R owner/repo, clear that repo only)
hunkr --help
hunkr help
Repo defaults to the origin remote when -R is omitted. hunkr <pr> --json --file path and hunkr diff <pr> --json --file path both emit a single diff.File object; without --file they emit { "files": [...] } (full PR JSON from hunkr <pr> --json also includes a top-level pr object).
Issue numbers return a 404 that is disambiguated: if /repos/<repo>/issues/<n> exists but has no pull_request field, the error is "<repo>#<n> is an issue, not a pull request: <title>".
-R, --repo owner/name repo (default: git remote origin)
--file path limit to one file
--json machine-readable output
--refresh force refetch, overwrite cache (alias --force, -f)
--ascii disable nerd icons
-h, --help show help
--ascii sets HUNKR_ASCII=1 for the TUI run. --refresh deletes the PR dir before fetching.
Default keys (from ~/.config/hunkr/config.toml, mac-like cmd+ also matches ctrl+/alt+; on darwin help shows ⌘P as well):
tab / shift+tab cycle tabs (not pane)
w / ctrl+w close tab
enter open file as tab
\ toggle file list
f focus file list
/ ctrl+p / cmd+p (⌘P on darwin) file search: picker overlay, fff-style, no clone
? (or cmd+/) help overlay
q / esc close overlay or quit
j/k n/p [ ] g/G ctrl-d/u navigate focused pane
Keys file_search, close_tab, quit, help, toggle_files, next_tab, prev_tab, confirm are wired through config (Match(msg.String(), binds)); j/k n/p [ ] g G ctrl-d/u remain hardcoded vim nav.
Header shows PR state/title and +add -del; second line shows files N and cached indicator; tab bar shows icons; file list (toggle) + virtualized diff; never blank while loading (streaming pages).
Nerd icons (internal/tui/icons.go): enabled when TTY and (HUNKR_NERD=1 or HUNKR_ASCII unset and TERM_PROGRAM in iTerm.app, WezTerm, ghostty, WarpTerminal, vscode or KITTY_WINDOW_ID set). --ascii or HUNKR_ASCII=1 forces ASCII. Private-use glyphs with ASCII fallback (langs: go, rs, ts/tsx, js/jsx, zig, py, c/h/cpp, md, json, yml, toml, sh).
HUNKR_TOKEN GitHub token (fallback GH_TOKEN, GITHUB_TOKEN, gh auth token)
HUNKR_DIR cache root (default $HOME/.hunkr, CacheDir is $HUNKR_DIR/cache)
HUNKR_NERD=1 force nerd icons
HUNKR_ASCII=1 force ASCII icons (also set via --ascii)
GH_HOST GitHub host (default github.com, alias GITHUB_HOST)
dir: $HUNKR_DIR/cache (default $HOME/.hunkr/cache)
layout: $HUNKR_DIR/cache/<owner>/<repo>/<pr>/{meta.json,index.json,patches/*.patch}
Hit with complete=true and no --refresh reads cache without GitHub calls. Fetch uses GET page 1, parses Link last page, then pool-of-6 for remaining pages; as each page arrives patches are written to disk and only meta is kept in RAM. complete=true only when all pages succeed.
Path: $XDG_CONFIG_HOME/hunkr/config.toml else $HOME/.config/hunkr/config.toml. If missing, hunkr writes the default (mkdir 0755) then loads.
Default config.toml:
# ~/.config/hunkr/config.toml
[ui]
ascii = false
[keys]
# cmd+* also matches ctrl+* (terminals rarely deliver ⌘; Ghostty/Kitty can map ⌘P → ctrl+p)
file_search = ["cmd+p", "ctrl+p", "/"]
close_tab = ["cmd+w", "ctrl+w", "w"]
quit = ["cmd+q", "ctrl+q", "q"]
help = ["?", "cmd+/"]
toggle_files = ["\\"]
next_tab = ["tab"]
prev_tab = ["shift+tab"]
confirm = ["enter"]Parse errors fall back to defaults (warning to stderr). --ascii overrides ui.ascii. Key matching is lowercase; cmd+/super+/⌘ also matches ctrl+ and alt+.
/ or ctrl+p / cmd+p (also ⌘P on macOS, shown as ⌘P on darwin) opens a file picker overlay, not vim search-in-buffer. It ranks the in-memory PR file list with an fff-inspired scorer: whole-path, smart-case (all-lower is case-insensitive), consecutive-run bonus, filename boost, and 1-skip typo tolerance when the query is ≥3 chars. No local clone is needed. The search runs on []FileMeta paths (including previous_filename when present, opening the current Filename). Results are virtualized (visible rows only), typing filters live, and even 2188 files is fine in-process. j/k and arrows move, enter opens the selected tab, esc closes, backspace deletes, ctrl+u clears.
The MCP tool find_changed_files uses the same fff.Search on filenames (ranking, not raw substring) with the same output shape.
Stdio server for Claude/Cursor/etc. Same cache as the CLI (no separate fetch).
{ "mcpServers": { "hunkr": { "command": "hunkr", "args": ["mcp"] } } }Tools (via modelcontextprotocol/go-sdk, mcp.AddTool):
get_diff: diff for a PR, optionally filtered to a single file (repo, pr_number, file?→diff.Fileor{files: diff.File[]})get_pr: PR metadata (repo, pr_number→gh.PR)get_file: file content at ref (repo, path, ref)get_changed_files: list changed files without hunks (repo, pr_number)get_checks: list check runs for PR head (repo, pr_number)create_review: create a review (repo, pr_number, body, event=APPROVE|REQUEST_CHANGES|COMMENT)add_review_comment: add review comment (repo, pr_number, path, line, body)get_review_comments: list review comments (repo, pr_number)reply_to_comment: reply to a review comment (repo, comment_id, body)get_symbol_context: symbol context around a line (repo, path, line, ref)list_cached_prs: list cached PRs ({})refresh_cache: refresh cache for a PR (repo, pr_number)clear_cache: clear cache (repo?,pr_number?: none clears all,repoclears repo,repo+pr_numberclears one PR;pr_numberwithoutrepois an error)find_changed_files: find changed files by substring query (repo, pr_number, query)
All cache-backed tools use EnsurePRCache / LoadMeta same as the CLI; get_pr returns cached PRMeta when complete.
- Format with
gofmt. Verify withgo test ./...andgo build ./cmd/hunkr. - Wrap errors with
%w. Nopanicin production code. No silentdefaulton enums. Handle every case explicitly. - Don’t add dependencies unless the job needs them. Don’t hold all patches in RAM, stream per file, keep the index light.
- Parser/index changes need tests (
internal/diff:go test ./internal/diff -count=1). - PRs welcome against
github.com/wvvb/hunkr. Keep the README as the docs.
MIT - see LICENSE.
