Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ All notable user-visible changes to Hunk are documented in this file.
### Added

- Added mouse-drag text selection in diff views that copies selected rows to the system clipboard via OSC 52. A `View > Copy decorations` toggle (or `copy_decorations` config) controls whether the clipboard includes diff rails, gutters, and file headers or only the changed code.
- Added `hunk diff --kitty-follow` plus bundled Kitty watcher support so marked live Hunk sessions can follow the active Kitty pane's repository.

### Changed

Expand Down
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,29 @@ Load the Hunk skill and use it for this review.

For the full live-session and `--agent-context` workflow guide, see [docs/agent-workflows.md](docs/agent-workflows.md).

### Following Kitty focus

Kitty users can mark one live working-tree review as a follow target:

```bash
hunk diff --kitty-follow
```

Then add the bundled watcher path to `kitty.conf`:

```conf
# Replace this with the output of `hunk kitty watcher-path`.
watcher /absolute/path/to/hunk-follow.py
```

Get the exact path with:

```bash
hunk kitty watcher-path
```

The watcher uses Kitty remote control to reload the marked Hunk session from the active Kitty pane's repository. For focus-change following from the watcher, configure Kitty remote control with a listen socket; see Kitty's [remote control docs](https://sw.kovidgoyal.net/kitty/remote-control/).

## Feature comparison

| Capability | [hunk](https://github.com/modem-dev/hunk) | [lumen](https://github.com/jnsahaj/lumen) | [difftastic](https://github.com/Wilfred/difftastic) | [delta](https://github.com/dandavison/delta) | [diff-so-fancy](https://github.com/so-fancy/diff-so-fancy) | [diff](https://www.gnu.org/software/diffutils/) |
Expand Down
19 changes: 19 additions & 0 deletions bin/hunk.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ function bundledSkillPath() {
return path.join(__dirname, "..", "skills", "hunk-review", "SKILL.md");
}

function bundledKittyWatcherPath() {
return path.join(__dirname, "..", "kitty", "hunk-follow.py");
}

function ensureExecutable(target) {
if (process.platform === "win32") {
return;
Expand Down Expand Up @@ -114,6 +118,21 @@ if (forwardedArgs.length === 2 && forwardedArgs[0] === "skill" && forwardedArgs[
process.exit(0);
}

if (
forwardedArgs.length === 2 &&
forwardedArgs[0] === "kitty" &&
forwardedArgs[1] === "watcher-path"
) {
const watcherPath = bundledKittyWatcherPath();
if (!fs.existsSync(watcherPath)) {
console.error(`hunk: could not locate the bundled Kitty watcher at ${watcherPath}`);
process.exit(1);
}

process.stdout.write(`${watcherPath}\n`);
process.exit(0);
}

const overrideBinary = process.env.HUNK_BIN_PATH;
if (overrideBinary) {
run(overrideBinary, forwardedArgs);
Expand Down
57 changes: 57 additions & 0 deletions kitty/hunk-follow.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Kitty watcher that asks marked Hunk sessions to follow the active pane."""

from __future__ import annotations

import os
import shutil
import subprocess
import time
from typing import Any


_last_sync_at_by_window: dict[int, float] = {}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unbounded growth of _last_sync_at_by_window

_last_sync_at_by_window is a module-level dict that accumulates one entry per unique window.id but never evicts stale entries. Kitty recycles integer window IDs within a session, so the actual risk of unbounded growth is low — but there is no on_close hook to clean up closed windows. Over a long Kitty session with many windows, this will hold a non-trivial number of stale float entries. Registering an on_close callback (def on_close(boss, window): _last_sync_at_by_window.pop(int(window.id), None)) would keep the dict bounded.

Prompt To Fix With AI
This is a comment left during a code review.
Path: kitty/hunk-follow.py
Line: 12

Comment:
**Unbounded growth of `_last_sync_at_by_window`**

`_last_sync_at_by_window` is a module-level dict that accumulates one entry per unique `window.id` but never evicts stale entries. Kitty recycles integer window IDs within a session, so the actual risk of unbounded growth is low — but there is no `on_close` hook to clean up closed windows. Over a long Kitty session with many windows, this will hold a non-trivial number of stale float entries. Registering an `on_close` callback (`def on_close(boss, window): _last_sync_at_by_window.pop(int(window.id), None)`) would keep the dict bounded.

How can I resolve this? If you propose a fix, please make it concise.



def _debounce_seconds() -> float:
raw_value = os.environ.get("HUNK_KITTY_FOLLOW_DEBOUNCE_MS", "250")
try:
return max(0, int(raw_value)) / 1000
except ValueError:
return 0.25


def _hunk_binary() -> str | None:
return os.environ.get("HUNK_BIN") or shutil.which("hunk")


def _sync(window_id: int) -> None:
hunk = _hunk_binary()
if not hunk:
return

args = [hunk, "kitty", "sync", "--window-id", str(window_id)]
listen_on = os.environ.get("KITTY_LISTEN_ON")
if listen_on:
args.extend(["--to", listen_on])

subprocess.Popen(
args,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
close_fds=True,
)


def on_focus_change(boss: Any, window: Any, data: dict[str, Any]) -> None:
if not data.get("focused"):
return

window_id = int(window.id)
now = time.monotonic()
last_sync_at = _last_sync_at_by_window.get(window_id, 0)
if now - last_sync_at < _debounce_seconds():
return

_last_sync_at_by_window[window_id] = now
_sync(window_id)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"files": [
"bin",
"dist/npm",
"kitty",
"skills",
"README.md",
"LICENSE"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ describe("session terminal metadata", () => {
TERM_PROGRAM: "tmux",
LC_TERMINAL: "iTerm2",
ITERM_SESSION_ID: "w1t2p3:ABCDEF",
KITTY_WINDOW_ID: "42",
WINDOWID: "9001",
TMUX_PANE: "%7",
},
tty: "/dev/ttys003",
Expand All @@ -18,6 +20,7 @@ describe("session terminal metadata", () => {
locations: [
{ source: "tty", tty: "/dev/ttys003" },
{ source: "tmux", paneId: "%7" },
{ source: "kitty", windowId: "42", terminalId: "9001" },
{
source: "iterm2",
windowId: "1",
Expand Down
9 changes: 9 additions & 0 deletions packages/session-broker-core/src/sessionTerminalMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,15 @@ export function resolveSessionTerminalMetadata({
pushLocation(locations, { source: "tmux", paneId: tmuxPane });
}

const kittyWindowId = trimmed(env.KITTY_WINDOW_ID);
if (kittyWindowId) {
pushLocation(locations, {
source: "kitty",
windowId: kittyWindowId,
terminalId: trimmed(env.WINDOWID),
});
}

const iTermSessionId = trimmed(env.ITERM_SESSION_ID);
if (iTermSessionId) {
pushLocation(locations, {
Expand Down
12 changes: 11 additions & 1 deletion scripts/build-prebuilt-artifact.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ function createTestRepo() {
const binaryName = binaryFilenameForSpec(spec);

mkdirSync(path.join(repoRoot, "dist"), { recursive: true });
mkdirSync(path.join(repoRoot, "kitty"), { recursive: true });
mkdirSync(path.join(repoRoot, "skills", "hunk-review"), { recursive: true });
writeFileSync(path.join(repoRoot, "dist", binaryName), "#!/bin/sh\necho hunk\n", {
mode: 0o600,
});
writeFileSync(path.join(repoRoot, "kitty", "hunk-follow.py"), "# watcher\n");
writeFileSync(path.join(repoRoot, "skills", "hunk-review", "SKILL.md"), "# Hunk review\n");

return { repoRoot, spec, binaryName };
Expand Down Expand Up @@ -46,7 +48,14 @@ describe("stagePrebuiltArtifact", () => {
expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow("Missing bundled Hunk review skill");
});

test("includes the bundled skill next to standalone release binaries", () => {
test("rejects missing bundled Kitty watcher with an actionable error", () => {
const { repoRoot } = createTestRepo();
rmSync(path.join(repoRoot, "kitty", "hunk-follow.py"), { force: true });

expect(() => stagePrebuiltArtifact({ repoRoot })).toThrow("Missing bundled Kitty watcher");
});

test("includes bundled support files next to standalone release binaries", () => {
const { repoRoot, spec, binaryName } = createTestRepo();
const outputRoot = path.join(tempRoot!, "artifacts");

Expand All @@ -55,6 +64,7 @@ describe("stagePrebuiltArtifact", () => {
expect(outputDir).toBe(path.join(outputRoot, spec.packageName));
expect(existsSync(path.join(outputDir, binaryName))).toBe(true);
expect(existsSync(path.join(outputDir, "metadata.json"))).toBe(true);
expect(existsSync(path.join(outputDir, "kitty", "hunk-follow.py"))).toBe(true);
expect(existsSync(path.join(outputDir, "skills", "hunk-review", "SKILL.md"))).toBe(true);

if (process.platform !== "win32") {
Expand Down
12 changes: 12 additions & 0 deletions scripts/build-prebuilt-artifact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ export function stagePrebuiltArtifact(options: StagePrebuiltArtifactOptions = {}
}

cpSync(skillsSource, path.join(outputDir, "skills"), { recursive: true });

const kittySource = path.join(repoRoot, "kitty");
if (!existsSync(kittySource)) {
throw new Error(`Missing Kitty integration directory at ${kittySource}.`);
}

const kittyWatcher = path.join(kittySource, "hunk-follow.py");
if (!existsSync(kittyWatcher)) {
throw new Error(`Missing bundled Kitty watcher at ${kittyWatcher}.`);
}

cpSync(kittySource, path.join(outputDir, "kitty"), { recursive: true });
writeFileSync(
path.join(outputDir, "metadata.json"),
`${JSON.stringify(
Expand Down
1 change: 1 addition & 0 deletions scripts/check-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const requiredPaths = [
"dist/npm/main.js",
"dist/npm/opentui/index.d.ts",
"dist/npm/opentui/index.js",
"kitty/hunk-follow.py",
"README.md",
"LICENSE",
"package.json",
Expand Down
1 change: 1 addition & 0 deletions scripts/check-prebuilt-pack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ assertPaths(metaPack, [
"dist/npm/main.js",
"dist/npm/opentui/index.d.ts",
"dist/npm/opentui/index.js",
"kitty/hunk-follow.py",
"skills/hunk-review/SKILL.md",
"README.md",
"LICENSE",
Expand Down
3 changes: 2 additions & 1 deletion scripts/stage-prebuilt-npm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ function stageMetaPackage(
cpSync(path.join(repoRoot, "dist", "npm"), path.join(metaDir, "dist", "npm"), {
recursive: true,
});
cpSync(path.join(repoRoot, "kitty"), path.join(metaDir, "kitty"), { recursive: true });
cpSync(path.join(repoRoot, "skills"), path.join(metaDir, "skills"), { recursive: true });
cpSync(path.join(repoRoot, "README.md"), path.join(metaDir, "README.md"));
cpSync(path.join(repoRoot, "LICENSE"), path.join(metaDir, "LICENSE"));
Expand All @@ -97,7 +98,7 @@ function stageMetaPackage(
hunk: "./bin/hunk.cjs",
hunkdiff: "./bin/hunk.cjs",
},
files: ["bin", "dist/npm", "skills", "README.md", "LICENSE"],
files: ["bin", "dist/npm", "kitty", "skills", "README.md", "LICENSE"],
type: rootPackage.type,
exports: rootPackage.exports,
keywords: rootPackage.keywords,
Expand Down
1 change: 1 addition & 0 deletions scripts/update-homebrew-formula.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ describe("update-homebrew-formula", () => {
expect(formula).toContain("hunkdiff-linux-x64.tar.gz");
expect(formula).toContain('chmod 0755, "hunk"');
expect(formula).toContain('libexec.install "hunk"');
expect(formula).toContain('libexec.install "kitty"');
expect(formula).toContain('libexec.install "skills"');
expect(formula).toContain(
'(bin/"hunk").write_env_script libexec/"hunk", HUNK_INSTALL_SOURCE: "homebrew"',
Expand Down
1 change: 1 addition & 0 deletions scripts/update-homebrew-formula.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ function formulaContent(options: Options) {
def install
chmod 0755, "hunk"
libexec.install "hunk"
libexec.install "kitty"
libexec.install "skills"
(bin/"hunk").write_env_script libexec/"hunk", HUNK_INSTALL_SOURCE: "homebrew"
end
Expand Down
49 changes: 49 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ describe("parseCli", () => {
expect(parsed.text).toContain("hunk diff");
expect(parsed.text).toContain("hunk show");
expect(parsed.text).toContain("hunk skill path");
expect(parsed.text).toContain("hunk kitty <subcommand>");
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");
Expand Down Expand Up @@ -103,6 +104,27 @@ describe("parseCli", () => {
});
});

test("parses Kitty follow opt-in for working-tree diffs", async () => {
const parsed = await parseCli(["bun", "hunk", "diff", "--kitty-follow"]);

expect(parsed).toMatchObject({
kind: "vcs",
staged: false,
options: {
kittyFollow: true,
},
});
});

test("rejects Kitty follow for non-working-tree diff forms", async () => {
await expect(parseCli(["bun", "hunk", "diff", "--kitty-follow", "--staged"])).rejects.toThrow(
"`--kitty-follow` only supports working-tree `hunk diff` sessions.",
);
await expect(parseCli(["bun", "hunk", "diff", "--kitty-follow", "main"])).rejects.toThrow(
"`--kitty-follow` only supports working-tree `hunk diff` sessions.",
);
});

test("parses staged git-style diff aliases", async () => {
const staged = await parseCli(["bun", "hunk", "diff", "--staged"]);
const cached = await parseCli(["bun", "hunk", "diff", "--cached"]);
Expand Down Expand Up @@ -225,6 +247,33 @@ describe("parseCli", () => {
});
});

test("parses Kitty integration commands", async () => {
expect(await parseCli(["bun", "hunk", "kitty", "watcher-path"])).toEqual({
kind: "kitty",
action: "watcher-path",
});

expect(
await parseCli([
"bun",
"hunk",
"kitty",
"sync",
"--window-id",
"42",
"--to",
"unix:/tmp/kitty",
"--json",
]),
).toEqual({
kind: "kitty",
action: "sync",
output: "json",
windowId: "42",
to: "unix:/tmp/kitty",
});
});

test("parses the daemon serve command", async () => {
const parsed = await parseCli(["bun", "hunk", "daemon", "serve"]);

Expand Down
Loading