Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
f935f61
feat(web): add synchronized browser review foundation
benvinegar Aug 9, 2026
32663ca
feat(web): synchronize browser review actions
benvinegar Aug 9, 2026
6e95e4d
feat(cli): expose local browser reviews
benvinegar Aug 9, 2026
9dd4304
test(web): complete browser review release gate
benvinegar Aug 9, 2026
967fde7
fix(web): publish reviews with added files
benvinegar Aug 10, 2026
e27aac3
feat(web): add secure Tailscale reviews
benvinegar Aug 10, 2026
160edea
fix(web): verify resources on Tailscale origins
benvinegar Aug 10, 2026
ff5ff49
fix(web): refresh daemon for browser asset updates
benvinegar Aug 10, 2026
a1f31f4
fix(web): stabilize large review rendering
benvinegar Aug 10, 2026
2da541d
Merge remote-tracking branch 'origin/main' into feat/browser-review
benvinegar Aug 10, 2026
de4ffca
fix(review): harden synchronized review lifecycle
benvinegar Aug 10, 2026
aef1eb9
fix(review): prevent browser runtime TUI regressions
benvinegar Aug 11, 2026
6e03645
fix(ci): restore cross-platform review checks
benvinegar Aug 11, 2026
99fb154
test(ci): stabilize platform-specific checks
benvinegar Aug 11, 2026
1627090
test(pty): wait for compiled watch input
benvinegar Aug 11, 2026
c76f231
test(pty): wait for navigation input
benvinegar Aug 11, 2026
d0e8ba4
test(ci): allow slow Windows git setup
benvinegar Aug 11, 2026
de0fa44
fix(review): preserve terminal parity
benvinegar Aug 11, 2026
c0fc819
Merge remote-tracking branch 'origin/main' into feat/browser-review
benvinegar Aug 11, 2026
5b17d9d
chore: checkpoint browser review parity
benvinegar Aug 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/local-browser-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Open synchronized capability-authenticated browser reviews locally or across your Tailscale network.
5 changes: 5 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Browser assets are generated canonically on Unix and must retain LF bytes on every checkout.
src/browser/assets/bootstrap.js text eol=lf -whitespace
src/browser/assets/review.css text eol=lf
src/browser/assets/review.html text eol=lf
src/browser/generated/assets.ts text eol=lf
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ jobs:
- name: Typecheck
run: bun run typecheck

- name: Check embedded browser assets
run: bun run check:browser-assets

- name: Theme contrast check
run: bun run test:theme-contrast

Expand Down Expand Up @@ -189,6 +192,11 @@ jobs:
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }}

- name: Verify compiled browser review assets
run: bun test ./test/cli/compiled-browser-review-assets.test.ts
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }}

- name: Verify staged prebuilt packs
run: bun run check:prebuilt-pack

Expand Down
16 changes: 16 additions & 0 deletions .github/workflows/pr-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ jobs:
- name: Typecheck
run: bun run typecheck

- name: Check embedded browser assets
run: bun run check:browser-assets

- name: Theme contrast check
run: bun run test:theme-contrast

Expand Down Expand Up @@ -127,6 +130,11 @@ jobs:
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }}

- name: Verify compiled browser review assets
run: bun test ./test/cli/compiled-browser-review-assets.test.ts
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/${{ matrix.executable }}

pr-validate:
name: Typecheck + Test + Smoke
needs: changes
Expand Down Expand Up @@ -175,6 +183,9 @@ jobs:
- name: Typecheck
run: bun run typecheck

- name: Check embedded browser assets
run: bun run check:browser-assets

- name: Theme contrast check
run: bun run test:theme-contrast

Expand Down Expand Up @@ -221,6 +232,11 @@ jobs:
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/dist/hunk

- name: Verify compiled browser review assets
run: bun test ./test/cli/compiled-browser-review-assets.test.ts
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/dist/hunk

- name: Verify compiled binary watch mode
run: bun test ./test/pty/watch.test.ts
env:
Expand Down
8 changes: 7 additions & 1 deletion .github/workflows/website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,14 @@ jobs:
- name: Check static links and metadata
run: bun run website:links

- name: Build compiled CLI for browser smoke tests
run: bun run build:bin

- name: Install Chromium
run: cd website && bunx playwright install --with-deps chromium
working-directory: website
run: bunx playwright install --with-deps chromium

- name: Run browser smoke tests
run: bun run website:test:browser
env:
HUNK_TEST_EXECUTABLE: ${{ github.workspace }}/dist/hunk
2 changes: 1 addition & 1 deletion .oxfmtrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"ignorePatterns": []
"ignorePatterns": ["src/browser/assets/bootstrap.js", "src/browser/generated/assets.ts"]
}
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"ignorePatterns": ["src/browser/assets/bootstrap.js", "src/browser/generated/assets.ts"],
"rules": {
"no-control-regex": "off"
}
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built
- inline AI and agent annotations beside the code
- split, stack, and responsive auto layouts
- watch mode for auto-reloading file and Git-backed reviews
- synchronized terminal and local browser review surfaces
- keyboard, mouse, pager, and Git difftool support

<table>
Expand Down Expand Up @@ -84,6 +85,25 @@ git diff --no-color | hunk patch - # review a patch from stdin

Watch mode remains continuous. Direct-file and Git-backed reviews normally use filesystem observation to refresh promptly, with periodic polling retained as a fallback for missed events or unavailable watchers. Jujutsu and Sapling reviews currently use polling rather than filesystem observation.

### Browser review

Add `--web` to any review command to use the synchronized browser surface instead of the terminal UI:

```bash
hunk diff --web
hunk diff --watch --web
hunk patch changes.patch --web --no-open
hunk diff --web --tailscale
hunk session open --repo .
hunk session open --repo . --tailscale --no-open
```

`--web` opens the default browser and keeps the owning Hunk process alive until `SIGINT` or `SIGTERM`; closing a tab does not stop a watched review. `--no-open` prints the capability URL without launching a browser. The URL grants access to that one live review, so treat it as a secret and do not share it.

Browser review is served by Hunk's loopback session daemon. Pass `--tailscale` to lazily add one browser-only listener on the machine's exact Tailscale IPv4 address; this requires the `tailscale` CLI to be installed and signed in. The resulting plain-HTTP URL is encrypted in transit by Tailscale/WireGuard, remains tailnet-only, and still requires the session capability. Broker websocket, health, and session API routes remain loopback-only. Unsafe remote daemon mode remains refused, and Hunk does not mutate `tailscale serve` configuration. If session brokering is disabled, omit `--web` to keep using the terminal UI.

Browser extension UI v1 includes host-rendered review data and semantic actions. OpenTUI sidebar components, terminal-only file views, and other renderer-specific extension components remain terminal-only; extension transforms and lifecycle hooks still run once in the owning review process for both surfaces.

### Working with agents

1. Open Hunk in another terminal with `hunk diff` or `hunk show`.
Expand Down Expand Up @@ -212,7 +232,7 @@ repository's `.hunk/extensions/` (after you explicitly trust that repository),
and from `--extension <path>` for development. `--no-extensions` turns those off
for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded.

A Phase 1 extension can contribute themes and file-extension → language
A v1 extension can contribute themes and file-extension → language
mappings, add a VCS backend, rewrite the changeset before review (collapse
lockfiles, reorder files by review priority), replace the file-navigation
sidebar with its own React component, react to lifecycle events, and show
Expand Down
4 changes: 3 additions & 1 deletion benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ bun run bench:huge-stream
bun run bench:large-stream-profile
bun run bench:memory
bun run bench:geometry-memory
bun run bench:review-projection-memory
bun run bench:navigation-memory
bun run bench:resize-memory
bun run bench:competitors
Expand All @@ -59,14 +60,15 @@ bun run bench:competitors
- `render-layout.ts` — measures pure split/stack row building, section geometry, and review-plan construction for many-small-files, balanced, and large-single-file streams.
- `highlight-prefetch.ts` — measures selected-file highlight startup and adjacent prefetch readiness.
- `large-stream.ts` — measures large split-stream first-frame and scroll cost.
- `interaction-latency.ts` — measures per-press `]` hunk-navigation latency and per-scroll-tick latency (median + p95) on the large stream, plus RSS/heap ceilings after first frame and after navigation (the default-suite slice of `memory.ts`).
- `interaction-latency.ts` — preserves the release-comparable 6-press/8-scroll metrics, adds separately named 60-observation navigation and scrolling distributions whose nearest-rank p95 is distinct from an isolated maximum stall, and measures runtime/AppHost-to-first-frame startup plus RSS/heap ceilings after first frame and the legacy navigation slice.
- `non-ascii-stream.ts` — measures first-frame and per-scroll-tick latency on a stream whose diff content embeds CJK, emoji, and box-drawing characters, exercising the string-width path on content rather than chrome glyphs.
- `wrapped-cjk.ts` — reproduces issue #579 with 518 wrapped Japanese Markdown lines plus one pathological long logical line, includes renderer setup in first-frame latency, and measures immediate/coalesced frames from a real wheel burst.
- `terminal-width.ts` — measures scalar-heavy CJK and emoji width calls plus the complex-cluster fallback against equivalent `string-width` reference paths, verifying identical width checksums.
- `huge-stream.ts` — opt-in huge tier (`--include-huge` or `HUNK_BENCH_INCLUDE_HUGE=1`): cold first frame, scroll-tick and hunk-navigation latency, and memory ceilings on ~1k files / 300k+ diff lines plus one giant ~50k-line file.
- `large-stream-profile.ts` — optional local profiler for the main pure planning stages behind the large split-stream benchmark.
- `memory.ts` — optional local RSS/heap profiler after fixture loading, planning, first frame, and next-hunk navigation.
- `geometry-memory.ts` — optional local retained-memory profiler for all-files section geometry, including JSC-native heap metrics and giant-file lazy planned-row materialization latency used by first copy selection.
- `review-projection-memory.ts` — optional profiler for canonical review projection time and retained RSS/JSC heap before and after bounded lazy first/all browser resource encoding; verifies that terminal startup measures and materializes zero canonical resources and reports retained encoded-cache bytes; accepts `--file-count` and `--lines-per-file`.
- `navigation-memory.ts` — optional local retained-memory profiler for repeated hunk navigation through a mounted review stream.
- `resize-memory.ts` — optional local retained-memory profiler for repeated terminal-width changes through a mounted review stream; this targets geometry-cache retention across resize variants.
- `competitors.ts` — optional local informational comparisons against `git diff --no-ext-diff`, `delta`, `difftastic`, and `diff-so-fancy` when installed.
Expand Down
45 changes: 36 additions & 9 deletions benchmarks/interaction-latency.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { performance } from "node:perf_hooks";
import { testRender } from "@opentui/react/test-utils";
import React from "react";
import { createReviewSessionRuntime } from "../src/app/reviewSessionRuntime";
import { AppHost } from "../src/ui/AppHost";
import {
createLargeSplitStreamBootstrap,
Expand All @@ -20,29 +21,48 @@ import {
renderPass,
} from "./lib/interaction";

const NAVIGATION_PRESSES = 6;
const SCROLL_TICKS = 8;
// Nearest-rank p95 needs enough observations that it does not collapse to one maximum stall.
const LEGACY_NAVIGATION_PRESSES = 6;
const LEGACY_SCROLL_TICKS = 8;
const NAVIGATION_OBSERVATIONS = 60;
const SCROLL_OBSERVATIONS = 60;

/** Measure `]` per-press latency plus memory ceilings on a fresh renderer. */
async function measureNavigation() {
const bootstrap = createLargeSplitStreamBootstrap();
const terminalStartupStart = performance.now();
const runtime = createReviewSessionRuntime(bootstrap);
const setup = await testRender(
React.createElement(AppHost, { bootstrap: createLargeSplitStreamBootstrap() }),
React.createElement(AppHost, { bootstrap, runtime }),
INTERACTION_VIEWPORT,
);

try {
const firstFrameStart = performance.now();
await renderPass(setup);
console.log(`METRIC first_frame_ms=${(performance.now() - firstFrameStart).toFixed(2)}`);
console.log(
`METRIC runtime_mount_first_frame_ms=${(performance.now() - terminalStartupStart).toFixed(2)}`,
);
printMemoryMetrics("after_first_frame");

// Settle initial async work (selection reveal, highlight kick-off) so the
// press latencies measure navigation, not startup spillover.
await renderPass(setup, 2);

const pressLatencies = await measureKeyPressLatencies(setup, "]", NAVIGATION_PRESSES);
printLatencyMetrics("hunk_nav_press", pressLatencies);
const legacyPressLatencies = await measureKeyPressLatencies(
setup,
"]",
LEGACY_NAVIGATION_PRESSES,
);
printLatencyMetrics("hunk_nav_press", legacyPressLatencies);
printMemoryMetrics("after_navigation");
const remainingPressLatencies = await measureKeyPressLatencies(
setup,
"]",
NAVIGATION_OBSERVATIONS - LEGACY_NAVIGATION_PRESSES,
);
printLatencyMetrics("hunk_nav_60_press", [...legacyPressLatencies, ...remainingPressLatencies]);
} finally {
await destroyRenderer(setup);
}
Expand All @@ -57,8 +77,13 @@ async function measureScrolling() {

try {
await renderPass(setup, 2);
const tickLatencies = await measureScrollTickLatencies(setup, SCROLL_TICKS);
printLatencyMetrics("scroll_tick", tickLatencies);
const legacyTickLatencies = await measureScrollTickLatencies(setup, LEGACY_SCROLL_TICKS);
printLatencyMetrics("scroll_tick", legacyTickLatencies);
const remainingTickLatencies = await measureScrollTickLatencies(
setup,
SCROLL_OBSERVATIONS - LEGACY_SCROLL_TICKS,
);
printLatencyMetrics("scroll_60_tick", [...legacyTickLatencies, ...remainingTickLatencies]);
} finally {
await destroyRenderer(setup);
}
Expand All @@ -67,7 +92,9 @@ async function measureScrolling() {
await measureNavigation();
await measureScrolling();

console.log(`METRIC navigation_presses=${NAVIGATION_PRESSES}`);
console.log(`METRIC scroll_ticks=${SCROLL_TICKS}`);
console.log(`METRIC navigation_presses=${LEGACY_NAVIGATION_PRESSES}`);
console.log(`METRIC scroll_ticks=${LEGACY_SCROLL_TICKS}`);
console.log(`METRIC navigation_observations=${NAVIGATION_OBSERVATIONS}`);
console.log(`METRIC scroll_observations=${SCROLL_OBSERVATIONS}`);
console.log(`METRIC files=${DEFAULT_FILE_COUNT}`);
console.log(`METRIC lines_per_file=${DEFAULT_LINES_PER_FILE}`);
1 change: 1 addition & 0 deletions benchmarks/large-stream-profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ const reviewPlanMs = measureMs(() => {
const rows = buildSplitRows(file, null, theme);
plannedRows += buildReviewRenderPlan({
fileId: file.id,
hunks: file.metadata.hunks,
rows,
showHunkHeaders: true,
visibleAgentNotes: [],
Expand Down
1 change: 1 addition & 0 deletions benchmarks/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ for (const file of bootstrap.changeset.files) {
const rows = buildSplitRows(file, null, theme);
plannedRows += buildReviewRenderPlan({
fileId: file.id,
hunks: file.metadata.hunks,
rows,
showHunkHeaders: true,
visibleAgentNotes: [],
Expand Down
1 change: 1 addition & 0 deletions benchmarks/render-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function measureScenario(name: string, files: ReturnType<typeof createLargeSplit
const rows = buildSplitRows(file, null, theme);
plannedRows += buildReviewRenderPlan({
fileId: file.id,
hunks: file.metadata.hunks,
rows,
showHunkHeaders: true,
visibleAgentNotes: [],
Expand Down
Loading
Loading