diff --git a/README.md b/README.md
index fd5734b5..e7147795 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ Hunk is a review-first terminal diff viewer for agent-authored changesets, built
- multi-file review stream with sidebar navigation
- inline AI and agent annotations beside the code
- split, stack, and responsive auto layouts
+- watch mode for auto-reloading file and Git-backed reviews
- keyboard, mouse, pager, and Git difftool support
@@ -52,6 +53,7 @@ Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI i
```bash
hunk diff # review current repo changes
hunk diff --staged
+hunk diff --watch # auto-reload as the working tree changes
hunk show # review the latest commit
hunk show HEAD~1 # review an earlier commit
```
@@ -59,8 +61,9 @@ hunk show HEAD~1 # review an earlier commit
### Working with raw files and patches
```bash
-hunk diff before.ts after.ts # compare two files directly
-git diff --no-color | hunk patch - # review a patch from stdin
+hunk diff before.ts after.ts # compare two files directly
+hunk diff before.ts after.ts --watch # auto-reload when either file changes
+git diff --no-color | hunk patch - # review a patch from stdin
```
### Working with agents
diff --git a/src/core/cli.ts b/src/core/cli.ts
index c8a29c23..26d80952 100644
--- a/src/core/cli.ts
+++ b/src/core/cli.ts
@@ -54,6 +54,7 @@ function buildCommonOptions(
theme?: string;
agentContext?: string;
pager?: boolean;
+ watch?: boolean;
},
argv: string[],
): CommonOptions {
@@ -62,6 +63,7 @@ function buildCommonOptions(
theme: options.theme,
agentContext: options.agentContext,
pager: options.pager ? true : undefined,
+ watch: options.watch ? true : undefined,
lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"),
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
hunkHeaders: resolveBooleanFlag(argv, "--hunk-headers", "--no-hunk-headers"),
@@ -69,7 +71,7 @@ function buildCommonOptions(
};
}
-/** Attach the shared mode/theme/agent-context flags to a subcommand parser. */
+/** Attach the shared view flags to a subcommand parser. */
function applyCommonOptions(command: Command) {
return command
.option("--mode ", "layout mode: auto, split, stack", parseLayoutMode)
@@ -86,6 +88,11 @@ function applyCommonOptions(command: Command) {
.option("--no-agent-notes", "hide agent notes by default");
}
+/** Attach auto-refresh support to review commands that can reopen their source input. */
+function applyWatchOption(command: Command) {
+ return command.option("--watch", "auto-reload when the current diff input changes");
+}
+
/** Resolve the CLI version from the nearest shipped package manifest. */
function resolveCliVersion() {
const candidatePaths = [
@@ -223,7 +230,9 @@ function resolveExplicitSessionSelector(
/** Parse the overloaded `hunk diff` command. */
async function parseDiffCommand(tokens: string[], argv: string[]): Promise {
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
- const command = createCommand("diff", "review Git diffs or compare two concrete files")
+ const command = applyWatchOption(
+ createCommand("diff", "review Git diffs or compare two concrete files"),
+ )
.option("--staged", "show staged changes instead of the working tree")
.option("--cached", "alias for --staged")
.argument("[targets...]");
@@ -287,7 +296,9 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise {
const { commandTokens, pathspecs } = splitPathspecArgs(tokens);
- const command = createCommand("show", "review the last commit or a given ref").argument("[ref]");
+ const command = applyWatchOption(
+ createCommand("show", "review the last commit or a given ref"),
+ ).argument("[ref]");
let parsedRef: string | undefined;
let parsedOptions: Record = {};
@@ -313,9 +324,8 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise {
- const command = createCommand(
- "patch",
- "review a patch file, or read a patch from stdin",
+ const command = applyWatchOption(
+ createCommand("patch", "review a patch file, or read a patch from stdin"),
).argument("[file]");
let parsedFile: string | undefined;
@@ -365,7 +375,7 @@ async function parsePagerCommand(
/** Parse Git difftool-style two-file review commands. */
async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise {
- const command = createCommand("difftool", "review Git difftool file pairs")
+ const command = applyWatchOption(createCommand("difftool", "review Git difftool file pairs"))
.argument("")
.argument("")
.argument("[path]");
@@ -912,9 +922,8 @@ async function parseStashCommand(tokens: string[], argv: string[]): Promise) {
+ switch (input.kind) {
+ case "git":
+ return runGitText({ input, args: buildGitDiffArgs(input) });
+ case "show":
+ return runGitText({ input, args: buildGitShowArgs(input) });
+ case "stash-show":
+ return runGitText({ input, args: buildGitStashShowArgs(input) });
+ }
+}
+
+/** Compute a change-detection signature for one watchable input. */
+export function computeWatchSignature(input: CliInput) {
+ const parts: string[] = [input.kind];
+
+ switch (input.kind) {
+ case "git":
+ case "show":
+ case "stash-show":
+ parts.push(gitPatchSignature(input));
+ break;
+ case "diff":
+ case "difftool":
+ parts.push(statSignature(input.left), statSignature(input.right));
+ break;
+ case "patch":
+ if (!input.file || input.file === "-") {
+ throw new Error("Watch mode requires a patch file path instead of stdin.");
+ }
+ parts.push(statSignature(input.file));
+ break;
+ }
+
+ if (input.options.agentContext && input.options.agentContext !== "-") {
+ parts.push(`agent:${statSignature(input.options.agentContext)}`);
+ }
+
+ return parts.join("\n---\n");
+}
diff --git a/src/ui/App.tsx b/src/ui/App.tsx
index f213338d..90bde4d4 100644
--- a/src/ui/App.tsx
+++ b/src/ui/App.tsx
@@ -20,6 +20,7 @@ import { resolveConfiguredCliInput } from "../core/config";
import { loadAppBootstrap } from "../core/loaders";
import { resolveRuntimeCliInput } from "../core/terminal";
import type { AppBootstrap, CliInput, LayoutMode } from "../core/types";
+import { canReloadInput, computeWatchSignature } from "../core/watch";
import { HunkHostClient } from "../mcp/client";
import {
createInitialSessionSnapshot,
@@ -55,11 +56,6 @@ function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
-/** Return whether the current input can be reloaded from disk or Git state. */
-function canRefreshInput(input: CliInput) {
- return input.kind !== "patch" || Boolean(input.file && input.file !== "-");
-}
-
/** Preserve the active shell view settings when rebuilding the current input. */
function withCurrentViewOptions(
input: CliInput,
@@ -96,7 +92,10 @@ function AppShell({
bootstrap: AppBootstrap;
hostClient?: HunkHostClient;
onQuit?: () => void;
- onReloadSession: (nextInput: CliInput) => Promise;
+ onReloadSession: (
+ nextInput: CliInput,
+ options?: { resetShell?: boolean },
+ ) => Promise;
}) {
const FILES_MIN_WIDTH = 22;
const DIFF_MIN_WIDTH = 48;
@@ -352,10 +351,11 @@ function AppShell({
jumpToFile(fileId, hunkIndex);
};
- const canRefreshCurrentInput = canRefreshInput(bootstrap.input);
+ const canRefreshCurrentInput = canReloadInput(bootstrap.input);
+ const watchEnabled = Boolean(bootstrap.input.options.watch && canRefreshCurrentInput);
/** Rebuild the current diff source while preserving the active shell view options. */
- const refreshCurrentInput = useCallback(() => {
+ const refreshCurrentInput = useCallback(async () => {
if (!canRefreshCurrentInput) {
return;
}
@@ -369,9 +369,7 @@ function AppShell({
wrapLines,
});
- void onReloadSession(nextInput).catch((error) => {
- console.error("Failed to reload the current diff.", error);
- });
+ await onReloadSession(nextInput, { resetShell: false });
}, [
bootstrap.input,
canRefreshCurrentInput,
@@ -384,6 +382,63 @@ function AppShell({
wrapLines,
]);
+ const triggerRefreshCurrentInput = useCallback(() => {
+ void refreshCurrentInput().catch((error) => {
+ console.error("Failed to reload the current diff.", error);
+ });
+ }, [refreshCurrentInput]);
+
+ useEffect(() => {
+ if (!watchEnabled) {
+ return;
+ }
+
+ let cancelled = false;
+ let polling = false;
+ let refreshing = false;
+ let lastSignature: string;
+
+ try {
+ lastSignature = computeWatchSignature(bootstrap.input);
+ } catch (error) {
+ console.error("Failed to initialize watch mode.", error);
+ return;
+ }
+
+ const pollForChanges = () => {
+ if (cancelled || polling || refreshing) {
+ return;
+ }
+
+ polling = true;
+
+ try {
+ const nextSignature = computeWatchSignature(bootstrap.input);
+ if (nextSignature !== lastSignature) {
+ lastSignature = nextSignature;
+ refreshing = true;
+ void refreshCurrentInput()
+ .catch((error) => {
+ console.error("Failed to auto-reload the current diff.", error);
+ })
+ .finally(() => {
+ refreshing = false;
+ });
+ }
+ } catch (error) {
+ console.error("Failed to poll watch mode input.", error);
+ } finally {
+ polling = false;
+ }
+ };
+
+ const interval = setInterval(pollForChanges, 250);
+ return () => {
+ cancelled = true;
+ clearInterval(interval);
+ };
+ }, [bootstrap.input, refreshCurrentInput, watchEnabled]);
+
/** Leave the app through the shell-owned shutdown path. */
const requestQuit = useCallback(() => {
onQuit();
@@ -399,7 +454,7 @@ function AppShell({
layoutMode,
moveAnnotatedFile,
moveHunk,
- refreshCurrentInput,
+ refreshCurrentInput: triggerRefreshCurrentInput,
requestQuit,
selectLayoutMode: setLayoutMode,
selectThemeId: setThemeId,
@@ -422,8 +477,8 @@ function AppShell({
layoutMode,
moveAnnotatedFile,
moveHunk,
- refreshCurrentInput,
requestQuit,
+ triggerRefreshCurrentInput,
showAgentNotes,
showHelp,
showHunkHeaders,
@@ -711,7 +766,7 @@ function AppShell({
}
if ((key.name === "r" || key.sequence === "r") && canRefreshCurrentInput) {
- refreshCurrentInput();
+ triggerRefreshCurrentInput();
closeMenu();
return;
}
@@ -913,7 +968,7 @@ export function App({
const [shellVersion, setShellVersion] = useState(0);
const reloadSession = useCallback(
- async (nextInput: CliInput) => {
+ async (nextInput: CliInput, options?: { resetShell?: boolean }) => {
const runtimeInput = resolveRuntimeCliInput(nextInput);
const configuredInput = resolveConfiguredCliInput(runtimeInput).input;
const nextBootstrap = await loadAppBootstrap(configuredInput);
@@ -930,7 +985,9 @@ export function App({
}
setActiveBootstrap(nextBootstrap);
- setShellVersion((current) => current + 1);
+ if (options?.resetShell !== false) {
+ setShellVersion((current) => current + 1);
+ }
return {
sessionId,
diff --git a/src/ui/hooks/useHunkSessionBridge.ts b/src/ui/hooks/useHunkSessionBridge.ts
index be9092a4..6d3d69c9 100644
--- a/src/ui/hooks/useHunkSessionBridge.ts
+++ b/src/ui/hooks/useHunkSessionBridge.ts
@@ -31,7 +31,10 @@ export function useHunkSessionBridge({
hostClient?: HunkHostClient;
jumpToFile: (fileId: string, nextHunkIndex?: number) => void;
openAgentNotes: () => void;
- reloadSession: (nextInput: CliInput) => Promise;
+ reloadSession: (
+ nextInput: CliInput,
+ options?: { resetShell?: boolean },
+ ) => Promise;
selectedFile: DiffFile | undefined;
selectedHunkIndex: number;
showAgentNotes: boolean;
diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx
index 457e5d83..cf85adb0 100644
--- a/test/app-interactions.test.tsx
+++ b/test/app-interactions.test.tsx
@@ -436,6 +436,57 @@ describe("App interactions", () => {
}
});
+ test("watch mode reloads the current file diff from disk", async () => {
+ const dir = mkdtempSync(join(tmpdir(), "hunk-watch-"));
+ const left = join(dir, "before.ts");
+ const right = join(dir, "after.ts");
+
+ writeFileSync(left, "export const answer = 41;\n");
+ writeFileSync(right, "export const answer = 42;\n");
+
+ const bootstrap = await loadAppBootstrap({
+ kind: "diff",
+ left,
+ right,
+ options: {
+ mode: "split",
+ watch: true,
+ },
+ });
+
+ const setup = await testRender(, {
+ width: 220,
+ height: 20,
+ });
+
+ try {
+ await flush(setup);
+
+ let frame = setup.captureCharFrame();
+ expect(frame).not.toContain("export const added = true;");
+
+ writeFileSync(right, "export const answer = 42;\nexport const added = true;\n");
+
+ let refreshed = false;
+ for (let attempt = 0; attempt < 40; attempt += 1) {
+ await flush(setup);
+ frame = setup.captureCharFrame();
+ if (frame.includes("export const added = true;")) {
+ refreshed = true;
+ break;
+ }
+ await Bun.sleep(25);
+ }
+
+ expect(refreshed).toBe(true);
+ } finally {
+ await act(async () => {
+ setup.renderer.destroy();
+ });
+ rmSync(dir, { force: true, recursive: true });
+ }
+ });
+
test("a shows notes that are visible in the current review viewport", async () => {
const bootstrap = createBootstrap();
bootstrap.changeset.files[1]!.agent = {
diff --git a/test/cli.test.ts b/test/cli.test.ts
index c73cde71..e5ec9abf 100644
--- a/test/cli.test.ts
+++ b/test/cli.test.ts
@@ -68,6 +68,7 @@ describe("parseCli", () => {
"--wrap",
"--no-hunk-headers",
"--agent-notes",
+ "--watch",
]);
expect(parsed).toMatchObject({
@@ -78,6 +79,7 @@ describe("parseCli", () => {
mode: "split",
theme: "paper",
agentContext: "notes.json",
+ watch: true,
lineNumbers: false,
wrapLines: true,
hunkHeaders: false,
diff --git a/test/startup.test.ts b/test/startup.test.ts
index 0ab6c9d9..dcea9b78 100644
--- a/test/startup.test.ts
+++ b/test/startup.test.ts
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test";
+import { HunkUserError } from "../src/core/errors";
import { prepareStartupPlan } from "../src/core/startup";
import type { AppBootstrap, CliInput, ParsedCliInput } from "../src/core/types";
@@ -120,6 +121,24 @@ describe("startup planning", () => {
expect(seenInputs).toHaveLength(3);
});
+ test("rejects watch mode for stdin-backed patch inputs", async () => {
+ const cliInput: CliInput = {
+ kind: "patch",
+ file: "-",
+ options: {
+ watch: true,
+ },
+ };
+
+ await expect(
+ prepareStartupPlan(["bun", "hunk", "patch", "-", "--watch"], {
+ parseCliImpl: async () => cliInput as ParsedCliInput,
+ resolveRuntimeCliInputImpl: (input) => input,
+ resolveConfiguredCliInputImpl: (input) => ({ input }) as never,
+ }),
+ ).rejects.toBeInstanceOf(HunkUserError);
+ });
+
test("opens the controlling terminal for piped patch startup", async () => {
const cliInput: CliInput = {
kind: "patch",