TUI for reviewing diffs, files, and documents with inline annotations, built with bubbletea.
Architecture: see docs/ARCHITECTURE.md for system design, data flows, interfaces, and design decisions.
- Build:
make build(output:.bin/revdiff) - Test:
make test(race detector + coverage, excludes mocks) - Lint:
make lintorgolangci-lint run - Format:
make fmtor~/.claude/format.sh - Generate mocks:
go generate ./... - Vendor after adding deps:
go mod vendor
app/- composition root (package main), split by concern:main.go(entrypoint +run()),config.go(options/parsing),stdin.go(stdin mode),renderer_setup.go(VCS wiring),themes.go(theme CLI + adapter),history_save.go(session save)app/diff/- VCS interaction (git + hg + jj), unified diff parsing, VCS detection, Mercurial + Jujutsu support.compare.go—CompareReaderrenderer for--compare-old/--compare-new(runsgit diff --no-index, no repo required)app/ui/- bubbletea TUI package. SingleModelstruct with state grouped into sub-structs (cfg,layout,file,modes,nav,search,annot), methods split across files by concern (~500 lines each). Each source file has a matching_test.go. Seeapp/ui/doc.gofor package docs,docs/ARCHITECTURE.mdfor file-by-file breakdown. Does not importapp/themeorapp/fsutil— theme operations go through theThemeCataloginterfaceapp/ui/style/- color/style resolution: hex-to-ANSI, lipgloss styles, SGR tracking, HSL math. Types:Resolver,Renderer,SGR. Alsodisplay.go- package-levelSanitizeFilenameForDisplayandTruncateLeftToWidth, the shared helpers every filename-rendering surface must route throughapp/ui/sidepane/- file tree + markdown TOC components with cursor/offset managementapp/ui/worddiff/- intra-line word-diff: tokenizer, LCS, line pairing, highlight insertionapp/ui/overlay/- layered popups: help, annotation list, theme selector, file picker. Manager enforces one-at-a-timeapp/highlight/- chroma syntax highlighting, foreground-only ANSIapp/keymap/- configurable keybindings (Actionconstants, parser, defaults, dump)app/theme/- Catalog-centric theme system:Theme(data + serialization) andCatalog(discovery, loading, installation, gallery). Zero standalone functions — all logic as methods. Files:theme.go(Theme struct),catalog.go(Catalog struct + all operations). 7 bundled + community galleryapp/annotation/- in-memory annotation store and structured outputapp/editor/- external$EDITORinvocation for multi-line annotations: temp-file lifecycle, editor resolution ($EDITOR → $VISUAL → vi), andCommand()API returning*exec.Cmd+ completion func fortea.ExecProcess. Consumed byapp/uivia theExternalEditorinterfaceapp/handoff/- prepares user-configured post-flush shell commands. Annotation snapshots are provided on stdin; stdout is suppressed so helpers cannot overwrite the TUI. Consumed byapp/uivia thePostFlushHookinterface andtea.ExecProcessapp/history/- review session auto-save to~/.config/revdiff/history/app/fsutil/- filesystem utilitiesapp/ui/mocks/- moq-generated mocks (never edit manually)
- Decouple OS/external concerns from UI: OS-level work (exec.Command, os.CreateTemp, env lookup, network calls) does not belong in
app/ui/, even for small helpers. Extract to a dedicated package (e.g.app/editor/) even when there's only one production caller. The cost of a new package is lower than keepingapp/ui/entangled with OS boundaries. - Minimize exported surface: first pass of a new package almost always over-exports. Before finalizing, ask "which of these does the caller actually need?" and unexport the rest. For stateless helpers, prefer unexported methods on the grouping struct over top-level unexported functions — matches the global "prefer methods over standalone utilities" rule.
- Consumer-side interfaces for external deps: when UI uses an external subsystem, the default shape is: interface in
app/ui/(consumer side), concrete type in the subsystem package, injection viaModelConfig(nil defaults to concrete). Direct import of a concrete type from an external package intoapp/uiis a smell — the interface documents the dependency direction and leaves room for alternate implementations, not just test mocks.
- Config file:
~/.config/revdiff/config(INI format via go-flags IniParser) - Precedence: CLI flags > env vars > config file > built-in defaults
--dump-configoutputs current defaults,--configoverrides pathno-ini:"true"tag excludes fields from config file (used for --config, --dump-config, --dump-theme, --list-themes, --init-themes, --version)- Themes dir:
~/.config/revdiff/themes/with 7 bundled themes, auto-created on first run --theme NAMEloads theme;--dump-themeexports resolved colors;--list-themeslists available;--init-themesre-creates bundled- Theme precedence:
--themeoverwrites all 23 color fields + chroma-style, ignoring--color-*flags or env vars - Theme values applied via
applyTheme()inthemes.gowhich overwritesopts.Colors.*afterparseArgs().colorFieldPtrs(opts)is the single source of truth for color key → struct field mapping — adding a new color requires changes intheme.gocolorKeys + options struct +colorFieldPtrs()inthemes.go - Theme ownership split:
app/theme.Catalogowns discovery/loading,app/ui.ThemeCataloginterface consumed by UI,app/themes.gowires adapter composing catalog + config persistence ini-nametags ensure config keys match CLI long flag names- Keybindings file:
~/.config/revdiff/keybindings(map <key> <action>/unmap <key>format) --keysoverrides keybindings path,--dump-keysprints effective bindings- CLI flag description style is minimal and atomic — match
--staged("show staged changes") /--blame("show blame gutter"). Never include "at startup", "on startup", "(mirrors X toggle)", "(same state as X)", or cross-references to runtime toggle keys in the struct tag description, README/docs.html/plugin config.md table rows, godoc, or usage example comments. The flag description states what the flag does; users discover runtime toggles via the keybindings table or status-bar legend. This rule applies to every surface that describes a flag. - Mode-gating pattern for CLI flags with mode-dependent applicability: when a flag is meaningful in some modes (working-tree, single-ref,
--staged) but not others (two-ref,--stdin,--compare-old/--compare-new), gate it at the composition root via a method onoptionsthat returns the resolved bool, parallel tooptions.ref(). Example:options.startupUntracked()returnsfalsein two-ref mode (a bora..b) so working-tree state doesn't leak into historical diffs.main.gothen wiresShowUntracked: opts.startupUntracked()intoModelConfig. The Model takes the resolved bool — it does NOT re-derive from CLI options or refs. Composition-root gating composes cleanly with the Model's own capability gate (e.g.cfg.ShowUntracked && cfg.LoadUntracked != nil) which handles the stdin/compare modes where the loader function is nil.
- Static site in
site/(index.html, docs.html, style.css), deployed to revdiff.com via Cloudflare Pages - Cloudflare Pages strips
.htmland 308-redirects/docs.html→/docs. Canonical tags,og:url, andsitemap.xmlentries for documentation pages must use the extension-less URL (/docs), not the source filename (/docs.html), or Google indexes a redirect and tanks CTR site/docs.htmlmust stay in sync with README.md - when adding features, flags, keybindings, or modes, update bothsite/index.htmllanding page should reflect major new features in the features grid and plugin sections- CRITICAL: After each release, update the version badge in
site/index.html(search forhero-badgediv) andsoftwareVersionin JSON-LD
- Plugin lives at
.claude-plugin/withplugin.json,marketplace.json, andskills/ - Skills path in
plugin.jsonis relative to repo root, not to.claude-plugin/ - CRITICAL: Version bumps happen at release only — never per-PR or per-change. Do NOT prompt to bump
plugin.json/marketplace.jsonafter a plugin file change; the bump is done as part of the release process. - When bumping at release, keep every marketplace entry synchronized with its plugin manifests. For
revdiff, update.claude-plugin/plugin.json,plugins/codex/.codex-plugin/plugin.json, and its version in.claude-plugin/marketplace.json. Forrevdiff-planning, updateplugins/revdiff-planning/.claude-plugin/plugin.json,plugins/revdiff-planning/.codex-plugin/plugin.json, and its version in.claude-plugin/marketplace.json. - CRITICAL: Defer plugin version bumps when the change depends on a new binary feature. If a plugin/launcher change relies on a
revdiffbinary feature, flag, env var, or exit code that is not yet in a tagged release, do NOT bumpplugin.json/marketplace.json/package.jsonon the feature branch. The plugin (marketplace) and the binary (brew /go install) version independently — bumping the plugin early ships an updated launcher to users still running an old binary, causing a hard mismatch (e.g. the launcher passes an unknown flag, the old binary exits 1, every plugin-triggered review fails). Bump plugin/package versions as part of the binary version release, after the binary is tagged. - Reference docs at
.claude-plugin/skills/revdiff/references/— keep in sync with README.md:install.md— installation methods and plugin setupconfig.md— options, colors, chroma stylesusage.md— examples, key bindings, output format
- Adding a new CLI flag requires SKILL.md updates, not just reference docs.
references/config.mdandreferences/usage.mddocument the flag's existence;SKILL.mdteaches AI agents when to pass it during automatic launches (e.g. "pass--untrackedwhen the recent change likely created new untracked files"). Without a SKILL.md entry, AI agents using the plugin will not know to pass the flag even though it's documented. Apply the same update toplugins/codex/skills/revdiff/SKILL.md(keep in sync with.claude-plugin/skills/revdiff/SKILL.md) and toplugins/pi/skills/revdiff/SKILL.md(which lists user-facing command examples). The launcher scripts (launch-revdiff.sh) pass"$@"through, so no script changes are needed beyond updating the usage-comment header for documentation parity. - Launchers must parse under bash 3.2 (
/bin/bashon stock macOS, where#!/usr/bin/env bashresolves unless a newer bash is on PATH). Its parser scans a$( )command substitution for quotes before it processes a heredoc inside it, so an odd number of apostrophes in a heredoc opened inside one opens a quote that never closes and the whole script fails to parse — reported a hundred lines later, on an unrelated valid line. Two heredocs per launcher are nested that way, theGHOSTTY_TERM_ID=$(osascript ...)andITERM_NEW_SESSION=$(osascript ...)captures; the close-pane heredocs beside them are plain commands and are unaffected. Keep the nested bodies apostrophe-free; balancing them is not a fix, since a later edit to one word re-breaks it. No parse check can guard this in CI — ubuntu and every Homebrew bash accept the broken form — soTestLauncherNestedHeredocsHaveNoApostrophesguards it textually instead, and/bin/bash -nis the direct check on macOS. Introduced by #309, reported in #314. - shellcheck is pinned in CI (
koalaman/shellcheck:v0.11.0, digest-pinned in.github/workflows/ci.ymlbecause a docker tag is mutable and a retag would change the checker silently) because the runner image's version drifts and a# shellcheck disable=is version-sensitive:SC2317("command appears unreachable") was split intoSC2329("function never invoked") in 0.11, so a suppression naming only the code your local shellcheck emits passes locally and fails CI — which is exactly how #344 broke. Name both codes. The one place this bites isherdr_cleanup_unlaunched: it is the only trap in the launcher that calls a function rather than inlining its commands, and shellcheck cannot see invocations inside the trap's quoted string. The warning is a false positive — mutating the function's body fails threeTestHerdrSignalPaneOwnershipcases — so suppress it, don't inline the function to appease the linter. Reproduce CI exactly withfind . -name '*.sh' -not -path './.git/*' -not -path './vendor/*' -print0 | xargs -0 docker run --rm -v "$PWD":/mnt:ro koalaman/shellcheck:v0.11.0@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d. - Launcher override chain: both Claude plugins resolve their launcher script via
resolve-launcher.shthroughuser → bundledlayers (first executable wins). The planning plugin's user layer is${CLAUDE_PLUGIN_DATA}/scripts/<launcher>under Claude and${PLUGIN_DATA}/scripts/<launcher>under Codex. There is no project-level (.claude/...or.codex/...) executable layer by design — the planning hook fires automatically in any repo, and a repo-controlled launcher would run on routine agent actions. The Pi extension and manual Codex diff-review skill do not use this plugin-data override. - Overlay stderr relay:
launch-revdiff.shappends2>$ERR_FILEtoREVDIFF_CMDonce, right after the argument loop, so every backend captures revdiff's stderr without per-backend edits (the redirect stays the trailing token throughwrite_rc_cmd/write_fifo_rc_cmd, the heredoc launch scripts, and the later/usr/bin/envprepend).print_output_and_exitreplays the file on any exit code other than 0 or 10 — those two are successes, and revdiff writes ordinary warnings to stderr, so relaying them would put noise on every successful review. All tenEXITtraps inlaunch-revdiff.shlist$ERR_FILE— an override that omits it leaks the file into$TMPDIR, whichTestShellLaunchersPreserveAnnotationExitCodecatches by asserting norevdiff-err-*survives the run. The stderr expectations live in that test because a second launcher-by-backend pass puts theapppackage over the timeout inmake race(now-timeout=180s, matching CI); a new launcher-wide behavior belongs in the same matrix, gated per launcher viarelaysStderr(launch-plan-review.shhas no relay). The sourced agent-deck window backend (agentdeck-window.sh) is the eleventh execution path and carries no relay code of its own: it builds its command throughwrite_rc_cmd, exits throughprint_output_and_exit, and installs noEXITtrap by design, so it rides the base one. It is the only backend absent fromlauncherBackends(), so the relay tests do not cover it.plugins/revdiff-planning/scripts/launch-plan-review.shhas no relay: its per-backend exit tails are duplicated inline with no shared helpers. - herdr pane-scoped overlay (
REVDIFF_HERDR_PANE=1): opt-in, off by default; unset, theherdr tab create+pane runblock is reached byte-identical, because pane mode is a block ahead of it that exits on its own rather than a flag threaded through the shared path. The tab block itself is byte-identical, but the EXIT trap and the generated launch script ARE shared — the ownership machinery there is inert in tab mode only becauseHERDR_TARGETstays empty, so treat both as pane/tab common code when editing.$HERDR_PANE_IDis a name collision — herdr injects it into every managed pane and the tab path uses it as its own local, so the caller's id is copied toHERDR_CALLER_PANEfirst; losing it types the launch command into the agent's own shell. Ownership isHERDR_TARGET(non-empty = a pane owes a close);herdr_close_paneclears it before shelling out so a re-entrant call cannot close twice. Cleanup is decided by evidence from the pane, not bypane runreturning — herdr may start the review before that call returns, or not after it. The dispatched script's first line touches$SENTINEL.started, andHERDR_DISPATCHEDis set to 1 immediately beforepane runis called;herdr_cleanup_unlaunchedpreserves the pane while either signal says a review may exist and the sentinel says it has not finished, closing and cleaning up otherwise (never started — holds only a shell; already finished — done).HERDR_DISPATCHEDis claimed beforepane runand never cleared, because a pending signal is serviced the instant that call returns and before any later assignment — ownership taken afterwards would miss a dispatch herdr had already accepted, and clearing it on the refusal path would let a signal landing inside that path's evidence grace close a pane whose review may already be running. Nothing needs the clear:herdr_close_panedischarges ownership by emptyingHERDR_TARGET, which is whatherdr_cleanup_unlaunchedgates on. A launcher killed while herdr is wedged inside the call therefore preserves the pane: the state is unknown, and unknown is never destroyed. The marker still matters on the failure path, where it is the only evidence that a refused-looking dispatch actually started. For the same reason a nonzeropane rundoes not close a pane that has already announced itself. That is whatSKILL.mdpromises the driving agent: a launcher killed on timeout leaves a live review open with nothing lost. Ownership does not depend on the order ofpane runand the cosmeticpane zoom— the marker settles it — so the zoom stays first and revdiff starts in an already-zoomed pane rather than being resized during startup. The marker is written withtouch … || true, never: >: a redirection failure on a special builtin kills a POSIX shell outright and revdiff would never run. After dispatch the script belongs to the pane (it ends withrm -f "$0"); the completion path removes it for a pane that died first.TestHerdrSignalPaneOwnershipsignals a real launcher to pin both halves; three of its cases reach the trap's close — wedged inpane split, interrupted inpane split, and wedged inpane zoom— all of them pre-dispatch, where the pane provably holds only a shell; every later case is the preserve half — without it the close is mutation-invisible. The block also addstrap 'exit 130' INT/trap 'exit 143' TERMfor agterm parity, and the tab path inherits them. They do not change whether cleanup runs — bash runs the EXIT trap on an untrapped SIGTERM too, same 143 — they change when: a trapped signal is deferred until the in-flight foreground command returns, so cleanup observes the state after the pendingherdrcall finishes rather than racing it. Pane-mode ownership depends on that ordering; for the tab path it is a no-op beyond a deterministic exit code. A trapped signal is deferred until the in-flight foreground command returns and then runs before the next statement, sotrap 'exit 143' TERMacrosspane splitwould exit after the pane exists but beforeHERDR_TARGETnames it, leaving the EXIT trap nothing to close. The split-and-parse window therefore installs recording traps (trap 'HERDR_SIGNALLED=130' INT,trap 'HERDR_SIGNALLED=143' TERM) and pays the signal once ownership is held; both exits from that window — success and the tab fallthrough — must restoreexit 130/exit 143first, or a signal on a path with no pane to protect is swallowed and the launcher hangs (killed on the tab fallthrough still exitsis the guard, via a bounded wait). The refusal path's evidence grace (a wall-clock interval of one to two seconds:SECONDS=0, loop while-lt 2) records signals for the same reason: exiting mid-grace hands the trap a state it must read as "may be live" and preserve, stranding a pane the completed check would have closed. A recording trap alone is not enough there, because a process-group signal (interactive Ctrl-C,kill -- -pgid) also kills the foregroundsleep: it returns nonzero andset -eaborts before the evidence check, resurrecting the strand. The grace is therefore measured on the wall clock,while [ ! -f marker ] && [ "$SECONDS" -lt 2 ]aroundsleep 0.3 || true: a killed sleep costs an early wakeup and nothing else,|| truekeeps it from tripping errexit, and the interval is served no matter how many signals arrive. An absent marker is evidence only once that interval has elapsed, and then the pane IS closed (a relentlessly signaled grace is still servedpins this with a minimum-duration assertion, since the close alone would also pass against a launcher that never waited). Each trap records its own status (130for INT,143for TERM) rather than a boolean, or a recorded Ctrl-C is paid as a fixedexit 143and misreports the signal. Reaching the grace deterministically needs no timing: a PATH-injectedsleepraises the signal before sleeping, so it is already pending when the grace sleep becomes the foreground command (FAKE_SLEEP_KILL=parent|group, the group variant requiringsetpgidor the kill lands ongo testitself). A trap with a command is reset to default in children, which is why this is safe where SIG_IGN is not. Do not bracket the create window withtrap '' INT TERM:trap ''is SIG_IGN, inherited acrossexec, so the herdr child ignores INT/TERM and a hung server makes the launcher unkillable except by SIGKILL. The probe coverspane split/get/close(all three are used at runtime; a CLI lackinggetabandons a live review);pane zoomis unprobed as cosmetic. The wait loop treats{"error":{"code":"pane_not_found"}}as authoritative death (nothing closed, nothing warned) and any other error as transient — it keeps polling, warning once per outage streak at 10 misses and backing the poll off from 0.3s to 2s so a downed control plane is not hammered (both reset on a good poll; the sentinel is still checked every iteration), because a generic error is not evidence the pane died and any deadline turns unknown liveness into a closed live review. Never guess which pane to close: a split that returns no parseable id warns and exits 1 rather than diffingpane list, since a recovered id may belong to another herdr client; an id equal to$HERDR_CALLER_PANEis rejected for the same reason. - agterm pane-scoped overlay (
REVDIFF_AGTERM_PANE=1): opt-in, off by default — unset leaves the agterm branch's call exactly as it was. It adds--pane $AGTERM_PANEtosession overlay openso the review covers the agent's pane alone instead of the whole session, and it needs all four of: the env var set to1,$AGTERM_PANEbeingleft/right(scratchis full-coverage with no sibling), a--pane-capable agtermctl (agterm_supports_pane_overlay, which short-circuits before the split read), and a split confirmed byagterm_session_split— a window-scopedtree --jsonread (treedefaults to the FRONTMOST window, so an unscoped read finds no session and reports every split as absent) parsed with jq, which reports "not split" when jq is missing. That probe exists because--panereached agtermctl only after agterm v0.9.0; it reads the PATH agtermctl, which is not always the CLI of the running app, and the post-call fallback is what covers the skew: on a nonzero exit whose captured agtermctl stderr matchespane overlay already open|pane not visible, the launcher retries session-wide (agterm refused before running revdiff, so nothing is re-executed — the grep is gated on agterm's own message precisely so a revdiff failure never triggers a second review). agtermctl's stderr is captured separately from revdiff's ($ERR_FILE) and replayed either way; its stdout is dropped becauseprint_output_and_exitowns the launcher's stdout. Both launcher copies carry it;TestAgtermPaneOverlayOptIncovers the gate, the fallback, and the default path. Known limitation, documented beside the gate:$AGTERM_PANEis baked into the shell's environ at spawn, so a pane agterm promoted into the main slot keepsright— promote-then-re-split scopes the overlay to the NEW sibling instead of this pane, and the fallback cannot catch it because that pane genuinely exists and agterm raises no error.session statustakes a stable--pane-idtoken for exactly this,overlay opendoes not yet, and failing closed to the session-wide overlay is deliberately not the answer. - Launcher env vars don't reach the tmux/zellij popup:
launch-revdiff.shspawns the revdiff process in a fresh shell inside the multiplexer popup that does NOT inherit the parent shell's environment, so env-var config set before the launch is dropped (e.g.REVDIFF_THEME=gruvbox launch-revdiff.sh HEAD~10does not apply the theme). Pass it as a CLI flag instead:launch-revdiff.sh --theme gruvbox HEAD~10. Applies to any env-var-configurable option launched through the overlay. - Testing locally:
claude --plugin-dir .claude-pluginloads the diff-review skill from this checkout without going through the marketplace;claude --plugin-dir plugins/revdiff-planningdoes the same for the planning hook. Use/reload-pluginsto pick up file edits mid-session.
- Codex skills live at
plugins/codex/skills/— two skills:revdiff(diff review) andrevdiff-plan(plan review via last Codex assistant message) - The
revdiffCodex plugin packages both skills; automatic plan review is distributed separately through therevdiff-planningCodex plugin - Codex marketplace metadata lives at
.agents/plugins/marketplace.json; keep its local sources aligned with each.codex-plugin/plugin.json - Keep Claude's default-discovered
PreToolUse/ExitPlanModeconfig inhooks/hooks.json; the Codex manifest explicitly points its opt-inStophook athooks/codex-hooks.json - Script path resolution in SKILL.md derives the installed plugin root from the skill's absolute catalogue path; marketplace installs live under Codex's plugin cache, not
~/.codex/skills/ - Scripts are copies from
.claude-plugin/skills/revdiff/scripts/, not symlinks — each has a source comment at top detect-ref.shdispatches by VCS (detect_git/detect_hg/detect_jj) viacommand -vprobes (jj → git → hg, matchingDetectVCSprecedence); git path stays byte-identical to the pre-refactor output.read-latest-history.shuses the same VCS probe order for repo-root resolution.- Codex automatic plan review runs only for
permission_mode=plan, prefers a complete plan inlast_assistant_message, and falls back whenever that field has no complete block to the last assistant message for the exact transcript/session/turn; manual/revdiff-planremains the best-effort rollout fallback - Testing locally: installs are copies from a marketplace, so point Codex at this checkout:
codex plugin marketplace remove revdiff(Codex refuses a second source under the same name, so the GitHub-added marketplace must go first),codex plugin marketplace add /absolute/path/to/checkout, thencodex plugin add revdiff@revdiff. After editing a skill or script,codex plugin remove revdiff@revdiff,codex plugin add revdiff@revdiffagain, and start a new session.
- Pi package defined in root
package.json, extensions and skills inplugins/pi/ - Pi review path is direct-terminal only:
/revdiff [args]suspends pi, runs therevdiffbinary directly, and sends captured annotations to the agent immediately. There is no Pi overlay mode, pending annotation widget/panel,/revdiff-rerun,/revdiff-results,/revdiff-apply,/revdiff-clear, or default post-edit reminder command. - Pi ships its own copy of
detect-ref.shatplugins/pi/scripts/detect-ref.sh(source-of-truth is.claude-plugin/skills/revdiff/scripts/detect-ref.sh— keep in sync, same pattern as the codex copy). The pi extension resolves the script relative to its own plugin root, never via.claude-plugin/; the pi package must stay installable standalone with no Claude plugin files present. Do not re-addlaunch-revdiff.shto the Pi package surface unless the workflow is explicitly changed. - CRITICAL: Version bumps happen at release only — never per-PR or per-change. Do NOT prompt to bump
package.jsonafter a pi plugin file change; the bump is done as part of the release process. - Version in
package.jsonis independently versioned (does not track the project's git tags)