From 9c5741b42bb35f10d9b3c3173220742492f1d6d9 Mon Sep 17 00:00:00 2001 From: Tridhatri Vallamkondu Date: Thu, 9 Jul 2026 11:06:11 -0400 Subject: [PATCH 1/4] Add idle timeout for watch refreshes --- .changeset/idle-watchers.md | 5 ++ README.md | 3 + src/core/cli.test.ts | 4 ++ src/core/cli.ts | 12 +++- src/core/config.test.ts | 44 ++++++++++++++ src/core/config.ts | 24 ++++++++ src/core/types.ts | 1 + src/core/watch.test.ts | 16 ++++- src/core/watch.ts | 22 +++++++ src/ui/App.tsx | 77 ++++++++++++++++++++++++- src/ui/AppHost.interactions.test.tsx | 64 ++++++++++++++++++++ src/ui/components/panes/DiffPane.tsx | 5 +- src/ui/hooks/useAppKeyboardShortcuts.ts | 4 ++ 13 files changed, 276 insertions(+), 5 deletions(-) create mode 100644 .changeset/idle-watchers.md diff --git a/.changeset/idle-watchers.md b/.changeset/idle-watchers.md new file mode 100644 index 00000000..510ec87e --- /dev/null +++ b/.changeset/idle-watchers.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Pause watch refresh polling after an opt-in idle timeout in seconds and refresh once when activity resumes. diff --git a/README.md b/README.md index a6189e45..3d76624f 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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 @@ -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: diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 45bdbb41..27804639 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -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( @@ -103,6 +104,8 @@ describe("parseCli", () => { "--agent-notes", "--transparent-bg", "--watch", + "--idle-after", + "120", ]); expect(parsed).toMatchObject({ @@ -119,6 +122,7 @@ describe("parseCli", () => { hunkHeaders: false, agentNotes: true, transparentBackground: true, + watchIdleAfterMs: 120_000, }, }); }); diff --git a/src/core/cli.ts b/src/core/cli.ts index 043ed265..12fffd28 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -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"; @@ -60,6 +61,7 @@ function buildCommonOptions( agentContext?: string; pager?: boolean; watch?: boolean; + idleAfter?: string; transparentBackground?: boolean; }, argv: string[], @@ -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"), @@ -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 ", + "pause --watch refreshes after this many seconds without keyboard or mouse activity", + ); } /** Render plain-text version output for `hunk --version`. */ @@ -151,6 +160,7 @@ function renderCliHelp() { "Common review options:", " --mode layout mode: auto, split, stack", " --watch auto-reload when the current diff input changes", + " --idle-after pause --watch refreshes after no activity", " --agent-context JSON sidecar with agent rationale", " --pager use pager-style chrome and controls", " --line-numbers / --no-line-numbers show or hide line numbers", diff --git a/src/core/config.test.ts b/src/core/config.test.ts index ac3a7f9f..40c50887 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -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 }); diff --git a/src/core/config.ts b/src/core/config.ts index fa261289..f1b09bed 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -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; @@ -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) { @@ -233,6 +247,10 @@ function readConfigPreferences(source: Record): 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), @@ -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, @@ -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, @@ -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, diff --git a/src/core/types.ts b/src/core/types.ts index 4c6c5c95..abd9aa01 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -86,6 +86,7 @@ export interface CommonOptions { agentContext?: string; pager?: boolean; watch?: boolean; + watchIdleAfterMs?: number; excludeUntracked?: boolean; lineNumbers?: boolean; wrapLines?: boolean; diff --git a/src/core/watch.test.ts b/src/core/watch.test.ts index 8f794683..5ab38738 100644 --- a/src/core/watch.test.ts +++ b/src/core/watch.test.ts @@ -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[] = []; @@ -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-"); diff --git a/src/core/watch.ts b/src/core/watch.ts index 04de1dfc..93192315 100644 --- a/src/core/watch.ts +++ b/src/core/watch.ts @@ -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}.`); + } + + 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 === "-") { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 63f8225a..1af0e564 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -6,6 +6,7 @@ import { import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react"; import type { AppBootstrap, CliInput, LayoutMode, UserNoteLineTarget } from "../core/types"; +import type { WatchSessionActivity } from "../core/watch"; import { canReloadInput, computeWatchSignature } from "../core/watch"; import type { HunkSessionBrokerClient, ReloadedSessionResult } from "../hunk-session/types"; import { MenuBar } from "./components/chrome/MenuBar"; @@ -153,6 +154,12 @@ export function App({ const [resizeStartWidth, setResizeStartWidth] = useState(null); const [sessionNoticeText, setSessionNoticeText] = useState(null); const sessionNoticeTimeoutRef = useRef | null>(null); + const watchIdleAfterMs = bootstrap.input.options.watchIdleAfterMs; + const watchUsesIdleLifecycle = typeof watchIdleAfterMs === "number"; + const watchActivityRef = useRef("active"); + const watchLastActivityAtRef = useRef(Date.now()); + const [watchActivity, setWatchActivity] = useState("active"); + const [watchActivityRevision, setWatchActivityRevision] = useState(0); const themeOptions = useMemo( () => availableThemes(bootstrap.customTheme), @@ -514,6 +521,12 @@ export function App({ const canRefreshCurrentInput = canReloadInput(bootstrap.input); const watchEnabled = Boolean(bootstrap.input.options.watch && canRefreshCurrentInput); + const watchShouldPoll = watchEnabled && (!watchUsesIdleLifecycle || watchActivity === "active"); + + const setWatchSessionActivity = useCallback((activity: WatchSessionActivity) => { + watchActivityRef.current = activity; + setWatchActivity(activity); + }, []); /** Rebuild the current diff source while preserving the active app view options. */ const refreshCurrentInput = useCallback(async () => { @@ -560,6 +573,24 @@ export function App({ }); }, [refreshCurrentInput]); + /** Record keyboard or mouse use, and refresh once when an idle watch session wakes up. */ + const markWatchSessionActivity = useCallback(() => { + if (!watchEnabled || !watchUsesIdleLifecycle) { + return; + } + + const wasIdle = watchActivityRef.current === "idle"; + watchLastActivityAtRef.current = Date.now(); + setWatchSessionActivity("active"); + setWatchActivityRevision((current) => current + 1); + + if (wasIdle) { + void refreshCurrentInput().catch((error) => { + console.error("Failed to refresh idle watch session after activity.", error); + }); + } + }, [refreshCurrentInput, setWatchSessionActivity, watchEnabled, watchUsesIdleLifecycle]); + const triggerEditSelectedFile = useCallback(() => { const basePath = bootstrap.input.kind === "vcs" || @@ -594,7 +625,41 @@ export function App({ ]); useEffect(() => { - if (!watchEnabled) { + if (!watchEnabled || !watchUsesIdleLifecycle || watchIdleAfterMs === undefined) { + setWatchSessionActivity("active"); + return; + } + + const elapsedMs = Date.now() - watchLastActivityAtRef.current; + const remainingMs = watchIdleAfterMs - elapsedMs; + if (remainingMs <= 0) { + setWatchSessionActivity("idle"); + return; + } + + const timeout = setTimeout(() => { + setWatchSessionActivity("idle"); + }, remainingMs); + + return () => { + clearTimeout(timeout); + }; + }, [ + setWatchSessionActivity, + watchActivityRevision, + watchEnabled, + watchIdleAfterMs, + watchUsesIdleLifecycle, + ]); + + useEffect(() => { + watchLastActivityAtRef.current = Date.now(); + setWatchSessionActivity("active"); + setWatchActivityRevision((current) => current + 1); + }, [bootstrap.input, setWatchSessionActivity]); + + useEffect(() => { + if (!watchShouldPoll) { return; } @@ -642,7 +707,7 @@ export function App({ cancelled = true; clearInterval(interval); }; - }, [bootstrap.input, refreshCurrentInput, watchEnabled]); + }, [bootstrap.input, refreshCurrentInput, watchShouldPoll]); /** Leave the app through the shared shutdown path. */ const requestQuit = useCallback(() => { @@ -834,6 +899,7 @@ export function App({ moveToHunk: review.moveToHunk, moveMenuItem, moveThemeSelector, + onActivity: markWatchSessionActivity, openMenu, openThemeSelector, pagerMode, @@ -862,6 +928,7 @@ export function App({ /** Start a mouse drag resize for the optional sidebar. */ const beginSidebarResize = (event: TuiMouseEvent) => { + markWatchSessionActivity(); if (event.button !== MouseButton.LEFT) { return; } @@ -875,6 +942,7 @@ export function App({ /** Update the sidebar width while a drag resize is active. */ const updateSidebarResize = (event: TuiMouseEvent) => { + markWatchSessionActivity(); if (!isResizingSidebar || resizeDragOriginX === null || resizeStartWidth === null) { return; } @@ -894,6 +962,7 @@ export function App({ /** End the current sidebar resize interaction. */ const endSidebarResize = (event?: TuiMouseEvent) => { + markWatchSessionActivity(); if (!isResizingSidebar) { return; } @@ -961,10 +1030,12 @@ export function App({ }} onMouseDrag={updateSidebarResize} onMouseDragEnd={(event) => { + markWatchSessionActivity(); endSidebarResize(event); cancelCopySelectionRef.current?.(); }} onMouseUp={(event) => { + markWatchSessionActivity(); endSidebarResize(event); closeMenu(); cancelCopySelectionRef.current?.(); @@ -1042,8 +1113,10 @@ export function App({ onCancelDraftNote={cancelDraftNote} onFocusDraftNote={focusDraftNote} onScrollCodeHorizontally={(delta) => { + markWatchSessionActivity(); scrollCodeHorizontally(delta * FAST_CODE_HORIZONTAL_SCROLL_COLUMNS); }} + onUserActivity={markWatchSessionActivity} onCopyFeedback={showTransientNotice} onSelectFile={jumpToFile} onToggleGap={review.toggleGap} diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 94daad81..601228e0 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -1724,6 +1724,70 @@ describe("App interactions", () => { } }); + test("watch mode pauses polling while idle and refreshes once when activity resumes", async () => { + const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-idle-")); + 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, + watchIdleAfterMs: 40, + }, + }); + + const setup = await testRender(, { + width: 220, + height: 20, + }); + + try { + await flush(setup); + await act(async () => { + await Bun.sleep(120); + await setup.renderOnce(); + }); + + writeFileSync(right, "export const answer = 42;\nexport const idleChange = true;\n"); + + let frame = setup.captureCharFrame(); + expect(frame).not.toContain("idleChange"); + + for (let attempt = 0; attempt < 10; attempt += 1) { + await flush(setup); + frame = setup.captureCharFrame(); + expect(frame).not.toContain("idleChange"); + await act(async () => { + await Bun.sleep(40); + await setup.renderOnce(); + }); + } + + await act(async () => { + await setup.mockInput.pressArrow("right"); + }); + + const refreshedFrame = await waitForFrame( + setup, + (currentFrame) => currentFrame.includes("idleChange"), + 30, + ); + expect(refreshedFrame).toContain("idleChange"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + rmSync(dir, { force: true, recursive: true }); + } + }); + test("watch mode preserves the resolved auto theme after refreshing the file diff", async () => { const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-theme-")); const left = join(dir, "before.ts"); diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index 58226ae7..ae65530a 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -216,6 +216,7 @@ export function DiffPane({ onCopyFeedback, onCopySelectionText, onScrollCodeHorizontally = () => {}, + onUserActivity = () => {}, onSelectFile, onToggleGap = NOOP_TOGGLE_GAP, onViewportCenteredHunkChange, @@ -265,6 +266,7 @@ export function DiffPane({ onCopyFeedback?: (text: string) => void; onCopySelectionText?: (text: string) => void | boolean; onScrollCodeHorizontally?: (delta: number) => void; + onUserActivity?: () => void; onSelectFile: (fileId: string) => void; onToggleGap?: (fileId: string, gapKey: string) => void; onViewportCenteredHunkChange?: (fileId: string, hunkIndex: number) => void; @@ -349,6 +351,7 @@ export function DiffPane({ /** Route shifted wheel input into horizontal code-column scrolling without disturbing vertical review scroll. */ const handleMouseScroll = useCallback( (event: TuiMouseEvent) => { + onUserActivity(); const scrollBox = scrollRef.current; const direction = event.scroll?.direction; if (!direction) { @@ -401,7 +404,7 @@ export function DiffPane({ event.preventDefault(); event.stopPropagation(); }, - [clearAddNoteHoverForScroll, onScrollCodeHorizontally, scrollRef, wrapLines], + [clearAddNoteHoverForScroll, onScrollCodeHorizontally, onUserActivity, scrollRef, wrapLines], ); const allAgentNotesByFile = useMemo(() => { diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index d6f35ded..bcd985ee 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -67,6 +67,7 @@ export interface UseAppKeyboardShortcutsOptions { moveToHunk: (delta: number) => void; moveMenuItem: (delta: number) => void; moveThemeSelector: (delta: number) => void; + onActivity?: () => void; openMenu: (menuId: MenuId) => void; openThemeSelector: () => void; pagerMode: boolean; @@ -111,6 +112,7 @@ export function useAppKeyboardShortcuts({ moveToHunk, moveMenuItem, moveThemeSelector, + onActivity, openMenu, openThemeSelector, pagerMode, @@ -576,6 +578,8 @@ export function useAppKeyboardShortcuts({ }; useKeyboard((key: KeyEvent) => { + onActivity?.(); + if (handleMenuToggleShortcut(key)) { return; } From 6f4a87bd630b94825917faf7f71583fe0e083354 Mon Sep 17 00:00:00 2001 From: Tridhatri Vallamkondu Date: Thu, 9 Jul 2026 11:52:54 -0400 Subject: [PATCH 2/4] Fix watch refresh races and idle timeout validation --- src/core/config.test.ts | 39 +++++++++++++++ src/core/config.ts | 4 ++ src/core/watch.test.ts | 2 +- src/core/watch.ts | 6 +++ src/ui/App.tsx | 46 +++++++++++------ src/ui/AppHost.interactions.test.tsx | 74 ++++++++++++++++++++++++++++ 6 files changed, 154 insertions(+), 17 deletions(-) diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 40c50887..3181a801 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -398,6 +398,7 @@ describe("config resolution", () => { kind: "vcs", staged: false, options: { + watch: true, watchIdleAfterMs: 5_000, }, }, @@ -431,6 +432,44 @@ describe("config resolution", () => { expect(envResolved.input.options.watchIdleAfterMs).toBe(300_000); }); + test("rejects explicit CLI watch idle timeout when resolved watch mode is disabled", () => { + expect(() => + resolveConfiguredCliInput( + { + kind: "vcs", + staged: false, + options: { + watchIdleAfterMs: 5_000, + }, + }, + { + cwd: createTempDir("hunk-config-cwd-"), + env: { HOME: createTempDir("hunk-config-home-") }, + }, + ), + ).toThrow("Use --idle-after with --watch."); + }); + + test("allows explicit CLI watch idle timeout when config enables watch mode", () => { + const home = createTempDir("hunk-config-home-"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync(join(home, ".config", "hunk", "config.toml"), "watch = true\n"); + + const resolved = resolveConfiguredCliInput( + { + kind: "vcs", + staged: false, + options: { + watchIdleAfterMs: 5_000, + }, + }, + { cwd: createTempDir("hunk-config-cwd-"), env: { HOME: home } }, + ); + + expect(resolved.input.options.watch).toBe(true); + expect(resolved.input.options.watchIdleAfterMs).toBe(5_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 }); diff --git a/src/core/config.ts b/src/core/config.ts index f1b09bed..f9193202 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -390,6 +390,10 @@ export function resolveConfiguredCliInput( colorMoved: resolvedOptions.colorMoved, }; + if (input.options.watchIdleAfterMs !== undefined && !resolvedOptions.watch) { + throw new Error("Use --idle-after with --watch."); + } + if (resolvedOptions.theme === "custom" && !resolvedCustomTheme) { throw new Error('Expected a [custom_theme] table when config selects theme = "custom".'); } diff --git a/src/core/watch.test.ts b/src/core/watch.test.ts index 5ab38738..33895047 100644 --- a/src/core/watch.test.ts +++ b/src/core/watch.test.ts @@ -80,12 +80,12 @@ 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("0")).toThrow("Invalid watch idle timeout"); expect(() => parseWatchIdleAfterSeconds("30s")).toThrow("Invalid watch idle timeout"); }); }); diff --git a/src/core/watch.ts b/src/core/watch.ts index 93192315..d8e26a82 100644 --- a/src/core/watch.ts +++ b/src/core/watch.ts @@ -16,6 +16,12 @@ export function parseWatchIdleAfterSeconds(value: string) { } const seconds = Number(trimmed); + if (seconds < 1) { + throw new Error( + `Invalid watch idle timeout: ${value}. Use a whole number of seconds like 120.`, + ); + } + const milliseconds = seconds * 1000; if (!Number.isSafeInteger(milliseconds)) { throw new Error(`Invalid watch idle timeout: ${value}.`); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 1af0e564..cfa88579 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -158,6 +158,7 @@ export function App({ const watchUsesIdleLifecycle = typeof watchIdleAfterMs === "number"; const watchActivityRef = useRef("active"); const watchLastActivityAtRef = useRef(Date.now()); + const watchRefreshInFlightRef = useRef(false); const [watchActivity, setWatchActivity] = useState("active"); const [watchActivityRevision, setWatchActivityRevision] = useState(0); @@ -573,6 +574,27 @@ export function App({ }); }, [refreshCurrentInput]); + /** Refresh a watched input once while sharing one in-flight guard across watch triggers. */ + const refreshWatchedInput = useCallback( + (failureMessage: string) => { + if (watchRefreshInFlightRef.current) { + return false; + } + + watchRefreshInFlightRef.current = true; + void refreshCurrentInput() + .catch((error) => { + console.error(failureMessage, error); + }) + .finally(() => { + watchRefreshInFlightRef.current = false; + }); + + return true; + }, + [refreshCurrentInput], + ); + /** Record keyboard or mouse use, and refresh once when an idle watch session wakes up. */ const markWatchSessionActivity = useCallback(() => { if (!watchEnabled || !watchUsesIdleLifecycle) { @@ -585,11 +607,9 @@ export function App({ setWatchActivityRevision((current) => current + 1); if (wasIdle) { - void refreshCurrentInput().catch((error) => { - console.error("Failed to refresh idle watch session after activity.", error); - }); + refreshWatchedInput("Failed to refresh idle watch session after activity."); } - }, [refreshCurrentInput, setWatchSessionActivity, watchEnabled, watchUsesIdleLifecycle]); + }, [refreshWatchedInput, setWatchSessionActivity, watchEnabled, watchUsesIdleLifecycle]); const triggerEditSelectedFile = useCallback(() => { const basePath = @@ -665,7 +685,6 @@ export function App({ let cancelled = false; let polling = false; - let refreshing = false; let lastSignature: string; try { @@ -676,7 +695,7 @@ export function App({ } const pollForChanges = () => { - if (cancelled || polling || refreshing) { + if (cancelled || polling) { return; } @@ -685,15 +704,10 @@ export function App({ 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; - }); + const refreshStarted = refreshWatchedInput("Failed to auto-reload the current diff."); + if (refreshStarted) { + lastSignature = nextSignature; + } } } catch (error) { console.error("Failed to poll watch mode input.", error); @@ -707,7 +721,7 @@ export function App({ cancelled = true; clearInterval(interval); }; - }, [bootstrap.input, refreshCurrentInput, watchShouldPoll]); + }, [bootstrap.input, refreshWatchedInput, watchShouldPoll]); /** Leave the app through the shared shutdown path. */ const requestQuit = useCallback(() => { diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 601228e0..5430d774 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -19,6 +19,7 @@ import { AGENT_SKILL_COMMAND, AGENT_SKILL_PROMPT } from "./components/chrome/Age import { resolveTheme } from "./themes"; const { loadAppBootstrap } = await import("../core/loaders"); +const { App } = await import("./App"); const { AppHost } = await import("./AppHost"); const TEST_KEY_PAGE_UP = "\x1B[5~"; @@ -1788,6 +1789,79 @@ describe("App interactions", () => { } }); + test("watch mode does not overlap wake-up refreshes with resumed polling", async () => { + const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-idle-race-")); + 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, + watchIdleAfterMs: 40, + }, + }); + + let resolveReload: (() => void) | undefined; + const onReloadSession = mock(async () => { + await new Promise((resolve) => { + resolveReload = resolve; + }); + + return { + sessionId: "local-session", + inputKind: bootstrap.input.kind, + title: bootstrap.changeset.title, + sourceLabel: bootstrap.changeset.sourceLabel, + fileCount: bootstrap.changeset.files.length, + selectedFilePath: bootstrap.changeset.files[0]?.path, + selectedHunkIndex: 0, + }; + }); + + const setup = await testRender( + undefined} />, + { + width: 220, + height: 20, + }, + ); + + try { + await flush(setup); + await act(async () => { + await Bun.sleep(120); + await setup.renderOnce(); + }); + + writeFileSync(right, "export const answer = 42;\nexport const idleChange = true;\n"); + + await act(async () => { + await setup.mockInput.pressArrow("right"); + }); + expect(onReloadSession).toHaveBeenCalledTimes(1); + + await act(async () => { + await Bun.sleep(320); + await setup.renderOnce(); + }); + expect(onReloadSession).toHaveBeenCalledTimes(1); + } finally { + resolveReload?.(); + await act(async () => { + await Bun.sleep(0); + setup.renderer.destroy(); + }); + rmSync(dir, { force: true, recursive: true }); + } + }); + test("watch mode preserves the resolved auto theme after refreshing the file diff", async () => { const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-theme-")); const left = join(dir, "before.ts"); From fcc7cdb33614a760fa1f072b8543e827f17d8e84 Mon Sep 17 00:00:00 2001 From: Tridhatri Vallamkondu Date: Thu, 9 Jul 2026 12:04:28 -0400 Subject: [PATCH 3/4] test: stabilize add-note navigation click coverage --- src/ui/components/ui-components.test.tsx | 29 +++++++++++++++++------- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index b163124b..0241cf95 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -856,6 +856,7 @@ describe("UI components", () => { hunkIndex: number; target?: { side: "old" | "new"; line: number }; }> = []; + let activeAffordance: unknown = null; let navigateToSecondHunk: (() => void) | null = null; function AddNoteNavigationHarness() { @@ -880,6 +881,9 @@ describe("UI components", () => { selectedHunkRevealRequestId: selectedHunk, separatorWidth: 92, width: 100, + onActiveAddNoteAffordanceChange: (affordance) => { + activeAffordance = affordance; + }, onStartUserNoteAtHunk: startUserNote, })} /> @@ -911,16 +915,25 @@ describe("UI components", () => { const addNoteX = affordanceLines[addNoteY]?.indexOf("[+]") ?? -1; expect(addNoteY).toBeGreaterThanOrEqual(0); expect(addNoteX).toBeGreaterThanOrEqual(0); - - await act(async () => { - await setup.mockMouse.moveTo(addNoteX + 1, addNoteY); - await setup.renderOnce(); - }); - await act(async () => { - await setup.mockMouse.click(addNoteX + 1, addNoteY); - await setup.renderOnce(); + expect(activeAffordance).toEqual({ + fileId: "target", + hunkIndex: 1, + target: { side: "new", line: 60 }, }); + for (const x of [addNoteX + 1, addNoteX, addNoteX + 2]) { + if (calls.length > 0) { + break; + } + + await act(async () => { + await setup.mockMouse.moveTo(x, addNoteY); + await setup.mockMouse.click(x, addNoteY); + await setup.renderOnce(); + await Bun.sleep(0); + }); + } + expect(calls).toEqual([ { fileId: "target", hunkIndex: 1, target: { side: "new", line: 60 } }, ]); From 26b53a7fd190cd92a4e51948604cd6aa684d0dfb Mon Sep 17 00:00:00 2001 From: Tridhatri Vallamkondu Date: Thu, 9 Jul 2026 22:31:11 -0400 Subject: [PATCH 4/4] refactor: make watch sleep automatic --- .changeset/idle-watchers.md | 2 +- README.md | 4 +- src/core/cli.test.ts | 4 -- src/core/cli.ts | 12 +--- src/core/config.test.ts | 83 ---------------------------- src/core/config.ts | 28 ---------- src/core/types.ts | 1 - src/core/watch.test.ts | 16 +----- src/core/watch.ts | 28 +--------- src/ui/App.tsx | 40 +++++++------- src/ui/AppHost.interactions.test.tsx | 70 +++++++++++++++++++++-- src/ui/AppHost.tsx | 4 ++ 12 files changed, 96 insertions(+), 196 deletions(-) diff --git a/.changeset/idle-watchers.md b/.changeset/idle-watchers.md index 510ec87e..df7d59ac 100644 --- a/.changeset/idle-watchers.md +++ b/.changeset/idle-watchers.md @@ -2,4 +2,4 @@ "hunkdiff": patch --- -Pause watch refresh polling after an opt-in idle timeout in seconds and refresh once when activity resumes. +Automatically pause watch refresh polling after one minute without user activity and refresh once when activity resumes. diff --git a/README.md b/README.md index 3d76624f..cf1ae064 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,6 @@ 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 ``` @@ -127,7 +126,6 @@ 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 @@ -139,7 +137,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. +Watch mode automatically pauses its refresh polling after one minute without keyboard or mouse activity. The next interaction refreshes the diff once and resumes polling. `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: diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 27804639..45bdbb41 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -56,7 +56,6 @@ 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( @@ -104,8 +103,6 @@ describe("parseCli", () => { "--agent-notes", "--transparent-bg", "--watch", - "--idle-after", - "120", ]); expect(parsed).toMatchObject({ @@ -122,7 +119,6 @@ describe("parseCli", () => { hunkHeaders: false, agentNotes: true, transparentBackground: true, - watchIdleAfterMs: 120_000, }, }); }); diff --git a/src/core/cli.ts b/src/core/cli.ts index 12fffd28..043ed265 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -12,7 +12,6 @@ import type { SessionCommentApplyItemInput, } from "./types"; import { resolveBundledHunkReviewSkillPath } from "./paths"; -import { parseWatchIdleAfterSeconds } from "./watch"; import { detectVcs } from "./vcs"; import { resolveCliVersion } from "./version"; @@ -61,7 +60,6 @@ function buildCommonOptions( agentContext?: string; pager?: boolean; watch?: boolean; - idleAfter?: string; transparentBackground?: boolean; }, argv: string[], @@ -72,8 +70,6 @@ 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"), @@ -104,12 +100,7 @@ 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") - .option( - "--idle-after ", - "pause --watch refreshes after this many seconds without keyboard or mouse activity", - ); + return command.option("--watch", "auto-reload when the current diff input changes"); } /** Render plain-text version output for `hunk --version`. */ @@ -160,7 +151,6 @@ function renderCliHelp() { "Common review options:", " --mode layout mode: auto, split, stack", " --watch auto-reload when the current diff input changes", - " --idle-after pause --watch refreshes after no activity", " --agent-context JSON sidecar with agent rationale", " --pager use pager-style chrome and controls", " --line-numbers / --no-line-numbers show or hide line numbers", diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 3181a801..ac3a7f9f 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -387,89 +387,6 @@ 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: { - watch: true, - 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("rejects explicit CLI watch idle timeout when resolved watch mode is disabled", () => { - expect(() => - resolveConfiguredCliInput( - { - kind: "vcs", - staged: false, - options: { - watchIdleAfterMs: 5_000, - }, - }, - { - cwd: createTempDir("hunk-config-cwd-"), - env: { HOME: createTempDir("hunk-config-home-") }, - }, - ), - ).toThrow("Use --idle-after with --watch."); - }); - - test("allows explicit CLI watch idle timeout when config enables watch mode", () => { - const home = createTempDir("hunk-config-home-"); - mkdirSync(join(home, ".config", "hunk"), { recursive: true }); - writeFileSync(join(home, ".config", "hunk", "config.toml"), "watch = true\n"); - - const resolved = resolveConfiguredCliInput( - { - kind: "vcs", - staged: false, - options: { - watchIdleAfterMs: 5_000, - }, - }, - { cwd: createTempDir("hunk-config-cwd-"), env: { HOME: home } }, - ); - - expect(resolved.input.options.watch).toBe(true); - expect(resolved.input.options.watchIdleAfterMs).toBe(5_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 }); diff --git a/src/core/config.ts b/src/core/config.ts index f9193202..fa261289 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -13,7 +13,6 @@ 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; @@ -112,19 +111,6 @@ 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) { @@ -247,10 +233,6 @@ function readConfigPreferences(source: Record): 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), @@ -275,7 +257,6 @@ 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, @@ -343,10 +324,6 @@ 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, @@ -375,7 +352,6 @@ 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, @@ -390,10 +366,6 @@ export function resolveConfiguredCliInput( colorMoved: resolvedOptions.colorMoved, }; - if (input.options.watchIdleAfterMs !== undefined && !resolvedOptions.watch) { - throw new Error("Use --idle-after with --watch."); - } - if (resolvedOptions.theme === "custom" && !resolvedCustomTheme) { throw new Error('Expected a [custom_theme] table when config selects theme = "custom".'); } diff --git a/src/core/types.ts b/src/core/types.ts index abd9aa01..4c6c5c95 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -86,7 +86,6 @@ export interface CommonOptions { agentContext?: string; pager?: boolean; watch?: boolean; - watchIdleAfterMs?: number; excludeUntracked?: boolean; lineNumbers?: boolean; wrapLines?: boolean; diff --git a/src/core/watch.test.ts b/src/core/watch.test.ts index 33895047..8f794683 100644 --- a/src/core/watch.test.ts +++ b/src/core/watch.test.ts @@ -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, parseWatchIdleAfterSeconds } from "./watch"; +import { computeWatchSignature } from "./watch"; import type { CliInput } from "./types"; const tempDirs: string[] = []; @@ -76,20 +76,6 @@ afterEach(() => { cleanupTempDirs(); }); -describe("parseWatchIdleAfterSeconds", () => { - test("parses watch idle seconds into milliseconds", () => { - expect(parseWatchIdleAfterSeconds("30")).toBe(30_000); - expect(parseWatchIdleAfterSeconds("120")).toBe(120_000); - }); - - test("rejects invalid watch idle seconds", () => { - expect(() => parseWatchIdleAfterSeconds("soon")).toThrow("Invalid watch idle timeout"); - expect(() => parseWatchIdleAfterSeconds("-1")).toThrow("Invalid watch idle timeout"); - expect(() => parseWatchIdleAfterSeconds("0")).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-"); diff --git a/src/core/watch.ts b/src/core/watch.ts index d8e26a82..4a1b8cad 100644 --- a/src/core/watch.ts +++ b/src/core/watch.ts @@ -2,33 +2,11 @@ import fs from "node:fs"; import { createVcsWatchSignature, getConfiguredVcsAdapter, operationFromInput } from "./vcs"; import type { CliInput } from "./types"; +/** Activity state that controls whether a watch session schedules expensive polling. */ 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); - if (seconds < 1) { - throw new Error( - `Invalid watch idle timeout: ${value}. Use a whole number of seconds like 120.`, - ); - } - - const milliseconds = seconds * 1000; - if (!Number.isSafeInteger(milliseconds)) { - throw new Error(`Invalid watch idle timeout: ${value}.`); - } - - return milliseconds; -} +/** Stop watch polling after one minute without keyboard or mouse activity. */ +export const DEFAULT_WATCH_INACTIVITY_SLEEP_MS = 60_000; /** Return whether the current input can be rebuilt from files or VCS state without rereading stdin. */ export function canReloadInput(input: CliInput) { diff --git a/src/ui/App.tsx b/src/ui/App.tsx index cfa88579..f1ede1af 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -7,7 +7,11 @@ import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react"; import type { AppBootstrap, CliInput, LayoutMode, UserNoteLineTarget } from "../core/types"; import type { WatchSessionActivity } from "../core/watch"; -import { canReloadInput, computeWatchSignature } from "../core/watch"; +import { + canReloadInput, + computeWatchSignature, + DEFAULT_WATCH_INACTIVITY_SLEEP_MS, +} from "../core/watch"; import type { HunkSessionBrokerClient, ReloadedSessionResult } from "../hunk-session/types"; import { MenuBar } from "./components/chrome/MenuBar"; import { StatusBar } from "./components/chrome/StatusBar"; @@ -94,6 +98,7 @@ export function App({ noticeText, onQuit = () => process.exit(0), onReloadSession, + watchInactivitySleepMs = DEFAULT_WATCH_INACTIVITY_SLEEP_MS, }: { bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; @@ -103,6 +108,7 @@ export function App({ nextInput: CliInput, options?: { resetApp?: boolean; sourcePath?: string }, ) => Promise; + watchInactivitySleepMs?: number; }) { const SIDEBAR_MIN_WIDTH = 22; const DIFF_MIN_WIDTH = 48; @@ -154,8 +160,6 @@ export function App({ const [resizeStartWidth, setResizeStartWidth] = useState(null); const [sessionNoticeText, setSessionNoticeText] = useState(null); const sessionNoticeTimeoutRef = useRef | null>(null); - const watchIdleAfterMs = bootstrap.input.options.watchIdleAfterMs; - const watchUsesIdleLifecycle = typeof watchIdleAfterMs === "number"; const watchActivityRef = useRef("active"); const watchLastActivityAtRef = useRef(Date.now()); const watchRefreshInFlightRef = useRef(false); @@ -522,7 +526,7 @@ export function App({ const canRefreshCurrentInput = canReloadInput(bootstrap.input); const watchEnabled = Boolean(bootstrap.input.options.watch && canRefreshCurrentInput); - const watchShouldPoll = watchEnabled && (!watchUsesIdleLifecycle || watchActivity === "active"); + const watchShouldPoll = watchEnabled && watchActivity === "active"; const setWatchSessionActivity = useCallback((activity: WatchSessionActivity) => { watchActivityRef.current = activity; @@ -597,7 +601,7 @@ export function App({ /** Record keyboard or mouse use, and refresh once when an idle watch session wakes up. */ const markWatchSessionActivity = useCallback(() => { - if (!watchEnabled || !watchUsesIdleLifecycle) { + if (!watchEnabled) { return; } @@ -609,7 +613,7 @@ export function App({ if (wasIdle) { refreshWatchedInput("Failed to refresh idle watch session after activity."); } - }, [refreshWatchedInput, setWatchSessionActivity, watchEnabled, watchUsesIdleLifecycle]); + }, [refreshWatchedInput, setWatchSessionActivity, watchEnabled]); const triggerEditSelectedFile = useCallback(() => { const basePath = @@ -645,13 +649,19 @@ export function App({ ]); useEffect(() => { - if (!watchEnabled || !watchUsesIdleLifecycle || watchIdleAfterMs === undefined) { + watchLastActivityAtRef.current = Date.now(); + setWatchSessionActivity("active"); + setWatchActivityRevision((current) => current + 1); + }, [setWatchSessionActivity, watchEnabled]); + + useEffect(() => { + if (!watchEnabled) { setWatchSessionActivity("active"); return; } const elapsedMs = Date.now() - watchLastActivityAtRef.current; - const remainingMs = watchIdleAfterMs - elapsedMs; + const remainingMs = watchInactivitySleepMs - elapsedMs; if (remainingMs <= 0) { setWatchSessionActivity("idle"); return; @@ -664,19 +674,7 @@ export function App({ return () => { clearTimeout(timeout); }; - }, [ - setWatchSessionActivity, - watchActivityRevision, - watchEnabled, - watchIdleAfterMs, - watchUsesIdleLifecycle, - ]); - - useEffect(() => { - watchLastActivityAtRef.current = Date.now(); - setWatchSessionActivity("active"); - setWatchActivityRevision((current) => current + 1); - }, [bootstrap.input, setWatchSessionActivity]); + }, [setWatchSessionActivity, watchActivityRevision, watchEnabled, watchInactivitySleepMs]); useEffect(() => { if (!watchShouldPoll) { diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 5430d774..cd7e1cef 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -1740,11 +1740,10 @@ describe("App interactions", () => { options: { mode: "split", watch: true, - watchIdleAfterMs: 40, }, }); - const setup = await testRender(, { + const setup = await testRender(, { width: 220, height: 20, }); @@ -1789,6 +1788,65 @@ describe("App interactions", () => { } }); + test("automatic watch sleep is based on user activity instead of background reloads", async () => { + const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-inactivity-")); + 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, + }); + const mountedAt = Date.now(); + + try { + await flush(setup); + writeFileSync(right, "export const answer = 42;\nexport const activeChange = true;\n"); + + const activeFrame = await waitForFrame( + setup, + (currentFrame) => currentFrame.includes("activeChange"), + 30, + ); + expect(activeFrame).toContain("activeChange"); + + const waitUntilIdleProbeMs = Math.max(0, mountedAt + 700 - Date.now()); + await act(async () => { + await Bun.sleep(waitUntilIdleProbeMs); + await setup.renderOnce(); + }); + + writeFileSync( + right, + "export const answer = 42;\nexport const activeChange = true;\nexport const idleChange = true;\n", + ); + + await act(async () => { + await Bun.sleep(350); + await setup.renderOnce(); + }); + expect(setup.captureCharFrame()).not.toContain("idleChange"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + rmSync(dir, { force: true, recursive: true }); + } + }); + test("watch mode does not overlap wake-up refreshes with resumed polling", async () => { const dir = mkdtempSync(join(process.cwd(), ".hunk-watch-idle-race-")); const left = join(dir, "before.ts"); @@ -1804,7 +1862,6 @@ describe("App interactions", () => { options: { mode: "split", watch: true, - watchIdleAfterMs: 40, }, }); @@ -1826,7 +1883,12 @@ describe("App interactions", () => { }); const setup = await testRender( - undefined} />, + undefined} + watchInactivitySleepMs={40} + />, { width: 220, height: 20, diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index c415a0ba..b144b88c 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -3,6 +3,7 @@ import { resolveConfiguredCliInput } from "../core/config"; import { loadAppBootstrap } from "../core/loaders"; import { resolveRuntimeCliInput } from "../core/terminal"; import type { AppBootstrap, CliInput } from "../core/types"; +import { DEFAULT_WATCH_INACTIVITY_SLEEP_MS } from "../core/watch"; import type { UpdateNotice } from "../core/updateNotice"; import { createInitialSessionSnapshot, @@ -22,11 +23,13 @@ export function AppHost({ hostClient, onQuit = () => process.exit(0), startupNoticeResolver, + watchInactivitySleepMs = DEFAULT_WATCH_INACTIVITY_SLEEP_MS, }: { bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; onQuit?: () => void; startupNoticeResolver?: () => Promise; + watchInactivitySleepMs?: number; }) { const [activeBootstrap, setActiveBootstrap] = useState(bootstrap); const [appVersion, setAppVersion] = useState(0); @@ -98,6 +101,7 @@ export function AppHost({ noticeText={startupNoticeText} onQuit={onQuit} onReloadSession={reloadSession} + watchInactivitySleepMs={watchInactivitySleepMs} /> ); }