Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
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/idle-watchers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Pause watch refresh polling after an opt-in idle timeout in seconds and refresh once when activity resumes.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI i
```bash
hunk diff # review current repo changes, including untracked files
hunk diff --watch # auto-reload as the working tree changes
hunk diff --watch --idle-after 120 # pause watch refreshes after 2 minutes idle
hunk show # review the latest commit
hunk show HEAD~1 # review an earlier commit
```
Expand Down Expand Up @@ -126,6 +127,7 @@ theme = "github-dark-default" # any built-in theme id, auto, or custom
mode = "auto" # auto, split, stack
vcs = "git" # git, jj, sl
watch = false
# watch_idle_after_seconds = 120
exclude_untracked = false
line_numbers = true
wrap_lines = false
Expand All @@ -137,6 +139,7 @@ transparent_background = false
`theme = "auto"` and `--theme auto` query the terminal background at startup, choose `github-light-default` for light backgrounds and `github-dark-default` for dark backgrounds, and fall back to `github-dark-default` if the terminal does not answer.
Older theme ids such as `graphite` and `paper` remain accepted as compatibility aliases.
`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only.
`watch_idle_after_seconds` pauses `--watch` refresh polling after no keyboard or mouse activity; it has no default, so watch mode keeps its current behavior unless you set this value. Set `HUNK_WATCH_IDLE_AFTER_SECONDS` or pass `--idle-after` for a one-off override.
`transparent_background` can also be written as `transparentBackground`.

Custom themes can inherit from any built-in theme and override only the colors you care about:
Expand Down
4 changes: 4 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe("parseCli", () => {
expect(parsed.text).toContain("Global options:");
expect(parsed.text).toContain("Common review options:");
expect(parsed.text).toContain("auto-reload when the current diff input changes");
expect(parsed.text).toContain("pause --watch refreshes after no activity");
expect(parsed.text).toContain("Git diff options:");
expect(parsed.text).toContain("Notes:");
expect(parsed.text).toContain(
Expand Down Expand Up @@ -103,6 +104,8 @@ describe("parseCli", () => {
"--agent-notes",
"--transparent-bg",
"--watch",
"--idle-after",
"120",
]);

expect(parsed).toMatchObject({
Expand All @@ -119,6 +122,7 @@ describe("parseCli", () => {
hunkHeaders: false,
agentNotes: true,
transparentBackground: true,
watchIdleAfterMs: 120_000,
},
});
});
Expand Down
12 changes: 11 additions & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import type {
SessionCommentApplyItemInput,
} from "./types";
import { resolveBundledHunkReviewSkillPath } from "./paths";
import { parseWatchIdleAfterSeconds } from "./watch";
import { detectVcs } from "./vcs";
import { resolveCliVersion } from "./version";

Expand Down Expand Up @@ -60,6 +61,7 @@ function buildCommonOptions(
agentContext?: string;
pager?: boolean;
watch?: boolean;
idleAfter?: string;
transparentBackground?: boolean;
},
argv: string[],
Expand All @@ -70,6 +72,8 @@ function buildCommonOptions(
agentContext: options.agentContext,
pager: options.pager ? true : undefined,
watch: options.watch ? true : undefined,
watchIdleAfterMs:
options.idleAfter !== undefined ? parseWatchIdleAfterSeconds(options.idleAfter) : undefined,
excludeUntracked: resolveBooleanFlag(argv, "--exclude-untracked", "--no-exclude-untracked"),
lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"),
wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"),
Expand Down Expand Up @@ -100,7 +104,12 @@ function applyCommonOptions(command: Command) {

/** 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");
return command
.option("--watch", "auto-reload when the current diff input changes")
.option(
"--idle-after <seconds>",
"pause --watch refreshes after this many seconds without keyboard or mouse activity",
);
}

/** Render plain-text version output for `hunk --version`. */
Comment thread
tridha643 marked this conversation as resolved.
Expand Down Expand Up @@ -151,6 +160,7 @@ function renderCliHelp() {
"Common review options:",
" --mode <mode> layout mode: auto, split, stack",
" --watch auto-reload when the current diff input changes",
" --idle-after <seconds> pause --watch refreshes after no activity",
" --agent-context <path> JSON sidecar with agent rationale",
" --pager use pager-style chrome and controls",
" --line-numbers / --no-line-numbers show or hide line numbers",
Expand Down
44 changes: 44 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,50 @@ describe("config resolution", () => {
expect(resolved.input.options.watch).toBe(expected);
});

test("resolves watch idle timeout seconds from env, config, and CLI", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), "watch_idle_after_seconds = 120\n");

const cwd = createTempDir("hunk-config-cwd-");
const cliResolved = resolveConfiguredCliInput(
{
kind: "vcs",
staged: false,
options: {
watchIdleAfterMs: 5_000,
},
},
{ cwd, env: { HOME: home, HUNK_WATCH_IDLE_AFTER_SECONDS: "300" } },
);
const configResolved = resolveConfiguredCliInput(
{
kind: "vcs",
staged: false,
options: {},
},
{ cwd, env: { HOME: home, HUNK_WATCH_IDLE_AFTER_SECONDS: "300" } },
);
const envResolved = resolveConfiguredCliInput(
{
kind: "vcs",
staged: false,
options: {},
},
{
cwd,
env: {
HOME: createTempDir("hunk-config-empty-home-"),
HUNK_WATCH_IDLE_AFTER_SECONDS: "300",
},
},
);

expect(cliResolved.input.options.watchIdleAfterMs).toBe(5_000);
expect(configResolved.input.options.watchIdleAfterMs).toBe(120_000);
expect(envResolved.input.options.watchIdleAfterMs).toBe(300_000);
});

test("defaults to git VCS mode and accepts registered VCS modes from config", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
Expand Down
24 changes: 24 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
PersistedViewPreferences,
VcsMode,
} from "./types";
import { parseWatchIdleAfterSeconds } from "./watch";

const BUILT_IN_THEME_IDS = BUNDLED_SHIKI_THEME_IDS;
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
Expand Down Expand Up @@ -111,6 +112,19 @@ function normalizeString(value: unknown) {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

/** Accept one watch idle timeout in seconds from config or environment. */
function normalizeWatchIdleAfterSeconds(value: unknown, keyPath: string) {
if (value === undefined) {
return undefined;
}

if (typeof value !== "number" && typeof value !== "string") {
throw new Error(`Expected ${keyPath} to be a whole number of seconds like 120.`);
}

return parseWatchIdleAfterSeconds(String(value));
}

/** Accept only #rrggbb theme colors and report the failing TOML key path. */
function normalizeThemeColor(value: unknown, keyPath: string) {
if (value === undefined) {
Expand Down Expand Up @@ -233,6 +247,10 @@ function readConfigPreferences(source: Record<string, unknown>): CommonOptions {
vcs: normalizeVcsMode(source.vcs),
theme: normalizeString(source.theme),
watch: normalizeBoolean(source.watch),
watchIdleAfterMs: normalizeWatchIdleAfterSeconds(
source.watch_idle_after_seconds,
"watch_idle_after_seconds",
),
excludeUntracked: normalizeBoolean(source.exclude_untracked),
lineNumbers: normalizeBoolean(source.line_numbers),
wrapLines: normalizeBoolean(source.wrap_lines),
Expand All @@ -257,6 +275,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti
agentContext: overrides.agentContext ?? base.agentContext,
pager: overrides.pager ?? base.pager,
watch: overrides.watch ?? base.watch,
watchIdleAfterMs: overrides.watchIdleAfterMs ?? base.watchIdleAfterMs,
excludeUntracked: overrides.excludeUntracked ?? base.excludeUntracked,
lineNumbers: overrides.lineNumbers ?? base.lineNumbers,
wrapLines: overrides.wrapLines ?? base.wrapLines,
Expand Down Expand Up @@ -324,6 +343,10 @@ export function resolveConfiguredCliInput(
agentContext: input.options.agentContext,
pager: input.options.pager ?? false,
watch: input.options.watch ?? false,
watchIdleAfterMs: normalizeWatchIdleAfterSeconds(
env.HUNK_WATCH_IDLE_AFTER_SECONDS,
"HUNK_WATCH_IDLE_AFTER_SECONDS",
),
excludeUntracked: false,
lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers,
wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines,
Expand Down Expand Up @@ -352,6 +375,7 @@ export function resolveConfiguredCliInput(
agentContext: input.options.agentContext,
pager: input.options.pager ?? false,
watch: input.options.watch ?? resolvedOptions.watch ?? false,
watchIdleAfterMs: input.options.watchIdleAfterMs ?? resolvedOptions.watchIdleAfterMs,
excludeUntracked: resolvedOptions.excludeUntracked ?? false,
theme: resolvedOptions.theme,
vcs: resolvedOptions.vcs ?? getDefaultVcsAdapter().id,
Expand Down
1 change: 1 addition & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export interface CommonOptions {
agentContext?: string;
pager?: boolean;
watch?: boolean;
watchIdleAfterMs?: number;
excludeUntracked?: boolean;
lineNumbers?: boolean;
wrapLines?: boolean;
Expand Down
16 changes: 15 additions & 1 deletion src/core/watch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { computeWatchSignature } from "./watch";
import { computeWatchSignature, parseWatchIdleAfterSeconds } from "./watch";
import type { CliInput } from "./types";

const tempDirs: string[] = [];
Expand Down Expand Up @@ -76,6 +76,20 @@ afterEach(() => {
cleanupTempDirs();
});

describe("parseWatchIdleAfterSeconds", () => {
test("parses watch idle seconds into milliseconds", () => {
expect(parseWatchIdleAfterSeconds("30")).toBe(30_000);
expect(parseWatchIdleAfterSeconds("120")).toBe(120_000);
expect(parseWatchIdleAfterSeconds("0")).toBe(0);
});

test("rejects invalid watch idle seconds", () => {
expect(() => parseWatchIdleAfterSeconds("soon")).toThrow("Invalid watch idle timeout");
expect(() => parseWatchIdleAfterSeconds("-1")).toThrow("Invalid watch idle timeout");
expect(() => parseWatchIdleAfterSeconds("30s")).toThrow("Invalid watch idle timeout");
});
});

describe("computeWatchSignature", () => {
test("does not embed full untracked file contents in git watch signatures", () => {
const dir = createTempRepo("hunk-watch-untracked-");
Expand Down
22 changes: 22 additions & 0 deletions src/core/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@ import fs from "node:fs";
import { createVcsWatchSignature, getConfiguredVcsAdapter, operationFromInput } from "./vcs";
import type { CliInput } from "./types";

export type WatchSessionActivity = "active" | "idle";

const WATCH_IDLE_SECONDS_PATTERN = /^\d+$/;

/** Parse a watch idle timeout in whole seconds into milliseconds for timer use. */
export function parseWatchIdleAfterSeconds(value: string) {
const trimmed = value.trim();
if (!WATCH_IDLE_SECONDS_PATTERN.test(trimmed)) {
throw new Error(
`Invalid watch idle timeout: ${value}. Use a whole number of seconds like 120.`,
);
}

const seconds = Number(trimmed);
const milliseconds = seconds * 1000;
if (!Number.isSafeInteger(milliseconds)) {
throw new Error(`Invalid watch idle timeout: ${value}.`);
}
Comment thread
tridha643 marked this conversation as resolved.
Outdated

return milliseconds;
}

/** Return whether the current input can be rebuilt from files or VCS state without rereading stdin. */
export function canReloadInput(input: CliInput) {
if (input.options.agentContext === "-") {
Expand Down
Loading