diff --git a/CHANGELOG.md b/CHANGELOG.md index 87a3d243..d8261102 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 996f1c82..1b119490 100644 --- a/README.md +++ b/README.md @@ -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/) | diff --git a/bin/hunk.cjs b/bin/hunk.cjs index 19e94359..f1fb5166 100755 --- a/bin/hunk.cjs +++ b/bin/hunk.cjs @@ -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; @@ -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); diff --git a/kitty/hunk-follow.py b/kitty/hunk-follow.py new file mode 100644 index 00000000..0b14fdb8 --- /dev/null +++ b/kitty/hunk-follow.py @@ -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] = {} + + +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) diff --git a/package.json b/package.json index 4c5b60b7..a12070e0 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "files": [ "bin", "dist/npm", + "kitty", "skills", "README.md", "LICENSE" diff --git a/packages/session-broker-core/src/sessionTerminalMetadata.test.ts b/packages/session-broker-core/src/sessionTerminalMetadata.test.ts index 9d75f02b..81e4c2ae 100644 --- a/packages/session-broker-core/src/sessionTerminalMetadata.test.ts +++ b/packages/session-broker-core/src/sessionTerminalMetadata.test.ts @@ -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", @@ -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", diff --git a/packages/session-broker-core/src/sessionTerminalMetadata.ts b/packages/session-broker-core/src/sessionTerminalMetadata.ts index 7b2b5595..0c07855b 100644 --- a/packages/session-broker-core/src/sessionTerminalMetadata.ts +++ b/packages/session-broker-core/src/sessionTerminalMetadata.ts @@ -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, { diff --git a/scripts/build-prebuilt-artifact.test.ts b/scripts/build-prebuilt-artifact.test.ts index 8f71d7dd..8fa7139d 100644 --- a/scripts/build-prebuilt-artifact.test.ts +++ b/scripts/build-prebuilt-artifact.test.ts @@ -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 }; @@ -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"); @@ -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") { diff --git a/scripts/build-prebuilt-artifact.ts b/scripts/build-prebuilt-artifact.ts index 71e53cc5..9e3eae4b 100644 --- a/scripts/build-prebuilt-artifact.ts +++ b/scripts/build-prebuilt-artifact.ts @@ -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( diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index d0baae92..485060a8 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -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", diff --git a/scripts/check-prebuilt-pack.ts b/scripts/check-prebuilt-pack.ts index 808e6c40..9bfffba3 100644 --- a/scripts/check-prebuilt-pack.ts +++ b/scripts/check-prebuilt-pack.ts @@ -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", diff --git a/scripts/stage-prebuilt-npm.ts b/scripts/stage-prebuilt-npm.ts index 06aaa411..f7fa8a7c 100644 --- a/scripts/stage-prebuilt-npm.ts +++ b/scripts/stage-prebuilt-npm.ts @@ -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")); @@ -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, diff --git a/scripts/update-homebrew-formula.test.ts b/scripts/update-homebrew-formula.test.ts index acdb2def..30fbad00 100644 --- a/scripts/update-homebrew-formula.test.ts +++ b/scripts/update-homebrew-formula.test.ts @@ -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"', diff --git a/scripts/update-homebrew-formula.ts b/scripts/update-homebrew-formula.ts index e7aec580..6cd67624 100644 --- a/scripts/update-homebrew-formula.ts +++ b/scripts/update-homebrew-formula.ts @@ -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 diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 402f1ac9..715e7167 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -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 "); 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"); @@ -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"]); @@ -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"]); diff --git a/src/core/cli.ts b/src/core/cli.ts index 6844e29c..862ee367 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -59,6 +59,7 @@ function buildCommonOptions( agentContext?: string; pager?: boolean; watch?: boolean; + kittyFollow?: boolean; }, argv: string[], ): CommonOptions { @@ -68,6 +69,7 @@ function buildCommonOptions( agentContext: options.agentContext, pager: options.pager ? true : undefined, watch: options.watch ? true : undefined, + kittyFollow: options.kittyFollow ? true : undefined, excludeUntracked: resolveBooleanFlag(argv, "--exclude-untracked", "--no-exclude-untracked"), lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"), wrapLines: resolveBooleanFlag(argv, "--wrap", "--no-wrap"), @@ -136,6 +138,7 @@ function renderCliHelp() { " hunk pager general Git pager wrapper with diff detection", " hunk difftool [path] review Git difftool file pairs", " hunk session inspect or control a live Hunk session", + " hunk kitty integrate live Hunk sessions with Kitty", " hunk skill path print the bundled Hunk review skill path", " hunk daemon serve run the local Hunk session daemon", "", @@ -375,6 +378,7 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise 0 ? pathspecs : undefined; + if (options.kittyFollow && (staged || parsedTargets.length > 0 || normalizedPathspecs)) { + throw new Error("`--kitty-follow` only supports working-tree `hunk diff` sessions."); + } + if (parsedTargets.length === 0) { return { kind: "vcs", @@ -572,6 +580,10 @@ function requireReloadableCliInput(input: ParsedCliInput): CliInput { throw new Error("Session reload cannot invoke another session command."); } + if (input.kind === "kitty") { + throw new Error("Session reload cannot invoke a Kitty integration command."); + } + if (input.kind === "patch" && (!input.file || input.file === "-")) { throw new Error("Session reload does not support `patch -` or stdin-backed patch input."); } @@ -1214,6 +1226,73 @@ async function parseSkillCommand(tokens: string[]): Promise { }; } +/** Parse commands used by Kitty watcher/keybinding integrations. */ +async function parseKittyCommand(tokens: string[]): Promise { + const [subcommand, ...rest] = tokens; + if (!subcommand || subcommand === "--help" || subcommand === "-h") { + return { + kind: "help", + text: + [ + "Usage: hunk kitty ", + "", + "Integrate live Hunk sessions with Kitty.", + "", + "Subcommands:", + " hunk kitty watcher-path print the bundled Kitty watcher path", + " hunk kitty sync --window-id reload a marked Hunk session from Kitty focus", + ].join("\n") + "\n", + }; + } + + if (subcommand === "watcher-path") { + const command = new Command("kitty watcher-path").description( + "print the bundled Kitty watcher path", + ); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + + return { + kind: "kitty", + action: "watcher-path", + }; + } + + if (subcommand === "sync") { + const command = new Command("kitty sync") + .description("reload one marked live Hunk session from the active Kitty pane") + .requiredOption("--window-id ", "Kitty window id from the focus-change event") + .option("--to
", "Kitty remote-control socket address for `kitten @ --to`") + .option("--json", "emit structured JSON"); + + let parsedOptions: { windowId?: string; to?: string; json?: boolean } = {}; + + command.action((options: { windowId?: string; to?: string; json?: boolean }) => { + parsedOptions = options; + }); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + + return { + kind: "kitty", + action: "sync", + output: parsedOptions.json ? "json" : "text", + windowId: parsedOptions.windowId!, + to: parsedOptions.to, + }; + } + + throw new Error(`Unknown kitty command: ${subcommand}`); +} + /** Parse `hunk daemon serve` as the canonical local daemon entrypoint. */ async function parseDaemonCommand(tokens: string[]): Promise { const [subcommand, ...rest] = tokens; @@ -1331,6 +1410,8 @@ export async function parseCli(argv: string[]): Promise { return parseStashCommand(rest, argv); case "session": return parseSessionCommand(rest); + case "kitty": + return parseKittyCommand(rest); case "skill": return parseSkillCommand(rest); case "daemon": diff --git a/src/core/config.ts b/src/core/config.ts index 6344f9e1..cbf40605 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -80,6 +80,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti agentContext: overrides.agentContext ?? base.agentContext, pager: overrides.pager ?? base.pager, watch: overrides.watch ?? base.watch, + kittyFollow: overrides.kittyFollow ?? base.kittyFollow, excludeUntracked: overrides.excludeUntracked ?? base.excludeUntracked, lineNumbers: overrides.lineNumbers ?? base.lineNumbers, wrapLines: overrides.wrapLines ?? base.wrapLines, @@ -143,6 +144,7 @@ export function resolveConfiguredCliInput( agentContext: input.options.agentContext, pager: input.options.pager ?? false, watch: input.options.watch ?? false, + kittyFollow: input.options.kittyFollow ?? false, excludeUntracked: false, lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers, wrapLines: DEFAULT_VIEW_PREFERENCES.wrapLines, @@ -171,6 +173,7 @@ export function resolveConfiguredCliInput( agentContext: input.options.agentContext, pager: input.options.pager ?? false, watch: input.options.watch ?? resolvedOptions.watch ?? false, + kittyFollow: input.options.kittyFollow ?? resolvedOptions.kittyFollow ?? false, excludeUntracked: resolvedOptions.excludeUntracked ?? false, vcs: resolvedOptions.vcs ?? "git", mode: resolvedOptions.mode ?? DEFAULT_VIEW_PREFERENCES.mode, diff --git a/src/core/paths.test.ts b/src/core/paths.test.ts index 6badeed7..43441945 100644 --- a/src/core/paths.test.ts +++ b/src/core/paths.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { + resolveBundledKittyWatcherPath, resolveBundledHunkReviewSkillPath, resolveGlobalConfigPath, resolveHunkStatePath, @@ -53,4 +54,23 @@ describe("paths", () => { rmSync(tempRoot, { recursive: true, force: true }); } }); + + test("locates the bundled Kitty watcher through a nested hunkdiff package", () => { + const tempRoot = createTempRoot("hunk-kitty-watcher-path-"); + + try { + const nestedPackageRoot = join(tempRoot, "node_modules", "hunkdiff"); + const watcherPath = join(nestedPackageRoot, "kitty", "hunk-follow.py"); + const fakeBinary = join(tempRoot, "node_modules", "hunkdiff-linux-x64", "bin", "hunk"); + + mkdirSync(dirname(watcherPath), { recursive: true }); + mkdirSync(dirname(fakeBinary), { recursive: true }); + writeFileSync(watcherPath, "# watcher\n"); + writeFileSync(fakeBinary, "binary\n"); + + expect(resolveBundledKittyWatcherPath([fakeBinary])).toBe(watcherPath); + } finally { + rmSync(tempRoot, { recursive: true, force: true }); + } + }); }); diff --git a/src/core/paths.ts b/src/core/paths.ts index 7ee88bf8..1d0e60d1 100644 --- a/src/core/paths.ts +++ b/src/core/paths.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import { dirname, join, resolve } from "node:path"; const HUNK_REVIEW_SKILL_RELATIVE_PATH = join("skills", "hunk-review", "SKILL.md"); +const KITTY_WATCHER_RELATIVE_PATH = join("kitty", "hunk-follow.py"); /** Resolve the base config directory Hunk should use for user-scoped files. */ export function resolveUserConfigDir(env: NodeJS.ProcessEnv = process.env) { @@ -75,3 +76,24 @@ export function resolveBundledHunkReviewSkillPath(searchRoots?: string[]) { throw new Error("Could not locate the bundled Hunk review skill."); } + +/** Resolve the bundled Kitty watcher path from source, npm, or prebuilt package layouts. */ +export function resolveBundledKittyWatcherPath(searchRoots?: string[]) { + const roots = searchRoots ?? [import.meta.dir, process.execPath]; + const relativeCandidates = [ + KITTY_WATCHER_RELATIVE_PATH, + join("hunkdiff", KITTY_WATCHER_RELATIVE_PATH), + join("node_modules", "hunkdiff", KITTY_WATCHER_RELATIVE_PATH), + ]; + + for (const root of roots) { + for (const relativePath of relativeCandidates) { + const resolvedPath = findRelativePathFromAncestors(root, relativePath); + if (resolvedPath) { + return resolvedPath; + } + } + } + + throw new Error("Could not locate the bundled Kitty watcher."); +} diff --git a/src/core/startup.test.ts b/src/core/startup.test.ts index ff4e45b5..206bec3c 100644 --- a/src/core/startup.test.ts +++ b/src/core/startup.test.ts @@ -65,6 +65,24 @@ describe("startup planning", () => { expect(loaded).toBe(false); }); + test("passes Kitty commands through without app bootstrap work", async () => { + let loaded = false; + + const plan = await prepareStartupPlan(["bun", "hunk", "kitty", "watcher-path"], { + parseCliImpl: async () => ({ kind: "kitty", action: "watcher-path" }), + loadAppBootstrapImpl: async () => { + loaded = true; + throw new Error("unreachable"); + }, + }); + + expect(plan).toEqual({ + kind: "kitty-command", + input: { kind: "kitty", action: "watcher-path" }, + }); + expect(loaded).toBe(false); + }); + test("routes non-diff pager stdin to the plain-text pager path", async () => { let loaded = false; diff --git a/src/core/startup.ts b/src/core/startup.ts index e046530c..60eeeafc 100644 --- a/src/core/startup.ts +++ b/src/core/startup.ts @@ -9,7 +9,13 @@ import { usesPipedPatchInput, type ControllingTerminal, } from "./terminal"; -import type { AppBootstrap, CliInput, ParsedCliInput, SessionCommandInput } from "./types"; +import type { + AppBootstrap, + CliInput, + KittyCommandInput, + ParsedCliInput, + SessionCommandInput, +} from "./types"; import { canReloadInput } from "./watch"; import { parseCli } from "./cli"; @@ -25,6 +31,10 @@ export type StartupPlan = kind: "session-command"; input: SessionCommandInput; } + | { + kind: "kitty-command"; + input: KittyCommandInput; + } | { kind: "plain-text-pager"; text: string; @@ -114,6 +124,13 @@ export async function prepareStartupPlan( }; } + if (parsedCliInput.kind === "kitty") { + return { + kind: "kitty-command", + input: parsedCliInput, + }; + } + if (parsedCliInput.kind === "pager") { const stdinText = await readStdinText(); const pagerOptions = parsedCliInput.options; diff --git a/src/core/types.ts b/src/core/types.ts index 8964e208..3e07035d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -74,6 +74,7 @@ export interface CommonOptions { agentContext?: string; pager?: boolean; watch?: boolean; + kittyFollow?: boolean; excludeUntracked?: boolean; lineNumbers?: boolean; wrapLines?: boolean; @@ -106,6 +107,23 @@ export interface DaemonServeCommandInput { kind: "daemon-serve"; } +export type KittyCommandOutput = "text" | "json"; + +export interface KittyWatcherPathCommandInput { + kind: "kitty"; + action: "watcher-path"; +} + +export interface KittySyncCommandInput { + kind: "kitty"; + action: "sync"; + output: KittyCommandOutput; + windowId: string; + to?: string; +} + +export type KittyCommandInput = KittyWatcherPathCommandInput | KittySyncCommandInput; + export type SessionCommandOutput = "text" | "json"; export interface SessionSelectorInput { @@ -284,6 +302,7 @@ export type ParsedCliInput = | HelpCommandInput | PagerCommandInput | DaemonServeCommandInput + | KittyCommandInput | SessionCommandInput; export interface AppBootstrap { diff --git a/src/hunk-session/cli.test.ts b/src/hunk-session/cli.test.ts index 2e6273ab..79541fb6 100644 --- a/src/hunk-session/cli.test.ts +++ b/src/hunk-session/cli.test.ts @@ -291,6 +291,7 @@ describe("Hunk session CLI formatters", () => { "session-1 repo working tree", " path: /repo", " repo: /repo", + " kitty follow: no", " terminal: ghostty", " location[tty]: /dev/ttys005", " location[tmux]: pane %7, session work", diff --git a/src/hunk-session/cli.ts b/src/hunk-session/cli.ts index 2248c02b..6337f44f 100644 --- a/src/hunk-session/cli.ts +++ b/src/hunk-session/cli.ts @@ -277,6 +277,7 @@ export function formatListOutput(sessions: ListedSession[]) { `${session.sessionId} ${session.title}`, ` path: ${session.cwd}`, ` repo: ${session.repoRoot ?? "-"}`, + ` kitty follow: ${session.kittyFollow ? "yes" : "no"}`, ...formatTerminalLines(terminal, { headerLabel: " terminal", locationLabel: " location", @@ -299,6 +300,7 @@ export function formatSessionOutput(session: ListedSession) { `Path: ${session.cwd}`, `Repo: ${session.repoRoot ?? "-"}`, `Input: ${session.inputKind}`, + `Kitty follow: ${session.kittyFollow ? "yes" : "no"}`, `Launched: ${session.launchedAt}`, ...formatTerminalLines(terminal, { headerLabel: "Terminal", diff --git a/src/hunk-session/projections.test.ts b/src/hunk-session/projections.test.ts index 6447613d..02aad843 100644 --- a/src/hunk-session/projections.test.ts +++ b/src/hunk-session/projections.test.ts @@ -34,12 +34,22 @@ describe("hunk session projections", () => { expect(buildListedHunkSession(entry)).toEqual( expect.objectContaining({ terminal: entry.registration.terminal, + kittyFollow: false, fileCount: 1, files: [expect.objectContaining({ path: "src/example.ts", hunkCount: 1 })], }), ); }); + test("buildListedHunkSession exposes Kitty follow opt-in", () => { + const listed = buildListedHunkSession({ + registration: createTestSessionRegistration({ kittyFollow: true }), + snapshot: createTestSessionSnapshot(), + }); + + expect(listed.kittyFollow).toBe(true); + }); + test("buildSelectedHunkSessionContext projects the current file and selected ranges", () => { const session = buildListedHunkSession({ registration: createTestSessionRegistration(), diff --git a/src/hunk-session/projections.ts b/src/hunk-session/projections.ts index e9b436b1..c28853a0 100644 --- a/src/hunk-session/projections.ts +++ b/src/hunk-session/projections.ts @@ -73,6 +73,7 @@ export function buildListedHunkSession(entry: HunkSessionEntryLike): ListedSessi launchedAt: entry.registration.launchedAt, terminal: entry.registration.terminal, inputKind: entry.registration.info.inputKind, + kittyFollow: entry.registration.info.kittyFollow ?? false, title: entry.registration.info.title, sourceLabel: entry.registration.info.sourceLabel, fileCount: entry.registration.info.files.length, diff --git a/src/hunk-session/sessionRegistration.test.ts b/src/hunk-session/sessionRegistration.test.ts new file mode 100644 index 00000000..1768b0c0 --- /dev/null +++ b/src/hunk-session/sessionRegistration.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import type { AppBootstrap } from "../core/types"; +import { createSessionRegistration, updateSessionRegistration } from "./sessionRegistration"; + +function createBootstrap(kittyFollow: boolean): AppBootstrap { + return { + input: { + kind: "vcs", + staged: false, + options: { kittyFollow }, + }, + changeset: { + id: "changeset:test", + sourceLabel: "/repo", + title: "repo working tree", + files: [], + }, + initialMode: "auto", + }; +} + +describe("hunk session registration", () => { + test("records Kitty follow opt-in on launch", () => { + const registration = createSessionRegistration(createBootstrap(true)); + + expect(registration.info.kittyFollow).toBe(true); + }); + + test("preserves Kitty follow opt-in across reloads", () => { + const registration = createSessionRegistration(createBootstrap(true)); + const updated = updateSessionRegistration(registration, createBootstrap(false)); + + expect(updated.info.kittyFollow).toBe(true); + }); +}); diff --git a/src/hunk-session/sessionRegistration.ts b/src/hunk-session/sessionRegistration.ts index 958d93cc..cd082dba 100644 --- a/src/hunk-session/sessionRegistration.ts +++ b/src/hunk-session/sessionRegistration.ts @@ -67,6 +67,7 @@ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionR inputKind: bootstrap.input.kind, title: bootstrap.changeset.title, sourceLabel: bootstrap.changeset.sourceLabel, + kittyFollow: bootstrap.input.options.kittyFollow, files: buildSessionFiles(bootstrap), }, }; @@ -85,6 +86,7 @@ export function updateSessionRegistration( inputKind: bootstrap.input.kind, title: bootstrap.changeset.title, sourceLabel: bootstrap.changeset.sourceLabel, + kittyFollow: current.info.kittyFollow || bootstrap.input.options.kittyFollow, files: buildSessionFiles(bootstrap), }, }; diff --git a/src/hunk-session/types.ts b/src/hunk-session/types.ts index 5fab6258..18f4d1a6 100644 --- a/src/hunk-session/types.ts +++ b/src/hunk-session/types.ts @@ -44,6 +44,7 @@ export interface HunkSessionInfo { inputKind: CliInput["kind"]; title: string; sourceLabel: string; + kittyFollow?: boolean; files: SessionReviewFile[]; } @@ -177,6 +178,7 @@ export interface ListedSession { launchedAt: string; terminal?: SessionTerminalMetadata; inputKind: CliInput["kind"]; + kittyFollow: boolean; title: string; sourceLabel: string; fileCount: number; diff --git a/src/hunk-session/wire.test.ts b/src/hunk-session/wire.test.ts index 489843ae..473e7d31 100644 --- a/src/hunk-session/wire.test.ts +++ b/src/hunk-session/wire.test.ts @@ -80,6 +80,7 @@ describe("hunk session wire parsing", () => { inputKind: "vcs", title: "repo working tree", sourceLabel: "/repo", + kittyFollow: true, files: [], }, }); @@ -88,6 +89,7 @@ describe("hunk session wire parsing", () => { inputKind: "vcs", title: "repo working tree", sourceLabel: "/repo", + kittyFollow: true, files: [], }); }); diff --git a/src/hunk-session/wire.ts b/src/hunk-session/wire.ts index c03c92fd..8cf530aa 100644 --- a/src/hunk-session/wire.ts +++ b/src/hunk-session/wire.ts @@ -216,6 +216,7 @@ function parseHunkSessionInfo(value: unknown): HunkSessionInfo | null { inputKind, title, sourceLabel, + kittyFollow: typeof record.kittyFollow === "boolean" ? record.kittyFollow : undefined, files: files as SessionReviewFile[], }; } diff --git a/src/kitty/sync.test.ts b/src/kitty/sync.test.ts new file mode 100644 index 00000000..5e92fddb --- /dev/null +++ b/src/kitty/sync.test.ts @@ -0,0 +1,229 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTestListedSession } from "../../test/helpers/session-daemon-fixtures"; +import type { HunkSessionCliClient } from "../hunk-session/cli"; +import type { ListedSession } from "../hunk-session/types"; +import { + parseKittyState, + resolveActiveKittyPane, + selectKittyFollowTarget, + syncKittyFollowSession, +} from "./sync"; + +const tempDirs: string[] = []; + +function createTempDir() { + const dir = mkdtempSync(join(tmpdir(), "hunk-kitty-sync-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + rmSync(dir, { recursive: true, force: true }); + } + } +}); + +function createKittyState({ + activeWindowId = 10, + hunkWindowId = 99, + cwd, + foregroundCwd = cwd, + foregroundCmdline = ["-zsh"], +}: { + activeWindowId?: number; + hunkWindowId?: number; + cwd: string; + foregroundCwd?: string; + foregroundCmdline?: string[]; +}) { + return parseKittyState([ + { + id: 1, + is_focused: true, + is_active: true, + tabs: [ + { + id: 2, + is_active: true, + windows: [ + { + id: activeWindowId, + is_active: true, + cwd, + cmdline: ["/bin/zsh"], + foreground_processes: [{ cmdline: foregroundCmdline, cwd: foregroundCwd }], + }, + { + id: hunkWindowId, + is_active: false, + cwd, + cmdline: ["/bin/zsh"], + foreground_processes: [{ cmdline: ["hunk", "diff", "--kitty-follow"], cwd }], + }, + ], + }, + ], + }, + ]); +} + +function createClient(sessions: ListedSession[]) { + const reloads: unknown[] = []; + const client = { + listSessions: async () => sessions, + reloadSession: async (input: unknown) => { + reloads.push(input); + return { + sessionId: sessions[0]?.sessionId ?? "session-1", + inputKind: "vcs", + title: "repo working tree", + sourceLabel: "/repo", + fileCount: 3, + selectedHunkIndex: 0, + }; + }, + } as unknown as HunkSessionCliClient; + + return { client, reloads }; +} + +function createFollowSession(overrides: Partial = {}) { + return createTestListedSession({ + sessionId: "follow-1", + repoRoot: "/old-repo", + kittyFollow: true, + terminal: { + locations: [{ source: "kitty", windowId: "99" }], + }, + ...overrides, + }); +} + +describe("Kitty follow sync", () => { + test("resolves the active Kitty pane and rejects stale focus events", () => { + const cwd = createTempDir(); + const state = createKittyState({ cwd, activeWindowId: 10 }); + + const activePane = resolveActiveKittyPane(state, "10"); + expect(typeof activePane).toBe("object"); + if (typeof activePane === "object") { + expect(activePane.window.id).toBe(10); + } + + expect(resolveActiveKittyPane(state, "99")).toBe("kitty-window-not-active"); + }); + + test("selects the marked Hunk session in the same Kitty OS window", () => { + const cwd = createTempDir(); + const state = createKittyState({ cwd }); + const sameOs = createFollowSession({ sessionId: "same-os" }); + const otherOs = createFollowSession({ + sessionId: "other-os", + terminal: { locations: [{ source: "kitty", windowId: "404" }] }, + }); + + expect(selectKittyFollowTarget([sameOs, otherOs], state, 1)).toEqual(sameOs); + }); + + test("reloads the selected marked session from the active pane foreground cwd", async () => { + const cwd = createTempDir(); + const state = createKittyState({ cwd }); + const { client, reloads } = createClient([createFollowSession()]); + + const result = await syncKittyFollowSession( + { kind: "kitty", action: "sync", output: "json", windowId: "10" }, + { + client, + loadKittyState: async () => state, + detectRepo: () => ({ id: "git", repoRoot: cwd }), + }, + ); + + expect(result).toMatchObject({ + status: "reloaded", + sessionId: "follow-1", + cwd, + repoRoot: cwd, + }); + expect(reloads).toEqual([ + expect.objectContaining({ + selector: { sessionId: "follow-1" }, + sourcePath: cwd, + nextInput: { kind: "vcs", staged: false, options: {} }, + }), + ]); + }); + + test("no-ops for non-repo panes and active Hunk panes", async () => { + const cwd = createTempDir(); + const sessions = [createFollowSession()]; + const { client } = createClient(sessions); + + const nonRepo = await syncKittyFollowSession( + { kind: "kitty", action: "sync", output: "json", windowId: "10" }, + { + client, + loadKittyState: async () => createKittyState({ cwd }), + detectRepo: () => null, + }, + ); + expect(nonRepo).toMatchObject({ status: "noop", reason: "not-a-repo" }); + + const activeHunk = await syncKittyFollowSession( + { kind: "kitty", action: "sync", output: "json", windowId: "99" }, + { + client, + loadKittyState: async () => createKittyState({ cwd, activeWindowId: 99 }), + detectRepo: () => ({ id: "git", repoRoot: cwd }), + }, + ); + expect(activeHunk).toMatchObject({ status: "noop", reason: "active-hunk-window" }); + }); + + test("no-ops when multiple marked sessions remain ambiguous", async () => { + const cwd = createTempDir(); + const state = parseKittyState([ + { + id: 1, + is_focused: true, + is_active: true, + tabs: [ + { + id: 2, + is_active: true, + windows: [ + { + id: 10, + is_active: true, + cwd, + cmdline: ["/bin/zsh"], + foreground_processes: [{ cmdline: ["-zsh"], cwd }], + }, + ], + }, + ], + }, + ]); + const { client } = createClient([ + createFollowSession({ sessionId: "one", terminal: undefined }), + createFollowSession({ sessionId: "two", terminal: undefined }), + ]); + + const result = await syncKittyFollowSession( + { kind: "kitty", action: "sync", output: "json", windowId: "10" }, + { + client, + loadKittyState: async () => state, + detectRepo: () => ({ id: "git", repoRoot: cwd }), + }, + ); + + expect(result).toMatchObject({ status: "noop", reason: "ambiguous-target" }); + }); +}); diff --git a/src/kitty/sync.ts b/src/kitty/sync.ts new file mode 100644 index 00000000..317f7c86 --- /dev/null +++ b/src/kitty/sync.ts @@ -0,0 +1,410 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, statSync } from "node:fs"; +import { basename } from "node:path"; +import { resolveBundledKittyWatcherPath } from "../core/paths"; +import { detectVcs } from "../core/vcs"; +import type { KittyCommandInput, KittySyncCommandInput } from "../core/types"; +import { + createHttpHunkSessionCliClient, + stringifyJson, + type HunkSessionCliClient, +} from "../hunk-session/cli"; +import type { ListedSession } from "../hunk-session/types"; + +export interface KittyForegroundProcess { + cmdline: string[]; + cwd?: string; + pid?: number; +} + +export interface KittyWindow { + id: number; + title?: string; + cwd?: string; + cmdline: string[]; + isActive: boolean; + foregroundProcesses: KittyForegroundProcess[]; +} + +export interface KittyTab { + id: number; + isActive: boolean; + windows: KittyWindow[]; +} + +export interface KittyOsWindow { + id: number; + isFocused: boolean; + isActive: boolean; + tabs: KittyTab[]; +} + +export interface ActiveKittyPane { + osWindow: KittyOsWindow; + tab: KittyTab; + window: KittyWindow; +} + +type KittySyncNoopReason = + | "active-hunk-window" + | "ambiguous-target" + | "kitty-window-not-active" + | "kitty-window-not-found" + | "no-active-kitty-pane" + | "no-marked-hunk-session" + | "non-directory" + | "not-a-repo" + | "unchanged"; + +export type KittySyncResult = + | { + status: "noop"; + reason: KittySyncNoopReason; + windowId?: string; + cwd?: string; + repoRoot?: string; + } + | { + status: "reloaded"; + sessionId: string; + windowId: string; + cwd: string; + repoRoot: string; + title: string; + fileCount: number; + }; + +export interface KittySyncDeps { + client?: HunkSessionCliClient; + loadKittyState?: (to?: string) => Promise; + detectRepo?: typeof detectVcs; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function parseOptionalString(value: unknown) { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function parseStringArray(value: unknown) { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function parseKittyForegroundProcess(value: unknown): KittyForegroundProcess | null { + const record = asRecord(value); + if (!record) { + return null; + } + + const cmdline = parseStringArray(record.cmdline); + if (cmdline.length === 0) { + return null; + } + + return { + cmdline, + cwd: parseOptionalString(record.cwd), + pid: typeof record.pid === "number" ? record.pid : undefined, + }; +} + +function parseKittyWindow(value: unknown): KittyWindow | null { + const record = asRecord(value); + if (!record || typeof record.id !== "number") { + return null; + } + + const foregroundProcesses = Array.isArray(record.foreground_processes) + ? record.foreground_processes + .map(parseKittyForegroundProcess) + .filter((process): process is KittyForegroundProcess => process !== null) + : []; + + return { + id: record.id, + title: parseOptionalString(record.title), + cwd: parseOptionalString(record.cwd), + cmdline: parseStringArray(record.cmdline), + isActive: record.is_active === true, + foregroundProcesses, + }; +} + +function parseKittyTab(value: unknown): KittyTab | null { + const record = asRecord(value); + if (!record || typeof record.id !== "number" || !Array.isArray(record.windows)) { + return null; + } + + return { + id: record.id, + isActive: record.is_active === true, + windows: record.windows + .map(parseKittyWindow) + .filter((window): window is KittyWindow => window !== null), + }; +} + +function parseKittyOsWindow(value: unknown): KittyOsWindow | null { + const record = asRecord(value); + if (!record || typeof record.id !== "number" || !Array.isArray(record.tabs)) { + return null; + } + + return { + id: record.id, + isFocused: record.is_focused === true, + isActive: record.is_active === true, + tabs: record.tabs.map(parseKittyTab).filter((tab): tab is KittyTab => tab !== null), + }; +} + +/** Parse the `kitten @ ls` window tree into the subset Hunk needs. */ +export function parseKittyState(value: unknown): KittyOsWindow[] { + return Array.isArray(value) + ? value + .map(parseKittyOsWindow) + .filter((osWindow): osWindow is KittyOsWindow => osWindow !== null) + : []; +} + +/** Return all Kitty windows with their parent OS-window id for target matching. */ +function flattenKittyWindows(osWindows: KittyOsWindow[]) { + return osWindows.flatMap((osWindow) => + osWindow.tabs.flatMap((tab) => + tab.windows.map((window) => ({ + osWindowId: osWindow.id, + window, + })), + ), + ); +} + +/** Resolve the currently active pane and reject stale focus events. */ +export function resolveActiveKittyPane( + osWindows: KittyOsWindow[], + expectedWindowId: string, +): ActiveKittyPane | KittySyncNoopReason { + const expectedId = Number.parseInt(expectedWindowId, 10); + const focusedOsWindow = + osWindows.find((osWindow) => osWindow.isFocused) ?? + osWindows.find((osWindow) => osWindow.isActive); + if (!focusedOsWindow) { + return "no-active-kitty-pane"; + } + + const activeTab = focusedOsWindow.tabs.find((tab) => tab.isActive); + const activeWindow = activeTab?.windows.find((window) => window.isActive); + if (!activeTab || !activeWindow) { + return "no-active-kitty-pane"; + } + + if (!Number.isFinite(expectedId)) { + return "kitty-window-not-found"; + } + + if (!flattenKittyWindows(osWindows).some(({ window }) => window.id === expectedId)) { + return "kitty-window-not-found"; + } + + if (activeWindow.id !== expectedId) { + return "kitty-window-not-active"; + } + + return { + osWindow: focusedOsWindow, + tab: activeTab, + window: activeWindow, + }; +} + +function getKittyWindowId(session: ListedSession) { + return session.terminal?.locations.find( + (location) => location.source === "kitty" && location.windowId, + )?.windowId; +} + +function isHunkCommand(cmdline: string[]) { + return cmdline.some((part) => { + const name = basename(part).toLowerCase(); + return name === "hunk" || name === "hunkdiff" || name.startsWith("hunkdiff-"); + }); +} + +function isActiveHunkWindow(activeWindow: KittyWindow, sessions: ListedSession[]) { + const activeWindowId = String(activeWindow.id); + const matchesRegisteredHunkWindow = sessions.some( + (session) => session.kittyFollow && getKittyWindowId(session) === activeWindowId, + ); + if (matchesRegisteredHunkWindow) { + return true; + } + + return activeWindow.foregroundProcesses.some((process) => isHunkCommand(process.cmdline)); +} + +function resolvePaneCwd(window: KittyWindow) { + return window.foregroundProcesses[0]?.cwd ?? window.cwd; +} + +function isDirectory(path: string) { + try { + return existsSync(path) && statSync(path).isDirectory(); + } catch { + return false; + } +} + +/** Pick the marked Hunk session that should follow the active Kitty pane. */ +export function selectKittyFollowTarget( + sessions: ListedSession[], + osWindows: KittyOsWindow[], + activeOsWindowId: number, +): ListedSession | KittySyncNoopReason { + const markedSessions = sessions.filter((session) => session.kittyFollow); + if (markedSessions.length === 0) { + return "no-marked-hunk-session"; + } + + const windowsById = new Map( + flattenKittyWindows(osWindows).map(({ osWindowId, window }) => [String(window.id), osWindowId]), + ); + const sameOsWindowSessions = markedSessions.filter((session) => { + const kittyWindowId = getKittyWindowId(session); + return kittyWindowId ? windowsById.get(kittyWindowId) === activeOsWindowId : false; + }); + + if (sameOsWindowSessions.length === 1) { + return sameOsWindowSessions[0]!; + } + + if (sameOsWindowSessions.length > 1) { + return "ambiguous-target"; + } + + return markedSessions.length === 1 ? markedSessions[0]! : "ambiguous-target"; +} + +async function readKittyState(to?: string) { + const args = ["@", ...(to ? ["--to", to] : []), "ls"]; + const proc = spawnSync("kitten", args, { + stdio: ["ignore", "pipe", "pipe"], + encoding: "utf8", + }); + + if (proc.error) { + throw proc.error; + } + + if (proc.status !== 0) { + throw new Error(proc.stderr || proc.stdout || "`kitten @ ls` failed."); + } + + return parseKittyState(JSON.parse(proc.stdout)); +} + +/** Reload one marked Hunk session from the active Kitty pane, or return a named no-op. */ +export async function syncKittyFollowSession( + input: KittySyncCommandInput, + deps: KittySyncDeps = {}, +): Promise { + const loadKittyState = deps.loadKittyState ?? readKittyState; + const client = deps.client ?? createHttpHunkSessionCliClient(); + const detectRepo = deps.detectRepo ?? detectVcs; + const osWindows = await loadKittyState(input.to); + const activePane = resolveActiveKittyPane(osWindows, input.windowId); + + if (typeof activePane === "string") { + return { status: "noop", reason: activePane, windowId: input.windowId }; + } + + let sessions: ListedSession[]; + try { + sessions = await client.listSessions(); + } catch { + return { status: "noop", reason: "no-marked-hunk-session", windowId: input.windowId }; + } + if (isActiveHunkWindow(activePane.window, sessions)) { + return { status: "noop", reason: "active-hunk-window", windowId: input.windowId }; + } + + const cwd = resolvePaneCwd(activePane.window); + if (!cwd || !isDirectory(cwd)) { + return { status: "noop", reason: "non-directory", windowId: input.windowId, cwd }; + } + + const detected = detectRepo(cwd); + if (!detected) { + return { status: "noop", reason: "not-a-repo", windowId: input.windowId, cwd }; + } + + const target = selectKittyFollowTarget(sessions, osWindows, activePane.osWindow.id); + if (typeof target === "string") { + return { + status: "noop", + reason: target, + windowId: input.windowId, + cwd, + repoRoot: detected.repoRoot, + }; + } + + if (target.repoRoot === detected.repoRoot) { + return { + status: "noop", + reason: "unchanged", + windowId: input.windowId, + cwd, + repoRoot: detected.repoRoot, + }; + } + + const result = await client.reloadSession({ + kind: "session", + action: "reload", + output: "json", + selector: { sessionId: target.sessionId }, + sourcePath: cwd, + nextInput: { + kind: "vcs", + staged: false, + options: {}, + }, + }); + + return { + status: "reloaded", + sessionId: result.sessionId, + windowId: input.windowId, + cwd, + repoRoot: detected.repoRoot, + title: result.title, + fileCount: result.fileCount, + }; +} + +function formatKittySyncOutput(result: KittySyncResult) { + if (result.status === "reloaded") { + return `Reloaded Hunk session ${result.sessionId} from ${result.repoRoot}.\n`; + } + + return `No Hunk session reloaded: ${result.reason}.\n`; +} + +/** Execute one non-TUI Kitty integration command. */ +export async function runKittyCommand(input: KittyCommandInput) { + switch (input.action) { + case "watcher-path": + return `${resolveBundledKittyWatcherPath()}\n`; + case "sync": { + const result = await syncKittyFollowSession(input); + return input.output === "json" ? stringifyJson({ result }) : formatKittySyncOutput(result); + } + } +} diff --git a/src/main.tsx b/src/main.tsx index 25e2f7ed..df72a888 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -29,6 +29,7 @@ import type { HunkSessionState, } from "./hunk-session/types"; import { runSessionCommand } from "./session/commands"; +import { runKittyCommand } from "./kitty/sync"; async function main() { const startupPlan = await prepareStartupPlan(); @@ -49,6 +50,11 @@ async function main() { process.exit(0); } + if (startupPlan.kind === "kitty-command") { + process.stdout.write(await runKittyCommand(startupPlan.input)); + process.exit(0); + } + if (startupPlan.kind === "plain-text-pager") { await pagePlainText(startupPlan.text); process.exit(0); diff --git a/src/session-broker/brokerServer.test.ts b/src/session-broker/brokerServer.test.ts index 42ddf044..f2ead97b 100644 --- a/src/session-broker/brokerServer.test.ts +++ b/src/session-broker/brokerServer.test.ts @@ -291,7 +291,7 @@ describe("Hunk session daemon server", () => { expect(capabilities.status).toBe(200); await expect(capabilities.json()).resolves.toMatchObject({ version: 1, - daemonVersion: 3, + daemonVersion: 4, actions: [ "list", "get", diff --git a/src/session/protocol.ts b/src/session/protocol.ts index a9c4adec..8b8608a9 100644 --- a/src/session/protocol.ts +++ b/src/session/protocol.ts @@ -31,7 +31,7 @@ export const HUNK_SESSION_API_VERSION = 1; * Version daemon/session compatibility separately from the HTTP action surface so newer Hunk * builds can refresh an older daemon even when it still exposes the same API endpoints. */ -export const HUNK_SESSION_DAEMON_VERSION = 3; +export const HUNK_SESSION_DAEMON_VERSION = 4; export type SessionDaemonAction = | "list" diff --git a/test/helpers/session-daemon-fixtures.ts b/test/helpers/session-daemon-fixtures.ts index 85eaac72..a69ae090 100644 --- a/test/helpers/session-daemon-fixtures.ts +++ b/test/helpers/session-daemon-fixtures.ts @@ -74,7 +74,10 @@ export function createTestSessionSnapshot( export function createTestSessionRegistration( overrides: Partial & Partial< - Pick + Pick< + HunkSessionRegistration["info"], + "inputKind" | "title" | "sourceLabel" | "kittyFollow" | "files" + > > & { info?: Partial; } = {}, @@ -83,6 +86,7 @@ export function createTestSessionRegistration( inputKind, title, sourceLabel, + kittyFollow, files, info: infoOverrides, ...registrationOverrides @@ -101,6 +105,7 @@ export function createTestSessionRegistration( inputKind: inputKind ?? infoOverrides?.inputKind ?? "vcs", title: title ?? infoOverrides?.title ?? "repo working tree", sourceLabel: sourceLabel ?? infoOverrides?.sourceLabel ?? "/repo", + kittyFollow: kittyFollow ?? infoOverrides?.kittyFollow, files: resolvedFiles, }, }; @@ -117,6 +122,7 @@ export function createTestListedSession(overrides: Partial = {}): repoRoot: "/repo", launchedAt: "2026-03-22T00:00:00.000Z", inputKind: "vcs", + kittyFollow: false, title: "repo working tree", sourceLabel: "/repo", ...overrides,