From cab19b1b62af4fb648da1bd44cb8551eb338d1e9 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sat, 5 Sep 2026 10:43:10 -0400 Subject: [PATCH 01/36] fix(keys): match alt across terminal protocols --- .changeset/fuzzy-keys-match.md | 5 +++++ docs/keybindings.md | 5 ++++- src/extension-api/keys.test.ts | 25 ++++++++++++++++++++++ src/extension-api/keys.ts | 24 ++++++++++++++++----- src/extension-api/types.ts | 2 ++ src/lib/commandKeys.test.ts | 31 ++++++++++++++++++++++++++- src/ui/lib/extensionKeyEvent.test.ts | 2 ++ src/ui/lib/extensionKeyEvent.ts | 1 + test/pty/key-routing.test.ts | 32 ++++++++++++++++++++++++++++ 9 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 .changeset/fuzzy-keys-match.md diff --git a/.changeset/fuzzy-keys-match.md b/.changeset/fuzzy-keys-match.md new file mode 100644 index 000000000..c9f4bfeb1 --- /dev/null +++ b/.changeset/fuzzy-keys-match.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Make `alt` and `option` keybindings work with both legacy terminal and Kitty keyboard encodings while preserving distinct Meta bindings when the terminal reports them. diff --git a/docs/keybindings.md b/docs/keybindings.md index 356b8f1e3..be44ed87a 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -35,7 +35,10 @@ shifted form (`"G"`), or a named key (`"tab"`, `"pageup"`, `"left"`, `"f2"`). `shift` applies to letters and named keys only — for a shifted symbol or digit, write the character the shift produces (`"!"`, not `"shift+1"`), since that is what terminals report. `ctrl+` also matches an unnamed bare control -byte; named Tab and Enter events stay distinct. +byte; named Tab and Enter events stay distinct. `alt`/`option` matches both +explicit Alt events and the Escape-prefixed form used by legacy terminals. A +legacy terminal cannot distinguish Alt from Meta, so `alt+n` and `meta+n` may +overlap there; Kitty keyboard events keep them distinct. Inline saved notes also expose clickable **Edit**, **Reply**, and (for reply-free user notes) **Delete** actions. `E` edits the first editable user note in the selected hunk and `R` replies diff --git a/src/extension-api/keys.test.ts b/src/extension-api/keys.test.ts index 0d8812c6a..e75683306 100644 --- a/src/extension-api/keys.test.ts +++ b/src/extension-api/keys.test.ts @@ -131,6 +131,31 @@ describe("matchesKeyChord", () => { expect(matchesKeyChord(parsed("r"), keyEvent({ name: "r", ctrl: true }))).toBe(false); }); + test("alt matches explicit option and legacy raw meta events", () => { + const alt = parsed("alt+n"); + expect(matchesKeyChord(alt, keyEvent({ name: "n", option: true }))).toBe(true); + expect( + matchesKeyChord(alt, keyEvent({ name: "n", meta: true, option: true, source: "kitty" })), + ).toBe(true); + expect(matchesKeyChord(alt, keyEvent({ name: "n", meta: true, source: "raw" }))).toBe(true); + expect(matchesKeyChord(alt, keyEvent({ name: "n", meta: true }))).toBe(true); + }); + + test("kitty keeps alt and meta distinct", () => { + const kittyAlt = keyEvent({ + name: "n", + meta: true, + option: true, + source: "kitty", + }); + const kittyMeta = keyEvent({ name: "n", meta: true, source: "kitty" }); + + expect(matchesKeyChord(parsed("alt+n"), kittyAlt)).toBe(true); + expect(matchesKeyChord(parsed("alt+n"), kittyMeta)).toBe(false); + expect(matchesKeyChord(parsed("meta+n"), kittyAlt)).toBe(false); + expect(matchesKeyChord(parsed("meta+n"), kittyMeta)).toBe(true); + }); + test("symbols match by sequence and ignore the shift flag", () => { const chord = parsed("{"); expect(matchesKeyChord(chord, keyEvent({ name: "[", sequence: "{", shift: true }))).toBe(true); diff --git a/src/extension-api/keys.ts b/src/extension-api/keys.ts index 50c56adcb..10afa208c 100644 --- a/src/extension-api/keys.ts +++ b/src/extension-api/keys.ts @@ -192,6 +192,24 @@ function matchesNamedKey(base: string, key: ExtensionKeyEvent) { return base === "space" && (name === " " || key.sequence === " "); } +/** Report whether an event carries the chord's modifiers across terminal protocols. */ +function matchesModifiers(parsed: ParsedKeyChord, key: ExtensionKeyEvent) { + if (Boolean(key.ctrl) !== parsed.ctrl) { + return false; + } + + const meta = Boolean(key.meta); + const option = Boolean(key.option); + + // Legacy terminals encode Alt as an Escape-prefixed key and can only report + // `meta`. Kitty distinguishes that legacy shape from a true Meta key. + if (parsed.option && !parsed.meta) { + return option || (meta && key.source !== "kitty"); + } + + return meta === parsed.meta && option === parsed.option; +} + /** * Report whether one key event is the parsed chord. * @@ -216,11 +234,7 @@ export function matchesKeyChord(parsed: ParsedKeyChord, key: ExtensionKeyEvent): return true; } - if (Boolean(key.ctrl) !== parsed.ctrl || Boolean(key.meta) !== parsed.meta) { - return false; - } - - if (Boolean(key.option) !== parsed.option) { + if (!matchesModifiers(parsed, key)) { return false; } diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 37e84fde8..455e3aaad 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -240,6 +240,8 @@ export interface ExtensionKeyEvent { /** The alt/option modifier. */ option?: boolean; shift?: boolean; + /** The terminal protocol that decoded the key, when the host exposes it. */ + source?: "raw" | "kitty"; } /* -------------------------------------------------------------------------- */ diff --git a/src/lib/commandKeys.test.ts b/src/lib/commandKeys.test.ts index 54c451581..18caf5742 100644 --- a/src/lib/commandKeys.test.ts +++ b/src/lib/commandKeys.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { KeyEvent } from "@opentui/core"; +import { KeyEvent, parseKeypress } from "@opentui/core"; import { matchesKeyChord, parseKeyChord, toKeyChordList } from "./commandKeys"; import { synthesizeKeyEvent } from "../ui/lib/syntheticKeyEvent"; @@ -19,6 +19,35 @@ function parsed(chord: string) { return result; } +/** Decode a real terminal sequence or fail the test when OpenTUI rejects it. */ +function decodedKey(sequence: string, useKittyKeyboard: boolean) { + const key = parseKeypress(sequence, { useKittyKeyboard }); + if (!key) { + throw new Error(`Could not decode terminal key ${JSON.stringify(sequence)}`); + } + + return key; +} + +describe("terminal modifier protocols", () => { + test("matches alt in legacy, explicit ANSI, and Kitty encodings", () => { + const alt = parsed("alt+n"); + + expect(matchesKeyChord(alt, decodedKey("\u001bn", false))).toBe(true); + expect(matchesKeyChord(alt, decodedKey("\u001b[27;3;110~", false))).toBe(true); + expect(matchesKeyChord(alt, decodedKey("\u001b[110;3u", true))).toBe(true); + }); + + test("distinguishes Kitty meta from Kitty alt", () => { + const kittyAlt = decodedKey("\u001b[110;3u", true); + const kittyMeta = decodedKey("\u001b[110;33u", true); + + expect(matchesKeyChord(parsed("alt+n"), kittyMeta)).toBe(false); + expect(matchesKeyChord(parsed("meta+n"), kittyAlt)).toBe(false); + expect(matchesKeyChord(parsed("meta+n"), kittyMeta)).toBe(true); + }); +}); + describe("synthesizeKeyEvent", () => { test("round-trips through the matcher for every chord form", () => { for (const chord of ["y", "G", "ctrl+shift+m", "f10", "{", "alt+left", ".", "space"]) { diff --git a/src/ui/lib/extensionKeyEvent.test.ts b/src/ui/lib/extensionKeyEvent.test.ts index 3c58d1c62..880754080 100644 --- a/src/ui/lib/extensionKeyEvent.test.ts +++ b/src/ui/lib/extensionKeyEvent.test.ts @@ -11,6 +11,7 @@ describe("toExtensionKeyEvent", () => { meta: false, option: true, shift: true, + source: "kitty", preventDefault() {}, stopPropagation() {}, } as unknown as KeyEvent; @@ -24,6 +25,7 @@ describe("toExtensionKeyEvent", () => { meta: false, option: true, shift: true, + source: "kitty", }); expect(snapshot).not.toBe(host); expect(Object.isFrozen(snapshot)).toBe(true); diff --git a/src/ui/lib/extensionKeyEvent.ts b/src/ui/lib/extensionKeyEvent.ts index a13983d3c..5c019bc6c 100644 --- a/src/ui/lib/extensionKeyEvent.ts +++ b/src/ui/lib/extensionKeyEvent.ts @@ -10,5 +10,6 @@ export function toExtensionKeyEvent(key: KeyEvent): ExtensionKeyEvent { meta: key.meta, option: key.option, shift: key.shift, + source: key.source, }); } diff --git a/test/pty/key-routing.test.ts b/test/pty/key-routing.test.ts index 0743fa19f..764c219cb 100644 --- a/test/pty/key-routing.test.ts +++ b/test/pty/key-routing.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { createPtyHarness, lineIndexOf, sleep } from "./harness"; const harness = createPtyHarness(); @@ -26,6 +28,36 @@ afterEach(() => { * the scroll box focus directly. */ describe("PTY key routing", () => { + test("an alt binding accepts the Escape-prefixed form from legacy terminals", async () => { + const configHome = harness.createIsolatedConfigHome(); + const configDir = join(configHome, "hunk"); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(configDir, "config.toml"), + '[keybindings]\n"hunk.app.toggleHelp" = "alt+n"\n', + ); + const fixture = harness.createTwoFileRepoFixture(); + const session = await harness.launchHunk({ + args: ["diff", "--mode", "split"], + cwd: fixture.dir, + cols: 220, + rows: 24, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); + await session.press("f10"); + await session.waitForText(/Reload/, { timeout: 5_000 }); + await session.press("escape"); + + session.writeRaw("\u001bn"); + await session.waitForText(/Controls help/, { timeout: 5_000 }); + } finally { + session.close(); + } + }); + test("one Escape closes the help overlay without wiping typed filter text", async () => { const fixture = harness.createTwoFileRepoFixture(); const session = await harness.launchHunk({ From f41a03d6378c6bcf10a6d2f742558dc6e63f399a Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:47:55 -0400 Subject: [PATCH 02/36] build: upgrade Bun to 1.4.2 (#990) --- .changeset/bun-1-4-2-runtime.md | 5 + .github/workflows/benchmarks.yml | 2 +- .github/workflows/ci.yml | 10 +- .github/workflows/install-vm.yml | 2 +- .github/workflows/pr-ci.yml | 8 +- .github/workflows/release-prebuilt-npm.yml | 12 +- .github/workflows/website.yml | 2 +- CONTRIBUTING.md | 2 +- bun.lock | 46 +++---- bunfig.toml | 5 + nix/bun.lock.nix | 112 ++++++++---------- nix/package.nix | 51 +++++++- package.json | 8 +- scripts/build-bin.test.ts | 16 ++- scripts/build-bin.ts | 11 +- scripts/verify-pr-release-notes.test.ts | 3 +- test/cli/compiled-headless-native-lib.test.ts | 3 +- .../compiled-opentui-positive-control.ts | 4 +- .../historical-pnpm-bun-corruption.sh | 8 +- 19 files changed, 182 insertions(+), 128 deletions(-) create mode 100644 .changeset/bun-1-4-2-runtime.md diff --git a/.changeset/bun-1-4-2-runtime.md b/.changeset/bun-1-4-2-runtime.md new file mode 100644 index 000000000..3b3bbf48d --- /dev/null +++ b/.changeset/bun-1-4-2-runtime.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Upgrade the bundled Bun runtime to 1.4.2 for lower memory use and runtime fixes. diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 0b2ddb129..8f3f3f5e6 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -29,7 +29,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eabc5cf5d..9b7a3a389 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,7 +41,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -104,7 +104,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile @@ -129,7 +129,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -182,7 +182,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -218,7 +218,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/install-vm.yml b/.github/workflows/install-vm.yml index 5625ec850..5b2ff8e42 100644 --- a/.github/workflows/install-vm.yml +++ b/.github/workflows/install-vm.yml @@ -31,7 +31,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install ordinary development dependencies run: bun install --frozen-lockfile diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 86b125a71..d665bb595 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -41,7 +41,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Verify release-note changeset env: @@ -64,7 +64,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -117,7 +117,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile @@ -142,7 +142,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 diff --git a/.github/workflows/release-prebuilt-npm.yml b/.github/workflows/release-prebuilt-npm.yml index 0dc76a89c..67681d104 100644 --- a/.github/workflows/release-prebuilt-npm.yml +++ b/.github/workflows/release-prebuilt-npm.yml @@ -48,7 +48,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Resolve npm dist-tag and GitHub latest status id: resolve @@ -87,7 +87,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile @@ -155,7 +155,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install dependencies run: bun install --frozen-lockfile @@ -195,7 +195,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -255,7 +255,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Set up Node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 @@ -302,7 +302,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Download platform artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 diff --git a/.github/workflows/website.yml b/.github/workflows/website.yml index 139ee2836..680e88f13 100644 --- a/.github/workflows/website.yml +++ b/.github/workflows/website.yml @@ -24,7 +24,7 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: - bun-version: 1.3.14 + bun-version: 1.4.2 - name: Install repository dependencies run: bun install --frozen-lockfile diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 84b7d8c4f..41094acba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -71,7 +71,7 @@ Keep the change focused on one user problem. Before adding another helper, state Requirements: -- Bun 1.3.14+ +- Bun 1.4.2+ - Node.js 22+ for npm package verification and release tasks - Git - macOS, Linux, or Windows diff --git a/bun.lock b/bun.lock index 333ce24c8..8293d3e78 100644 --- a/bun.lock +++ b/bun.lock @@ -5,7 +5,7 @@ "": { "name": "hunk", "dependencies": { - "bun": "^1.3.14", + "bun": "^1.4.2", "chokidar": "^4.0.3", "commander": "^14.0.3", "diff": "^8.0.3", @@ -24,7 +24,7 @@ "@opentui/react": "^0.5.6", "@pierre/diffs": "1.3.5", "@shikijs/themes": "3.23.0", - "@types/bun": "1.3.14", + "@types/bun": "1.4.1", "@types/react": "^19.2.14", "@types/ws": "^8.18.1", "dependency-cruiser": "18.2.0", @@ -34,7 +34,7 @@ "oxfmt": "^0.41.0", "oxlint": "^1.56.0", "react": "^19.2.4", - "simple-git-hooks": "^2.13.1", + "simple-git-hooks": "^2.14.0", "tuistory": "^0.11.0", "typescript": "^5.9.3", }, @@ -135,37 +135,29 @@ "@opentui/react": ["@opentui/react@0.5.6", "", { "dependencies": { "@opentui/core": "0.5.6", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-dMQTlcD1zlDTQC8r9rZKlHD7CORkY2M5D7tqhgoccoOK9p6q05FqrdRCTYHu1+VZYafqIV0k2IpMY2e34sngiQ=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.14", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="], + "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.4.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="], - "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="], + "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.4.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="], - "@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.14", "", { "os": "darwin", "cpu": "x64" }, "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="], + "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.4.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="], - "@oven/bun-freebsd-aarch64": ["@oven/bun-freebsd-aarch64@1.3.14", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="], + "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.4.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="], - "@oven/bun-freebsd-x64": ["@oven/bun-freebsd-x64@1.3.14", "", { "os": "freebsd", "cpu": "x64" }, "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="], + "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="], - "@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="], + "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.4.2", "", { "os": "android", "cpu": "arm64" }, "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ=="], - "@oven/bun-linux-aarch64-android": ["@oven/bun-linux-aarch64-android@1.3.14", "", { "os": "android", "cpu": "arm64" }, "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="], + "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.4.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ=="], - "@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.14", "", { "os": "linux", "cpu": "arm64" }, "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="], + "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="], - "@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="], + "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.4.2", "", { "os": "android", "cpu": "x64" }, "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw=="], - "@oven/bun-linux-x64-android": ["@oven/bun-linux-x64-android@1.3.14", "", { "os": "android", "cpu": "x64" }, "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="], + "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.4.2", "", { "os": "linux", "cpu": "x64" }, "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="], - "@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="], + "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.4.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="], - "@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="], - - "@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.14", "", { "os": "linux", "cpu": "x64" }, "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="], - - "@oven/bun-windows-aarch64": ["@oven/bun-windows-aarch64@1.3.14", "", { "os": "win32", "cpu": "arm64" }, "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="], - - "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="], - - "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.14", "", { "os": "win32", "cpu": "x64" }, "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="], + "@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.4.2", "", { "os": "win32", "cpu": "x64" }, "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="], "@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.142.0", "", { "os": "android", "cpu": "arm" }, "sha512-ZiRGDutGsv1G6bL/ozy/koC0Sv39T1DqyoC4KD1DOy9ZoACm1O5UWhEK2c02Qdk+4lfLVkvFa/mQ0fm/4h1BtQ=="], @@ -353,7 +345,7 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="], "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], @@ -385,11 +377,11 @@ "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "bun": ["bun@1.3.14", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.14", "@oven/bun-darwin-x64": "1.3.14", "@oven/bun-darwin-x64-baseline": "1.3.14", "@oven/bun-freebsd-aarch64": "1.3.14", "@oven/bun-freebsd-x64": "1.3.14", "@oven/bun-linux-aarch64": "1.3.14", "@oven/bun-linux-aarch64-android": "1.3.14", "@oven/bun-linux-aarch64-musl": "1.3.14", "@oven/bun-linux-x64": "1.3.14", "@oven/bun-linux-x64-android": "1.3.14", "@oven/bun-linux-x64-baseline": "1.3.14", "@oven/bun-linux-x64-musl": "1.3.14", "@oven/bun-linux-x64-musl-baseline": "1.3.14", "@oven/bun-windows-aarch64": "1.3.14", "@oven/bun-windows-x64": "1.3.14", "@oven/bun-windows-x64-baseline": "1.3.14" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="], + "bun": ["bun@1.4.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.4.2", "@oven/bun-darwin-x64": "1.4.2", "@oven/bun-freebsd-aarch64": "1.4.2", "@oven/bun-freebsd-x64": "1.4.2", "@oven/bun-linux-aarch64": "1.4.2", "@oven/bun-linux-aarch64-android": "1.4.2", "@oven/bun-linux-aarch64-musl": "1.4.2", "@oven/bun-linux-x64": "1.4.2", "@oven/bun-linux-x64-android": "1.4.2", "@oven/bun-linux-x64-musl": "1.4.2", "@oven/bun-windows-aarch64": "1.4.2", "@oven/bun-windows-x64": "1.4.2" }, "os": [ "!aix", "!sunos", "!openbsd", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="], "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -633,7 +625,7 @@ "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], - "simple-git-hooks": ["simple-git-hooks@2.13.1", "", { "bin": { "simple-git-hooks": "cli.js" } }, "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ=="], + "simple-git-hooks": ["simple-git-hooks@2.14.0", "", { "bin": { "simple-git-hooks": "cli.js" } }, "sha512-kkTORPAuxQz2g1QUuN0J8yGWT28tjGBfPOdJ4xT7Vbeu30veKUZQIoID2qGgCDAakaQn59UeZJJ4cFGB8PVP2A=="], "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], diff --git a/bunfig.toml b/bunfig.toml index 36eede24a..02b8e6eab 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -1,3 +1,8 @@ [install] # Only install packages published at least 7 days ago. minimumReleaseAge = 604800 + +# Bun 1.4 defaults workspaces to the isolated linker, which nests packages under +# node_modules/.bun/@/node_modules/. simple-git-hooks resolves its +# own package.json one directory too high in that layout, so preserve Hunk's hoisted layout. +linker = "hoisted" diff --git a/nix/bun.lock.nix b/nix/bun.lock.nix index 7d5849c2c..35fa53654 100644 --- a/nix/bun.lock.nix +++ b/nix/bun.lock.nix @@ -142,69 +142,53 @@ url = "https://registry.npmjs.org/@opentui/react/-/react-0.5.6.tgz"; hash = "sha512-dMQTlcD1zlDTQC8r9rZKlHD7CORkY2M5D7tqhgoccoOK9p6q05FqrdRCTYHu1+VZYafqIV0k2IpMY2e34sngiQ=="; }; - "@oven/bun-darwin-aarch64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.14.tgz"; - hash = "sha512-Omj20SuiHBOUjUBIyqtkNjSUIjOtEOJwmbix/ZyFH4BaQ6OZTaaRWIR4TjHVz0yadHgli6lLTiAh1uarnvD49A=="; + "@oven/bun-darwin-aarch64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.4.2.tgz"; + hash = "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="; }; - "@oven/bun-darwin-x64-baseline@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.14.tgz"; - hash = "sha512-OSfsTZstc898HHElhU4NccaBGOSSDn5VfahiVTnidZ9B/+wb7WTyfZJaBeJcfjwJ9H2W9uTh2TGtl3UfcXgV9g=="; + "@oven/bun-darwin-x64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.4.2.tgz"; + hash = "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="; }; - "@oven/bun-darwin-x64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.14.tgz"; - hash = "sha512-FFj3QdU/OhlDyZOJ8CWfN5eWLpRlT4qjZg7lMQi7jA6GuoY5ajlO1zWLP/MuHYRSbXQUvV52RejNi8DVnAp13w=="; + "@oven/bun-freebsd-aarch64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.4.2.tgz"; + hash = "sha512-SMNItMw1Z8QeeQVKnw8jA7xQNkeXdP+OPgin4Wi/QTx/B8RHHLnuZfqmFy7NtVeT2NF0kKYppW4WWd2CCYZjhQ=="; }; - "@oven/bun-freebsd-aarch64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-freebsd-aarch64/-/bun-freebsd-aarch64-1.3.14.tgz"; - hash = "sha512-LIKrXaFxAHybVO5Pf+9XP2FHUj/5APvXTUKk9dqHm5iFz4oH+W24cmhjkJirNujh9hKeTyrpWSe3no9JZKowIw=="; + "@oven/bun-freebsd-x64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.4.2.tgz"; + hash = "sha512-THbPKXhO54N0DpFRKZNDZpQ7dpbX0bWASuARckAUS9wRtFIHsiY+uULXJvxJGo2YD1YewvXQ4G8Fj7XT5oBCiw=="; }; - "@oven/bun-freebsd-x64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-freebsd-x64/-/bun-freebsd-x64-1.3.14.tgz"; - hash = "sha512-uwD+fGUH1ADpIF3B1U2jWzzb20QwRLZfj5QZ28GUCGrAJ/nTmWrD6YYGsblCY1wuhldRez3lU40AyuvSCyLYmw=="; + "@oven/bun-linux-aarch64-android@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.4.2.tgz"; + hash = "sha512-3mZKO2rhsNgbAUtAHC1UKUlF2zTxFraDZT/Elv8wzyH0fJL9h+Iv3TgB9lO63w89PRn3eFe+NRA1bhVgikKNPQ=="; }; - "@oven/bun-linux-aarch64-android@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-android/-/bun-linux-aarch64-android-1.3.14.tgz"; - hash = "sha512-y4kq5b85lsrmFb9Xvi4w9mA5IEFJkLMrSmYn06q24KjL9rUWDWO3VFZEtteZxUN5+ec3Zm5S8OnJw1umaCbVjA=="; + "@oven/bun-linux-aarch64-musl@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.4.2.tgz"; + hash = "sha512-+Sm6y+lSiSFBOtXmnekp5Q6n1tUKlyv71FCPWBc61Cgb14T5eBs8SN/nh4MUCOKzONkI3O+as3MGUgikS4aCBQ=="; }; - "@oven/bun-linux-aarch64-musl@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.14.tgz"; - hash = "sha512-jmqOA92Cd1NL/1XBd4bFkJLxQ86K0RW7ohxS2qzzAvuitO4JiIxjjTeCspoU44zCozH72HpfZfUE2On31OjnWA=="; + "@oven/bun-linux-aarch64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.4.2.tgz"; + hash = "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="; }; - "@oven/bun-linux-aarch64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.14.tgz"; - hash = "sha512-X5SsPZHs+iYO8R/efIcRtc7gT2Q2DgPfliCxEkx4cXBumwkw0c/EsHMNwH3EgGpCDaZ7IYVPhpCG/xBOQHEwZw=="; + "@oven/bun-linux-x64-android@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.4.2.tgz"; + hash = "sha512-6HC5tzcC79113n2IHCTJMWv+HsQImv4ZFEK2XpYLxY6HbT8tM4cUM2Zv1bHZBQsS3jv/zYBamDJ1UX7If0d5tw=="; }; - "@oven/bun-linux-x64-android@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64-android/-/bun-linux-x64-android-1.3.14.tgz"; - hash = "sha512-qe9e1d+3VAEU7nAA2ol9Jvmy/o99PVMSgZhHn7Q/9O3YcDrfEqyQ8zm4zoe5qTEo8HZH0dN03Le0Ys2eQPs7eg=="; + "@oven/bun-linux-x64-musl@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.4.2.tgz"; + hash = "sha512-vVTKUg1bnPhRP/Hp73jIVoFh2vPFNYEqYX0ERKfZBOQEEHitNAeukZzzuUDZS0SoDCIpuWUGSpd/CDMbjdR+Uw=="; }; - "@oven/bun-linux-x64-baseline@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.14.tgz"; - hash = "sha512-q/8EdOC0yUE8FPeoOVq8/Pw5I9/tJaYmUfO/uDUAREx8IUnOJH1RJ5A3BjFqre8pvJoiZA9AovPJq5FnNNjSxA=="; + "@oven/bun-linux-x64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.4.2.tgz"; + hash = "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="; }; - "@oven/bun-linux-x64-musl-baseline@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.14.tgz"; - hash = "sha512-n6iE71G4lQE4XkrZhQQcL5YUlxDbnq6nqV7zeQi33PMsLT/0kYE+RvHOtBWZ3w0wMdXZfINmp63hIb9ijUBGtw=="; + "@oven/bun-windows-aarch64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.4.2.tgz"; + hash = "sha512-8EJ1ST7339WJE3poPW5nBgVW/lWf9HBz4W27ZUNhburKmcBLOByPyE6DP9fHD8FQGm5c+ilUN2hX1mrW0jxq9Q=="; }; - "@oven/bun-linux-x64-musl@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.14.tgz"; - hash = "sha512-GBCB/k/sIqcr06eTNgg7g46qiUv35Jasx4XiccJ/n7RGqrE4RWUD/XJBbWFprVPjvqd59+QtSnS99XGqvftHfg=="; - }; - "@oven/bun-linux-x64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.14.tgz"; - hash = "sha512-7OVTAKvwfPmSbIV1HpdOoVVx5VRc427GuPPne93N6vk4eQBPId9nXmZDh9/zGaKPdbVjVtQSZafWQoUjx38Utw=="; - }; - "@oven/bun-windows-aarch64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-windows-aarch64/-/bun-windows-aarch64-1.3.14.tgz"; - hash = "sha512-T7s3x/BsVKQObGU6QDkZeI6wKynzqGbBH1yI77jrrj5siElclxr3DQrDIk8CV4G5/SJq2HHq4kpLyYY2DKCSmA=="; - }; - "@oven/bun-windows-x64-baseline@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.14.tgz"; - hash = "sha512-uIjLUC1S9DWgICzuoMba7vurBJnBruE4S5CxnvmZkdqWVXRzx1Rgu636HoH+k0qeaQCFh3jeG3JQ1y6fRHv0sw=="; - }; - "@oven/bun-windows-x64@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.14.tgz"; - hash = "sha512-mUFWL3BoYkNpjd8e9PqROiFF/1Xeotq20mABJsiQH62jM1g5zqWh4khw1RZ6bX8Q8fWvlPaxG1PjofkmjUi3vg=="; + "@oven/bun-windows-x64@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.4.2.tgz"; + hash = "sha512-+bN6OuVld/9diT/RLSXSW7JE6CvNE3gL9XsAEjULi1nUsXd6DNO6GuA9jNdNb3r8PdJFnYHr5aypNV1Oj3Rd9g=="; }; "@oxc-parser/binding-android-arm-eabi@0.142.0" = fetchurl { url = "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.142.0.tgz"; @@ -578,9 +562,9 @@ url = "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz"; hash = "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="; }; - "@types/bun@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/@types/bun/-/bun-1.3.14.tgz"; - hash = "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="; + "@types/bun@1.4.1" = fetchurl { + url = "https://registry.npmjs.org/@types/bun/-/bun-1.4.1.tgz"; + hash = "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="; }; "@types/hast@3.0.4" = fetchurl { url = "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz"; @@ -654,13 +638,13 @@ url = "https://registry.npmjs.org/bun-ffi-structs/-/bun-ffi-structs-0.3.1.tgz"; hash = "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="; }; - "bun-types@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/bun-types/-/bun-types-1.3.14.tgz"; - hash = "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="; + "bun-types@1.4.1" = fetchurl { + url = "https://registry.npmjs.org/bun-types/-/bun-types-1.4.1.tgz"; + hash = "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="; }; - "bun@1.3.14" = fetchurl { - url = "https://registry.npmjs.org/bun/-/bun-1.3.14.tgz"; - hash = "sha512-aB6GVd42x1Y5ie1K16SF+oLGtgSkwX9hgoDdIW88pjvfTccU8F1vfpoOt34QLv0dZ1v3XimtaxPlZUG81Gx9Zg=="; + "bun@1.4.2" = fetchurl { + url = "https://registry.npmjs.org/bun/-/bun-1.4.2.tgz"; + hash = "sha512-TrSXo6HJfIEaczpb3kjX82I2pL47vK1QUNmHRCUdz9IzaOwa9lzOXSWwu2l18YHE3sNfGRapVLd4nNm+22vVVA=="; }; "ccount@2.0.1" = fetchurl { url = "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz"; @@ -1162,9 +1146,9 @@ url = "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz"; hash = "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="; }; - "simple-git-hooks@2.13.1" = fetchurl { - url = "https://registry.npmjs.org/simple-git-hooks/-/simple-git-hooks-2.13.1.tgz"; - hash = "sha512-WszCLXwT4h2k1ufIXAgsbiTOazqqevFCIncOuUBZJ91DdvWcC5+OFkluWRQPrcuSYd8fjq+o2y1QfWqYMoAToQ=="; + "simple-git-hooks@2.14.0" = fetchurl { + url = "https://registry.npmjs.org/simple-git-hooks/-/simple-git-hooks-2.14.0.tgz"; + hash = "sha512-kkTORPAuxQz2g1QUuN0J8yGWT28tjGBfPOdJ4xT7Vbeu30veKUZQIoID2qGgCDAakaQn59UeZJJ4cFGB8PVP2A=="; }; "sisteransi@1.0.5" = fetchurl { url = "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"; diff --git a/nix/package.nix b/nix/package.nix index a9ee5818c..13925e510 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -1,11 +1,46 @@ { - bun, + autoPatchelfHook, bun2nix, + fetchurl, lib, makeWrapper, + stdenv, ... }: let packageJson = lib.importJSON ../package.json; + bunVersion = lib.removePrefix "bun@" packageJson.packageManager; + bunCompilerArchives = { + "aarch64-darwin" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-${bunVersion}.tgz"; + hash = "sha512-MXdZkP1featqxZ+/VTXWG1BVjM4OGBehVY2Q88EeUj/7L0UMeCGItmyPYTN+wxvlGJ6F66JEtzsw+GvQWewnag=="; + }; + "x86_64-darwin" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-${bunVersion}.tgz"; + hash = "sha512-gZTxZuLjkUhAWjTETu3tw0WhsEdNkJ64daj60ybhPf835a2yollV3yTkK9JozvzKPx4TRFzLSl8C+U525pxVbw=="; + }; + "aarch64-linux" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-${bunVersion}.tgz"; + hash = "sha512-3BBP9ovJ2RGHFH6Ae1CAtxNtG1+YY6GD6rmYbsUosoAk9+OEl6zeDQ/k4fBkc6dYOJCtWnx8hUxzNzQATSmvYQ=="; + }; + "x86_64-linux" = fetchurl { + url = "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-${bunVersion}.tgz"; + hash = "sha512-9/E/UXOTpSo3YsV5g+FhtTd/qTpiWoKuxS12cqtuYA1ssu9fRAoPQnipFgGyck3tWO63iUdxBiygq+kELFawng=="; + }; + }; + bunCompilerArchive = bunCompilerArchives.${stdenv.hostPlatform.system}; + bunCompiler = stdenv.mkDerivation { + pname = "bun-compiler"; + version = bunVersion; + src = bunCompilerArchive; + sourceRoot = "package"; + dontBuild = true; + dontStrip = true; + nativeBuildInputs = lib.optionals stdenv.isLinux [autoPatchelfHook]; + installPhase = '' + mkdir -p $out/bin + cp -p bin/bun $out/bin/bun + ''; + }; in bun2nix.mkDerivation { pname = "hunkdiff"; @@ -22,9 +57,21 @@ in buildPhase = '' runHook preBuild mkdir -p .bun-tmp .bun-install + + # Compile with the pinned release archive instead of nixpkgs' older Bun. + bun_compiler=${bunCompiler}/bin/bun + if [ ! -x "$bun_compiler" ]; then + echo "Bun compiler archive did not contain an executable" >&2 + exit 1 + fi + if [ "$("$bun_compiler" --version)" != "${bunVersion}" ]; then + echo "Expected Bun compiler ${bunVersion}" >&2 + exit 1 + fi + BUN_TMPDIR=$PWD/.bun-tmp \ BUN_INSTALL=$PWD/.bun-install \ - ${bun}/bin/bun build --compile \ + "$bun_compiler" build --compile \ --no-compile-autoload-bunfig \ "./src/main.tsx" \ --outfile "hunk-bin" diff --git a/package.json b/package.json index 351997caf..1b761ece8 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "nix:update-lock": "nix run .#update-bun-lock" }, "dependencies": { - "bun": "^1.3.14", + "bun": "^1.4.2", "chokidar": "^4.0.3", "commander": "^14.0.3", "diff": "^8.0.3", @@ -141,7 +141,7 @@ "@opentui/react": "^0.5.6", "@pierre/diffs": "1.3.5", "@shikijs/themes": "3.23.0", - "@types/bun": "1.3.14", + "@types/bun": "1.4.1", "@types/react": "^19.2.14", "@types/ws": "^8.18.1", "dependency-cruiser": "18.2.0", @@ -151,7 +151,7 @@ "oxfmt": "^0.41.0", "oxlint": "^1.56.0", "react": "^19.2.4", - "simple-git-hooks": "^2.13.1", + "simple-git-hooks": "^2.14.0", "tuistory": "^0.11.0", "typescript": "^5.9.3" }, @@ -175,7 +175,7 @@ "engines": { "node": ">=22" }, - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.4.2", "pi": { "skills": [ "./skills" diff --git a/scripts/build-bin.test.ts b/scripts/build-bin.test.ts index 1b4301035..1331f1a7c 100644 --- a/scripts/build-bin.test.ts +++ b/scripts/build-bin.test.ts @@ -1,5 +1,19 @@ import { describe, expect, test } from "bun:test"; -import { compileTargetForHost } from "./build-bin"; +import path from "node:path"; +import { bunCompilerCacheEnvironment, compileTargetForHost } from "./build-bin"; + +describe("bunCompilerCacheEnvironment", () => { + test("keeps downloaded compile targets separate across Bun upgrades", () => { + const oldRuntime = bunCompilerCacheEnvironment("repo", "1.3.14"); + const newRuntime = bunCompilerCacheEnvironment("repo", "1.4.2"); + + expect(newRuntime).toEqual({ + BUN_INSTALL: path.join("repo", ".bun-install", "1.4.2"), + BUN_INSTALL_CACHE_DIR: path.join("repo", ".bun-install", "1.4.2", "install", "cache"), + }); + expect(oldRuntime.BUN_INSTALL_CACHE_DIR).not.toBe(newRuntime.BUN_INSTALL_CACHE_DIR); + }); +}); describe("compileTargetForHost", () => { test("compiles every x64 platform against Bun's baseline runtime", () => { diff --git a/scripts/build-bin.ts b/scripts/build-bin.ts index b6e84498e..870d29963 100644 --- a/scripts/build-bin.ts +++ b/scripts/build-bin.ts @@ -37,6 +37,15 @@ export function compileTargetForHost( return null; } +/** Returns compiler cache variables scoped to the Bun version that populates them. */ +export function bunCompilerCacheEnvironment(repoRoot: string, runtimeVersion: string) { + const installRoot = path.join(repoRoot, ".bun-install", runtimeVersion); + return { + BUN_INSTALL: installRoot, + BUN_INSTALL_CACHE_DIR: path.join(installRoot, "install", "cache"), + }; +} + if (import.meta.main) { const repoRoot = path.resolve(import.meta.dir, ".."); const distDir = path.join(repoRoot, "dist"); @@ -69,7 +78,7 @@ if (import.meta.main) { env: { ...process.env, BUN_TMPDIR: path.join(repoRoot, ".bun-tmp"), - BUN_INSTALL: path.join(repoRoot, ".bun-install"), + ...bunCompilerCacheEnvironment(repoRoot, Bun.version), }, }, ); diff --git a/scripts/verify-pr-release-notes.test.ts b/scripts/verify-pr-release-notes.test.ts index dca493e69..821a59bce 100644 --- a/scripts/verify-pr-release-notes.test.ts +++ b/scripts/verify-pr-release-notes.test.ts @@ -198,6 +198,7 @@ describe("verifyPrReleaseNotes", () => { expect(existsSync(path.join(root, "status-call.json"))).toBe(false); }); + // Hosted Windows can spend more than Bun's default deadline on the two fixture commits. test("routes a stable promotion that removes prerelease state through Changesets", async () => { const { root } = createTestRepo(); writeGeneratedPrerelease(root); @@ -218,7 +219,7 @@ describe("verifyPrReleaseNotes", () => { expect(JSON.parse(readFileSync(path.join(root, "status-call.json"), "utf8"))).toEqual([ `--since=${prereleaseBase}`, ]); - }); + }, 10_000); test("rejects an initial version older than the carried stable changelog", async () => { const { root, base } = createTestRepo(); diff --git a/test/cli/compiled-headless-native-lib.test.ts b/test/cli/compiled-headless-native-lib.test.ts index 8d260beed..5ffb4de21 100644 --- a/test/cli/compiled-headless-native-lib.test.ts +++ b/test/cli/compiled-headless-native-lib.test.ts @@ -9,7 +9,8 @@ const executable = process.env.HUNK_TEST_EXECUTABLE : undefined; const compiledTest = executable ? test : test.skip; const compiledLinuxTest = executable && process.platform === "linux" ? test : test.skip; -const BUN_NATIVE_ARTIFACT_PATTERN = /^\.[0-9a-f]{16}-[0-9a-f]{8}\.(?:so|dylib|dll)$/; +const BUN_NATIVE_ARTIFACT_PATTERN = + /^(?:\.[0-9a-f]{16}-[0-9a-f]{8}|\.bun-\d+-[0-9a-f]{16})\.(?:so|dylib|dll)$/; const positiveControlBuildRoot = executable ? mkdtempSync(resolve(tmpdir(), "hunk-compiled-opentui-control-")) : undefined; diff --git a/test/cli/fixtures/compiled-opentui-positive-control.ts b/test/cli/fixtures/compiled-opentui-positive-control.ts index 761a1aa78..da4c073e4 100644 --- a/test/cli/fixtures/compiled-opentui-positive-control.ts +++ b/test/cli/fixtures/compiled-opentui-positive-control.ts @@ -1,3 +1,3 @@ -import { RGBA } from "@opentui/core"; +import { resolveRenderLib } from "@opentui/core"; -process.stdout.write(RGBA.fromHex("#ffffff").toString()); +process.stdout.write(resolveRenderLib() ? "loaded" : "missing"); diff --git a/test/cli/install-vm/scenarios/historical-pnpm-bun-corruption.sh b/test/cli/install-vm/scenarios/historical-pnpm-bun-corruption.sh index fb02ff86d..0c31459c4 100755 --- a/test/cli/install-vm/scenarios/historical-pnpm-bun-corruption.sh +++ b/test/cli/install-vm/scenarios/historical-pnpm-bun-corruption.sh @@ -8,12 +8,8 @@ pnpm config set registry https://registry.npmjs.org/ hunkdiff_version=${HISTORICAL_HUNKDIFF_VERSION:?HISTORICAL_HUNKDIFF_VERSION is required} bun_version=${HISTORICAL_BUN_VERSION:?HISTORICAL_BUN_VERSION is required} -# Force the historical Hunk dependency to the exact Bun release that mutated pnpm's projection. -cat >"$HOME/pnpm/global/pnpm-workspace.yaml" </dev/null) run_expect historical-bun-version 0 node -e 'console.log(require(process.argv[1]).version)' "$bun_manifest" assert_contains historical-bun-pinned "$command_dir/historical-bun-version.log" "$bun_version" From 921956215557de8792a953758358b07c23d1c33c Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:18:06 -0400 Subject: [PATCH 03/36] feat: add static-first history browsing (#979) --- .changeset/pretty-git-history.md | 5 + README.md | 12 +- docs/extension-architecture.md | 9 + docs/extensions.md | 70 +++- docs/keybindings.md | 7 + scripts/generate-docs.test.ts | 2 + scripts/generate-docs.ts | 36 +- skills/hunk-extensions/SKILL.md | 2 +- src/app/cli.test.ts | 102 +++++ src/app/cli.ts | 207 +++++++++- src/app/historyBootstrap.ts | 157 ++++++++ src/app/startup.ts | 33 ++ src/core/history/lanePlanner.test.ts | 87 ++++ src/core/history/lanePlanner.ts | 100 +++++ src/core/history/types.ts | 32 ++ src/core/process/pager.test.ts | 49 ++- src/core/process/pager.ts | 91 ++++- src/core/process/relaunch.test.ts | 26 ++ src/core/process/relaunch.ts | 31 ++ src/core/run/cliCommandNames.ts | 1 + src/core/run/commandInputs.ts | 26 ++ src/core/vcs/index.ts | 39 ++ src/core/vcs/types.ts | 24 +- src/extension-api/index.ts | 7 + src/extension-api/types.ts | 106 ++++- .../default/vcs/git/history.test.ts | 164 ++++++++ src/extensions/default/vcs/git/history.ts | 381 ++++++++++++++++++ src/extensions/default/vcs/git/index.ts | 16 + .../default/vcs/jujutsu/history.test.ts | 252 ++++++++++++ src/extensions/default/vcs/jujutsu/history.ts | 369 +++++++++++++++++ src/extensions/default/vcs/jujutsu/index.ts | 11 + src/extensions/runExtension.test.ts | 325 ++++++++++++++- src/extensions/runExtension.ts | 361 ++++++++++++++++- src/extensions/types.ts | 7 + src/main.tsx | 12 + src/session/broker/brokerLauncher.ts | 50 +-- src/ui/App.tsx | 10 +- src/ui/history/runInteractiveHistory.test.ts | 103 +++++ src/ui/history/runInteractiveHistory.ts | 364 +++++++++++++++++ src/ui/history/runStaticHistory.test.ts | 191 +++++++++ src/ui/history/runStaticHistory.ts | 139 +++++++ src/ui/history/staticProjection.test.ts | 115 ++++++ src/ui/history/staticProjection.ts | 265 ++++++++++++ src/ui/history/terminalInput.test.ts | 53 +++ src/ui/history/terminalInput.ts | 193 +++++++++ src/ui/history/types.ts | 20 + test/cli/log.test.ts | 245 +++++++++++ test/pty/log-integration.test.ts | 97 +++++ 48 files changed, 4884 insertions(+), 120 deletions(-) create mode 100644 .changeset/pretty-git-history.md create mode 100644 src/app/historyBootstrap.ts create mode 100644 src/core/history/lanePlanner.test.ts create mode 100644 src/core/history/lanePlanner.ts create mode 100644 src/core/history/types.ts create mode 100644 src/core/process/relaunch.test.ts create mode 100644 src/core/process/relaunch.ts create mode 100644 src/extensions/default/vcs/git/history.test.ts create mode 100644 src/extensions/default/vcs/git/history.ts create mode 100644 src/extensions/default/vcs/jujutsu/history.test.ts create mode 100644 src/extensions/default/vcs/jujutsu/history.ts create mode 100644 src/ui/history/runInteractiveHistory.test.ts create mode 100644 src/ui/history/runInteractiveHistory.ts create mode 100644 src/ui/history/runStaticHistory.test.ts create mode 100644 src/ui/history/runStaticHistory.ts create mode 100644 src/ui/history/staticProjection.test.ts create mode 100644 src/ui/history/staticProjection.ts create mode 100644 src/ui/history/terminalInput.test.ts create mode 100644 src/ui/history/terminalInput.ts create mode 100644 src/ui/history/types.ts create mode 100644 test/cli/log.test.ts create mode 100644 test/pty/log-integration.test.ts diff --git a/.changeset/pretty-git-history.md b/.changeset/pretty-git-history.md new file mode 100644 index 000000000..49cef1d39 --- /dev/null +++ b/.changeset/pretty-git-history.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add themed, static-first Git and Jujutsu history with compact output and an interactive browser that opens selected commits in Hunk. diff --git a/README.md b/README.md index d327d7a3f..5257ebf5d 100644 --- a/README.md +++ b/README.md @@ -87,11 +87,21 @@ hunk --fast # experimentally offload eligible syntax highligh hunk diff --watch # auto-reload as the working tree changes hunk show # review the latest commit hunk show HEAD~1 # review an earlier commit +hunk log # print the selected provider's repository history +hunk log --interactive # browse history; Enter opens a commit in Hunk ``` +`hunk log` is a static-first, read-only history surface, not a repository manager. The selected VCS +adapter owns traversal, filtering, refs, and how a history item opens for review; the bundled Git +and Jujutsu adapters both implement that public capability. Default output keeps full commit, +author, date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact rows, and `--theme` +uses the same palette as Hunk review. Static output remains safe for pipes, redirects, and normal +terminal scrollback. The explicit interactive mode stays a single history list; after opening a +commit, quit its normal Hunk review to return to the same selection. + ### Working with Jujutsu and Sapling -Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). +Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. `hunk log --vcs jj` also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). ### Working with raw files and patches diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 9bd50888b..ccf422cd3 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -346,6 +346,15 @@ bootstrap marker and also consults the available catalog; startup performs a second root/config pass when a global, config-path, or CLI adapter recognizes a repository unavailable to the bundled catalog. +`hunk log` follows the same boundary. Core/app and `src/ui/history/` own the built-in command, +validated graph planning, presentation, themes, paging, terminal lifecycle, and child-process +orchestration. The selected adapter's public `history` capability owns traversal, filtering, +immutable revision and parent identities, structured decorations, and the declarative review action +for a selected item. The host treats those ids as opaque and never constructs provider revision +syntax or decides root/merge comparison semantics. History pages remain child-before-parent across +the full cursor, including page boundaries; the extension conversion boundary validates that +ordering before core or UI consumes it. + ## Public contract rules The authoring surface is the `hunkdiff/extension` export — a façade over diff --git a/docs/extensions.md b/docs/extensions.md index 179eb1dbc..d4844dd62 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -280,10 +280,11 @@ new instances and run that shutdown/startup pair around the replacement. ### `hunk.apiVersion` -The API generation this Hunk speaks (currently `18`). Branch on it if you want -one file to support several Hunk versions. Version 18 lets lifecycle and custom-event handlers -request a host-owned review reload; version 17 adds structured review metadata to delegated -patch commands and projects it into pane availability and component props; version 16 adds pane-wide +The API generation this Hunk speaks (currently `19`). Branch on it if you want +one file to support several Hunk versions. Version 19 adds provider-owned history +enumeration and review planning; version 18 lets lifecycle and custom-event handlers request +a host-owned review reload; version 17 adds structured review metadata to delegated patch +commands and projects it into pane availability and component props; version 16 adds pane-wide `onActivate`; version 15 added `{ side, line }` to opted-in pane `currentLine` paint; version 14 added structured `rangeEndpoints` to two-revision VCS diff requests; version 13 added saved-note parent identities and @@ -500,6 +501,67 @@ reuses one is skipped with a notice. map off entirely — produces a clear "not supported" error for that command instead of a crash. +API version 17 adds the optional, read-only `history` capability used by the built-in `hunk log` surface: + +```ts +hunk.registerVcsAdapter({ + id: "hg-history", + name: "Mercurial history", + detect: () => null, + history: { + async open() { + return { + async read({ signal }) { + signal?.throwIfAborted(); + return { commits: [], done: true }; + }, + close() {}, + }; + }, + planReview(commit) { + return commit.parentRevisionIds[0] + ? { + kind: "revision-range", + fromRevisionId: commit.parentRevisionIds[0], + toRevisionId: commit.revisionId, + } + : { kind: "revision-show", revisionId: commit.revisionId }; + }, + }, +}); +``` + +The snippet above demonstrates static history production only; it is not a complete interactive +adapter. Add a `revision-show` operation for `revision-show` actions and a `working-tree-diff` +operation that accepts `rangeEndpoints` for `revision-range` actions before advertising interactive +opening. Otherwise Enter reports that the corresponding review operation is unsupported. + +History is deliberately separate from patch-producing `operations`. The built-in host owns command +routing, graph planning, themes, terminal lifecycle, and static/interactive presentation. The +adapter owns every repository semantic: traversal and filtering, immutable identities, refs, and +`planReview`'s decision about how roots and merges open through that adapter's ordinary review +operations. Hunk treats revision ids as opaque strings and never invents provider revision syntax. + +Commits must carry an immutable full `revisionId`, display id, ordered parent ids, subject, optional +message body, author (and optional email), ISO authored time, and structured ref decorations. The +optional `logicalId` identifies the same logical change across provider rewrites (for example, a +Jujutsu change id); Hunk treats it as metadata and continues to key graph and review operations by +immutable `revisionId`. A `head` decoration carries an optional `attachedLocalBranch`; use that field +rather than embedding an arrow or branch identity in its display label. + +Every source must emit commits in **child-before-parent topological order**. If both a child and one +of its parents are included, the child appears first. This invariant spans the source's complete +lifetime: page boundaries do not reset it, and a parent returned on one page cannot be followed by +its child on a later page. Reads may return at most the requested limit and must distinguish a page +boundary from repository end with `done`. Hunk copies and validates every page, strips terminal +controls from display metadata, rejects duplicate revisions and parent-before-child output across +pages, forwards cancellation, and closes the source at EOF or failure. + +The bundled Git and Jujutsu extensions implement this public capability today; Sapling currently +reports it as unsupported. Jujutsu supplies commit/change identities, bookmarks, tags, traversal, +and native merge-review semantics without routing through a colocated Git repository. Third-party +adapters use exactly the same contract. + A `load` result is patch text plus how to label it. Everything else on it is optional, and each optional field buys one thing: diff --git a/docs/keybindings.md b/docs/keybindings.md index 356b8f1e3..3d8291273 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -45,6 +45,13 @@ deleted until its replies are removed. The built-in commands and the keys they ship with: +`hunk log --interactive` is a separate, fixed read-only history entry point rather than part of +the configurable review command table. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`, +`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `y` to copy the full commit id, +`Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit +id to open it immediately, click elsewhere on a row to select it, or double-click a row to open it. +Quitting the opened review returns to the retained history selection and viewport. + | Command id | Does | Default keys | | ---------------------------------------------- | ---------------------------------------------- | ---------------------------- | | `hunk.app.openAgentSkill` | Show agent skill | _(none)_ | diff --git a/scripts/generate-docs.test.ts b/scripts/generate-docs.test.ts index 11fb0d7d2..a94d27f3b 100644 --- a/scripts/generate-docs.test.ts +++ b/scripts/generate-docs.test.ts @@ -36,6 +36,8 @@ describe("generated website references", () => { expect(reference).toContain("hunk diff --staged"); expect(reference).toContain("--no-transparent-bg"); expect(reference).toContain("hunk markup render"); + expect(reference).not.toContain("## `hunk log`"); + expect(reference).not.toContain("`--vcs `"); expect(reference).toMatch( new RegExp( `\\| \\x60${SESSION_BROKER_HOST_ENV}\\x60\\s+\\| Bind host; defaults to loopback \\x60${DEFAULT_SESSION_BROKER_HOST}\\x60\\.`, diff --git a/scripts/generate-docs.ts b/scripts/generate-docs.ts index 75fb29470..6b1205827 100644 --- a/scripts/generate-docs.ts +++ b/scripts/generate-docs.ts @@ -50,18 +50,22 @@ function renderOptionTable(options: readonly (CliReferenceOption | AgentCommandO return "This command has no command-specific options."; } - const rows = options.map((option) => { - const referenceOption = option as CliReferenceOption; - const details = [ - option.description, - referenceOption.defaultValue ? `Default: ${referenceOption.defaultValue}.` : undefined, - referenceOption.hidden ? "Compatibility inverse; omitted from `--help`." : undefined, - "required" in option && option.required ? "Required." : undefined, - ] - .filter(Boolean) - .join(" "); - return `| \`${tableCell(option.flag)}\` | ${tableCell(proseSafe(details))} |`; - }); + const rows = options + .filter((option) => (option as CliReferenceOption).publicDocs !== false) + .map((option) => { + const referenceOption = option as CliReferenceOption; + const details = [ + option.description, + referenceOption.defaultValue ? `Default: ${referenceOption.defaultValue}.` : undefined, + referenceOption.hidden + ? (referenceOption.hiddenNote ?? "Hidden from `--help`.") + : undefined, + "required" in option && option.required ? "Required." : undefined, + ] + .filter(Boolean) + .join(" "); + return `| \`${tableCell(option.flag)}\` | ${tableCell(proseSafe(details))} |`; + }); return ["| Option | Description |", "| --- | --- |", ...rows].join("\n"); } @@ -118,8 +122,9 @@ function renderUsage(lines: readonly string[]) { /** Render the deterministic exhaustive CLI reference. */ export function renderCliReference() { - const commandSections = (Object.values(CLI_REFERENCE_COMMANDS) as CliReferenceCommand[]).map( - (command) => { + const commandSections = (Object.values(CLI_REFERENCE_COMMANDS) as CliReferenceCommand[]) + .filter((command) => command.publicDocs !== false) + .map((command) => { const pieces = [ `## \`hunk ${command.path}\``, "", @@ -164,8 +169,7 @@ export function renderCliReference() { ); } return pieces.join("\n"); - }, - ); + }); const sessionSections = SESSION_AGENT_COMMAND_LIST.map((command) => { const pieces = [ diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index f4c8ae713..363a36ec7 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -111,7 +111,7 @@ bad or duplicate id is skipped with a startup notice. | Reload after an external agent changes reviewed inputs | `ctx.review.requestReload()` in an event | | Read user-supplied settings | `hunk.config` (`[extension.]` table) | | Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command | -| Branch on the API generation (currently `18`) | `hunk.apiVersion` | +| Branch on the API generation (currently `17`) | `hunk.apiVersion` | Registration is only valid while the factory runs — Hunk seals the API object afterwards. diff --git a/src/app/cli.test.ts b/src/app/cli.test.ts index dd9333691..3c27a99ed 100644 --- a/src/app/cli.test.ts +++ b/src/app/cli.test.ts @@ -58,6 +58,9 @@ describe("parseCli", () => { expect(parsed.text).toContain("Usage:"); expect(parsed.text).toContain("hunk diff"); expect(parsed.text).toContain("hunk show"); + expect(parsed.text).toContain("hunk log"); + expect(parsed.text).toContain("attractive repository history"); + expect(parsed.text).not.toContain("Git commit history"); expect(parsed.text).toContain("hunk skill path"); expect(parsed.text).toContain("Global options:"); expect(parsed.text).toContain("Common review options:"); @@ -488,6 +491,105 @@ describe("parseCli", () => { }); }); + test("parses static and interactive log options without review flags", async () => { + expect( + await parseCli([ + "bun", + "hunk", + "log", + "main..feature", + "--first-parent", + "-n", + "25", + "--author", + "Ada", + "--color", + "never", + "--ascii", + "--interactive", + "--", + "src/app.ts", + ]), + ).toEqual({ + kind: "history", + revision: "main..feature", + firstParent: true, + maxCount: 25, + author: "Ada", + pathspecs: ["src/app.ts"], + color: "never", + format: "medium", + ascii: true, + interactive: true, + extensionsEnabled: true, + extensionPaths: [], + }); + }); + + test("parses compact aliases, themes, and command-local extension disabling", async () => { + expect( + await parseCli(["bun", "hunk", "log", "--oneline", "--theme", "nord", "--no-extensions"]), + ).toMatchObject({ + kind: "history", + format: "compact", + theme: "nord", + extensionsEnabled: false, + }); + expect(await parseCli(["bun", "hunk", "--no-extensions", "log"])).toMatchObject({ + kind: "history", + extensionsEnabled: false, + }); + }); + + test("rejects unsupported log flags and conflicting traversal starts", async () => { + await expect(parseCli(["bun", "hunk", "log", "--pretty=raw"])).rejects.toThrow( + "unknown option", + ); + await expect(parseCli(["bun", "hunk", "log", "HEAD", "--all"])).rejects.toThrow( + "either a revision/range or --all", + ); + await expect(parseCli(["bun", "hunk", "--fast", "log"])).rejects.toThrow("review command"); + }); + + test("preserves opaque provider-planned history reviews through the private handoff", async () => { + const encoded = (value: unknown) => + Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); + expect( + await parseCli([ + "bun", + "hunk", + "diff", + "--history-review", + encoded({ + kind: "revision-range", + fromRevisionId: "-opaque:parent/α", + toRevisionId: "opaque:commit/β", + }), + "--vcs", + "-custom", + ]), + ).toMatchObject({ + kind: "vcs", + rangeEndpoints: { from: "-opaque:parent/α", to: "opaque:commit/β" }, + options: { vcs: "-custom" }, + }); + expect( + await parseCli([ + "bun", + "hunk", + "show", + "--history-review", + encoded({ kind: "revision-show", revisionId: "-opaque:root/γ" }), + "--vcs", + "demo", + ]), + ).toMatchObject({ + kind: "show", + ref: "-opaque:root/γ", + options: { vcs: "demo" }, + }); + }); + test("parses show mode with optional ref and pathspecs", async () => { const parsed = await parseCli(["bun", "hunk", "show", "HEAD~1", "--", "src/app.ts"]); diff --git a/src/app/cli.ts b/src/app/cli.ts index 4c6d4483b..53c0d8bb0 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -55,6 +55,7 @@ import { import { DEFAULT_FILE_GAP, DEFAULT_HUNK_GAP, parseReviewGap } from "../core/run/reviewGap"; import { DEFAULT_TAB_WIDTH, parseTabWidth } from "../core/run/tabWidth"; import { resolveCliVersion } from "../core/run/version"; +import type { ExtensionVcsHistoryReviewAction } from "../extension-api/types"; /** Structured option metadata shared by Commander registration and generated CLI docs. */ export interface CliReferenceOption { @@ -64,6 +65,7 @@ export interface CliReferenceOption { | "layout" | "cursorLine" | "positiveInt" + | "nonNegativeInt" | "tabWidth" | "fileGap" | "hunkGap" @@ -72,6 +74,10 @@ export interface CliReferenceOption { /** Default applied directly by Commander (as opposed to a config-resolved default). */ readonly commanderDefault?: string; readonly hidden?: boolean; + /** Include this option in released website documentation; defaults to true. */ + readonly publicDocs?: boolean; + /** Additional generated-documentation context for a hidden option. */ + readonly hiddenNote?: string; } /** Structured command metadata used by runtime parsers and generated CLI docs. */ @@ -80,6 +86,8 @@ export interface CliReferenceCommand { readonly summary: string; readonly synopsis: readonly string[]; readonly aliases?: readonly string[]; + /** Include this command in released website documentation; defaults to true. */ + readonly publicDocs?: boolean; /** Additional prose rendered after this command's generated usage block. */ readonly details?: readonly string[]; readonly options?: readonly CliReferenceOption[]; @@ -96,6 +104,12 @@ export const COMMON_REVIEW_OPTIONS = [ parse: "cursorLine", }, { flag: "--theme ", description: "named theme override" }, + { + flag: "--vcs ", + description: "select a VCS provider", + hidden: true, + publicDocs: false, + }, AUXILIARY_AGENT_OPTIONS.agentContext, { flag: "--pager", description: "use pager-style chrome" }, AUXILIARY_AGENT_OPTIONS.experimental, @@ -159,6 +173,7 @@ const DIFF_OPTIONS = [ flag: `--no-${AUXILIARY_AGENT_OPTIONS.excludeUntracked.flag.slice(2)}`, description: "include untracked files in working tree reviews", hidden: true, + hiddenNote: "Compatibility inverse; omitted from `--help`.", }, ] as const satisfies readonly CliReferenceOption[]; @@ -188,6 +203,51 @@ export const CLI_REFERENCE_COMMANDS = { commonReviewOptions: true, watch: true, }, + log: { + path: "log", + // Release preparation enables this when an installable build contains history browsing. + publicDocs: false, + summary: "print an attractive repository history", + synopsis: ["hunk log [revision-expression] [-- ]"], + details: [ + "Static output is the default. Use --interactive for the experimental history browser.", + "The selected VCS provider defines revision, filtering, and review semantics.", + ], + options: [ + { flag: "--all", description: "include history from every provider-visible head" }, + { flag: "--first-parent", description: "follow only the first parent of merge commits" }, + { + flag: "-n, --max-count ", + description: "stop after this many commits", + parse: "nonNegativeInt", + }, + { flag: "--author ", description: "limit commits by author" }, + { flag: "--grep ", description: "limit commits by subject or message" }, + { flag: "--since ", description: "show commits newer than a provider date" }, + { flag: "--until ", description: "show commits older than a provider date" }, + { + flag: "--color ", + description: "color output: auto, always, never", + commanderDefault: "auto", + }, + { + flag: "--format ", + description: "record format: medium or compact", + commanderDefault: "medium", + }, + { flag: "--oneline", description: "alias for --format compact" }, + { flag: "--theme ", description: "use the same theme as Hunk review" }, + { flag: "--ascii", description: "use an ASCII graph" }, + { flag: "--interactive", description: "browse history and open commits in Hunk" }, + { flag: "--vcs ", description: "select a VCS history provider" }, + { + flag: "--extension ", + description: "load an extension entry file or directory (repeatable)", + parse: "collect", + }, + { flag: "--no-extensions", description: "disable user extensions for this run" }, + ], + }, "stash-show": { path: "stash show", summary: "review a stash entry as a full Hunk changeset", @@ -379,6 +439,7 @@ function buildCommonOptions( mode?: LayoutMode; cursorLine?: CursorLine; theme?: string; + vcs?: string; agentContext?: string; pager?: boolean; watch?: boolean; @@ -396,6 +457,7 @@ function buildCommonOptions( mode: options.mode, cursorLine: options.cursorLine, theme: options.theme, + vcs: options.vcs, agentContext: options.agentContext, pager: options.pager ? true : undefined, watch: options.watch ? true : undefined, @@ -435,6 +497,8 @@ function applyReferenceOption(command: Command, option: CliReferenceOption) { commanderOption.argParser(parseCursorLine); } else if (option.parse === "positiveInt") { commanderOption.argParser(parsePositiveInt); + } else if (option.parse === "nonNegativeInt") { + commanderOption.argParser(parseNonNegativeInt); } else if (option.parse === "tabWidth") { commanderOption.argParser(parseTabWidth); } else if (option.parse === "fileGap") { @@ -512,6 +576,7 @@ function renderCliHelp() { " hunk diff --staged [-- ] review staged changes", " hunk diff --files compare two concrete files", " hunk show [target] [-- ] review the last commit or a given target", + " hunk log [target] [-- ] print an attractive repository history", " hunk stash show [ref] review a stash entry (git only)", " hunk patch [file] review a patch file or stdin", " hunk pager general Git pager wrapper with diff detection", @@ -758,10 +823,48 @@ function resolveReloadSelector( ); } -/** Parse the overloaded `hunk diff` command. */ +/** Decode a private child-process handoff without interpreting provider revision ids. */ +function decodeHistoryReviewAction(payload: unknown): ExtensionVcsHistoryReviewAction { + if (typeof payload !== "string" || payload.length === 0) { + throw new Error("Invalid history review handoff."); + } + let value: unknown; + try { + value = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")); + } catch { + throw new Error("Invalid history review handoff."); + } + if (!value || typeof value !== "object") throw new Error("Invalid history review handoff."); + const action = value as Record; + if ( + action.kind === "revision-show" && + typeof action.revisionId === "string" && + action.revisionId.length > 0 + ) { + return { kind: "revision-show", revisionId: action.revisionId }; + } + if ( + action.kind === "revision-range" && + typeof action.fromRevisionId === "string" && + action.fromRevisionId.length > 0 && + typeof action.toRevisionId === "string" && + action.toRevisionId.length > 0 + ) { + return { + kind: "revision-range", + fromRevisionId: action.fromRevisionId, + toRevisionId: action.toRevisionId, + }; + } + throw new Error("Invalid history review handoff."); +} + +/** Parse the provider-neutral `hunk diff` command. */ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCliReferenceCommand("diff").argument("[targets...]"); + const command = createCliReferenceCommand("diff") + .addOption(new Option("--history-review ").hideHelp()) + .argument("[targets...]"); let parsedTargets: string[] = []; let parsedOptions: Record = {}; @@ -783,6 +886,26 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise typeof value === "string") : undefined; + const historyReview = parsedOptions.historyReview; + + if (historyReview !== undefined) { + const action = decodeHistoryReviewAction(historyReview); + if ( + action.kind !== "revision-range" || + files || + parsedTargets.length > 0 || + staged || + normalizedPathspecs + ) { + throw new Error("Invalid history review handoff for `hunk diff`."); + } + return { + kind: "vcs", + rangeEndpoints: { from: action.fromRevisionId, to: action.toRevisionId }, + staged: false, + options, + }; + } if (files) { if (files.length !== 2 || parsedTargets.length > 0 || staged || normalizedPathspecs) { @@ -842,10 +965,12 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCliReferenceCommand("show").argument("[ref]"); + const command = createCliReferenceCommand("show") + .addOption(new Option("--history-review ").hideHelp()) + .argument("[ref]"); let parsedRef: string | undefined; let parsedOptions: Record = {}; @@ -861,11 +986,77 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise 0) { + throw new Error("Invalid history review handoff for `hunk show`."); + } + return { kind: "show", ref: action.revisionId, options }; + } + return { kind: "show", ref: parsedRef, pathspecs: pathspecs.length > 0 ? pathspecs : undefined, - options: buildCommonOptions(parsedOptions, argv), + options, + }; +} + +/** Parse the deliberately small static-first `hunk log` grammar. */ +async function parseHistoryCommand( + tokens: string[], + extensionsEnabled: boolean, +): Promise { + const { commandTokens, pathspecs } = splitPathspecArgs(tokens); + const command = createCliReferenceCommand("log").argument("[revision]"); + let revision: string | undefined; + let options: Record = {}; + + command.action((parsedRevision: string | undefined, parsedOptions: Record) => { + revision = parsedRevision; + options = parsedOptions; + }); + if (commandTokens.includes("--help") || commandTokens.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + await parseStandaloneCommand(command, commandTokens); + + const color = options.color; + if (color !== "auto" && color !== "always" && color !== "never") { + throw new Error(`Invalid color mode: ${String(color)}`); + } + const requestedFormat = options.format; + if (requestedFormat !== "medium" && requestedFormat !== "compact") { + throw new Error(`Invalid history format: ${String(requestedFormat)}`); + } + const format = options.oneline ? "compact" : requestedFormat; + if (revision && options.all) { + throw new Error("`hunk log` accepts either a revision/range or --all, not both."); + } + const extensionPaths = Array.isArray(options.extension) + ? options.extension.filter((value): value is string => typeof value === "string") + : []; + + return { + kind: "history", + ...(revision ? { revision } : {}), + ...(options.all ? { all: true } : {}), + ...(options.firstParent ? { firstParent: true } : {}), + ...(typeof options.maxCount === "number" ? { maxCount: options.maxCount } : {}), + ...(typeof options.author === "string" ? { author: options.author } : {}), + ...(typeof options.grep === "string" ? { grep: options.grep } : {}), + ...(typeof options.since === "string" ? { since: options.since } : {}), + ...(typeof options.until === "string" ? { until: options.until } : {}), + ...(pathspecs.length ? { pathspecs } : {}), + color, + format, + ascii: Boolean(options.ascii), + interactive: Boolean(options.interactive), + ...(typeof options.theme === "string" ? { theme: options.theme } : {}), + ...(typeof options.vcs === "string" ? { vcs: options.vcs } : {}), + extensionsEnabled: extensionsEnabled && options.extensions !== false, + extensionPaths, }; } @@ -963,6 +1154,7 @@ function requireReloadableCliInput(input: ParsedCliInput): CliInput { input.kind === "markup-guide" || input.kind === "extension-manage" || input.kind === "extension-cli" || + input.kind === "history" || input.kind === "update" ) { throw new Error( @@ -1966,6 +2158,7 @@ async function parseStashCommand( } const REVIEW_COMMAND_NAMES = new Set(["diff", "show", "patch", "pager", "difftool", "stash"]); +const EXTENSION_AWARE_COMMAND_NAMES = new Set([...REVIEW_COMMAND_NAMES, "log"]); interface LeadingCliFlags { args: string[]; @@ -2080,7 +2273,7 @@ export async function parseCli(argv: string[]): Promise { if ( extensionFlagTokens.length > 0 && isBuiltInCliCommandName(commandName) && - !REVIEW_COMMAND_NAMES.has(commandName) + !EXTENSION_AWARE_COMMAND_NAMES.has(commandName) ) { throw new Error( `\`${extensionFlagTokens[0]}\` must be used with a Hunk review command or an ` + @@ -2095,6 +2288,8 @@ export async function parseCli(argv: string[]): Promise { return parseDiffCommand(reviewRest, argv); case "show": return parseShowCommand(reviewRest, argv); + case "log": + return parseHistoryCommand(reviewRest, extensionsEnabled); case "patch": return parsePatchCommand(reviewRest, argv); case "pager": diff --git a/src/app/historyBootstrap.ts b/src/app/historyBootstrap.ts new file mode 100644 index 000000000..a60d57be0 --- /dev/null +++ b/src/app/historyBootstrap.ts @@ -0,0 +1,157 @@ +import type { HistoryCommandInput } from "../core/run/commandInputs"; +import { collectSessionCustomThemes } from "../core/theme/customThemes"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryReviewAction, + NamedCustomThemeConfig, +} from "../extension-api/types"; +import { sanitizeTerminalLine } from "../lib/terminalText"; +import { + detectVcs, + extendVcsCatalog, + getDefaultVcsAdapter, + getVcsAdapter, + openVcsHistory, + planVcsHistoryReview, +} from "../core/vcs"; +import type { VcsCatalog, VcsHistorySource } from "../core/vcs/types"; +import { resolveExtensionVcsAdapters, resolveSessionVcsId } from "../extensions/apply"; +import { emitExtensionEvent, retireExtensionLoadResult } from "../extensions/events"; +import { mergeStartupNotices } from "../extensions/startup"; +import type { ExtensionLoadResult } from "../extensions/types"; +import { resolveConfiguredExtensions } from "./extensionBootstrap"; + +/** Fully owned resources required by static or interactive history output. */ +export interface HistoryBootstrap { + input: HistoryCommandInput; + source: VcsHistorySource; + providerId: string; + providerName: string; + repoRoot: string; + extensions: ExtensionLoadResult; + notices: readonly string[]; + customThemes: readonly NamedCustomThemeConfig[]; + planReview(commit: ExtensionVcsHistoryCommit): Promise; + close(): Promise; +} + +/** Resolve configured/user VCS adapters and open the selected history capability. */ +export async function loadHistoryBootstrap({ + input, + cwd = process.cwd(), + env = process.env, + baseVcsCatalog, + previousLoad, +}: { + input: HistoryCommandInput; + cwd?: string; + env?: NodeJS.ProcessEnv; + baseVcsCatalog: VcsCatalog; + previousLoad?: ExtensionLoadResult; +}): Promise { + // Reuse the established extension/config discovery with a non-executed review-shaped input. + // History-specific flags remain separate and never inherit review view preferences. + const runtimeInput = { + kind: "show" as const, + options: { + ...(input.vcs ? { vcs: input.vcs } : {}), + ...(input.theme ? { theme: input.theme } : {}), + extensions: input.extensionsEnabled, + ...(input.extensionPaths.length ? { extensionPaths: [...input.extensionPaths] } : {}), + }, + }; + const resolved = await resolveConfiguredExtensions({ + runtimeInput, + cwd, + env, + baseVcsCatalog, + previousLoad, + }); + const extensionAdapters = resolveExtensionVcsAdapters( + resolved.extensions.registry, + baseVcsCatalog, + ); + const sessionThemes = collectSessionCustomThemes( + resolved.configured.customThemes, + resolved.extensions.registry.themes, + ); + const catalog = extendVcsCatalog(baseVcsCatalog, extensionAdapters.adapters); + const explicitVcsId = input.vcs ?? resolved.configured.explicitVcsId; + const detection = detectVcs(cwd, catalog); + const settledVcs = resolveSessionVcsId(explicitVcsId, cwd, catalog); + const providerId = settledVcs.vcsId ?? detection?.id ?? getDefaultVcsAdapter(catalog).id; + let adapter; + try { + adapter = getVcsAdapter(providerId, catalog); + } catch (error) { + await retireExtensionLoadResult(resolved.extensions); + throw error; + } + let selectedDetection; + try { + selectedDetection = adapter.detect(cwd); + } catch { + selectedDetection = null; + } + const repoRoot = selectedDetection?.repoRoot ?? cwd; + + let source: VcsHistorySource; + try { + source = await openVcsHistory( + adapter, + { + ...(input.revision ? { revision: input.revision } : {}), + ...(input.all ? { all: true } : {}), + ...(input.firstParent ? { firstParent: true } : {}), + ...(input.maxCount !== undefined ? { maxCount: input.maxCount } : {}), + ...(input.author !== undefined ? { author: input.author } : {}), + ...(input.grep !== undefined ? { grep: input.grep } : {}), + ...(input.since !== undefined ? { since: input.since } : {}), + ...(input.until !== undefined ? { until: input.until } : {}), + ...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}), + }, + { cwd: repoRoot }, + catalog, + ); + emitExtensionEvent(resolved.extensions, "startup", { cwd }); + } catch (error) { + await retireExtensionLoadResult(resolved.extensions); + throw error; + } + + const resolvedTheme = resolved.configured.input.options.theme; + let closed = false; + return { + input: resolvedTheme ? { ...input, theme: resolvedTheme } : input, + source, + providerId: sanitizeTerminalLine(adapter.id), + providerName: sanitizeTerminalLine(adapter.name), + repoRoot, + extensions: resolved.extensions, + customThemes: sessionThemes.themes, + notices: [ + ...(mergeStartupNotices(resolved.configured.startupNotices, resolved.extensions) ?? []).map( + (notice) => sanitizeTerminalLine(notice.message), + ), + ...sessionThemes.notices.map((notice) => sanitizeTerminalLine(notice.message)), + ...extensionAdapters.issues.map((issue) => sanitizeTerminalLine(issue.message)), + ...(settledVcs.unknownVcsId + ? [ + `Configured VCS "${sanitizeTerminalLine(settledVcs.unknownVcsId)}" is unavailable; using ${sanitizeTerminalLine(adapter.name)}.`, + ] + : []), + ], + planReview(commit) { + return planVcsHistoryReview(adapter, commit, { cwd: repoRoot }); + }, + async close() { + if (closed) return; + closed = true; + try { + await source.close(); + } finally { + await retireExtensionLoadResult(resolved.extensions); + } + }, + }; +} diff --git a/src/app/startup.ts b/src/app/startup.ts index bea624496..253c3d352 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -18,6 +18,7 @@ import type { CliInput, ExtensionCliInvocationInput, ExtensionManageCommandInput, + HistoryCommandInput, MarkupRenderCommandInput, ParsedCliInput, SelfUpdateCommandInput, @@ -58,6 +59,11 @@ export type StartupPlan = kind: "session-command"; input: SessionCommandInput; } + | { + kind: "history-static" | "history-interactive"; + bootstrap: import("./historyBootstrap").HistoryBootstrap; + input: HistoryCommandInput; + } | { kind: "plain-text-pager"; text: string; @@ -136,6 +142,13 @@ function applyDelegatedExtensionFlags( input: ParsedCliInput, invocation: ExtensionCliInvocationInput, ): ParsedCliInput { + if (input.kind === "history") { + return { + ...input, + extensionsEnabled: invocation.extensionsEnabled, + extensionPaths: [...invocation.extensionPaths], + }; + } if (!("options" in input)) return input; return { ...input, @@ -373,6 +386,26 @@ export async function prepareStartupPlan( }); } + if (parsedCliInput.kind === "history") { + const baseVcsCatalog = await loadBaseVcsCatalog(); + const { loadHistoryBootstrap } = await import("./historyBootstrap"); + const bootstrap = await loadHistoryBootstrap({ + input: parsedCliInput, + cwd: startupCwd, + env, + baseVcsCatalog, + previousLoad: preloadedExtensions, + }); + // The runner owns source/extension retirement; unlike ordinary headless plans, history must + // retain its provider cursor until every page has been consumed. + preloadedExtensions = undefined; + return { + kind: parsedCliInput.interactive ? "history-interactive" : "history-static", + bootstrap, + input: parsedCliInput, + }; + } + if (parsedCliInput.kind === "pager") { const stdinText = await whileStartupOwnsExtensions(readStdinText); const pagerOptions = parsedCliInput.options; diff --git a/src/core/history/lanePlanner.test.ts b/src/core/history/lanePlanner.test.ts new file mode 100644 index 000000000..95ed565d8 --- /dev/null +++ b/src/core/history/lanePlanner.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "bun:test"; +import { createHistoryLaneCheckpoint, planHistoryPage } from "./lanePlanner"; +import type { HistoryCommit } from "./types"; + +/** Build one deterministic history commit for graph tests. */ +function commit(revisionId: string, parentRevisionIds: string[] = []): HistoryCommit { + return { + revisionId, + displayId: revisionId, + parentRevisionIds, + subject: revisionId, + authorName: "Test", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; +} + +describe("history lane planning", () => { + test("preserves first-parent continuity and ordered merge parents", () => { + const planned = planHistoryPage([ + commit("merge", ["main", "side"]), + commit("side", ["base"]), + commit("main", ["base"]), + commit("base"), + ]); + + expect(planned.rows.map((row) => [row.commit.revisionId, row.lane])).toEqual([ + ["merge", 0], + ["side", 1], + ["main", 0], + ["base", 0], + ]); + expect(planned.checkpoint.lanes).toEqual([]); + }); + + test("draws the side branch converging into an already-active first parent", () => { + const planned = planHistoryPage([ + commit("merge", ["main", "side"]), + commit("side", ["main"]), + commit("main"), + ]); + + expect(planned.rows[1]!.convergences).toEqual([{ from: 1, to: 0 }]); + expect(planned.checkpoint.lanes).toEqual([]); + }); + + test("uses explicit graph parents without changing review parents", () => { + const filtered = { ...commit("match", ["omitted"]), graphParentRevisionIds: [] }; + const olderFiltered = { + ...commit("older-match", ["another-omitted"]), + graphParentRevisionIds: [], + }; + const planned = planHistoryPage([filtered, olderFiltered]); + + expect(planned.rows.map((row) => row.lanesAfter)).toEqual([[], []]); + expect(filtered.parentRevisionIds).toEqual(["omitted"]); + }); + + test("produces identical rows across every page partition", () => { + const commits = [ + commit("merge", ["main", "side"]), + commit("side", ["base"]), + commit("main", ["base"]), + commit("base"), + ]; + const whole = planHistoryPage(commits); + // Every bit chooses whether a page ends after that commit, covering all 2^(n-1) partitions. + for (let boundaries = 0; boundaries < 1 << (commits.length - 1); boundaries += 1) { + let checkpoint = createHistoryLaneCheckpoint(); + const rows = []; + let start = 0; + for (let index = 0; index < commits.length; index += 1) { + if (index < commits.length - 1 && (boundaries & (1 << index)) === 0) continue; + const page = planHistoryPage(commits.slice(start, index + 1), checkpoint); + rows.push(...page.rows); + checkpoint = page.checkpoint; + start = index + 1; + } + expect(rows).toEqual(whole.rows); + expect(checkpoint).toEqual(whole.checkpoint); + } + }); + + test("rejects duplicate revisions within one page", () => { + expect(() => planHistoryPage([commit("same"), commit("same")])).toThrow("duplicate revision"); + }); +}); diff --git a/src/core/history/lanePlanner.ts b/src/core/history/lanePlanner.ts new file mode 100644 index 000000000..237b9095e --- /dev/null +++ b/src/core/history/lanePlanner.ts @@ -0,0 +1,100 @@ +import type { + HistoryCommit, + HistoryGraphCell, + HistoryLaneCheckpoint, + PlannedHistoryPage, +} from "./types"; + +/** Create the empty graph state used before the first history page. */ +export function createHistoryLaneCheckpoint(): HistoryLaneCheckpoint { + return { lanes: [] }; +} + +/** Return unique graph parent ids in the provider's declared order. */ +function orderedUniqueParents(commit: HistoryCommit) { + const seen = new Set(); + return (commit.graphParentRevisionIds ?? commit.parentRevisionIds).filter((parent) => { + if (seen.has(parent)) return false; + seen.add(parent); + return true; + }); +} + +/** Plan one newest-first page while preserving enough lane state for continuation. */ +export function planHistoryPage( + commits: readonly HistoryCommit[], + checkpoint: HistoryLaneCheckpoint = createHistoryLaneCheckpoint(), +): PlannedHistoryPage { + const lanes = [...checkpoint.lanes]; + const pageRevisionIds = new Set(); + const rows: PlannedHistoryPage["rows"] = []; + + for (const commit of commits) { + if (pageRevisionIds.has(commit.revisionId)) { + throw new Error(`History contains duplicate revision ${commit.revisionId}.`); + } + pageRevisionIds.add(commit.revisionId); + + let lane = lanes.indexOf(commit.revisionId); + if (lane < 0) { + // Independent tips (for example `--all`) enter at the left edge. Missing shallow parents + // may also produce a new tip; neither case invents ancestry. + lane = 0; + lanes.unshift(commit.revisionId); + } + + const lanesBefore = [...lanes]; + const parents = orderedUniqueParents(commit); + const existingParents = parents.filter((parent) => { + const index = lanesBefore.indexOf(parent); + return index >= 0 && index !== lane; + }); + lanes.splice(lane, 1, ...parents); + + // A merge parent can already be active through another child. Keep its leftmost lane and + // collapse duplicates so topology state stays bounded by the active frontier. + const deduplicated: string[] = []; + for (const revisionId of lanes) { + if (!deduplicated.includes(revisionId)) deduplicated.push(revisionId); + } + lanes.splice(0, lanes.length, ...deduplicated); + + const convergences = lanesBefore.flatMap((revisionId, from) => { + if (from === lane) return []; + const to = lanes.indexOf(revisionId); + return to >= 0 && to !== from ? [{ from, to }] : []; + }); + for (const parent of existingParents) { + const to = lanes.indexOf(parent); + if ( + to >= 0 && + lane !== to && + !convergences.some((edge) => edge.from === lane && edge.to === to) + ) { + convergences.push({ from: lane, to }); + } + } + const width = Math.max(lanesBefore.length, lanes.length, lane + 1); + const cells: HistoryGraphCell[] = Array.from({ length: width }, (_, index) => ({ + kind: index === lane ? "node" : index < lanesBefore.length ? "vertical" : "empty", + })); + + rows.push({ + commit, + lane, + cells, + lanesBefore, + lanesAfter: [...lanes], + parentLanes: parents.flatMap((parent) => { + const index = lanes.indexOf(parent); + return index < 0 ? [] : [index]; + }), + convergences, + }); + } + + return { + rows, + checkpoint: { lanes: [...lanes] }, + }; +} diff --git a/src/core/history/types.ts b/src/core/history/types.ts new file mode 100644 index 000000000..2e1205bed --- /dev/null +++ b/src/core/history/types.ts @@ -0,0 +1,32 @@ +import type { ExtensionVcsHistoryCommit } from "../../extension-api/types"; + +/** One normalized commit in newest-first provider order. */ +export type HistoryCommit = ExtensionVcsHistoryCommit; + +/** JSON-safe state required to continue planning graph lanes on a later page. */ +export interface HistoryLaneCheckpoint { + lanes: string[]; +} + +/** One symbolic lane shown on a commit row. */ +export interface HistoryGraphCell { + kind: "vertical" | "node" | "empty"; +} + +/** One symbolic graph row plus the lane mapping after its commit. */ +export interface HistoryGraphRow { + commit: HistoryCommit; + lane: number; + cells: HistoryGraphCell[]; + lanesBefore: string[]; + lanesAfter: string[]; + parentLanes: number[]; + /** Existing active lanes that collapse into a parent lane after this commit. */ + convergences: Array<{ from: number; to: number }>; +} + +/** A planned page and the checkpoint used by the following page. */ +export interface PlannedHistoryPage { + rows: HistoryGraphRow[]; + checkpoint: HistoryLaneCheckpoint; +} diff --git a/src/core/process/pager.test.ts b/src/core/process/pager.test.ts index 84c7d2960..ec4d2449e 100644 --- a/src/core/process/pager.test.ts +++ b/src/core/process/pager.test.ts @@ -20,6 +20,7 @@ function createClosingPager(code = 0) { const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07"; const CSI_CLEAR_SCREEN = "\x1b[2J"; const DCS_PAYLOAD = "\x1bPqpayload\x1b\\"; +const DEFAULT_TEXT_PAGER = process.platform === "win32" ? "more" : "less -R"; function expectNoUnsafeTerminalControls(text: string) { expect(text).not.toContain(OSC52_CLIPBOARD); @@ -116,17 +117,17 @@ describe("general pager detection", () => { describe("plain text pager fallback", () => { test("falls back to less when no pager is configured", () => { - expect(resolveTextPagerCommand({})).toBe("less -R"); + expect(resolveTextPagerCommand({})).toBe(DEFAULT_TEXT_PAGER); }); test("prefers HUNK_TEXT_PAGER and avoids recursive hunk launches", () => { expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "bat --paging=always" })).toBe( "bat --paging=always", ); - expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "hunk pager" })).toBe("less -R"); - expect(resolveTextPagerCommand({ PAGER: "env FOO=1 hunk pager" })).toBe("less -R"); + expect(resolveTextPagerCommand({ HUNK_TEXT_PAGER: "hunk pager" })).toBe(DEFAULT_TEXT_PAGER); + expect(resolveTextPagerCommand({ PAGER: "env FOO=1 hunk pager" })).toBe(DEFAULT_TEXT_PAGER); expect(resolveTextPagerCommand({ PAGER: String.raw`"C:\tools\hunk.exe" pager` })).toBe( - "less -R", + DEFAULT_TEXT_PAGER, ); }); @@ -342,7 +343,7 @@ describe("plain text pager fallback", () => { }); test("supports simple env wrappers while still blocking recursive hunk pagers", async () => { - expect(resolveTextPagerCommand({ PAGER: "env LESS=FRX hunk pager" })).toBe("less -R"); + expect(resolveTextPagerCommand({ PAGER: "env LESS=FRX hunk pager" })).toBe(DEFAULT_TEXT_PAGER); await pagePlainText( "plain text", @@ -358,6 +359,44 @@ describe("plain text pager fallback", () => { ); }); + test("accepts EPIPE only when an early-closing pager exits successfully", async () => { + const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; + pager.stdin = new PassThrough(); + await pagePlainText( + "long output", + { PAGER: "less -R" }, + createPagerDeps({ + spawnImpl() { + queueMicrotask(() => { + const error = Object.assign(new Error("broken pipe"), { code: "EPIPE" }); + pager.stdin.emit("error", error); + pager.emit("close", 0); + }); + return pager as never; + }, + }), + ); + }); + + test("rejects EPIPE when the pager itself reports failure", async () => { + const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; + pager.stdin = new PassThrough(); + const promise = pagePlainText( + "long output", + { PAGER: "less -R" }, + createPagerDeps({ + spawnImpl() { + queueMicrotask(() => { + pager.stdin.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })); + pager.emit("close", 1); + }); + return pager as never; + }, + }), + ); + await expect(promise).rejects.toThrow("Pager command failed"); + }); + test("throws when the pager exits with a non-zero status", async () => { const pager = new EventEmitter() as EventEmitter & { stdin: PassThrough }; pager.stdin = new PassThrough(); diff --git a/src/core/process/pager.ts b/src/core/process/pager.ts index a946ca1d1..a874646e8 100644 --- a/src/core/process/pager.ts +++ b/src/core/process/pager.ts @@ -15,7 +15,7 @@ export function looksLikePatchInput(text: string) { ); } -const DEFAULT_TEXT_PAGER_COMMAND = "less -R"; +const DEFAULT_TEXT_PAGER_COMMAND = process.platform === "win32" ? "more" : "less -R"; interface ResolvedPagerCommand { command: string; @@ -142,9 +142,14 @@ export interface PlainTextPagerDeps { spawnImpl: (command: string, args: string[], options: SpawnOptions) => ChildProcess; } -/** Stream plain text through a normal pager, or write directly when not attached to a terminal. */ -export async function pagePlainText( - text: string, +export interface PlainTextPagerWriter { + /** Return false when the pager closed early and the producer should stop reading. */ + write(text: string): Promise; + close(): Promise; +} + +/** Open one pager writer so callers can stream bounded chunks after deciding output will overflow. */ +export function openPlainTextPager( env: NodeJS.ProcessEnv = process.env, deps: PlainTextPagerDeps = { // Write through the descriptor rather than `process.stdout`: a piped consumer takes one @@ -158,45 +163,95 @@ export async function pagePlainText( }, spawnImpl: spawn, }, -) { +): PlainTextPagerWriter { if (!deps.stdout.isTTY) { - deps.stdout.write(sanitizeTerminalText(text)); - return; + return { + async write(text) { + deps.stdout.write(sanitizeTerminalText(text)); + }, + async close() {}, + }; } - const safeText = sanitizeTerminalText(text, { preserveAnsiStyle: true }); - const pagerSpec = resolveTextPagerSpec(env); const pagerCommand = pagerSpec.displayCommand; - let pager: ChildProcess; try { pager = deps.spawnImpl(pagerSpec.command, pagerSpec.args, { shell: false, stdio: ["pipe", "inherit", "inherit"], - env: { - ...env, - ...pagerSpec.env, - }, + env: { ...env, ...pagerSpec.env }, }); } catch (error) { throw new Error(`Pager command failed: ${pagerCommand}`, { cause: error }); } let spawnError: unknown; + let stdinError: NodeJS.ErrnoException | undefined; + let closed = false; + let pagerExited = false; const closeCode = new Promise((resolve) => { pager.once("error", (error) => { spawnError = error; }); pager.once("close", (code) => { + pagerExited = true; resolve(typeof code === "number" ? code : null); }); }); + pager.stdin?.once("error", (error: NodeJS.ErrnoException) => { + stdinError = error; + }); - pager.stdin?.end(safeText); - const code = await closeCode; + return { + async write(text) { + if (closed || pagerExited || stdinError?.code === "EPIPE") return false; + const safeText = sanitizeTerminalText(text, { preserveAnsiStyle: true }); + if (!pager.stdin?.write(safeText)) { + await new Promise((resolve) => { + const finish = () => { + pager.stdin?.off("drain", finish); + pager.stdin?.off("error", finish); + pager.off("close", finish); + resolve(); + }; + pager.stdin?.once("drain", finish); + pager.stdin?.once("error", finish); + pager.once("close", finish); + }); + } + return !pagerExited && stdinError?.code !== "EPIPE"; + }, + async close() { + if (closed) return; + closed = true; + if (!pager.stdin?.destroyed) pager.stdin?.end(); + const code = await closeCode; + const normalEarlyQuit = stdinError?.code === "EPIPE" && code === 0; + if ( + spawnError || + (stdinError && !normalEarlyQuit) || + (typeof code === "number" && code !== 0) + ) { + throw new Error(`Pager command failed: ${pagerCommand}`, { + cause: spawnError ?? stdinError, + }); + } + }, + }; +} - if (spawnError || (typeof code === "number" && code !== 0)) { - throw new Error(`Pager command failed: ${pagerCommand}`, { cause: spawnError }); +/** Stream plain text through a normal pager, or write directly when not attached to a terminal. */ +export async function pagePlainText( + text: string, + env: NodeJS.ProcessEnv = process.env, + deps: PlainTextPagerDeps = { stdout: process.stdout, spawnImpl: spawn }, +) { + if (!deps.stdout.isTTY) { + deps.stdout.write(sanitizeTerminalText(text)); + return; } + const pager = openPlainTextPager(env, deps); + await pager.write(text); + await pager.close(); } diff --git a/src/core/process/relaunch.test.ts b/src/core/process/relaunch.test.ts new file mode 100644 index 000000000..4241f0f57 --- /dev/null +++ b/src/core/process/relaunch.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test"; +import { resolveCurrentHunkCommand } from "./relaunch"; + +describe("current Hunk relaunch resolution", () => { + test("keeps source and wrapper entrypoints", () => { + expect(resolveCurrentHunkCommand(["bun", "/repo/src/main.tsx"], "/bin/bun")).toEqual({ + command: "/bin/bun", + args: ["/repo/src/main.tsx"], + }); + expect(resolveCurrentHunkCommand(["node", "C:\\pkg\\bin\\hunk.cjs"], "node.exe")).toEqual({ + command: "node.exe", + args: ["C:\\pkg\\bin\\hunk.cjs"], + }); + }); + + test("uses the real executable for compiled Bun virtual paths", () => { + expect(resolveCurrentHunkCommand(["bun", "/$bunfs/root/hunk"], "/usr/bin/hunk")).toEqual({ + command: "/usr/bin/hunk", + args: [], + }); + expect(resolveCurrentHunkCommand(["bun", "B:\\~BUN\\root\\hunk.exe"], "C:\\hunk.exe")).toEqual({ + command: "C:\\hunk.exe", + args: [], + }); + }); +}); diff --git a/src/core/process/relaunch.ts b/src/core/process/relaunch.ts new file mode 100644 index 000000000..c8744851c --- /dev/null +++ b/src/core/process/relaunch.ts @@ -0,0 +1,31 @@ +const SCRIPT_ENTRYPOINT_PATTERN = /[\\/]|\.(?:[cm]?js|tsx?)$/; +const BUNFS_PREFIX = "/$bunfs/"; +const BUNFS_WINDOWS_PREFIX = "b:/~bun/"; + +export interface HunkLaunchCommand { + command: string; + args: string[]; +} + +/** Return whether an entrypoint is Bun's virtual compiled-executable path. */ +function isBunfsEntrypoint(entrypoint: string) { + return ( + entrypoint.startsWith(BUNFS_PREFIX) || + entrypoint.replaceAll("\\", "/").toLowerCase().startsWith(BUNFS_WINDOWS_PREFIX) + ); +} + +/** Resolve the executable and stable prefix needed to launch this Hunk installation again. */ +export function resolveCurrentHunkCommand( + argv = process.argv, + execPath = process.execPath, +): HunkLaunchCommand { + const entrypoint = argv[1]; + if (entrypoint && isBunfsEntrypoint(entrypoint)) { + return { command: execPath, args: [] }; + } + if (entrypoint && !entrypoint.startsWith("-") && SCRIPT_ENTRYPOINT_PATTERN.test(entrypoint)) { + return { command: execPath, args: [entrypoint] }; + } + return { command: execPath, args: [] }; +} diff --git a/src/core/run/cliCommandNames.ts b/src/core/run/cliCommandNames.ts index 0ec53b717..a0baa3659 100644 --- a/src/core/run/cliCommandNames.ts +++ b/src/core/run/cliCommandNames.ts @@ -2,6 +2,7 @@ export const BUILT_IN_CLI_COMMAND_NAMES = new Set([ "diff", "show", + "log", "patch", "pager", "difftool", diff --git a/src/core/run/commandInputs.ts b/src/core/run/commandInputs.ts index 8dca48432..b8652415e 100644 --- a/src/core/run/commandInputs.ts +++ b/src/core/run/commandInputs.ts @@ -110,6 +110,31 @@ export type CliInput = export type ReviewNoteSource = "ai" | "agent" | "user"; export type SessionCommentListType = "live" | "all" | ReviewNoteSource; +export type HistoryColorMode = "auto" | "always" | "never"; +export type HistoryFormat = "medium" | "compact"; + +/** Static-first VCS history invocation, deliberately separate from review view options. */ +export interface HistoryCommandInput { + kind: "history"; + revision?: string; + all?: boolean; + firstParent?: boolean; + maxCount?: number; + author?: string; + grep?: string; + since?: string; + until?: string; + pathspecs?: string[]; + color: HistoryColorMode; + format: HistoryFormat; + ascii: boolean; + interactive: boolean; + theme?: string; + vcs?: string; + extensionsEnabled: boolean; + extensionPaths: string[]; +} + export interface HelpCommandInput { kind: "help"; text: string; @@ -350,6 +375,7 @@ export type ExtensionManageCommandInput = export type ParsedCliInput = | CliInput + | HistoryCommandInput | HelpCommandInput | PagerCommandInput | DaemonServeCommandInput diff --git a/src/core/vcs/index.ts b/src/core/vcs/index.ts index 53113bc91..2a1e072c2 100644 --- a/src/core/vcs/index.ts +++ b/src/core/vcs/index.ts @@ -1,6 +1,11 @@ import { relative, resolve } from "node:path"; import { HUNK_DEFAULT_VCS_DETECTION_PRIORITY } from "../../extension-api/types"; import { HunkUserError } from "../run/errors"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryReviewAction, +} from "../../extension-api/types"; import type { CliInput } from "../run/commandInputs"; import type { VcsAdapter, @@ -8,6 +13,7 @@ import type { VcsDetection, VcsId, VcsLoadContext, + VcsHistorySource, VcsOperation, VcsPatchResult, VcsReviewInput, @@ -152,6 +158,39 @@ export async function loadVcsReview( return await handler.load(operation.input, context); } +/** Open a provider-neutral history source or report that the selected backend lacks one. */ +export async function openVcsHistory( + adapter: VcsAdapter, + input: ExtensionVcsHistoryInput, + context: VcsLoadContext, + catalog: VcsCatalog, +): Promise { + if (!adapter.history) { + const supportingAdapter = catalog.adapters.find((candidate) => candidate.history); + throw new HunkUserError(`\`hunk log\` is not supported by ${adapter.name}.`, [ + ...(supportingAdapter + ? [`Use \`--vcs ${supportingAdapter.id}\` in a compatible repository.`] + : []), + "Use a VCS adapter that implements history browsing.", + ]); + } + return await adapter.history.open(input, context); +} + +/** Ask the selected provider how one opaque history item should open in review. */ +export async function planVcsHistoryReview( + adapter: VcsAdapter, + commit: ExtensionVcsHistoryCommit, + context: VcsLoadContext, +): Promise { + if (!adapter.history) { + throw new HunkUserError(`\`hunk log\` is not supported by ${adapter.name}.`, [ + "Use a VCS adapter that implements history browsing.", + ]); + } + return await adapter.history.planReview(commit, context); +} + /** Build an adapter event plan, falling back to signature polling. */ export function createVcsWatchPlan( adapter: VcsAdapter, diff --git a/src/core/vcs/types.ts b/src/core/vcs/types.ts index bb056911c..062676429 100644 --- a/src/core/vcs/types.ts +++ b/src/core/vcs/types.ts @@ -1,4 +1,10 @@ -import type { ExtensionVcsWatchPlan } from "../../extension-api/types"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistoryReviewAction, + ExtensionVcsWatchPlan, +} from "../../extension-api/types"; import type { DiffFile } from "../changeset/model"; import type { VcsDiffCommandInput, @@ -39,6 +45,21 @@ export interface VcsOperations { "stash-show"?: VcsOperation; } +/** Internal history cursor after extension-boundary validation. */ +export interface VcsHistorySource { + read(options: { limit: number; signal?: AbortSignal }): Promise; + close(): Promise; +} + +/** Optional provider-neutral read-only history and review-planning capability. */ +export interface VcsHistoryCapability { + open(input: ExtensionVcsHistoryInput, context: VcsLoadContext): Promise; + planReview( + commit: ExtensionVcsHistoryCommit, + context: VcsLoadContext, + ): Promise; +} + /** * One adapter operation's result, after the conversion boundary. * @@ -73,6 +94,7 @@ export interface VcsAdapter { name: string; detect(cwd: string): VcsDetection | null; operations: VcsOperations; + history?: VcsHistoryCapability; /** Detection order weight; higher is consulted first. See the public contract. */ detectionPriority?: number; } diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index bf6057743..f2445ad17 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -148,6 +148,13 @@ export type { ExtensionVcsFileSourceResult, ExtensionVcsFileSourceTooLarge, ExtensionVcsFileStats, + ExtensionVcsHistoryCapability, + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryDecoration, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistoryReviewAction, + ExtensionVcsHistorySource, ExtensionVcsLoadContext, ExtensionVcsOperation, ExtensionVcsOperations, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 37e84fde8..5184853ae 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -21,7 +21,7 @@ * Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading * older extensions without guessing at their expectations. */ -export const HUNK_EXTENSION_API_VERSION = 18; +export const HUNK_EXTENSION_API_VERSION = 19; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -727,6 +727,108 @@ export interface ExtensionVcsShowInput { options: ExtensionVcsReviewOptions; } +/** One structured ref decorating a history commit. */ +export type ExtensionVcsHistoryDecoration = + | { + kind: "head"; + /** Display label for detached HEAD; normally `HEAD`. */ + label: string; + /** Local branch HEAD is attached to, without display punctuation. */ + attachedLocalBranch?: string; + } + | { + kind: "local-branch" | "remote-branch" | "tag" | "ref"; + label: string; + }; + +/** One immutable commit summary returned by a VCS history provider. */ +export interface ExtensionVcsHistoryCommit { + revisionId: string; + displayId: string; + parentRevisionIds: string[]; + /** + * Parent ids used only for graph topology when traversal filters omit intermediate commits. + * Omit this field when graph parents are identical to `parentRevisionIds`. + */ + graphParentRevisionIds?: string[]; + subject: string; + /** Commit message content after the subject, preserving paragraph breaks. */ + body?: string; + authorName: string; + authorEmail?: string; + authoredAt: string; + decorations: ExtensionVcsHistoryDecoration[]; + /** + * Optional logical identity that remains stable when the provider rewrites a revision. + * + * A Jujutsu change id is the canonical example. This is metadata only: Hunk + * continues to key graph and review operations by the immutable `revisionId`. + */ + logicalId?: string; +} + +/** Provider-neutral history traversal accepted by `hunk log`. */ +export interface ExtensionVcsHistoryInput { + revision?: string; + all?: boolean; + firstParent?: boolean; + maxCount?: number; + author?: string; + grep?: string; + since?: string; + until?: string; + pathspecs?: string[]; +} + +/** + * One bounded history read in child-before-parent topological order. + * + * Across every page from one source, a commit must appear before any of its + * parents that the source emits. Page boundaries never reset that invariant. + * `done` distinguishes EOF from a page boundary. + */ +export interface ExtensionVcsHistoryPage { + commits: ExtensionVcsHistoryCommit[]; + done: boolean; +} + +/** A cancellable history cursor owned by its provider. */ +export interface ExtensionVcsHistorySource { + /** Read the next page while preserving the source-wide topological order. */ + read(options: { limit: number; signal?: AbortSignal }): Promise; + close(): void | Promise; +} + +/** A provider-owned declaration of how Hunk should review one history item. */ +export type ExtensionVcsHistoryReviewAction = + | { + kind: "revision-show"; + revisionId: string; + } + | { + kind: "revision-range"; + fromRevisionId: string; + toRevisionId: string; + }; + +/** Optional read-only history capability implemented independently of review operations. */ +export interface ExtensionVcsHistoryCapability { + open( + input: ExtensionVcsHistoryInput, + context: ExtensionVcsLoadContext, + ): ExtensionVcsHistorySource | Promise; + /** + * Declare how to open one returned commit in Hunk's ordinary review surface. + * + * Providers own root and merge semantics. Revision ids are opaque to the + * host; the returned action is passed to this adapter's review operation. + */ + planReview( + commit: ExtensionVcsHistoryCommit, + context: ExtensionVcsLoadContext, + ): ExtensionVcsHistoryReviewAction | Promise; +} + /** Stash review request, as extension adapters receive it. */ export interface ExtensionVcsStashShowInput { kind: "stash-show"; @@ -972,6 +1074,8 @@ export interface ExtensionVcsAdapter { name: string; detect(cwd: string): ExtensionVcsDetection | null; operations?: ExtensionVcsOperations; + /** Optional static/interactive history enumeration capability. */ + history?: ExtensionVcsHistoryCapability; /** * Where this adapter sits in detection order; higher is consulted first. * diff --git a/src/extensions/default/vcs/git/history.test.ts b/src/extensions/default/vcs/git/history.test.ts new file mode 100644 index 000000000..a5826c30e --- /dev/null +++ b/src/extensions/default/vcs/git/history.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test } from "bun:test"; +import { createGitVcsAdapter } from "./index"; +import { buildGitHistoryArgs, gitHistoryUsesBoundaryTopology, parseGitHistory } from "./history"; + +describe("Git history production", () => { + test("builds the strict supported query with literal pathspec separation", () => { + expect( + buildGitHistoryArgs({ + revision: "main..feature", + all: true, + firstParent: true, + maxCount: 12, + author: "Ada", + grep: "parser", + since: "2.weeks", + until: "yesterday", + pathspecs: ["src/file with spaces.ts", "--not-an-option"], + }), + ).toEqual([ + "log", + "--topo-order", + "--parents", + "--no-show-signature", + "--no-color", + "--abbrev=8", + "-z", + "--format=%H%x00%h%x00%P%x00%an%x00%ae%x00%aI%x00%s%x00%b", + "--all", + "--first-parent", + "--max-count=12", + "--author=Ada", + "--grep=parser", + "--since=2.weeks", + "--until=yesterday", + "main..feature", + "--", + "src/file with spaces.ts", + "--not-an-option", + ]); + }); + + test("refuses option-like revisions", () => { + expect(() => buildGitHistoryArgs({ revision: "--output=/tmp/pwn" })).toThrow( + "Refused history revision", + ); + }); + + test("parses NUL-delimited commits and copies structured decorations", () => { + const decorations = new Map([["a".repeat(40), [{ kind: "head" as const, label: "HEAD" }]]]); + const text = [ + "a".repeat(40), + "aaaaaaaa", + `${"b".repeat(40)} ${"c".repeat(40)}`, + "Ada Lovelace", + "ada@example.com", + "2026-01-02T03:04:05Z", + "Merge work", + "Detailed rationale.\n", + ].join("\0"); + expect(parseGitHistory(text, decorations)).toEqual([ + { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: ["b".repeat(40), "c".repeat(40)], + subject: "Merge work", + body: "Detailed rationale.\n", + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + authoredAt: "2026-01-02T03:04:05Z", + decorations: [{ kind: "head", label: "HEAD" }], + }, + ]); + }); + + test("marks repeated filtered gaps as graph boundaries without losing review parents", () => { + expect(gitHistoryUsesBoundaryTopology({ author: "Ada" })).toBe(true); + expect(gitHistoryUsesBoundaryTopology({ grep: "fix" })).toBe(true); + expect(gitHistoryUsesBoundaryTopology({})).toBe(false); + + const record = (revision: string, parent: string, subject: string) => + [ + revision.repeat(40), + revision.repeat(8), + parent.repeat(40), + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + subject, + "", + ].join("\0"); + const commits = parseGitHistory( + `${record("a", "d", "match one")}\0${record("b", "e", "match two")}\0${record("c", "f", "match three")}`, + new Map(), + false, + true, + ); + expect(commits.map((commit) => commit.parentRevisionIds)).toEqual([ + ["d".repeat(40)], + ["e".repeat(40)], + ["f".repeat(40)], + ]); + expect(commits.map((commit) => commit.graphParentRevisionIds)).toEqual([[], [], []]); + }); + + test("drops excluded secondary parents for first-parent topology", () => { + const text = [ + "a".repeat(40), + "aaaaaaaa", + `${"b".repeat(40)} ${"c".repeat(40)}`, + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + "Merge", + "", + ].join("\0"); + expect(parseGitHistory(text, new Map(), true)[0]!.parentRevisionIds).toEqual(["b".repeat(40)]); + }); + + test("owns first-parent merge and root review semantics", async () => { + const history = createGitVcsAdapter().history!; + const root = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [], + subject: "Root", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + expect(await history.planReview(root)).toEqual({ + kind: "revision-show", + revisionId: root.revisionId, + }); + expect( + await history.planReview({ + ...root, + revisionId: "b".repeat(40), + parentRevisionIds: ["c".repeat(40), "d".repeat(40)], + }), + ).toEqual({ + kind: "revision-range", + fromRevisionId: "c".repeat(40), + toRevisionId: "b".repeat(40), + }); + }); + + test("rejects truncated records and invalid SHA object ids", () => { + expect(() => parseGitHistory("id\0short\0parent")).toThrow("truncated history record"); + expect(() => + parseGitHistory( + [ + "not-a-sha", + "short", + "", + "Ada", + "ada@example.com", + "2026-01-01T00:00:00Z", + "Bad", + "", + ].join("\0"), + ), + ).toThrow("invalid history object id"); + }); +}); diff --git a/src/extensions/default/vcs/git/history.ts b/src/extensions/default/vcs/git/history.ts new file mode 100644 index 000000000..b99c05947 --- /dev/null +++ b/src/extensions/default/vcs/git/history.ts @@ -0,0 +1,381 @@ +import { spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; +import { + HunkExtensionUserError, + type ExtensionVcsHistoryCommit, + type ExtensionVcsHistoryDecoration, + type ExtensionVcsHistoryInput, + type ExtensionVcsHistorySource, +} from "hunkdiff/extension"; + +const HISTORY_FIELDS_PER_COMMIT = 8; +const FULL_OBJECT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const ABBREVIATED_OBJECT_ID_PATTERN = /^[0-9a-f]{4,64}$/; +const MAX_SYNC_OUTPUT_BYTES = 8 * 1024 * 1024; +const MAX_DECORATION_REFS = 10_000; +const MAX_HISTORY_STDERR_BYTES = 16 * 1024; + +interface GitHistoryOptions { + cwd: string; + gitExecutable?: string; +} + +/** Run one shell-free bounded Git query and retain byte-exact NUL delimiters. */ +function runGit( + args: string[], + { cwd, gitExecutable = "git" }: GitHistoryOptions, + acceptedExitCodes: readonly number[] = [0], +) { + let result: ReturnType; + try { + result = Bun.spawnSync([gitExecutable, ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + maxBuffer: MAX_SYNC_OUTPUT_BYTES, + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + }); + } catch { + throw new HunkExtensionUserError(`Could not run ${gitExecutable}.`, { + suggestions: ["Install Git or configure Hunk to use another VCS backend."], + }); + } + + if (!acceptedExitCodes.includes(result.exitCode)) { + const message = result.stderr?.toString().trim().split("\n")[0]; + throw new HunkExtensionUserError(message || "Git could not read this repository.", { + suggestions: ["Check the revision, filters, and repository, then try again."], + }); + } + return result.stdout?.toString() ?? ""; +} + +/** Resolve the repository root, including a bare repository with no worktree. */ +function resolveHistoryRepoRoot(options: GitHistoryOptions) { + const worktree = runGit(["rev-parse", "--show-toplevel"], options, [0, 128]).trim(); + if (worktree) return worktree; + const gitDir = runGit(["rev-parse", "--absolute-git-dir"], options).trim(); + if (!gitDir) { + throw new HunkExtensionUserError("Not inside a Git repository.", { + suggestions: ["Run `hunk log` from a Git worktree or bare repository."], + }); + } + return gitDir; +} + +/** Return whether a default HEAD traversal has no first commit yet. */ +function hasHead(options: GitHistoryOptions) { + return runGit(["rev-parse", "--verify", "--quiet", "HEAD"], options, [0, 1]).trim().length > 0; +} + +/** Refuse a positional revision that Git could reinterpret as an option. */ +function requireRevision(value: string) { + if (!value || value.startsWith("-")) { + throw new HunkExtensionUserError(`Refused history revision \`${value}\`.`, { + suggestions: ["Pass a revision or range such as `HEAD`, `main`, or `main..feature`."], + }); + } + return value; +} + +/** Snapshot ref labels separately so display punctuation is never parsed as structure. */ +function readDecorations(options: GitHistoryOptions) { + const byCommit = new Map(); + const add = (revisionId: string, decoration: ExtensionVcsHistoryDecoration) => { + const entries = byCommit.get(revisionId) ?? []; + entries.push(decoration); + byCommit.set(revisionId, entries); + }; + + const raw = runGit( + [ + "for-each-ref", + `--count=${MAX_DECORATION_REFS + 1}`, + "--format=%(objectname)%00%(objecttype)%00%(refname)%00%(*objectname)%00", + "refs/heads", + "refs/remotes", + "refs/tags", + ], + options, + ); + const records = raw.split("\n").filter(Boolean); + if (records.length > MAX_DECORATION_REFS) { + throw new HunkExtensionUserError( + `Git history has more than ${MAX_DECORATION_REFS.toLocaleString("en-US")} decorated refs.`, + { suggestions: ["Reduce repository refs before running `hunk log`."] }, + ); + } + for (const record of records) { + const [objectId, objectType, refName, peeledId] = record.split("\0"); + if (!objectId || !objectType || !refName) continue; + const revisionId = objectType === "tag" && peeledId ? peeledId : objectId; + const decoration: ExtensionVcsHistoryDecoration = refName.startsWith("refs/heads/") + ? { kind: "local-branch", label: refName.slice("refs/heads/".length) } + : refName.startsWith("refs/remotes/") + ? { kind: "remote-branch", label: refName.slice("refs/remotes/".length) } + : refName.startsWith("refs/tags/") + ? { kind: "tag", label: refName.slice("refs/tags/".length) } + : { kind: "ref", label: refName }; + add(revisionId, decoration); + } + + const headId = runGit(["rev-parse", "--verify", "--quiet", "HEAD"], options, [0, 1]).trim(); + if (headId) { + const branch = runGit(["symbolic-ref", "--quiet", "--short", "HEAD"], options, [0, 1]).trim(); + add(headId, { + kind: "head", + label: "HEAD", + ...(branch ? { attachedLocalBranch: branch } : {}), + }); + } + + const order = new Map([ + ["head", 0], + ["local-branch", 1], + ["remote-branch", 2], + ["tag", 3], + ["ref", 4], + ]); + for (const entries of byCommit.values()) { + entries.sort( + (left, right) => + (order.get(left.kind) ?? 9) - (order.get(right.kind) ?? 9) || + (left.label < right.label ? -1 : left.label > right.label ? 1 : 0), + ); + } + return byCommit; +} + +/** Build deterministic Git log argv from the deliberately small public grammar. */ +export function buildGitHistoryArgs(input: ExtensionVcsHistoryInput) { + const args = [ + "log", + "--topo-order", + "--parents", + "--no-show-signature", + "--no-color", + "--abbrev=8", + "-z", + "--format=%H%x00%h%x00%P%x00%an%x00%ae%x00%aI%x00%s%x00%b", + ]; + if (input.all) args.push("--all"); + if (input.firstParent) args.push("--first-parent"); + if (input.maxCount !== undefined) args.push(`--max-count=${input.maxCount}`); + if (input.author !== undefined) args.push(`--author=${input.author}`); + if (input.grep !== undefined) args.push(`--grep=${input.grep}`); + if (input.since !== undefined) args.push(`--since=${input.since}`); + if (input.until !== undefined) args.push(`--until=${input.until}`); + if (input.revision !== undefined) args.push(requireRevision(input.revision)); + if (input.pathspecs?.length) args.push("--", ...input.pathspecs); + return args; +} + +/** Parse fixed NUL-delimited machine fields into immutable commit summaries. */ +export function parseGitHistory( + text: string, + decorations: ReadonlyMap = new Map(), + firstParent = false, + omitGraphParents = false, +): ExtensionVcsHistoryCommit[] { + if (!text) return []; + const fields = text.split("\0"); + if (fields.length % HISTORY_FIELDS_PER_COMMIT === 1 && fields.at(-1) === "") fields.pop(); + if (fields.length % HISTORY_FIELDS_PER_COMMIT !== 0) { + throw new Error("Git returned a truncated history record."); + } + + const commits: ExtensionVcsHistoryCommit[] = []; + for (let offset = 0; offset < fields.length; offset += HISTORY_FIELDS_PER_COMMIT) { + const revisionId = fields[offset]!; + const displayId = fields[offset + 1]!; + const parents = fields[offset + 2]!; + const authorName = fields[offset + 3]!; + const authorEmail = fields[offset + 4]!; + const authoredAt = fields[offset + 5]!; + const subject = fields[offset + 6]!; + const body = fields[offset + 7]!; + if (!revisionId || !displayId || !authoredAt) { + throw new Error("Git returned an incomplete history record."); + } + const allParents = parents ? parents.split(" ").filter(Boolean) : []; + const parentRevisionIds = firstParent ? allParents.slice(0, 1) : allParents; + if ( + !FULL_OBJECT_ID_PATTERN.test(revisionId) || + !ABBREVIATED_OBJECT_ID_PATTERN.test(displayId) || + parentRevisionIds.some((parent) => !FULL_OBJECT_ID_PATTERN.test(parent)) + ) { + throw new Error("Git returned an invalid history object id."); + } + commits.push({ + revisionId, + displayId, + parentRevisionIds, + ...(omitGraphParents ? { graphParentRevisionIds: [] } : {}), + subject: subject || "(no commit message)", + ...(body ? { body } : {}), + authorName: authorName || "Unknown author", + ...(authorEmail ? { authorEmail } : {}), + authoredAt, + decorations: [...(decorations.get(revisionId) ?? [])], + }); + } + return commits; +} + +/** Return whether traversal filters can omit direct parents from the emitted commit stream. */ +export function gitHistoryUsesBoundaryTopology(input: ExtensionVcsHistoryInput) { + return Boolean( + input.author !== undefined || + input.grep !== undefined || + input.since !== undefined || + input.until !== undefined || + input.pathspecs?.length, + ); +} + +/** Open a cancellable streaming history cursor over one long-lived Git process. */ +export function openGitHistory( + input: ExtensionVcsHistoryInput, + { cwd, gitExecutable = "git" }: GitHistoryOptions, +): ExtensionVcsHistorySource & { repoRoot: string } { + const repoRoot = resolveHistoryRepoRoot({ cwd, gitExecutable }); + const queryOptions = { cwd: repoRoot, gitExecutable }; + const empty = input.maxCount === 0 || (!input.revision && !input.all && !hasHead(queryOptions)); + if (empty) { + return { + repoRoot, + async read() { + return { commits: [], done: true }; + }, + close() {}, + }; + } + + const decorations = readDecorations(queryOptions); + const omitGraphParents = gitHistoryUsesBoundaryTopology(input); + + let child: ReturnType; + try { + child = spawn(gitExecutable, buildGitHistoryArgs(input), { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, + windowsHide: true, + }); + } catch { + throw new HunkExtensionUserError(`Could not run ${gitExecutable}.`, { + suggestions: ["Install Git or configure Hunk to use another VCS backend."], + }); + } + + const decoder = new StringDecoder("utf8"); + const queue: ExtensionVcsHistoryCommit[] = []; + const fields: string[] = []; + const waiters = new Set<() => void>(); + let buffered = ""; + let stderr = ""; + let completed = false; + let closed = false; + let failure: unknown; + let reading = false; + const wake = () => { + for (const waiter of waiters) waiter(); + waiters.clear(); + }; + const consume = (text: string) => { + buffered += text; + for (;;) { + const delimiter = buffered.indexOf("\0"); + if (delimiter < 0) break; + fields.push(buffered.slice(0, delimiter)); + buffered = buffered.slice(delimiter + 1); + if (fields.length === HISTORY_FIELDS_PER_COMMIT) { + queue.push( + ...parseGitHistory( + `${fields.join("\0")}\0`, + decorations, + input.firstParent, + omitGraphParents, + ), + ); + fields.length = 0; + } + } + wake(); + }; + + child.stdout!.on("data", (chunk: Buffer) => { + consume(decoder.write(chunk)); + if (queue.length >= 512) child.stdout!.pause(); + }); + child.stderr!.setEncoding("utf8"); + child.stderr!.on("data", (chunk: string) => { + if (stderr.length < MAX_HISTORY_STDERR_BYTES) { + stderr += chunk.slice(0, MAX_HISTORY_STDERR_BYTES - stderr.length); + } + }); + child.once("error", (error) => { + failure = error; + completed = true; + wake(); + }); + child.once("close", (code) => { + consume(decoder.end()); + if (!failure && (fields.length > 0 || buffered.length > 0)) { + failure = new Error("Git returned a truncated history record."); + } else if (!failure && code !== 0 && !closed) { + failure = new HunkExtensionUserError( + stderr.trim().split("\n")[0] || "Git could not read this history.", + { + suggestions: ["Check the revision, filters, and repository, then try again."], + }, + ); + } + completed = true; + wake(); + }); + + const waitForData = (signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(signal.reason ?? new Error("History read aborted.")); + return; + } + const ready = () => { + signal?.removeEventListener("abort", abort); + resolve(); + }; + const abort = () => { + waiters.delete(ready); + reject(signal?.reason ?? new Error("History read aborted.")); + }; + waiters.add(ready); + signal?.addEventListener("abort", abort, { once: true }); + }); + + return { + repoRoot, + async read({ limit, signal }) { + if (reading) throw new Error("Concurrent Git history reads are not supported."); + reading = true; + try { + const target = Math.min(limit, 256); + while (queue.length < target && !completed && !closed) await waitForData(signal); + if (signal?.aborted) throw signal.reason ?? new Error("History read aborted."); + if (failure) throw failure; + const commits = queue.splice(0, target); + if (!completed && !closed && queue.length < 256) child.stdout!.resume(); + return { commits, done: (completed || closed) && queue.length === 0 }; + } finally { + reading = false; + } + }, + close() { + if (closed) return; + closed = true; + if (!completed) child.kill(); + wake(); + }, + }; +} diff --git a/src/extensions/default/vcs/git/index.ts b/src/extensions/default/vcs/git/index.ts index d3a1b27ff..c92204ed6 100644 --- a/src/extensions/default/vcs/git/index.ts +++ b/src/extensions/default/vcs/git/index.ts @@ -19,6 +19,7 @@ import { type GitBackedInput, type GitDiffEndpoints, } from "./commands"; +import { openGitHistory } from "./history"; import { gitEndpointSourceSpec, readGitFileSource } from "./source"; import { describeDiffRange } from "../diffRange"; import { @@ -279,6 +280,21 @@ export function createGitVcsAdapter({ name: "Git", detect: detectGitRepo, detectionPriority: HUNK_VCS_DETECTION_BASELINE_PRIORITY, + history: { + open(input, { cwd }) { + return openGitHistory(input, { cwd, gitExecutable }); + }, + planReview(commit) { + const firstParent = commit.parentRevisionIds[0]; + return firstParent + ? { + kind: "revision-range" as const, + fromRevisionId: firstParent, + toRevisionId: commit.revisionId, + } + : { kind: "revision-show" as const, revisionId: commit.revisionId }; + }, + }, operations: { "working-tree-diff": { async load(input, { cwd }) { diff --git a/src/extensions/default/vcs/jujutsu/history.test.ts b/src/extensions/default/vcs/jujutsu/history.test.ts new file mode 100644 index 000000000..fb4fb0e32 --- /dev/null +++ b/src/extensions/default/vcs/jujutsu/history.test.ts @@ -0,0 +1,252 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createJjVcsAdapter } from "./index"; +import { + buildJjHistoryArgs, + buildJjHistoryRevset, + jjHistoryUsesBoundaryTopology, + openJjHistory, + parseJjHistory, +} from "./history"; + +const tempDirs: string[] = []; +const jjTest = Bun.which("jj") ? test : test.skip; + +/** Create a real JJ-only workspace without a colocated `.git` directory. */ +function createJjOnlyTestRepo() { + const repo = realpathSync(mkdtempSync(join(tmpdir(), "hunk-jj-history-"))); + tempDirs.push(repo); + const init = Bun.spawnSync(["jj", "git", "init", "--no-colocate", repo], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + if (init.exitCode !== 0) throw new Error(init.stderr.toString()); + return repo; +} + +/** Run JJ with a deterministic identity for fixture commits. */ +function jj(repo: string, ...args: string[]) { + const result = Bun.spawnSync( + [ + "jj", + "--config", + 'user.name="Ada Lovelace"', + "--config", + 'user.email="ada@example.com"', + ...args, + ], + { cwd: repo, stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ); + if (result.exitCode !== 0) throw new Error(result.stderr.toString()); + return result.stdout.toString(); +} + +afterEach(() => { + for (const repo of tempDirs.splice(0)) rmSync(repo, { recursive: true, force: true }); +}); + +describe("Jujutsu history production", () => { + test("builds a provider-owned revset and preserves literal filesets", () => { + expect( + buildJjHistoryArgs({ + revision: "main..@", + firstParent: true, + maxCount: 12, + author: 'Ada "A"', + grep: "parser", + since: "2026-01-01", + until: "2026-02-01", + pathspecs: ["src/file with spaces.ts", "--not-an-option"], + }), + ).toEqual([ + "--ignore-working-copy", + "--no-pager", + "--color", + "never", + "log", + "--no-graph", + "--revisions", + '(first_ancestors((main..@)) ~ root()) & author(substring:"Ada \\"A\\"") & description(substring:"parser") & author_date(after:"2026-01-01") & author_date(before:"2026-02-01")', + "--template", + expect.any(String), + "--limit", + "12", + "--", + "src/file with spaces.ts", + "--not-an-option", + ]); + expect(buildJjHistoryRevset({ all: true })).toBe("(ancestors(visible_heads()) ~ root())"); + }); + + test("rejects option-like revisions", () => { + expect(() => buildJjHistoryRevset({ revision: "--at-operation=@" })).toThrow( + "Refused Jujutsu history revision", + ); + }); + + test("parses descriptions, logical ids, parents, and structured JJ refs", () => { + const raw = [ + "a".repeat(40), + "kkkkkkkk", + "k".repeat(32), + `${"b".repeat(40)} ${"c".repeat(40)}`, + "Ada Lovelace", + "ada@example.com", + "2026-01-02T03:04:05+00:00", + "Subject\n\nBody paragraph.\n", + "1", + `main\x1ffeature`, + "main@git", + "v1.0.0", + "v1.0.0@origin", + ].join("\0"); + expect(parseJjHistory(raw)).toEqual([ + { + revisionId: "a".repeat(40), + displayId: "kkkkkkkk", + logicalId: "k".repeat(32), + parentRevisionIds: ["b".repeat(40), "c".repeat(40)], + subject: "Subject", + body: "Body paragraph.", + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + authoredAt: "2026-01-02T03:04:05+00:00", + decorations: [ + { kind: "head", label: "@" }, + { kind: "local-branch", label: "main" }, + { kind: "local-branch", label: "feature" }, + { kind: "remote-branch", label: "main@git" }, + { kind: "tag", label: "v1.0.0" }, + { kind: "tag", label: "v1.0.0@origin" }, + ], + }, + ]); + }); + + test("keeps review parents but marks filtered topology as a boundary", () => { + expect(jjHistoryUsesBoundaryTopology({})).toBe(false); + expect(jjHistoryUsesBoundaryTopology({ revision: "main..@" })).toBe(false); + expect(jjHistoryUsesBoundaryTopology({ pathspecs: ["src"] })).toBe(true); + expect(jjHistoryUsesBoundaryTopology({ author: "Ada" })).toBe(true); + + const raw = [ + "a".repeat(40), + "aaaaaaaa", + "k".repeat(32), + "b".repeat(40), + "Ada", + "", + "2026-01-01T00:00:00+00:00", + "Commit\n", + "0", + "", + "", + "", + "", + ].join("\0"); + const parsed = parseJjHistory(raw, false, true)[0]!; + expect(parsed.parentRevisionIds).toEqual(["b".repeat(40)]); + expect(parsed.graphParentRevisionIds).toEqual([]); + }); + + test("drops JJ's synthetic root and excluded merge parents", () => { + const raw = [ + "a".repeat(40), + "aaaaaaaa", + "k".repeat(32), + `${"b".repeat(40)} ${"0".repeat(40)} ${"c".repeat(40)}`, + "Ada", + "", + "2026-01-01T00:00:00+00:00", + "Commit\n", + "0", + "", + "", + "", + "", + ].join("\0"); + expect(parseJjHistory(raw, true)[0]!.parentRevisionIds).toEqual(["b".repeat(40)]); + }); + + test("owns ordinary, merge, and root review semantics with revision-show", async () => { + const history = createJjVcsAdapter().history!; + for (const parentRevisionIds of [[], ["b".repeat(40)], ["b".repeat(40), "c".repeat(40)]]) { + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + logicalId: "k".repeat(32), + parentRevisionIds, + subject: "Commit", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00+00:00", + decorations: [], + }; + expect(await history.planReview(commit)).toEqual({ + kind: "revision-show", + revisionId: commit.revisionId, + }); + } + }); + + jjTest( + "streams bounded child-before-parent pages from a JJ-only repository", + async () => { + const repo = createJjOnlyTestRepo(); + expect(existsSync(join(repo, ".git"))).toBe(false); + writeFileSync(join(repo, "history.txt"), "one\n"); + jj(repo, "commit", "-m", "First commit"); + writeFileSync(join(repo, "history.txt"), "two\n"); + jj(repo, "commit", "-m", "Second commit\n\nDetailed body."); + jj(repo, "bookmark", "create", "main", "-r", "@-"); + jj(repo, "tag", "set", "v1.0.0", "-r", "@-"); + + const source = openJjHistory({ maxCount: 4 }, { cwd: repo }); + const commits = []; + for (;;) { + const page = await source.read({ limit: 1 }); + expect(page.commits.length).toBeLessThanOrEqual(1); + commits.push(...page.commits); + if (page.done) break; + } + await source.close(); + + expect(commits.map((commit) => commit.subject)).toEqual([ + "(no description set)", + "Second commit", + "First commit", + ]); + expect(commits[1]!.body).toBe("Detailed body."); + expect(commits[1]!.displayId).toMatch(/^[k-z]{8}$/); + expect(commits[1]!.logicalId).toMatch(/^[k-z]{32}$/); + expect(commits[1]!.decorations).toContainEqual({ kind: "local-branch", label: "main" }); + expect(commits[1]!.decorations).toContainEqual({ kind: "tag", label: "v1.0.0" }); + for (let index = 0; index < commits.length; index += 1) { + for (const parent of commits[index]!.parentRevisionIds) { + const parentIndex = commits.findIndex((commit) => commit.revisionId === parent); + if (parentIndex >= 0) expect(parentIndex).toBeGreaterThan(index); + } + } + + const filtered = openJjHistory({ pathspecs: ["history.txt"] }, { cwd: repo }); + const filteredPage = await filtered.read({ limit: 4 }); + await filtered.close(); + const filteredWithParent = filteredPage.commits.find( + (commit) => commit.parentRevisionIds.length > 0, + ); + expect(filteredWithParent?.parentRevisionIds).toHaveLength(1); + expect(filteredWithParent?.graphParentRevisionIds).toEqual([]); + + const cancelled = openJjHistory({}, { cwd: repo }); + const abort = new AbortController(); + abort.abort(new Error("cancel fixture")); + await expect(cancelled.read({ limit: 1, signal: abort.signal })).rejects.toThrow( + "cancel fixture", + ); + await cancelled.close(); + }, + 20_000, + ); +}); diff --git a/src/extensions/default/vcs/jujutsu/history.ts b/src/extensions/default/vcs/jujutsu/history.ts new file mode 100644 index 000000000..4f2138538 --- /dev/null +++ b/src/extensions/default/vcs/jujutsu/history.ts @@ -0,0 +1,369 @@ +import { spawn } from "node:child_process"; +import { StringDecoder } from "node:string_decoder"; +import { + HunkExtensionUserError, + type ExtensionVcsHistoryCommit, + type ExtensionVcsHistoryDecoration, + type ExtensionVcsHistoryInput, + type ExtensionVcsHistorySource, +} from "hunkdiff/extension"; +import { normalizePathForOS } from "../../../../lib/osPath"; + +const HISTORY_FIELDS_PER_COMMIT = 13; +const REF_SEPARATOR = "\x1f"; +const FULL_COMMIT_ID_PATTERN = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/; +const DISPLAY_ID_PATTERN = /^[a-z0-9]{4,64}$/; +const ROOT_COMMIT_ID_PATTERN = /^0+$/; + +/** Emits stable machine fields without consulting a user's log template. */ +const JJ_HISTORY_TEMPLATE = + [ + "commit_id", + "change_id.short(8)", + "change_id", + 'parents.map(|p| p.commit_id()).join(" ")', + "author.name()", + "author.email()", + 'author.timestamp().format("%Y-%m-%dT%H:%M:%S%:z")', + "description", + 'if(current_working_copy, "1", "0")', + 'local_bookmarks.map(|r| r.name()).join("\\x1f")', + 'remote_bookmarks.map(|r| r.name() ++ "@" ++ r.remote()).join("\\x1f")', + 'local_tags.map(|r| r.name()).join("\\x1f")', + 'remote_tags.map(|r| r.name() ++ "@" ++ r.remote()).join("\\x1f")', + ].join(' ++ "\\0" ++ ') + ' ++ "\\0"'; + +export interface JjHistoryOptions { + cwd: string; + jjExecutable?: string; +} + +/** Return one safely quoted Jujutsu string literal for a generated revset. */ +function quoteRevsetString(value: string) { + return JSON.stringify(value); +} + +/** Reject a positional revset that the CLI could reinterpret as an option. */ +function requireHistoryRevision(value: string) { + if (!value || value.startsWith("-")) { + throw new HunkExtensionUserError(`Refused Jujutsu history revision \`${value}\`.`, { + suggestions: ["Pass a revision or revset such as `@`, `main`, or `main..@`."], + }); + } + return value; +} + +/** Build the provider-owned revset while preserving honest Jujutsu semantics. */ +export function buildJjHistoryRevset(input: ExtensionVcsHistoryInput) { + const start = input.revision + ? `(${requireHistoryRevision(input.revision)})` + : input.all + ? "visible_heads()" + : "@"; + const traversal = input.firstParent ? `first_ancestors(${start})` : `ancestors(${start})`; + const filters = [`(${traversal} ~ root())`]; + if (input.author !== undefined) { + filters.push(`author(substring:${quoteRevsetString(input.author)})`); + } + if (input.grep !== undefined) { + filters.push(`description(substring:${quoteRevsetString(input.grep)})`); + } + if (input.since !== undefined) { + filters.push(`author_date(after:${quoteRevsetString(input.since)})`); + } + if (input.until !== undefined) { + filters.push(`author_date(before:${quoteRevsetString(input.until)})`); + } + return filters.join(" & "); +} + +/** Build one deterministic, non-mutating `jj log` invocation. */ +export function buildJjHistoryArgs(input: ExtensionVcsHistoryInput) { + const args = [ + "--ignore-working-copy", + "--no-pager", + "--color", + "never", + "log", + "--no-graph", + "--revisions", + buildJjHistoryRevset(input), + "--template", + JJ_HISTORY_TEMPLATE, + ]; + if (input.maxCount !== undefined) args.push("--limit", String(input.maxCount)); + if (input.pathspecs?.length) args.push("--", ...input.pathspecs); + return args; +} + +/** Split a Jujutsu description into the medium-format subject and body fields. */ +function splitDescription(description: string) { + const normalized = description.endsWith("\n") ? description.slice(0, -1) : description; + const boundary = normalized.indexOf("\n"); + if (boundary < 0) { + return { subject: normalized || "(no description set)" }; + } + const subject = normalized.slice(0, boundary) || "(no description set)"; + const body = normalized.slice(boundary + 1).replace(/^\n/, ""); + return { subject, ...(body ? { body } : {}) }; +} + +/** Convert one ref-list field into typed, provider-neutral decorations. */ +function appendDecorations( + target: ExtensionVcsHistoryDecoration[], + field: string, + kind: ExtensionVcsHistoryDecoration["kind"], +) { + for (const label of field.split(REF_SEPARATOR)) { + if (label) target.push({ kind, label }); + } +} + +/** Parse fixed NUL-delimited Jujutsu template records. */ +export function parseJjHistory( + text: string, + firstParent = false, + omitGraphParents = false, +): ExtensionVcsHistoryCommit[] { + if (!text) return []; + const fields = text.split("\0"); + if (fields.length % HISTORY_FIELDS_PER_COMMIT === 1 && fields.at(-1) === "") fields.pop(); + if (fields.length % HISTORY_FIELDS_PER_COMMIT !== 0) { + throw new Error("Jujutsu returned a truncated history record."); + } + + const commits: ExtensionVcsHistoryCommit[] = []; + for (let offset = 0; offset < fields.length; offset += HISTORY_FIELDS_PER_COMMIT) { + const revisionId = fields[offset]!; + const displayId = fields[offset + 1]!; + const logicalId = fields[offset + 2]!; + const parents = fields[offset + 3]!; + const authorName = fields[offset + 4]!; + const authorEmail = fields[offset + 5]!; + const authoredAt = fields[offset + 6]!; + const description = fields[offset + 7]!; + const currentWorkingCopy = fields[offset + 8]!; + const localBookmarks = fields[offset + 9]!; + const remoteBookmarks = fields[offset + 10]!; + const localTags = fields[offset + 11]!; + const remoteTags = fields[offset + 12]!; + + if ( + !FULL_COMMIT_ID_PATTERN.test(revisionId) || + !DISPLAY_ID_PATTERN.test(displayId) || + !logicalId || + !authoredAt + ) { + throw new Error("Jujutsu returned an invalid history record."); + } + const allParents = parents + ? parents.split(" ").filter((parent) => parent && !ROOT_COMMIT_ID_PATTERN.test(parent)) + : []; + if (allParents.some((parent) => !FULL_COMMIT_ID_PATTERN.test(parent))) { + throw new Error("Jujutsu returned an invalid history parent commit id."); + } + + const decorations: ExtensionVcsHistoryDecoration[] = []; + if (currentWorkingCopy === "1") decorations.push({ kind: "head", label: "@" }); + appendDecorations(decorations, localBookmarks, "local-branch"); + appendDecorations(decorations, remoteBookmarks, "remote-branch"); + appendDecorations(decorations, localTags, "tag"); + appendDecorations(decorations, remoteTags, "tag"); + + commits.push({ + revisionId, + displayId, + parentRevisionIds: firstParent ? allParents.slice(0, 1) : allParents, + ...(omitGraphParents ? { graphParentRevisionIds: [] } : {}), + ...splitDescription(description), + authorName: authorName || "Unknown author", + ...(authorEmail ? { authorEmail } : {}), + authoredAt, + decorations, + logicalId, + }); + } + return commits; +} + +/** Run a small synchronous JJ query used only to establish the repository root. */ +function resolveHistoryRepoRoot({ cwd, jjExecutable = "jj" }: JjHistoryOptions) { + let result: ReturnType; + try { + result = Bun.spawnSync( + [jjExecutable, "--ignore-working-copy", "--no-pager", "--color", "never", "root"], + { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" }, + ); + } catch { + throw new HunkExtensionUserError(`Could not run ${jjExecutable}.`, { + suggestions: ["Install Jujutsu or configure Hunk to use another VCS backend."], + }); + } + if (result.exitCode !== 0) { + const message = result.stderr?.toString().trim().split("\n")[0]; + throw new HunkExtensionUserError(message || "Jujutsu could not read this repository.", { + suggestions: ["Run `hunk log --vcs jj` from a Jujutsu workspace."], + }); + } + const repoRoot = result.stdout?.toString().trim(); + if (!repoRoot) throw new Error("Jujutsu returned an empty repository root."); + return normalizePathForOS(repoRoot); +} + +/** Return whether traversal filters can omit direct parents from the emitted commit stream. */ +export function jjHistoryUsesBoundaryTopology(input: ExtensionVcsHistoryInput) { + return Boolean( + input.author !== undefined || + input.grep !== undefined || + input.since !== undefined || + input.until !== undefined || + input.pathspecs?.length, + ); +} + +/** Open a bounded, cancellable cursor over one long-lived `jj log` process. */ +export function openJjHistory( + input: ExtensionVcsHistoryInput, + { cwd, jjExecutable = "jj" }: JjHistoryOptions, +): ExtensionVcsHistorySource & { repoRoot: string } { + const repoRoot = resolveHistoryRepoRoot({ cwd, jjExecutable }); + if (input.maxCount === 0) { + return { + repoRoot, + async read() { + return { commits: [], done: true }; + }, + close() {}, + }; + } + + const omitGraphParents = jjHistoryUsesBoundaryTopology(input); + const child = spawn(jjExecutable, buildJjHistoryArgs(input), { + cwd: repoRoot, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + const decoder = new StringDecoder("utf8"); + const queue: ExtensionVcsHistoryCommit[] = []; + const fields: string[] = []; + const waiters = new Set<() => void>(); + let buffered = ""; + let stderr = ""; + let completed = false; + let closed = false; + let reading = false; + let failure: unknown; + + const wake = () => { + for (const waiter of waiters) waiter(); + waiters.clear(); + }; + const stop = () => { + if (closed) return; + closed = true; + if (!completed) child.kill(); + wake(); + }; + const consume = (text: string) => { + buffered += text; + for (;;) { + const delimiter = buffered.indexOf("\0"); + if (delimiter < 0) break; + fields.push(buffered.slice(0, delimiter)); + buffered = buffered.slice(delimiter + 1); + if (fields.length === HISTORY_FIELDS_PER_COMMIT) { + queue.push( + ...parseJjHistory(`${fields.join("\0")}\0`, input.firstParent, omitGraphParents), + ); + fields.length = 0; + } + } + wake(); + }; + + child.stdout!.on("data", (chunk: Buffer) => { + try { + consume(decoder.write(chunk)); + if (queue.length >= 512) child.stdout!.pause(); + } catch (error) { + failure = error; + completed = true; + child.kill(); + wake(); + } + }); + child.stderr!.setEncoding("utf8"); + child.stderr!.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", () => { + failure = new HunkExtensionUserError(`Could not run ${jjExecutable}.`, { + suggestions: ["Install Jujutsu or configure Hunk to use another VCS backend."], + }); + completed = true; + wake(); + }); + child.once("close", (code) => { + if (!failure) { + try { + consume(decoder.end()); + } catch (error) { + failure = error; + } + } + if (!failure && (fields.length > 0 || buffered.length > 0)) { + failure = new Error("Jujutsu returned a truncated history record."); + } else if (!failure && code !== 0 && !closed) { + failure = new HunkExtensionUserError( + stderr.trim().split("\n")[0] || "Jujutsu could not read this history.", + { suggestions: ["Check the revset, filters, and filesets, then try again."] }, + ); + } + completed = true; + wake(); + }); + + const waitForData = (signal?: AbortSignal) => + new Promise((resolve, reject) => { + if (signal?.aborted) { + stop(); + reject(signal.reason ?? new Error("History read aborted.")); + return; + } + const ready = () => { + signal?.removeEventListener("abort", abort); + resolve(); + }; + const abort = () => { + waiters.delete(ready); + stop(); + reject(signal?.reason ?? new Error("History read aborted.")); + }; + waiters.add(ready); + signal?.addEventListener("abort", abort, { once: true }); + }); + + return { + repoRoot, + async read({ limit, signal }) { + if (reading) throw new Error("Concurrent Jujutsu history reads are not supported."); + if (signal?.aborted) { + stop(); + throw signal.reason ?? new Error("History read aborted."); + } + reading = true; + try { + const target = Math.min(Math.max(1, limit), 256); + while (queue.length < target && !completed && !closed) await waitForData(signal); + if (signal?.aborted) throw signal.reason ?? new Error("History read aborted."); + if (failure) throw failure; + const commits = queue.splice(0, target); + if (!completed && !closed && queue.length < 256) child.stdout!.resume(); + return { commits, done: (completed || closed) && queue.length === 0 }; + } finally { + reading = false; + } + }, + close: stop, + }; +} diff --git a/src/extensions/default/vcs/jujutsu/index.ts b/src/extensions/default/vcs/jujutsu/index.ts index 48018f053..f39cc9f3e 100644 --- a/src/extensions/default/vcs/jujutsu/index.ts +++ b/src/extensions/default/vcs/jujutsu/index.ts @@ -10,6 +10,7 @@ import { runJjText, type JjDiffEndpoints, } from "./commands"; +import { openJjHistory } from "./history"; import { readJjFileSource } from "./source"; import { describeDiffRange } from "../diffRange"; import { @@ -131,6 +132,16 @@ export function createJjVcsAdapter({ jjExecutable = "jj" }: Readonly { expect(mismatches).toEqual(["mercurial"]); }); + test("preserves terminal-safe adapter ids that begin with a dash", () => { + const adapter = toInternalVcsAdapter({ + id: "-custom", + name: "Custom", + detect: () => ({ id: "-custom", repoRoot: "/repo" }), + }); + + expect(adapter.id).toBe("-custom"); + expect(adapter.detect("/repo")).toEqual({ id: "-custom", repoRoot: "/repo" }); + }); + test("passes a matching detection through untouched, with no diagnostic", () => { const mismatches: string[] = []; const detection = { id: "hg", repoRoot: "/repo" }; @@ -833,10 +844,24 @@ describe("toInternalVcsAdapter detection ids", () => { (returnedId) => mismatches.push(returnedId), ); - expect(adapter.detect("/repo")).toBe(detection); + expect(adapter.detect("/repo")).toEqual(detection); expect(mismatches).toEqual([]); }); + test("snapshots and sanitizes adapter metadata before it reaches diagnostics", () => { + let nameReads = 0; + const adapter = toInternalVcsAdapter({ + id: "demo", + get name() { + nameReads += 1; + return nameReads === 1 ? "Demo\x1b[2J" : "Changed"; + }, + detect: () => null, + }); + expect(adapter.name).toBe("Demo"); + expect(nameReads).toBe(1); + }); + test("treats a detection without a usable repoRoot as no detection", () => { // `detectVcs` measures distance with `path.relative(detected.repoRoot, cwd)`, // and does it outside its own per-adapter try/catch — so a missing repoRoot @@ -947,3 +972,301 @@ describe("toInternalVcsAdapter detection ids", () => { ]); }); }); + +describe("toInternalVcsAdapter history boundary", () => { + test("requires providers to own selected-item review planning", () => { + expect(() => + toInternalVcsAdapter({ + id: "incomplete", + name: "Incomplete", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [], done: true }), + close() {}, + }), + } as never, + }), + ).toThrow("history must provide open() and planReview() functions"); + }); + + test("copies and sanitizes bounded history pages", async () => { + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [] as string[], + subject: "safe\x1b]52;c;cHdu\x07\nspoof", + authorName: "Ada\rLovelace", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [{ kind: "head" as const, label: "HEAD\x1b[2J" }], + }; + let closed = 0; + const adapter = toInternalVcsAdapter({ + id: "demo", + name: "Demo", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [commit], done: true }), + close: () => { + closed += 1; + }, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + const page = await source.read({ limit: 1 }); + + expect(page.commits[0]?.subject).toBe("safespoof"); + expect(page.commits[0]?.authorName).toBe("AdaLovelace"); + expect(page.commits[0]?.decorations[0]?.label).toBe("HEAD"); + expect(page.commits[0]).not.toBe(commit); + expect(closed).toBe(1); + }); + + test("rejects unsafe revision ids and required display fields erased by sanitization", async () => { + const validCommit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [] as string[], + subject: "Safe subject", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + const readCommit = async (commit: typeof validCommit) => { + const adapter = toInternalVcsAdapter({ + id: "adversarial", + name: "Adversarial", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [commit], done: true }), + close() {}, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + return adapter + .history!.open({}, { cwd: "/repo" }) + .then((source) => source.read({ limit: 1 })); + }; + + await expect(readCommit({ ...validCommit, revisionId: `unsafe\tid` })).rejects.toThrow( + "terminal-safe immutable revision id", + ); + for (const field of ["displayId", "subject", "authorName"] as const) { + await expect(readCommit({ ...validCommit, [field]: "\x1b[2J" })).rejects.toThrow( + `${field} must remain non-empty after sanitization`, + ); + } + }); + + test("rejects tabs in provider-owned review action revision ids", async () => { + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [] as string[], + subject: "Safe subject", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + const adapter = toInternalVcsAdapter({ + id: "adversarial-plan", + name: "Adversarial plan", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [], done: true }), + close() {}, + }), + planReview: () => ({ kind: "revision-show", revisionId: "unsafe\tid" }), + }, + }); + + await expect(adapter.history!.planReview(commit, { cwd: "/repo" })).rejects.toThrow( + "terminal-safe immutable revision id", + ); + }); + + test("snapshots source, page, commit, and decoration accessors exactly once", async () => { + const reads = { sourceRead: 0, commits: 0, subject: 0, label: 0 }; + const commit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [], + get subject() { + reads.subject += 1; + return reads.subject === 1 ? "Stable" : "Changed"; + }, + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [ + { + kind: "tag", + get label() { + reads.label += 1; + return reads.label === 1 ? "v1" : "changed"; + }, + }, + ], + }; + const page = { + get commits() { + reads.commits += 1; + return reads.commits === 1 ? [commit] : [{}, {}]; + }, + done: true, + }; + const publicSource = { + get read() { + reads.sourceRead += 1; + return async () => page; + }, + close() {}, + }; + const adapter = toInternalVcsAdapter({ + id: "demo", + name: "Demo", + detect: () => null, + history: { + open: () => publicSource as never, + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + const result = await source.read({ limit: 1 }); + expect(result.commits[0]?.subject).toBe("Stable"); + expect(result.commits[0]?.decorations[0]?.label).toBe("v1"); + expect(reads).toEqual({ sourceRead: 1, commits: 1, subject: 1, label: 1 }); + }); + + test("closes malformed and over-limit sources once", async () => { + let closed = 0; + const adapter = toInternalVcsAdapter({ + id: "demo", + name: "Demo", + detect: () => null, + history: { + open: () => ({ + read: async () => ({ commits: [{}, {}], done: false }) as never, + close: () => { + closed += 1; + }, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + await expect(source.read({ limit: 1 })).rejects.toThrow("more commits"); + await source.close(); + expect(closed).toBe(1); + }); + + test("preserves custom-provider paging and provider-owned opaque review plans", async () => { + const root = { + revisionId: "opaque:root/revision", + displayId: "root", + parentRevisionIds: [], + subject: "Root", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }; + const child = { + ...root, + revisionId: "opaque:child/revision", + displayId: "child", + parentRevisionIds: [root.revisionId], + subject: "Child", + }; + let page = 0; + const adapter = toInternalVcsAdapter({ + id: "opaque", + name: "Opaque VCS", + detect: (cwd) => ({ id: "opaque", repoRoot: cwd }), + history: { + open: () => ({ + read: async () => + page++ === 0 ? { commits: [child], done: false } : { commits: [root], done: true }, + close() {}, + }), + planReview: (selected) => + selected.parentRevisionIds.length + ? { + kind: "revision-range", + fromRevisionId: `opaque:base-for/${selected.revisionId}`, + toRevisionId: selected.revisionId, + } + : { kind: "revision-show", revisionId: `opaque:root-view/${selected.revisionId}` }, + }, + }); + + const source = await adapter.history!.open({}, { cwd: "/repo" }); + expect((await source.read({ limit: 1 })).commits.map((commit) => commit.revisionId)).toEqual([ + child.revisionId, + ]); + expect((await source.read({ limit: 1 })).commits.map((commit) => commit.revisionId)).toEqual([ + root.revisionId, + ]); + await expect(adapter.history!.planReview(child, { cwd: "/repo" })).resolves.toEqual({ + kind: "revision-range", + fromRevisionId: `opaque:base-for/${child.revisionId}`, + toRevisionId: child.revisionId, + }); + await expect(adapter.history!.planReview(root, { cwd: "/repo" })).resolves.toEqual({ + kind: "revision-show", + revisionId: `opaque:root-view/${root.revisionId}`, + }); + }); + + test("rejects parent-before-child ordering across page boundaries", async () => { + const commit = (revisionId: string, parentRevisionIds: string[]) => ({ + revisionId, + displayId: revisionId, + parentRevisionIds, + subject: revisionId, + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }); + let page = 0; + const adapter = toInternalVcsAdapter({ + id: "misordered", + name: "Misordered", + detect: () => null, + history: { + open: () => ({ + read: async () => + page++ === 0 + ? { commits: [commit("parent", [])], done: false } + : { commits: [commit("child", ["parent"])], done: true }, + close() {}, + }), + planReview: (selected) => ({ + kind: "revision-show", + revisionId: selected.revisionId, + }), + }, + }); + const source = await adapter.history!.open({}, { cwd: "/repo" }); + await source.read({ limit: 1 }); + await expect(source.read({ limit: 1 })).rejects.toThrow( + "VCS history returned parent parent before child child.", + ); + }); +}); diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index 6345da02b..d1619d4ca 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -27,8 +27,14 @@ import { import { parseKeyChord, toKeyChordList } from "../lib/commandKeys"; import { toUserFacingError } from "../core/run/errors"; import { toInternalVcsPatchResult } from "./vcsPatchResult"; -import type { ExtensionVcsOperation } from "../extension-api/types"; -import type { VcsAdapter, VcsOperation, VcsReviewInput } from "../core/vcs/types"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryReviewAction, + ExtensionVcsHistorySource, + ExtensionVcsOperation, +} from "../extension-api/types"; +import type { VcsAdapter, VcsHistorySource, VcsOperation, VcsReviewInput } from "../core/vcs/types"; +import { sanitizeTerminalLine, sanitizeTerminalText } from "../lib/terminalText"; import { defaultExtensionPaneSize, extensionPaneSize, isVerticalPanePlacement } from "./panes"; import { isReservedExtensionCliCommandName, @@ -170,6 +176,263 @@ function toInternalVcsOperation( }; } +/** Snapshot named properties once so accessors cannot change values after validation. */ +function snapshotProperties(value: Record, keys: readonly string[]) { + const snapshot: Record = {}; + for (const key of keys) snapshot[key] = value[key]; + return snapshot; +} + +/** Snapshot an array's length and each accepted element once, including for proxied arrays. */ +function snapshotArray(value: unknown[], maximum = Number.MAX_SAFE_INTEGER) { + const length = value.length; + if (!Number.isSafeInteger(length) || length < 0 || length > maximum) { + throw new Error("VCS history returned more values than allowed."); + } + const snapshot: unknown[] = []; + for (let index = 0; index < length; index += 1) snapshot.push(value[index]); + return snapshot; +} + +/** Copy and validate one extension-provided history commit before it reaches core or UI. */ +function normalizeHistoryCommit(value: unknown): ExtensionVcsHistoryCommit { + if (!isPlainObject(value)) { + throw new Error("VCS history returned a commit that is not an object."); + } + const snapshot = snapshotProperties(value, [ + "revisionId", + "displayId", + "parentRevisionIds", + "graphParentRevisionIds", + "subject", + "body", + "authorName", + "authorEmail", + "authoredAt", + "decorations", + "logicalId", + ]); + const required = (key: string) => + assertNonEmptyString(snapshot[key], `VCS history commit ${key} must be a non-empty string.`); + const safeDisplay = (key: "displayId" | "subject" | "authorName") => { + const text = sanitizeTerminalLine(required(key)).replaceAll("\t", " "); + if (text.trim().length === 0) { + throw new Error(`VCS history commit ${key} must remain non-empty after sanitization.`); + } + return text; + }; + const safeRevision = (revision: unknown, label: string) => { + const text = assertNonEmptyString(revision, `${label} must be a non-empty string.`); + if (text.includes("\t") || sanitizeTerminalLine(text) !== text) { + throw new Error(`${label} must be a terminal-safe immutable revision id.`); + } + return text; + }; + const revisionId = safeRevision(snapshot.revisionId, "VCS history commit revisionId"); + const displayId = safeDisplay("displayId"); + const authoredAt = required("authoredAt"); + if ( + !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(authoredAt) || + Number.isNaN(Date.parse(authoredAt)) + ) { + throw new Error("VCS history commit authoredAt must be an ISO timestamp."); + } + if (!Array.isArray(snapshot.parentRevisionIds)) { + throw new Error("VCS history commit parentRevisionIds must be an array."); + } + if ( + snapshot.graphParentRevisionIds !== undefined && + !Array.isArray(snapshot.graphParentRevisionIds) + ) { + throw new Error("VCS history commit graphParentRevisionIds must be an array when present."); + } + if (!Array.isArray(snapshot.decorations)) { + throw new Error("VCS history commit decorations must be an array."); + } + + const parentValues = snapshotArray(snapshot.parentRevisionIds, 256); + const graphParentValues = Array.isArray(snapshot.graphParentRevisionIds) + ? snapshotArray(snapshot.graphParentRevisionIds, 256) + : undefined; + const decorationValues = snapshotArray(snapshot.decorations, 256); + const parentRevisionIds = parentValues.map((parent) => + safeRevision(parent, "VCS history parent revision id"), + ); + const graphParentRevisionIds = graphParentValues?.map((parent) => + safeRevision(parent, "VCS history graph parent revision id"), + ); + const decorationKinds = new Set(["head", "local-branch", "remote-branch", "tag", "ref"]); + const decorations = decorationValues.map((decoration) => { + if (!isPlainObject(decoration)) { + throw new Error("VCS history returned an invalid decoration."); + } + const fields = snapshotProperties(decoration, ["kind", "label", "attachedLocalBranch"]); + if (typeof fields.kind !== "string" || !decorationKinds.has(fields.kind)) { + throw new Error("VCS history returned an invalid decoration."); + } + const kind = fields.kind as ExtensionVcsHistoryCommit["decorations"][number]["kind"]; + const label = sanitizeTerminalLine( + assertNonEmptyString(fields.label, "VCS history decoration labels must be non-empty."), + ).replaceAll("\t", " "); + if (kind === "head") { + const attachedLocalBranch = + fields.attachedLocalBranch === undefined + ? undefined + : sanitizeTerminalLine( + assertNonEmptyString( + fields.attachedLocalBranch, + "VCS history attached local branch must be non-empty.", + ), + ).replaceAll("\t", " "); + return { kind, label, ...(attachedLocalBranch ? { attachedLocalBranch } : {}) }; + } + if (fields.attachedLocalBranch !== undefined) { + throw new Error("Only a VCS history HEAD decoration may name an attached local branch."); + } + return { kind, label }; + }); + + return { + revisionId, + displayId, + parentRevisionIds, + ...(graphParentRevisionIds ? { graphParentRevisionIds } : {}), + subject: safeDisplay("subject"), + ...(typeof snapshot.body === "string" + ? { + body: sanitizeTerminalText(snapshot.body, { + preserveNewlines: true, + preserveTabs: false, + }), + } + : {}), + authorName: safeDisplay("authorName"), + ...(typeof snapshot.authorEmail === "string" + ? { authorEmail: sanitizeTerminalLine(snapshot.authorEmail).replaceAll("\t", " ") } + : {}), + authoredAt, + decorations, + ...(typeof snapshot.logicalId === "string" + ? { logicalId: sanitizeTerminalLine(snapshot.logicalId).replaceAll("\t", " ") } + : {}), + }; +} + +/** Copy and validate a provider-owned plan for opening one opaque history item. */ +function normalizeHistoryReviewAction(value: unknown): ExtensionVcsHistoryReviewAction { + if (!isPlainObject(value)) { + throw new Error("VCS history planReview() must return an action object."); + } + const fields = snapshotProperties(value, [ + "kind", + "revisionId", + "fromRevisionId", + "toRevisionId", + ]); + const revision = (candidate: unknown, label: string) => { + const text = assertNonEmptyString(candidate, `${label} must be a non-empty string.`); + if (text.includes("\t") || sanitizeTerminalLine(text) !== text) { + throw new Error(`${label} must be a terminal-safe immutable revision id.`); + } + return text; + }; + if (fields.kind === "revision-show") { + return { kind: "revision-show", revisionId: revision(fields.revisionId, "revisionId") }; + } + if (fields.kind === "revision-range") { + return { + kind: "revision-range", + fromRevisionId: revision(fields.fromRevisionId, "fromRevisionId"), + toRevisionId: revision(fields.toRevisionId, "toRevisionId"), + }; + } + throw new Error("VCS history planReview() returned an unsupported action kind."); +} + +/** Wrap an extension history source with bounded reads, copying, cleanup, and error translation. */ +async function toInternalHistorySource( + source: ExtensionVcsHistorySource, +): Promise { + if (!isPlainObject(source)) { + throw new Error("VCS history open() must return a source object."); + } + const sourceFields = snapshotProperties(source, ["read", "close"]); + const sourceRead = sourceFields.read; + const sourceClose = sourceFields.close; + if (typeof sourceRead !== "function" || typeof sourceClose !== "function") { + if (typeof sourceClose === "function") { + try { + await sourceClose.call(source); + } catch { + // The malformed shape remains the primary failure. + } + } + throw new Error("VCS history open() must return a source with read() and close()."); + } + let closed = false; + let done = false; + const acceptedIds = new Set(); + const close = async () => { + if (closed) return; + closed = true; + try { + await sourceClose.call(source); + } catch (error) { + throw toUserFacingError(error); + } + }; + + return { + async read({ limit, signal }) { + if (closed || done) return { commits: [], done: true }; + if (!Number.isSafeInteger(limit) || limit <= 0) { + throw new Error("VCS history reads require a positive integer limit."); + } + try { + if (signal?.aborted) throw signal.reason ?? new Error("History read aborted."); + const page = await sourceRead.call(source, { limit, signal }); + if (!isPlainObject(page)) { + throw new Error("VCS history returned an invalid page."); + } + const pageFields = snapshotProperties(page, ["commits", "done"]); + if (!Array.isArray(pageFields.commits) || typeof pageFields.done !== "boolean") { + throw new Error("VCS history returned an invalid page."); + } + let commitValues: unknown[]; + try { + commitValues = snapshotArray(pageFields.commits, limit); + } catch { + throw new Error("VCS history returned more commits than the requested page limit."); + } + const commits = commitValues.map(normalizeHistoryCommit); + for (const commit of commits) { + if (acceptedIds.has(commit.revisionId)) { + throw new Error(`VCS history returned duplicate revision ${commit.revisionId}.`); + } + const emittedParent = commit.parentRevisionIds.find((parent) => acceptedIds.has(parent)); + if (emittedParent) { + throw new Error( + `VCS history returned parent ${emittedParent} before child ${commit.revisionId}.`, + ); + } + acceptedIds.add(commit.revisionId); + } + done = pageFields.done; + if (done) await close(); + return { commits, done }; + } catch (error) { + try { + await close(); + } catch { + // Preserve the read/validation failure as the primary error. + } + throw toUserFacingError(error); + } + }, + close, + }; +} + /** * Accept the public adapter shape as the internal one. * @@ -199,7 +462,22 @@ export function toInternalVcsAdapter( /** Diagnostic sink for a `detect()` result whose id was rewritten. */ reportDetectionIdMismatch?: (returnedId: string) => void, ): VcsAdapter { - const operations = adapter.operations; + const adapterFields = snapshotProperties(adapter as unknown as Record, [ + "id", + "name", + "operations", + "history", + "detect", + "detectionPriority", + ]); + const adapterId = assertNonEmptyString(adapterFields.id, "registerVcsAdapter requires an id."); + if (sanitizeTerminalLine(adapterId) !== adapterId) { + throw new Error("registerVcsAdapter requires a terminal-safe id."); + } + const adapterName = sanitizeTerminalLine( + assertNonEmptyString(adapterFields.name, "registerVcsAdapter requires a name."), + ).replaceAll("\t", " "); + const operations = adapterFields.operations; if (operations !== undefined && !isPlainObject(operations)) { throw new Error("registerVcsAdapter requires operations to be an object of review operations."); } @@ -213,39 +491,88 @@ export function toInternalVcsAdapter( } } - const detect = adapter.detect; + const history = adapterFields.history; + const historyFields = isPlainObject(history) + ? snapshotProperties(history, ["open", "planReview"]) + : undefined; + const historyOpen = historyFields?.open; + const historyPlanReview = historyFields?.planReview; + if ( + history !== undefined && + (!isPlainObject(history) || + typeof historyOpen !== "function" || + typeof historyPlanReview !== "function") + ) { + throw new Error("registerVcsAdapter history must provide open() and planReview() functions."); + } + + const openHistory = historyOpen as NonNullable["open"]; + const planHistoryReview = historyPlanReview as NonNullable< + ExtensionVcsAdapter["history"] + >["planReview"]; + const detect = adapterFields.detect; + if (typeof detect !== "function") { + throw new Error("registerVcsAdapter requires a detect() function."); + } // Report once per adapter: detection runs on every session and reload, and a // repeated diagnostic for one authoring mistake is noise, not information. let reportedMismatch = false; return { - ...adapter, + id: adapterId, + name: adapterName, + detectionPriority: + typeof adapterFields.detectionPriority === "number" + ? adapterFields.detectionPriority + : undefined, detect(cwd: string) { const detected = detect(cwd); if (!detected || !isPlainObject(detected)) { return null; } - // `repoRoot` is a path every caller measures distance against, and the - // measurement happens outside detection's own error handling — so a - // detection missing it throws past `detectVcs` and aborts startup rather - // than being skipped. Treat it as "did not recognize this directory". - if (typeof detected.repoRoot !== "string" || detected.repoRoot.length === 0) { + // Snapshot detection metadata before validation so accessors cannot change it later. + const detectionFields = snapshotProperties(detected, ["id", "repoRoot"]); + if (typeof detectionFields.repoRoot !== "string" || detectionFields.repoRoot.length === 0) { return null; } - if (detected.id === adapter.id) { - return detected; - } - - if (!reportedMismatch) { + if (detectionFields.id !== adapterId && !reportedMismatch) { reportedMismatch = true; - reportDetectionIdMismatch?.(String(detected.id)); + reportDetectionIdMismatch?.(sanitizeTerminalLine(String(detectionFields.id))); } - return { ...detected, id: adapter.id }; + return { id: adapterId, repoRoot: detectionFields.repoRoot }; }, operations: internalOperations, + ...(history && { + history: { + async open(input, context) { + try { + const source = await openHistory.call( + history, + { + ...input, + ...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}), + }, + context, + ); + return await toInternalHistorySource(source); + } catch (error) { + throw toUserFacingError(error); + } + }, + async planReview(commit, context) { + try { + return normalizeHistoryReviewAction( + await planHistoryReview.call(history, normalizeHistoryCommit(commit), context), + ); + } catch (error) { + throw toUserFacingError(error); + } + }, + }, + }), }; } diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 2fd466a62..7d281f052 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -76,6 +76,13 @@ export type { ExtensionSessionOptions, ExtensionThemeConfig, ExtensionVcsAdapter, + ExtensionVcsHistoryCapability, + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryDecoration, + ExtensionVcsHistoryInput, + ExtensionVcsHistoryPage, + ExtensionVcsHistoryReviewAction, + ExtensionVcsHistorySource, ExtensionWorkspace, ExtensionWorkspaceWriteRequest, ExtensionWorkspaceWriteResult, diff --git a/src/main.tsx b/src/main.tsx index 2e923b5cb..7137bc4ee 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -88,6 +88,18 @@ async function main() { ); } + if (startupPlan.kind === "history-static") { + const { runStaticHistory } = await import("./ui/history/runStaticHistory"); + await runStaticHistory(startupPlan.bootstrap); + return; + } + + if (startupPlan.kind === "history-interactive") { + const { runInteractiveHistory } = await import("./ui/history/runInteractiveHistory"); + await runInteractiveHistory(startupPlan.bootstrap); + return; + } + if (startupPlan.kind === "plain-text-pager") { await pagePlainText(startupPlan.text); process.exit(0); diff --git a/src/session/broker/brokerLauncher.ts b/src/session/broker/brokerLauncher.ts index 7bb9e257b..7fd6452b0 100644 --- a/src/session/broker/brokerLauncher.ts +++ b/src/session/broker/brokerLauncher.ts @@ -13,9 +13,8 @@ import { parseBrokerString, parseExactBrokerRecord, } from "@hunk/session-broker-core"; +import { resolveCurrentHunkCommand } from "../../core/process/relaunch"; import { resolveSessionBrokerConfig, type ResolvedSessionBrokerConfig } from "./brokerConfig"; - -const SCRIPT_ENTRYPOINT_PATTERN = /[\\/]|\.(?:[cm]?js|tsx?)$/; const DEFAULT_DAEMON_LOCK_STALE_MS = 15_000; const DEFAULT_DAEMON_STARTUP_TIMEOUT_MS = 3_000; const DEFAULT_DAEMON_HEALTH_POLL_INTERVAL_MS = 100; @@ -80,22 +79,6 @@ export interface EnsureSessionBrokerAvailableOptions { }) => ChildProcess; } -/** Detect Bun's virtual filesystem prefix used inside compiled single-file executables. */ -const BUNFS_PREFIX = "/$bunfs/"; -/** Bun's Windows equivalent mounts the compiled bundle on a virtual B: drive. */ -const BUNFS_WINDOWS_PREFIX = "b:/~bun/"; - -/** True when argv[1] is a Bun single-file-executable virtual path on any platform. */ -function isBunfsEntrypoint(entrypoint: string) { - if (entrypoint.startsWith(BUNFS_PREFIX)) { - return true; - } - - // Windows reports the virtual path with either separator depending on the shell, so - // normalize before comparing (e.g. "B:\\~BUN\\root\\hunk.exe" or "B:/~BUN/root/hunk.exe"). - return entrypoint.replaceAll("\\", "/").toLowerCase().startsWith(BUNFS_WINDOWS_PREFIX); -} - function safeRuntimeToken(value: string) { return value.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "") || "default"; } @@ -358,35 +341,8 @@ export function resolveDaemonLaunchCommand( argv = process.argv, execPath = process.execPath, ): DaemonLaunchCommand { - const entrypoint = argv[1]; - - // Bun-compiled single-file executables report argv as - // ["bun", "/$bunfs/root/", ...userArgs] (Unix) - // ["bun", "B:/~BUN/root/.exe", ...userArgs] (Windows) - // with execPath pointing to the real binary on disk. - // Detect the virtual path and use execPath directly; letting the Windows form fall through - // to the script-entrypoint branch would relaunch the binary with the virtual path as a bogus - // first argument and the daemon would never start (#502). - if (entrypoint && isBunfsEntrypoint(entrypoint)) { - return { - command: execPath, - args: ["daemon", "serve"], - }; - } - - // Running from source or a JS wrapper (bun src/main.tsx, node bin/hunk.cjs): - // reuse the runtime + script entrypoint. - if (entrypoint && !entrypoint.startsWith("-") && SCRIPT_ENTRYPOINT_PATTERN.test(entrypoint)) { - return { - command: execPath, - args: [entrypoint, "daemon", "serve"], - }; - } - - return { - command: execPath, - args: ["daemon", "serve"], - }; + const current = resolveCurrentHunkCommand(argv, execPath); + return { command: current.command, args: [...current.args, "daemon", "serve"] }; } /** Resolve the runtime paths used to coordinate one broker daemon per loopback host/port. */ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6ff7452df..8cb01ce8d 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -361,7 +361,9 @@ export function App({ currentPreferences: currentViewPreferences, configPath: bootstrap.viewPreferencesConfigPath, pagerMode, - promptSaveViewPreferences: bootstrap.input.options.promptSaveViewPreferences !== false, + promptSaveViewPreferences: + bootstrap.input.options.promptSaveViewPreferences !== false && + process.env.HUNK_RETURN_TO_HISTORY !== "1", transientViewPreferences: extensionSessionOptions.transientViewPreferences, onQuit, showNotice: showSessionNotice, @@ -1076,7 +1078,11 @@ export function App({ toggleFilesPane, triggerEditSelectedFile, triggerRefreshCurrentInput, - }), + }).map((command) => + process.env.HUNK_RETURN_TO_HISTORY === "1" && command.id === "hunk.app.quit" + ? { ...command, title: "Back to history" } + : command, + ), ...extensionAppCommands.commands, ], publishCommandExecuted, diff --git a/src/ui/history/runInteractiveHistory.test.ts b/src/ui/history/runInteractiveHistory.test.ts new file mode 100644 index 000000000..8d29616d4 --- /dev/null +++ b/src/ui/history/runInteractiveHistory.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "bun:test"; +import { PassThrough } from "node:stream"; +import type { HistoryRuntime } from "./types"; +import { historyReviewArgs, runInteractiveHistory } from "./runInteractiveHistory"; + +describe("history review child arguments", () => { + test("encodes provider-owned opaque actions without exposing ids to CLI option parsing", () => { + const range = historyReviewArgs({ + kind: "revision-range", + fromRevisionId: "-opaque:merge-parent/α", + toRevisionId: "opaque:merge-child/β", + }); + expect(range.slice(0, 2)).toEqual(["diff", "--history-review"]); + expect(JSON.parse(Buffer.from(range[2]!, "base64url").toString("utf8"))).toEqual({ + kind: "revision-range", + fromRevisionId: "-opaque:merge-parent/α", + toRevisionId: "opaque:merge-child/β", + }); + + const root = historyReviewArgs({ kind: "revision-show", revisionId: "-opaque:root/revision" }); + expect(root.slice(0, 2)).toEqual(["show", "--history-review"]); + expect(JSON.parse(Buffer.from(root[2]!, "base64url").toString("utf8"))).toEqual({ + kind: "revision-show", + revisionId: "-opaque:root/revision", + }); + }); +}); + +describe("interactive history loading", () => { + test("q interrupts an exhaustive read and closes the provider", async () => { + const stdin = new PassThrough() as unknown as NodeJS.ReadStream; + const stdout = new PassThrough() as unknown as NodeJS.WriteStream; + Object.assign(stdin, { isTTY: true, setRawMode() {} }); + Object.assign(stdout, { isTTY: true, columns: 80, rows: 12 }); + let reads = 0; + let readAborted = false; + let closed = false; + const runtime: HistoryRuntime = { + input: { + kind: "history", + color: "never", + format: "compact", + ascii: false, + interactive: true, + extensionsEnabled: false, + extensionPaths: [], + }, + source: { + async read({ signal }) { + reads += 1; + if (reads === 1) { + return { + commits: [ + { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: [], + subject: "First", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }, + ], + done: false, + }; + } + return await new Promise((_, reject) => { + signal?.addEventListener( + "abort", + () => { + readAborted = true; + reject(signal.reason); + }, + { once: true }, + ); + }); + }, + async close() {}, + }, + providerId: "test", + providerName: "Test", + repoRoot: "/repo", + notices: [], + customThemes: [], + async planReview() { + return { kind: "revision-show", revisionId: "a".repeat(40) }; + }, + async close() { + closed = true; + }, + }; + + const running = runInteractiveHistory(runtime, { stdin, stdout }); + while (reads < 1) await Bun.sleep(1); + // Terminals may coalesce the exhaustive-navigation key and quit. + stdin.write("Gq"); + await running; + expect(reads).toBe(2); + + expect(readAborted).toBe(true); + expect(closed).toBe(true); + }); +}); diff --git a/src/ui/history/runInteractiveHistory.ts b/src/ui/history/runInteractiveHistory.ts new file mode 100644 index 000000000..ca46ad514 --- /dev/null +++ b/src/ui/history/runInteractiveHistory.ts @@ -0,0 +1,364 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { createHistoryLaneCheckpoint, planHistoryPage } from "../../core/history/lanePlanner"; +import type { HistoryGraphRow, HistoryLaneCheckpoint } from "../../core/history/types"; +import { resolveCurrentHunkCommand } from "../../core/process/relaunch"; +import { HunkUserError } from "../../core/run/errors"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { fitText } from "../lib/text"; +import { + background, + foreground, + getHistoryCommitIdBounds, + projectHistoryRow, + resolveHistoryColor, + resolveHistoryTheme, +} from "./staticProjection"; +import { TerminalInputReader } from "./terminalInput"; +import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; +import type { HistoryRuntime } from "./types"; + +const ENTER_ALT = "\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h"; +const LEAVE_ALT = "\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"; + +/** Convert a provider-owned review declaration into one child Hunk invocation. */ +export function historyReviewArgs(action: ExtensionVcsHistoryReviewAction) { + const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64url"); + return [action.kind === "revision-range" ? "diff" : "show", "--history-review", payload]; +} + +/** Run one provider-planned child Hunk review after yielding terminal ownership. */ +async function openCommitReview( + bootstrap: HistoryRuntime, + action: ExtensionVcsHistoryReviewAction, +) { + const current = resolveCurrentHunkCommand(); + const extensionArgs = bootstrap.input.extensionPaths.flatMap((path) => [ + "--extension", + resolve(path), + ]); + const reviewArgs = historyReviewArgs(action); + const args = [ + ...current.args, + ...reviewArgs, + "--vcs", + bootstrap.providerId, + ...(bootstrap.input.extensionsEnabled ? extensionArgs : ["--no-extensions"]), + ]; + const child = spawn(current.command, args, { + cwd: bootstrap.repoRoot, + env: { ...process.env, HUNK_RETURN_TO_HISTORY: "1" }, + stdio: "inherit", + }); + return await new Promise((resolveExit, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolveExit(signal ? 1 : (code ?? 1))); + }); +} + +/** Browse history as one minimal graph list and open immutable commits in ordinary Hunk review. */ +export async function runInteractiveHistory( + bootstrap: HistoryRuntime, + { + stdin = process.stdin, + stdout = process.stdout, + }: { + stdin?: NodeJS.ReadStream; + stdout?: NodeJS.WriteStream; + } = {}, +) { + if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { + await bootstrap.close(); + throw new HunkUserError("`hunk log --interactive` requires a terminal.", [ + "Use plain `hunk log` for pipes and redirected output.", + ]); + } + + const input = new TerminalInputReader(stdin); + const abort = new AbortController(); + const rows: HistoryGraphRow[] = []; + let checkpoint: HistoryLaneCheckpoint = createHistoryLaneCheckpoint(); + let historyDone = false; + let selected = 0; + let top = 0; + let search = ""; + let notice = bootstrap.notices[0] ? sanitizeTerminalLine(bootstrap.notices[0]) : ""; + let lastClick = { index: -1, at: 0 }; + let active = false; + let stopped = false; + let loading = false; + const theme = resolveHistoryTheme(bootstrap.input.theme, bootstrap.customThemes); + + /** Read one page while letting quit interrupt an exhaustive traversal. */ + const readInterruptibly = async () => { + const pending = bootstrap.source.read({ limit: 256, signal: abort.signal }); + const settled = pending.then( + (page) => ({ kind: "page" as const, page }), + (error: unknown) => ({ kind: "error" as const, error }), + ); + const deferredKeys: string[] = []; + for (;;) { + const keyWait = new AbortController(); + // Put input first so a queued `q` wins when a fast provider page and + // coalesced `Gq` input are both already ready. + const result = await Promise.race([ + input.next(keyWait.signal).then((key) => ({ kind: "key" as const, key })), + settled, + ]); + if (result.kind === "page") { + keyWait.abort(); + input.prepend(deferredKeys); + return result.page; + } + if (result.kind === "error") { + keyWait.abort(); + input.prepend(deferredKeys); + throw result.error; + } + if (result.key === "q" || result.key === "\x03") { + cleanup(); + await settled; + return undefined; + } + deferredKeys.push(result.key); + } + }; + + /** Fetch one bounded continuation page and preserve graph state across it. */ + const loadMore = async (interruptible = false) => { + if (historyDone || loading || stopped) return; + loading = true; + try { + const page = interruptible + ? await readInterruptibly() + : await bootstrap.source.read({ limit: 256, signal: abort.signal }); + if (!page) return; + if (!page.done && page.commits.length === 0) + throw new Error("VCS history returned an empty page before EOF."); + const planned = planHistoryPage(page.commits, checkpoint); + rows.push(...planned.rows); + checkpoint = planned.checkpoint; + historyDone = page.done; + if (rows.length === 0 && historyDone) notice = "No commits found."; + } finally { + loading = false; + } + }; + const loadAll = async () => { + while (!historyDone && !stopped) await loadMore(true); + }; + + const terminalWidth = () => (stdout.columns && stdout.columns > 0 ? stdout.columns : 80); + const terminalHeight = () => (stdout.rows && stdout.rows > 0 ? stdout.rows : 24); + const enterTerminal = () => { + stdin.setRawMode?.(true); + input.resume(); + stdout.write(ENTER_ALT); + active = true; + }; + const leaveTerminal = () => { + if (!active) return; + active = false; + input.pause(); + stdout.write(LEAVE_ALT); + stdin.setRawMode?.(false); + }; + const clampViewport = () => { + selected = Math.max(0, Math.min(Math.max(0, rows.length - 1), selected)); + const height = Math.max(1, terminalHeight() - 1); + if (selected < top) top = selected; + if (selected >= top + height) top = selected - height + 1; + top = Math.max(0, Math.min(top, Math.max(0, rows.length - height))); + }; + const render = () => { + if (!active) return; + clampViewport(); + const width = Math.max(1, terminalWidth()); + const height = Math.max(1, terminalHeight() - 1); + const visible = rows.slice(top, top + height); + const color = resolveHistoryColor({ + mode: bootstrap.input.color, + stdoutIsTTY: true, + env: process.env, + }); + const lines = visible.map((row, offset) => { + const isSelected = top + offset === selected; + const text = projectHistoryRow(row, { + ascii: bootstrap.input.ascii || process.env.TERM === "dumb", + color: color && !isSelected, + theme, + width, + }); + return isSelected && color + ? `${background(theme.selectedHunk)}${foreground(theme.text)}${text}\x1b[0m` + : isSelected + ? `\x1b[7m${text}\x1b[0m` + : text; + }); + while (lines.length < height) lines.push(""); + const footer = search + ? `/${search}` + : notice || + `↑↓/jk move / search n/N match y copy enter open q quit${historyDone ? "" : " ↓ load more"}`; + const footerText = fitText(footer, width, "…"); + const styledFooter = color + ? `${background(theme.panelAlt)}${foreground(theme.muted)}${footerText}\x1b[0m` + : `\x1b[7m${footerText}\x1b[0m`; + stdout.write(`\x1b[H\x1b[2J${lines.join("\n")}\n${styledFooter}`); + }; + const findMatch = async (direction: 1 | -1) => { + if (!search || rows.length === 0) return; + await loadAll(); + const needle = search.toLocaleLowerCase(); + for (let step = 1; step <= rows.length; step += 1) { + const index = (selected + direction * step + rows.length) % rows.length; + const commit = rows[index]!.commit; + const haystack = [ + commit.revisionId, + commit.displayId, + commit.subject, + commit.body ?? "", + commit.authorName, + commit.authorEmail ?? "", + ...commit.decorations.map((entry) => entry.label), + ] + .join(" ") + .toLocaleLowerCase(); + if (haystack.includes(needle)) { + selected = index; + notice = ""; + return; + } + } + notice = `No match for ${sanitizeTerminalLine(search)}`; + }; + const editSearch = async () => { + let draft = search; + for (;;) { + search = draft; + render(); + const key = await input.next(); + if (key === "\r" || key === "\n") { + search = draft; + await findMatch(1); + return; + } + if (key === "\x1b") return; + if (key === "\x03") { + cleanup(); + return; + } + if (key === "\x7f") draft = Array.from(draft).slice(0, -1).join(""); + else if (/^[^\x00-\x1f\x7f]+$/u.test(key)) draft += key; + } + }; + /** Open one provider-planned review while yielding terminal ownership. */ + const openRowReview = async (row: HistoryGraphRow) => { + const reviewAction = await bootstrap.planReview(row.commit); + input.discardPending(); + leaveTerminal(); + const code = await openCommitReview(bootstrap, reviewAction); + if (!stopped) enterTerminal(); + notice = code === 0 ? "" : `Could not open ${row.commit.displayId}`; + }; + const cleanup = () => { + if (stopped) return; + stopped = true; + abort.abort(new Error("History browser stopped.")); + leaveTerminal(); + }; + const onResize = () => { + if (active) render(); + }; + const stopForSignal = (exitCode: number) => { + cleanup(); + process.exitCode = exitCode; + input.close(); + }; + const onInterrupt = () => stopForSignal(130); + const onHangup = () => stopForSignal(129); + const onTerminate = () => stopForSignal(143); + + process.once("SIGINT", onInterrupt); + process.once("SIGHUP", onHangup); + process.once("SIGTERM", onTerminate); + stdout.on("resize", onResize); + try { + await loadMore(); + if (stopped) return; + enterTerminal(); + render(); + while (!stopped) { + const key = await input.next(); + const height = Math.max(1, terminalHeight() - 1); + if (key === "q" || key === "\x03") break; + if (key === "\x1b[B" || key === "j") { + if (selected + 1 >= rows.length && !historyDone) await loadMore(); + selected += 1; + } else if (key === "\x1b[A" || key === "k") selected -= 1; + else if (key === "\x1b[6~") { + while (selected + height >= rows.length && !historyDone) await loadMore(); + selected += height; + } else if (key === "\x1b[5~") selected -= height; + else if (["\x1b[H", "\x1b[1~", "\x1bOH", "g"].includes(key)) selected = 0; + else if (["\x1b[F", "\x1b[4~", "\x1bOF", "G"].includes(key)) { + await loadAll(); + selected = rows.length - 1; + } else if (key === "/") await editSearch(); + else if (key === "n") await findMatch(1); + else if (key === "N") await findMatch(-1); + else if (key === "y" && rows[selected]) { + stdout.write( + `\x1b]52;c;${Buffer.from(rows[selected]!.commit.revisionId).toString("base64")}\x07`, + ); + notice = `Copied ${rows[selected]!.commit.displayId}`; + } else if ((key === "\r" || key === "\n") && rows[selected]) { + await openRowReview(rows[selected]!); + if (stopped) break; + } else { + const mouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(key); + if (mouse) { + const button = Number(mouse[1]); + const screenColumn = Number(mouse[2]) - 1; + const screenRow = Number(mouse[3]) - 1; + const visibleCount = Math.min(height, rows.length - top); + if (button === 64) selected -= 3; + else if (button === 65) { + if (selected + 3 >= rows.length && !historyDone) await loadMore(); + selected += 3; + } else if ( + button === 0 && + mouse[4] === "M" && + screenRow >= 0 && + screenRow < visibleCount + ) { + const index = top + screenRow; + const row = rows[index]!; + selected = index; + const idBounds = getHistoryCommitIdBounds( + row, + bootstrap.input.ascii || process.env.TERM === "dumb", + ); + const clickedCommitId = screenColumn >= idBounds.start && screenColumn < idBounds.end; + const now = Date.now(); + if (clickedCommitId || (lastClick.index === index && now - lastClick.at < 400)) { + await openRowReview(row); + } + lastClick = { index, at: now }; + } + } + } + render(); + } + } catch (error) { + if (!stopped) throw error; + } finally { + cleanup(); + input.close(); + stdout.off("resize", onResize); + process.off("SIGINT", onInterrupt); + process.off("SIGHUP", onHangup); + process.off("SIGTERM", onTerminate); + await bootstrap.close(); + } +} diff --git a/src/ui/history/runStaticHistory.test.ts b/src/ui/history/runStaticHistory.test.ts new file mode 100644 index 000000000..d2e5a6f41 --- /dev/null +++ b/src/ui/history/runStaticHistory.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test"; +import { EventEmitter } from "node:events"; +import type { HistoryCommit } from "../../core/history/types"; +import type { HistoryRuntime } from "./types"; +import { runStaticHistory } from "./runStaticHistory"; + +/** Create a page-backed runtime and expose whether cleanup ran. */ +function runtime(commits: HistoryCommit[], maxCount?: number) { + let offset = 0; + let closed = 0; + let reads = 0; + const value: HistoryRuntime = { + input: { + kind: "history", + color: "never", + format: "medium", + ascii: false, + interactive: false, + extensionsEnabled: true, + extensionPaths: [], + ...(maxCount !== undefined ? { maxCount } : {}), + }, + providerId: "test", + providerName: "Test", + repoRoot: "/repo", + notices: [], + customThemes: [], + async planReview(commit) { + return { kind: "revision-show", revisionId: commit.revisionId }; + }, + source: { + async read({ limit }) { + reads += 1; + const page = commits.slice(offset, offset + limit); + offset += page.length; + return { commits: page, done: offset >= commits.length }; + }, + async close() {}, + }, + async close() { + closed += 1; + }, + }; + return { value, closed: () => closed, reads: () => reads }; +} + +const commits: HistoryCommit[] = ["a", "b"].map((id, index) => ({ + revisionId: id, + displayId: id.repeat(8), + parentRevisionIds: index === 0 ? ["b"] : [], + subject: `Commit ${id}`, + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], +})); + +describe("static history runner", () => { + test("writes complete rows directly to non-TTY output and closes", async () => { + const history = runtime(commits); + let output = ""; + let paged = ""; + await runStaticHistory(history.value, { + stdout: { + isTTY: false, + columns: 80, + rows: 24, + write: (text) => ((output += String(text)), true), + }, + stderr: { write: () => true }, + env: {}, + pageText: async (text) => { + paged = text; + }, + }); + expect(output).toContain("Commit a"); + expect(output).toContain("Commit b"); + expect(paged).toBe(""); + expect(history.closed()).toBe(1); + }); + + test("treats a downstream EPIPE as normal and still closes the source", async () => { + const history = runtime(commits); + const stdout = new EventEmitter() as EventEmitter & { + isTTY: boolean; + columns: number; + rows: number; + write(text: string): boolean; + }; + stdout.isTTY = false; + stdout.columns = 80; + stdout.rows = 24; + stdout.write = () => { + queueMicrotask(() => + stdout.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })), + ); + return false; + }; + await runStaticHistory(history.value, { + stdout: stdout as never, + stderr: { write: () => true }, + env: {}, + pageText: async () => {}, + }); + expect(history.closed()).toBe(1); + }); + + test("uses the pager only when TTY rows overflow", async () => { + const history = runtime(commits); + let paged = ""; + await runStaticHistory(history.value, { + stdout: { isTTY: true, columns: 80, rows: 2, write: () => true }, + stderr: { write: () => true }, + env: { TERM: "xterm" }, + pageText: async (text) => { + paged = text; + }, + }); + expect(paged).toContain("Commit a"); + expect(paged).toContain("Commit b"); + }); + + test("streams overflowing TTY pages through one bounded pager writer", async () => { + const history = runtime(commits); + let written = ""; + let closes = 0; + await runStaticHistory(history.value, { + stdout: { isTTY: true, columns: 80, rows: 2, write: () => true }, + stderr: { write: () => true }, + env: { TERM: "xterm" }, + pageText: async () => { + throw new Error("buffered pager should not run"); + }, + openPager: () => ({ + async write(text) { + written += text; + }, + async close() { + closes += 1; + }, + }), + }); + expect(written).toContain("Commit a"); + expect(written).toContain("Commit b"); + expect(closes).toBe(1); + }); + + test("stops traversing history when the pager closes early", async () => { + const manyCommits = Array.from({ length: 600 }, (_, index) => ({ + ...commits[1]!, + revisionId: `commit-${index}`, + displayId: `c${index}`, + subject: `Commit ${index}`, + })); + const history = runtime(manyCommits); + let closes = 0; + await runStaticHistory(history.value, { + stdout: { isTTY: true, columns: 80, rows: 2, write: () => true }, + stderr: { write: () => true }, + env: { TERM: "xterm" }, + pageText: async () => {}, + openPager: () => ({ + async write() { + return false; + }, + async close() { + closes += 1; + }, + }), + }); + expect(history.reads()).toBe(1); + expect(history.closed()).toBe(1); + expect(closes).toBe(1); + }); + + test("keeps max-count zero silent even on a TTY", async () => { + const history = runtime([], 0); + let output = ""; + await runStaticHistory(history.value, { + stdout: { + isTTY: true, + columns: 80, + rows: 24, + write: (text) => ((output += String(text)), true), + }, + stderr: { write: () => true }, + env: {}, + pageText: async () => {}, + }); + expect(output).toBe(""); + }); +}); diff --git a/src/ui/history/runStaticHistory.ts b/src/ui/history/runStaticHistory.ts new file mode 100644 index 000000000..3527d4c09 --- /dev/null +++ b/src/ui/history/runStaticHistory.ts @@ -0,0 +1,139 @@ +import { createHistoryLaneCheckpoint, planHistoryPage } from "../../core/history/lanePlanner"; +import { + openPlainTextPager, + pagePlainText, + type PlainTextPagerWriter, +} from "../../core/process/pager"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { + projectHistoryConvergence, + projectHistoryRecord, + projectHistoryRow, + resolveHistoryColor, + resolveHistoryTheme, +} from "./staticProjection"; +import type { HistoryRuntime } from "./types"; + +export interface StaticHistoryDeps { + stdout: Pick & + Partial>; + stderr: Pick; + env: NodeJS.ProcessEnv; + pageText: (text: string, env: NodeJS.ProcessEnv) => Promise; + /** Production opens a streaming pager; tests may omit it to exercise the legacy seam. */ + openPager?: (env: NodeJS.ProcessEnv) => PlainTextPagerWriter; +} + +/** Consume history incrementally and print safe normal-screen records. */ +export async function runStaticHistory( + bootstrap: HistoryRuntime, + deps: StaticHistoryDeps = { + stdout: process.stdout, + stderr: process.stderr, + env: process.env, + pageText: pagePlainText, + openPager: (env) => openPlainTextPager(env), + }, +) { + const { input } = bootstrap; + for (const notice of bootstrap.notices) { + deps.stderr.write(`hunk: warning: ${sanitizeTerminalLine(notice)}\n`); + } + + let outputClosed = false; + let outputFailure: unknown; + let releaseDrain: (() => void) | undefined; + const onOutputError = (error: NodeJS.ErrnoException) => { + if (error.code === "EPIPE") outputClosed = true; + else outputFailure = error; + releaseDrain?.(); + }; + deps.stdout.on?.("error", onOutputError); + const writeOutput = async (text: string) => { + if (outputFailure) throw outputFailure; + if (outputClosed) return false; + const accepted = deps.stdout.write(text); + if (!accepted && deps.stdout.once) { + await new Promise((resolve) => { + releaseDrain = resolve; + deps.stdout.once!("drain", resolve); + }); + releaseDrain = undefined; + } + if (outputFailure) throw outputFailure; + return !outputClosed; + }; + const stdoutIsTTY = Boolean(deps.stdout.isTTY); + const ascii = input.ascii || deps.env.TERM === "dumb"; + const color = resolveHistoryColor({ mode: input.color, stdoutIsTTY, env: deps.env }); + const theme = resolveHistoryTheme(input.theme, bootstrap.customThemes); + const terminalColumns = deps.stdout.columns; + const width = stdoutIsTTY + ? terminalColumns && terminalColumns > 0 + ? terminalColumns + : 80 + : undefined; + const bufferedLines: string[] = []; + const terminalRows = deps.stdout.rows; + const availableRows = Math.max(1, (terminalRows && terminalRows > 0 ? terminalRows : 24) - 1); + let pager: PlainTextPagerWriter | undefined; + let checkpoint = createHistoryLaneCheckpoint(); + let done = false; + let commitCount = 0; + try { + while (!done) { + const page = await bootstrap.source.read({ limit: 256 }); + const planned = planHistoryPage(page.commits, checkpoint); + checkpoint = planned.checkpoint; + const lines = planned.rows.flatMap((row) => + input.format === "compact" + ? [ + projectHistoryRow(row, { ascii, color, theme, width }), + projectHistoryConvergence(row, { ascii, color, theme, width }), + ].filter(Boolean) + : projectHistoryRecord(row, { ascii, color, theme, width }), + ); + commitCount += planned.rows.length; + if (stdoutIsTTY) { + if (pager) { + if ((await pager.write(`${lines.join("\n")}\n`)) === false) return; + } else { + bufferedLines.push(...lines); + if (deps.openPager && bufferedLines.length > availableRows) { + pager = deps.openPager(deps.env); + if ((await pager.write(`${bufferedLines.join("\n")}\n`)) === false) return; + bufferedLines.length = 0; + } + } + } else if (lines.length > 0 && !(await writeOutput(`${lines.join("\n")}\n`))) { + return; + } + done = page.done; + if (page.commits.length === 0 && !done) { + throw new Error("VCS history returned an empty page before the end of history."); + } + } + + if (commitCount === 0) { + if (stdoutIsTTY && input.maxCount !== 0) await writeOutput("No commits found.\n"); + return; + } + if (!stdoutIsTTY) return; + if (pager) { + await pager.close(); + pager = undefined; + return; + } + + const text = `${bufferedLines.join("\n")}\n`; + if (bufferedLines.length > availableRows) await deps.pageText(text, deps.env); + else await writeOutput(text); + } finally { + try { + await pager?.close(); + } finally { + deps.stdout.off?.("error", onOutputError); + await bootstrap.close(); + } + } +} diff --git a/src/ui/history/staticProjection.test.ts b/src/ui/history/staticProjection.test.ts new file mode 100644 index 000000000..1520bd128 --- /dev/null +++ b/src/ui/history/staticProjection.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { measureTextWidth } from "../lib/text"; +import { planHistoryPage } from "../../core/history/lanePlanner"; +import type { HistoryCommit } from "../../core/history/types"; +import { + formatHistoryDecorations, + getHistoryCommitIdBounds, + projectHistoryRecord, + projectHistoryRow, + renderHistoryContinuation, + renderHistoryConvergence, + renderHistoryGraph, + resolveHistoryColor, + resolveHistoryTheme, +} from "./staticProjection"; + +const commit: HistoryCommit = { + revisionId: "a".repeat(40), + displayId: "aaaaaaaa", + parentRevisionIds: ["b".repeat(40), "c".repeat(40)], + subject: "Improve 日本語 rendering\x1b]52;c;cHdu\x07\nspoof", + body: "First paragraph.\n\nSecond paragraph.", + authorName: "Ada\rLovelace", + authoredAt: "2026-01-02T03:04:05Z", + decorations: [ + { kind: "head", label: "HEAD\x1b[2J", attachedLocalBranch: "main" }, + { kind: "local-branch", label: "main" }, + { kind: "remote-branch", label: "origin/main" }, + { kind: "tag", label: "v1.0.0" }, + ], +}; +const row = planHistoryPage([commit]).rows[0]!; + +describe("static history projection", () => { + test("renders portable graph palettes", () => { + expect(renderHistoryGraph(row, false)).toBe("●─┬"); + expect(renderHistoryGraph(row, true)).toBe("*-+"); + + const octopus = planHistoryPage([{ ...commit, parentRevisionIds: ["b", "c", "d"] }]).rows[0]!; + expect(renderHistoryGraph(octopus, false)).toBe("●─┬─┬"); + expect(renderHistoryGraph(octopus, true)).toBe("*-+-+"); + }); + + test("sanitizes metadata and fits narrow output by display cells", () => { + const text = projectHistoryRow(row, { ascii: false, color: false, width: 40 }); + expect(text).not.toContain("\x1b"); + expect(text).not.toContain("\n"); + expect(text).toContain("aaaaaaaa"); + }); + + test("reports the compact commit-id hit target in display cells", () => { + const bounds = getHistoryCommitIdBounds(row); + const text = projectHistoryRow(row, { ascii: false, color: false }); + expect(text.slice(bounds.start, bounds.end)).toBe("aaaaaaaa"); + expect(bounds.end - bounds.start).toBe(8); + }); + + test("honors actual sub-20-column terminal widths", () => { + for (const width of [1, 8, 19]) { + const lines = projectHistoryRecord(row, { ascii: false, color: false, width }); + expect(lines.every((line) => measureTextWidth(line) <= width)).toBe(true); + } + }); + + test("keeps complete logical rows when width is omitted", () => { + const text = projectHistoryRow(row, { ascii: true, color: false }); + expect(text).toContain("Improve 日本語 rendering"); + expect(text).toContain("AdaLovelace"); + expect(text).toContain("2026-01-02"); + }); + + test("renders Git-like medium records with full metadata, bodies, and typed decorations", () => { + const lines = projectHistoryRecord(row, { ascii: false, color: false }); + expect(lines.join("\n")).toContain(`commit ${"a".repeat(40)}`); + expect(lines.join("\n")).toContain("Author: AdaLovelace"); + expect(lines.join("\n")).toContain("Date: 2026-01-02 03:04:05Z"); + expect(lines.join("\n")).toContain("First paragraph."); + const continuation = renderHistoryContinuation(row, false); + expect(lines[3]).toBe(continuation); + expect(lines[4]).toBe(`${continuation} Improve 日本語 renderingspoof`); + expect(lines[5]).toBe(continuation); + expect(lines[6]).toBe(`${continuation} First paragraph.`); + expect(lines.at(-1)).toBe(continuation); + expect(formatHistoryDecorations(row)).toBe(" (HEAD -> main, origin/main, tag: v1.0.0)"); + }); + + test("renders the golden no-fast-forward merge convergence", () => { + const planned = planHistoryPage([ + { ...commit, revisionId: "merge", parentRevisionIds: ["main", "side"] }, + { ...commit, revisionId: "side", parentRevisionIds: ["main"], decorations: [] }, + { ...commit, revisionId: "main", parentRevisionIds: [], decorations: [] }, + ]); + expect(renderHistoryConvergence(planned.rows[1]!, false)).toBe("│╯"); + expect(renderHistoryConvergence(planned.rows[1]!, true)).toBe("|/"); + }); + + test("resolves colors through the shared Hunk theme catalog", () => { + expect(resolveHistoryTheme("catppuccin-mocha").id).toBe("catppuccin-mocha"); + }); + + test("honors explicit color over terminal conventions", () => { + expect( + resolveHistoryColor({ mode: "always", stdoutIsTTY: false, env: { NO_COLOR: "1" } }), + ).toBe(true); + expect(resolveHistoryColor({ mode: "auto", stdoutIsTTY: true, env: { NO_COLOR: "1" } })).toBe( + false, + ); + expect(resolveHistoryColor({ mode: "auto", stdoutIsTTY: true, env: { NO_COLOR: "" } })).toBe( + false, + ); + expect(resolveHistoryColor({ mode: "auto", stdoutIsTTY: true, env: { TERM: "dumb" } })).toBe( + false, + ); + }); +}); diff --git a/src/ui/history/staticProjection.ts b/src/ui/history/staticProjection.ts new file mode 100644 index 000000000..552e28df8 --- /dev/null +++ b/src/ui/history/staticProjection.ts @@ -0,0 +1,265 @@ +import type { HistoryGraphRow } from "../../core/history/types"; +import type { NamedCustomThemeConfig } from "../../extension-api/types"; +import { sanitizeTerminalLine, sanitizeTerminalText } from "../../lib/terminalText"; +import { fitText, measureTextWidth } from "../lib/text"; +import { resolveTheme, type AppTheme } from "../themes"; + +export interface HistoryProjectionOptions { + ascii: boolean; + color: boolean; + theme?: AppTheme; + /** Omit width to emit complete unpadded logical rows for pipes and files. */ + width?: number; +} + +/** Resolve history colors from the same built-in and custom themes as review. */ +export function resolveHistoryTheme( + themeId: string | undefined, + customThemes: readonly NamedCustomThemeConfig[] = [], +) { + return resolveTheme(themeId, null, customThemes); +} + +/** Convert a validated #rrggbb theme color to a terminal SGR foreground. */ +export function foreground(color: string) { + const [red, green, blue] = [1, 3, 5].map((offset) => + Number.parseInt(color.slice(offset, offset + 2), 16), + ); + return `\x1b[38;2;${red};${green};${blue}m`; +} + +/** Convert a validated #rrggbb theme color to a terminal SGR background. */ +export function background(color: string) { + const [red, green, blue] = [1, 3, 5].map((offset) => + Number.parseInt(color.slice(offset, offset + 2), 16), + ); + return `\x1b[48;2;${red};${green};${blue}m`; +} + +/** Render one symbolic lane prefix without consulting commit metadata or refs. */ +export function renderHistoryGraph(row: HistoryGraphRow, ascii: boolean) { + const vertical = ascii ? "|" : "│"; + const node = ascii ? "*" : "●"; + if (row.parentLanes.length <= 1) { + return row.cells + .map((cell) => (cell.kind === "node" ? node : cell.kind === "vertical" ? vertical : " ")) + .join(" ") + .trimEnd(); + } + + const laneCount = Math.max(row.lanesBefore.length, row.lanesAfter.length); + const characters = Array.from({ length: Math.max(1, laneCount * 2 - 1) }, () => " "); + for (let lane = 0; lane < row.lanesAfter.length; lane += 1) characters[lane * 2] = vertical; + characters[row.lane * 2] = node; + for (const parentLane of row.parentLanes.slice(1)) { + const from = Math.min(row.lane * 2, parentLane * 2); + const to = Math.max(row.lane * 2, parentLane * 2); + for (let index = from + 1; index < to; index += 1) { + if (index % 2 === 1) characters[index] = ascii ? "-" : "─"; + else if (characters[index] === vertical) characters[index] = ascii ? "+" : "┼"; + } + characters[parentLane * 2] = ascii ? "+" : parentLane > row.lane ? "┬" : "┴"; + } + return characters.join("").trimEnd(); +} + +/** Render active lanes after a commit for its metadata and message continuation lines. */ +export function renderHistoryContinuation(row: HistoryGraphRow, ascii: boolean) { + const vertical = ascii ? "|" : "│"; + return row.lanesAfter + .map(() => vertical) + .join(" ") + .trimEnd(); +} + +/** Render a lane-collapse transition that makes converging ancestry explicit. */ +export function renderHistoryConvergence(row: HistoryGraphRow, ascii: boolean) { + if (row.convergences.length === 0) return ""; + const width = Math.max(row.lanesBefore.length, row.lanesAfter.length) * 2 - 1; + const chars = Array.from({ length: Math.max(1, width) }, () => " "); + for (let lane = 0; lane < row.lanesAfter.length; lane += 1) chars[lane * 2] = ascii ? "|" : "│"; + for (const { from, to } of row.convergences) { + const start = Math.min(from, to) * 2 + 1; + const end = Math.max(from, to) * 2 - 1; + for (let index = start; index <= end; index += 1) { + chars[index] = from > to ? (ascii ? "/" : "╯") : ascii ? "\\" : "╰"; + } + } + return chars.join("").trimEnd(); +} + +/** Format typed refs in familiar Git decoration vocabulary without inferring topology. */ +export function formatHistoryDecorations(row: HistoryGraphRow) { + const values = row.commit.decorations + .map((entry) => ({ + kind: entry.kind, + label: sanitizeTerminalLine(entry.label).replaceAll("\t", " "), + ...(entry.kind === "head" && entry.attachedLocalBranch + ? { + attachedLocalBranch: sanitizeTerminalLine(entry.attachedLocalBranch).replaceAll( + "\t", + " ", + ), + } + : {}), + })) + .filter((entry) => entry.label); + const headIndex = values.findIndex((entry) => entry.kind === "head"); + const head = headIndex >= 0 ? values[headIndex] : undefined; + const attachedBranch = head?.attachedLocalBranch ?? ""; + const branchIndex = attachedBranch + ? values.findIndex((entry) => entry.kind === "local-branch" && entry.label === attachedBranch) + : -1; + const labels: string[] = []; + if (head) labels.push(attachedBranch ? `${head.label} -> ${attachedBranch}` : head.label); + for (let index = 0; index < values.length; index += 1) { + if (index === headIndex || index === branchIndex) continue; + const entry = values[index]!; + labels.push(entry.kind === "tag" ? `tag: ${entry.label}` : entry.label); + } + return labels.length ? ` (${labels.join(", ")})` : ""; +} + +/** Clamp a logical terminal line before applying styling. */ +function clampLine(text: string, width: number | undefined) { + return width === undefined ? text : fitText(text, Math.max(1, width), "…"); +} + +/** Apply a semantic theme color only when styling is enabled. */ +function styled(text: string, color: string, enabled: boolean) { + return enabled ? `${foreground(color)}${text}\x1b[0m` : text; +} + +/** Color graph cells with a stable lane-index palette derived from the active Hunk theme. */ +function styledGraph(text: string, theme: AppTheme) { + const palette = [ + theme.accent, + theme.addedSignColor, + theme.removedSignColor, + theme.fileRenamed, + theme.noteBorder, + ]; + return Array.from(text, (character, index) => + character === " " + ? character + : styled(character, palette[Math.floor(index / 2) % palette.length]!, true), + ).join(""); +} + +/** Return the zero-based display-cell bounds occupied by a compact row's commit id. */ +export function getHistoryCommitIdBounds(row: HistoryGraphRow, ascii = false) { + const graphPrefix = `${renderHistoryGraph(row, ascii)} `; + const displayId = sanitizeTerminalLine(row.commit.displayId).replaceAll("\t", " "); + const start = measureTextWidth(graphPrefix); + return { start, end: start + measureTextWidth(displayId) }; +} + +/** Render one safe compact history row from symbolic topology and normalized metadata. */ +export function projectHistoryRow(row: HistoryGraphRow, options: HistoryProjectionOptions) { + const theme = options.theme ?? resolveHistoryTheme(undefined); + const graph = renderHistoryGraph(row, options.ascii); + const displayId = sanitizeTerminalLine(row.commit.displayId).replaceAll("\t", " "); + const subject = sanitizeTerminalLine(row.commit.subject).replaceAll("\t", " "); + const refs = formatHistoryDecorations(row); + const author = sanitizeTerminalLine(row.commit.authorName).replaceAll("\t", " "); + const date = row.commit.authoredAt.slice(0, 10); + const graphPrefix = `${graph} `; + const hashPrefix = `${displayId} `; + let suffix = `${subject}${refs} ${author} ${date}`; + if (options.width !== undefined) { + const available = Math.max( + 1, + options.width - measureTextWidth(graphPrefix) - measureTextWidth(hashPrefix), + ); + const candidates = [ + `${subject}${refs} ${author} ${date}`, + `${subject}${refs} ${date}`, + `${subject}${refs}`, + subject, + ]; + suffix = + candidates.find((candidate) => measureTextWidth(candidate) <= available) ?? + fitText(subject, available, "…"); + } + const plain = clampLine(`${graphPrefix}${hashPrefix}${suffix}`, options.width); + if (!options.color) return plain; + const graphText = plain.slice(0, graphPrefix.length); + const hashText = plain.slice(graphPrefix.length, graphPrefix.length + hashPrefix.length); + return `${styledGraph(graphText, theme)}${styled(hashText, theme.accent, true)}${plain.slice(graphPrefix.length + hashPrefix.length)}`; +} + +/** Render a themed standalone lane-collapse line for compact static output. */ +export function projectHistoryConvergence(row: HistoryGraphRow, options: HistoryProjectionOptions) { + const plain = clampLine(renderHistoryConvergence(row, options.ascii), options.width); + if (!plain || !options.color) return plain; + const theme = options.theme ?? resolveHistoryTheme(undefined); + return styledGraph(plain, theme); +} + +/** Render a complete Git-like medium record, including body and graph continuation. */ +export function projectHistoryRecord(row: HistoryGraphRow, options: HistoryProjectionOptions) { + const theme = options.theme ?? resolveHistoryTheme(undefined); + const graph = renderHistoryGraph(row, options.ascii); + const continuation = renderHistoryContinuation(row, options.ascii); + const fullId = sanitizeTerminalLine(row.commit.revisionId); + const refs = formatHistoryDecorations(row); + const authorName = sanitizeTerminalLine(row.commit.authorName).replaceAll("\t", " "); + const authorEmail = row.commit.authorEmail + ? ` <${sanitizeTerminalLine(row.commit.authorEmail).replaceAll("\t", " ")}>` + : ""; + const date = sanitizeTerminalLine(row.commit.authoredAt).replace("T", " "); + const subject = sanitizeTerminalLine(row.commit.subject).replaceAll("\t", " "); + const body = row.commit.body + ? sanitizeTerminalText(row.commit.body, { preserveNewlines: true, preserveTabs: false }).split( + "\n", + ) + : []; + while (body.at(-1) === "") body.pop(); + const prefix = (laneText: string) => (laneText ? `${laneText} ` : " "); + const plainLines = [ + `${prefix(graph)}commit ${fullId}${refs}`, + `${prefix(continuation)}Author: ${authorName}${authorEmail}`, + `${prefix(continuation)}Date: ${date}`, + prefix(continuation).trimEnd(), + `${prefix(continuation)} ${subject}`, + ...(body.length + ? [ + prefix(continuation).trimEnd(), + ...body.map((line) => `${prefix(continuation)} ${line}`), + ] + : []), + ]; + const convergence = renderHistoryConvergence(row, options.ascii); + if (convergence) plainLines.push(convergence); + plainLines.push(prefix(continuation).trimEnd()); + const fitted = plainLines.map((line) => clampLine(line, options.width)); + if (!options.color) return fitted; + return fitted.map((line, index) => { + if (!line) return line; + const laneWidth = index === 0 ? prefix(graph).length : prefix(continuation).length; + const lane = line.slice(0, laneWidth); + const rest = line.slice(laneWidth); + if (index === 0 && rest.startsWith("commit ")) { + const hashStart = "commit ".length; + const hashEnd = Math.min(rest.length, hashStart + fullId.length); + return `${styledGraph(lane, theme)}${rest.slice(0, hashStart)}${styled(rest.slice(hashStart, hashEnd), theme.accent, true)}${styled(rest.slice(hashEnd), theme.addedSignColor, true)}`; + } + const color = index >= 4 ? theme.text : theme.muted; + return `${styledGraph(lane, theme)}${styled(rest, color, true)}`; + }); +} + +/** Resolve color according to explicit CLI precedence and conventional terminal signals. */ +export function resolveHistoryColor({ + mode, + stdoutIsTTY, + env, +}: { + mode: "auto" | "always" | "never"; + stdoutIsTTY: boolean; + env: NodeJS.ProcessEnv; +}) { + if (mode === "always") return true; + if (mode === "never") return false; + return stdoutIsTTY && env.TERM !== "dumb" && !("NO_COLOR" in env); +} diff --git a/src/ui/history/terminalInput.test.ts b/src/ui/history/terminalInput.test.ts new file mode 100644 index 000000000..0d18822e4 --- /dev/null +++ b/src/ui/history/terminalInput.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test"; +import { PassThrough } from "node:stream"; +import { TerminalInputReader, TerminalInputTokenizer } from "./terminalInput"; + +describe("TerminalInputTokenizer", () => { + test("queues multiple navigation and action keys from one chunk", () => { + const tokenizer = new TerminalInputTokenizer(); + + expect(tokenizer.push("\x1b[B\r")).toEqual(["\x1b[B", "\r"]); + }); + + test("retains split CSI and mouse sequences until they are complete", () => { + const tokenizer = new TerminalInputTokenizer(); + + expect(tokenizer.push("\x1b[")).toEqual([]); + expect(tokenizer.push("B/fi")).toEqual(["\x1b[B", "/", "f", "i"]); + expect(tokenizer.push("\x1b[<0;12;")).toEqual([]); + expect(tokenizer.push("4Mq")).toEqual(["\x1b[<0;12;4M", "q"]); + }); + + test("preserves UTF-8 characters split across byte chunks", () => { + const tokenizer = new TerminalInputTokenizer(); + const bytes = Buffer.from("猫"); + + expect(tokenizer.push(bytes.subarray(0, 2))).toEqual([]); + expect(tokenizer.push(bytes.subarray(2))).toEqual(["猫"]); + }); + + test("flushes a standalone escape without consuming the next action", () => { + const tokenizer = new TerminalInputTokenizer(); + + expect(tokenizer.push("\x1b")).toEqual([]); + expect(tokenizer.hasStandaloneEscape()).toBe(true); + expect(tokenizer.flushStandaloneEscape()).toEqual(["\x1b"]); + expect(tokenizer.push("q")).toEqual(["q"]); + }); +}); + +describe("TerminalInputReader", () => { + test("cancels a temporary wait without losing restored input", async () => { + const stream = new PassThrough() as unknown as NodeJS.ReadStream; + const reader = new TerminalInputReader(stream); + const abort = new AbortController(); + const waiting = reader.next(abort.signal); + + abort.abort(new Error("stop waiting")); + await expect(waiting).rejects.toThrow("stop waiting"); + reader.prepend(["j", "q"]); + expect(await reader.next()).toBe("j"); + expect(await reader.next()).toBe("q"); + reader.close(); + }); +}); diff --git a/src/ui/history/terminalInput.ts b/src/ui/history/terminalInput.ts new file mode 100644 index 000000000..52c5b395c --- /dev/null +++ b/src/ui/history/terminalInput.ts @@ -0,0 +1,193 @@ +import { StringDecoder } from "node:string_decoder"; + +const ESCAPE = "\x1b"; + +/** Splits raw terminal bytes into complete key and mouse tokens across arbitrary chunks. */ +export class TerminalInputTokenizer { + private readonly decoder = new StringDecoder("utf8"); + private buffered = ""; + + /** Add one raw input chunk and return every complete token now available. */ + push(chunk: Buffer | string) { + this.buffered += typeof chunk === "string" ? chunk : this.decoder.write(chunk); + return this.takeCompleteTokens(); + } + + /** Return whether a lone Escape is waiting for a possible sequence suffix. */ + hasStandaloneEscape() { + return this.buffered === ESCAPE; + } + + /** Resolve a lone buffered Escape after the terminal's sequence grace period. */ + flushStandaloneEscape() { + if (!this.hasStandaloneEscape()) return []; + this.buffered = ""; + return [ESCAPE]; + } + + /** Flush decoder state and expose any remaining input when the stream closes. */ + finish() { + this.buffered += this.decoder.end(); + const tokens = this.takeCompleteTokens(); + if (this.buffered) { + tokens.push(...Array.from(this.buffered)); + this.buffered = ""; + } + return tokens; + } + + /** Consume complete characters, CSI sequences, and SS3 sequences from the buffer. */ + private takeCompleteTokens() { + const tokens: string[] = []; + while (this.buffered) { + if (!this.buffered.startsWith(ESCAPE)) { + const token = String.fromCodePoint(this.buffered.codePointAt(0)!); + tokens.push(token); + this.buffered = this.buffered.slice(token.length); + continue; + } + + if (this.buffered.length === 1) break; + const prefix = this.buffered[1]; + if (prefix === "[") { + let finalIndex = -1; + for (let index = 2; index < this.buffered.length; index += 1) { + const code = this.buffered.charCodeAt(index); + if (code >= 0x40 && code <= 0x7e) { + finalIndex = index; + break; + } + } + if (finalIndex < 0) break; + tokens.push(this.buffered.slice(0, finalIndex + 1)); + this.buffered = this.buffered.slice(finalIndex + 1); + continue; + } + + if (prefix === "O") { + if (this.buffered.length < 3) break; + tokens.push(this.buffered.slice(0, 3)); + this.buffered = this.buffered.slice(3); + continue; + } + + // Hunk has no Alt-key bindings here, so preserve Escape as its own action. + tokens.push(ESCAPE); + this.buffered = this.buffered.slice(1); + } + return tokens; + } +} + +/** Queues tokenized terminal input while allowing terminal ownership to pause for child review. */ +export class TerminalInputReader { + private readonly tokenizer = new TerminalInputTokenizer(); + private readonly queued: string[] = []; + private readonly waiting: Array<{ + resolve: (token: string) => void; + reject: (error: Error) => void; + }> = []; + private escapeTimer: ReturnType | undefined; + private endedError: Error | undefined; + + constructor(private readonly stream: NodeJS.ReadStream) { + stream.on("data", this.onData); + stream.on("end", this.onEnd); + stream.on("error", this.onError); + } + + /** Resume delivery from the caller-owned terminal stream. */ + resume() { + this.stream.resume(); + } + + /** Pause delivery while another process owns the terminal. */ + pause() { + this.clearEscapeTimer(); + this.stream.pause(); + } + + /** Return the next complete token, allowing a temporary caller to cancel its wait. */ + next(signal?: AbortSignal) { + const token = this.queued.shift(); + if (token !== undefined) return Promise.resolve(token); + if (this.endedError) return Promise.reject(this.endedError); + if (signal?.aborted) { + return Promise.reject(signal.reason ?? new Error("Terminal input wait aborted.")); + } + return new Promise((resolve, reject) => { + const waiter = { + resolve: (value: string) => { + signal?.removeEventListener("abort", onAbort); + resolve(value); + }, + reject: (error: Error) => { + signal?.removeEventListener("abort", onAbort); + reject(error); + }, + }; + const onAbort = () => { + const index = this.waiting.indexOf(waiter); + if (index >= 0) this.waiting.splice(index, 1); + waiter.reject(signal?.reason ?? new Error("Terminal input wait aborted.")); + }; + this.waiting.push(waiter); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + /** Restore temporarily consumed tokens to the front of the input queue. */ + prepend(tokens: readonly string[]) { + this.queued.unshift(...tokens); + } + + /** Drop typeahead before transferring terminal ownership to a child process. */ + discardPending() { + this.queued.length = 0; + } + + /** Detach listeners and reject any pending read. */ + close(error = new Error("Terminal input closed.")) { + this.finish(error, false); + } + + private readonly onData = (chunk: Buffer | string) => { + this.clearEscapeTimer(); + this.enqueue(this.tokenizer.push(chunk)); + if (this.tokenizer.hasStandaloneEscape()) { + this.escapeTimer = setTimeout(() => { + this.escapeTimer = undefined; + this.enqueue(this.tokenizer.flushStandaloneEscape()); + }, 25); + this.escapeTimer.unref?.(); + } + }; + + private readonly onEnd = () => this.finish(new Error("Terminal input closed."), true); + private readonly onError = (error: Error) => this.finish(error, true); + + private enqueue(tokens: string[]) { + for (const token of tokens) { + const waiter = this.waiting.shift(); + if (waiter) waiter.resolve(token); + else this.queued.push(token); + } + } + + private finish(error: Error, flush: boolean) { + if (this.endedError) return; + this.clearEscapeTimer(); + if (flush) this.enqueue(this.tokenizer.finish()); + this.endedError = error; + this.stream.off("data", this.onData); + this.stream.off("end", this.onEnd); + this.stream.off("error", this.onError); + this.stream.pause(); + for (const waiter of this.waiting.splice(0)) waiter.reject(error); + } + + private clearEscapeTimer() { + if (this.escapeTimer) clearTimeout(this.escapeTimer); + this.escapeTimer = undefined; + } +} diff --git a/src/ui/history/types.ts b/src/ui/history/types.ts new file mode 100644 index 000000000..5f63a1f98 --- /dev/null +++ b/src/ui/history/types.ts @@ -0,0 +1,20 @@ +import type { HistoryCommandInput } from "../../core/run/commandInputs"; +import type { VcsHistorySource } from "../../core/vcs/types"; +import type { + ExtensionVcsHistoryCommit, + ExtensionVcsHistoryReviewAction, + NamedCustomThemeConfig, +} from "../../extension-api/types"; + +/** Renderer-facing history resources, excluding app and extension ownership details. */ +export interface HistoryRuntime { + input: HistoryCommandInput; + source: VcsHistorySource; + providerId: string; + providerName: string; + repoRoot: string; + notices: readonly string[]; + customThemes: readonly NamedCustomThemeConfig[]; + planReview(commit: ExtensionVcsHistoryCommit): Promise; + close(): Promise; +} diff --git a/test/cli/log.test.ts b/test/cli/log.test.ts new file mode 100644 index 000000000..753465573 --- /dev/null +++ b/test/cli/log.test.ts @@ -0,0 +1,245 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const tempDirs: string[] = []; +const mainPath = resolve(import.meta.dir, "../../src/main.tsx"); +const jjTest = Bun.which("jj") ? test : test.skip; + +/** Run a command and fail the fixture immediately when it cannot complete. */ +function run(argv: string[], cwd: string, env: NodeJS.ProcessEnv = process.env) { + const proc = Bun.spawnSync(argv, { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env, + }); + return { + code: proc.exitCode, + stdout: proc.stdout?.toString() ?? "", + stderr: proc.stderr?.toString() ?? "", + }; +} + +/** Create a two-commit repository with deterministic author and dates. */ +function createRepo() { + const cwd = mkdtempSync(join(tmpdir(), "hunk-log-test-")); + tempDirs.push(cwd); + expect(run(["git", "init", "-q"], cwd).code).toBe(0); + const env = { + ...process.env, + GIT_AUTHOR_NAME: "Ada Lovelace", + GIT_AUTHOR_EMAIL: "ada@example.com", + GIT_COMMITTER_NAME: "Ada Lovelace", + GIT_COMMITTER_EMAIL: "ada@example.com", + }; + writeFileSync(join(cwd, "history.txt"), "one\n"); + expect(run(["git", "add", "history.txt"], cwd, env).code).toBe(0); + expect( + run(["git", "commit", "-q", "-m", "First commit"], cwd, { + ...env, + GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z", + GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z", + }).code, + ).toBe(0); + writeFileSync(join(cwd, "history.txt"), "two\n"); + const secondEnv = { + ...env, + GIT_AUTHOR_DATE: "2026-01-02T00:00:00Z", + GIT_COMMITTER_DATE: "2026-01-02T00:00:00Z", + }; + expect( + run(["git", "commit", "-qa", "-m", "Second commit", "-m", "A detailed body."], cwd, secondEnv) + .code, + ).toBe(0); + expect(run(["git", "tag", "v1.0.0"], cwd, secondEnv).code).toBe(0); + expect(run(["git", "tag", "-a", "v1.0.0-annotated", "-m", "release"], cwd, secondEnv).code).toBe( + 0, + ); + return cwd; +} + +/** Create a JJ-only fixture with no colocated Git worktree. */ +function createJjRepo() { + const cwd = mkdtempSync(join(tmpdir(), "hunk-log-jj-test-")); + tempDirs.push(cwd); + expect(run(["jj", "git", "init", "--no-colocate", cwd], tmpdir()).code).toBe(0); + const jj = (...args: string[]) => + run( + [ + "jj", + "--config", + 'user.name="Grace Hopper"', + "--config", + 'user.email="grace@example.com"', + ...args, + ], + cwd, + ); + writeFileSync(join(cwd, "history.txt"), "one\n"); + expect(jj("commit", "-m", "JJ first commit").code).toBe(0); + writeFileSync(join(cwd, "history.txt"), "two\n"); + expect(jj("commit", "-m", "JJ second commit\n\nJJ body.").code).toBe(0); + expect(jj("bookmark", "create", "main", "-r", "@-").code).toBe(0); + expect(jj("tag", "set", "v2.0.0", "-r", "@-").code).toBe(0); + expect(existsSync(join(cwd, ".git"))).toBe(false); + return cwd; +} + +afterEach(() => { + for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +describe("hunk log CLI contract", () => { + test("prints deterministic complete static rows without terminal controls", () => { + const cwd = createRepo(); + const result = run(["bun", "run", mainPath, "log", "--color", "never"], cwd); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("Second commit"); + expect(result.stdout).toContain("First commit"); + expect(result.stdout).toContain("A detailed body."); + expect(result.stdout).toContain("Author: Ada Lovelace "); + expect(result.stdout).toContain("Date: 2026-01-02 00:00:00Z"); + expect(result.stdout).toMatch(/commit [0-9a-f]{40} \(HEAD -> /); + expect(result.stdout).toContain("tag: v1.0.0"); + expect(result.stdout).toContain("tag: v1.0.0-annotated"); + expect(result.stdout).not.toContain("\x1b"); + }); + + test("supports explicit compact output and shared themes", () => { + const cwd = createRepo(); + const result = run( + [ + "bun", + "run", + mainPath, + "log", + "--oneline", + "--theme", + "catppuccin-mocha", + "--color", + "always", + "-n", + "1", + ], + cwd, + ); + expect(result.code).toBe(0); + expect(result.stdout).toContain("Second commit"); + expect(result.stdout).not.toContain("Author:"); + expect(result.stdout).toContain("\x1b[38;2;"); + }); + + test("honors provider filters, all-head traversal, and first-parent", () => { + const cwd = createRepo(); + expect(run(["git", "switch", "-q", "-c", "filtered-side", "HEAD~1"], cwd).code).toBe(0); + writeFileSync(join(cwd, "side.txt"), "side\n"); + const env = { + ...process.env, + GIT_AUTHOR_NAME: "Grace Hopper", + GIT_AUTHOR_EMAIL: "grace@example.com", + GIT_COMMITTER_NAME: "Grace Hopper", + GIT_COMMITTER_EMAIL: "grace@example.com", + GIT_AUTHOR_DATE: "2026-01-03T00:00:00Z", + GIT_COMMITTER_DATE: "2026-01-03T00:00:00Z", + }; + expect(run(["git", "add", "side.txt"], cwd, env).code).toBe(0); + expect(run(["git", "commit", "-q", "-m", "Side-only match"], cwd, env).code).toBe(0); + expect(run(["git", "switch", "-q", "-"], cwd).code).toBe(0); + + const result = run( + [ + "bun", + "run", + mainPath, + "log", + "--all", + "--first-parent", + "--author", + "Grace", + "--grep", + "Side-only", + "--since", + "2026-01-03T00:00:00Z", + "--until", + "2026-01-04T00:00:00Z", + "--color", + "never", + ], + cwd, + ); + + expect(result).toMatchObject({ code: 0, stderr: "" }); + expect(result.stdout).toContain("Side-only match"); + expect(result.stdout).toContain("Author: Grace Hopper "); + expect(result.stdout).not.toContain("Second commit"); + expect(result.stdout).not.toContain("First commit"); + }); + + test("honors max count, pathspecs, ASCII, and explicit color", () => { + const cwd = createRepo(); + const result = run( + [ + "bun", + "run", + mainPath, + "log", + "-n", + "1", + "--ascii", + "--color", + "always", + "--", + "history.txt", + ], + cwd, + ); + + expect(result.code).toBe(0); + const plain = result.stdout.replace(/\x1b\[[0-9;]*m/g, ""); + expect(plain).toContain("* commit"); + expect(result.stdout).toContain("Second commit"); + expect(result.stdout).not.toContain("First commit"); + expect(result.stdout).toContain("\x1b["); + }); + + jjTest( + "uses the bundled JJ provider in a JJ-only repository", + () => { + const cwd = createJjRepo(); + const result = run(["bun", "run", mainPath, "log", "--vcs", "jj", "--color", "never"], cwd); + + expect(result.code).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("JJ second commit"); + expect(result.stdout).toContain("JJ first commit"); + expect(result.stdout).toContain("JJ body."); + expect(result.stdout).toContain("Author: Grace Hopper "); + expect(result.stdout).toContain("main"); + expect(result.stdout).toContain("tag: v2.0.0"); + expect(result.stdout).toMatch(/commit [0-9a-f]{40}/); + expect(result.stdout).not.toContain("\x1b"); + }, + 20_000, + ); + + test("is silent for max count zero and reports non-repositories cleanly", () => { + const cwd = createRepo(); + expect(run(["bun", "run", mainPath, "log", "-n", "0"], cwd)).toMatchObject({ + code: 0, + stdout: "", + stderr: "", + }); + + const outside = mkdtempSync(join(tmpdir(), "hunk-log-outside-")); + tempDirs.push(outside); + const failed = run(["bun", "run", mainPath, "log"], outside); + expect(failed.code).toBe(1); + expect(failed.stderr).toContain("not a git repository"); + expect(failed.stdout).not.toContain("\x1b"); + }); +}); diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts new file mode 100644 index 000000000..56b3e956b --- /dev/null +++ b/test/pty/log-integration.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); +const tempDirs: string[] = []; +setDefaultTimeout(45_000); + +/** Run one Git fixture command with deterministic author identity. */ +function git(cwd: string, args: string[]) { + const proc = Bun.spawnSync(["git", ...args], { + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_AUTHOR_NAME: "History Tester", + GIT_AUTHOR_EMAIL: "history@example.com", + GIT_COMMITTER_NAME: "History Tester", + GIT_COMMITTER_EMAIL: "history@example.com", + }, + }); + if (proc.exitCode !== 0) throw new Error(proc.stderr?.toString() ?? "Git fixture failed."); +} + +/** Create two commits whose selected diff is visible in ordinary Hunk review. */ +function createHistoryRepo() { + const cwd = mkdtempSync(join(tmpdir(), "hunk-log-pty-")); + tempDirs.push(cwd); + git(cwd, ["init", "-q"]); + writeFileSync(join(cwd, "history.ts"), "export const historyValue = 'first';\n"); + git(cwd, ["add", "history.ts"]); + git(cwd, ["commit", "-qm", "First history commit"]); + writeFileSync(join(cwd, "history.ts"), "export const historyValue = 'second';\n"); + git(cwd, ["commit", "-qam", "Second history commit"]); + return cwd; +} + +afterEach(() => { + harness.cleanup(); + for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true }); +}); + +describe("interactive hunk log", () => { + test("opens the selected immutable commit and returns to the retained history", async () => { + const cwd = createHistoryRepo(); + const session = await harness.launchHunk({ + args: ["log", "--interactive", "--color", "never", "--no-extensions"], + cwd, + cols: 100, + rows: 20, + }); + + try { + const history = await session.waitForText(/Second history commit/, { + timeout: 15_000, + }); + expect(history).toContain("First history commit"); + expect(history).toContain("enter open"); + + // The first compact row starts with one graph cell plus two spaces, so x=4 + // lands inside its visible commit id. One press opens without a double-click. + session.writeRaw("\x1b[<0;5;1M"); + const review = await session.waitForText(/historyValue = 'second'/, { + timeout: 15_000, + }); + expect(review).toContain("history.ts"); + + await session.press("q"); + const returned = await session.waitForText(/Second history commit/, { + timeout: 15_000, + }); + expect(returned).toContain("enter open"); + + // Terminals may coalesce rapid navigation and activation into one stdin chunk. + session.writeRaw("\x1b[B\r"); + const rootReview = await session.waitForText(/historyValue = 'first'/, { + timeout: 15_000, + }); + expect(rootReview).toContain("history.ts"); + await session.press("q"); + await session.waitForText(/First history commit/, { timeout: 15_000 }); + + // Opening again without moving proves return restored the immutable-id selection. + await session.press("enter"); + await session.waitForText(/historyValue = 'first'/, { timeout: 15_000 }); + await session.press("q"); + await session.waitForText(/First history commit/, { timeout: 15_000 }); + await session.press("q"); + } finally { + session.close(); + } + }); +}); From b4d4f6b588b6a04cc3d71db4bd74f058c895369f Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:18:07 -0400 Subject: [PATCH 04/36] refactor(ui): share desktop chrome with history (#983) --- .changeset/shared-log-chrome.md | 5 + docs/keybindings.md | 11 +- skills/hunk-extensions/SKILL.md | 2 +- src/app/historyBootstrap.test.ts | 81 +++ src/app/historyBootstrap.ts | 55 +- src/core/vcs/index.ts | 4 +- src/core/vcs/types.ts | 3 + src/extension-api/index.ts | 1 + src/extension-api/types.ts | 7 + .../default/vcs/git/history.test.ts | 15 + src/extensions/default/vcs/git/index.ts | 11 +- .../default/vcs/jujutsu/history.test.ts | 13 +- src/extensions/default/vcs/jujutsu/index.ts | 14 +- src/extensions/runExtension.test.ts | 20 +- src/extensions/runExtension.ts | 19 +- src/extensions/types.ts | 1 + src/ui/App.tsx | 1 + src/ui/AppHost.extensions.test.tsx | 6 +- src/ui/AppHost.file-views.test.tsx | 6 +- src/ui/components/chrome/HelpDialog.tsx | 24 +- src/ui/components/chrome/MenuBar.tsx | 45 +- src/ui/components/chrome/MenuDropdown.tsx | 50 +- .../components/chrome/ThemeSelectorDialog.tsx | 21 +- src/ui/components/chrome/menu.ts | 27 +- src/ui/components/ui-components.test.tsx | 147 +++++ src/ui/history/runInteractiveHistory.test.ts | 80 +-- src/ui/history/runInteractiveHistory.ts | 367 +----------- src/ui/history/runStaticHistory.test.ts | 3 + src/ui/history/terminalInput.test.ts | 53 -- src/ui/history/terminalInput.ts | 193 ------ src/ui/history/types.ts | 8 +- src/ui/hooks/useMenuController.test.tsx | 54 ++ src/ui/hooks/useMenuController.ts | 37 +- .../hooks/useThemeSelectorController.test.tsx | 3 + src/ui/hooks/useThemeSelectorController.ts | 6 +- src/ui/lib/appMenus.ts | 2 +- src/ui/lib/ui-lib.test.ts | 20 +- src/ui/log/LogApp.tsx | 549 ++++++++++++++++++ src/ui/log/ParentSelectorDialog.test.tsx | 40 ++ src/ui/log/ParentSelectorDialog.tsx | 79 +++ src/ui/log/colorPolicy.test.ts | 22 + src/ui/log/colorPolicy.ts | 37 ++ src/ui/log/commands.test.ts | 71 +++ src/ui/log/commands.ts | 228 ++++++++ src/ui/log/controller.test.ts | 175 ++++++ src/ui/log/controller.ts | 346 +++++++++++ src/ui/log/logHelp.ts | 4 + src/ui/log/reviewLaunch.ts | 41 ++ src/ui/log/runInteractiveLog.test.ts | 10 + src/ui/log/runInteractiveLog.tsx | 136 +++++ test/pty/log-integration.test.ts | 150 ++++- 51 files changed, 2518 insertions(+), 785 deletions(-) create mode 100644 .changeset/shared-log-chrome.md create mode 100644 src/app/historyBootstrap.test.ts delete mode 100644 src/ui/history/terminalInput.test.ts delete mode 100644 src/ui/history/terminalInput.ts create mode 100644 src/ui/log/LogApp.tsx create mode 100644 src/ui/log/ParentSelectorDialog.test.tsx create mode 100644 src/ui/log/ParentSelectorDialog.tsx create mode 100644 src/ui/log/colorPolicy.test.ts create mode 100644 src/ui/log/colorPolicy.ts create mode 100644 src/ui/log/commands.test.ts create mode 100644 src/ui/log/commands.ts create mode 100644 src/ui/log/controller.test.ts create mode 100644 src/ui/log/controller.ts create mode 100644 src/ui/log/logHelp.ts create mode 100644 src/ui/log/reviewLaunch.ts create mode 100644 src/ui/log/runInteractiveLog.test.ts create mode 100644 src/ui/log/runInteractiveLog.tsx diff --git a/.changeset/shared-log-chrome.md b/.changeset/shared-log-chrome.md new file mode 100644 index 000000000..5bd4ad805 --- /dev/null +++ b/.changeset/shared-log-chrome.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add Hunk's desktop menu chrome, live theme picker, and provider-owned merge-parent selection to the interactive repository history browser. diff --git a/docs/keybindings.md b/docs/keybindings.md index 3d8291273..39e8e57e8 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -46,11 +46,14 @@ deleted until its replies are removed. The built-in commands and the keys they ship with: `hunk log --interactive` is a separate, fixed read-only history entry point rather than part of -the configurable review command table. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`, -`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `y` to copy the full commit id, -`Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit +the configurable review command table. `F10` opens its File, View, Navigate, Commit, and Help menus; +View includes Hunk's shared theme selector. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`, +`g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `r` to refresh, `y` to copy +the full commit id, `Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit id to open it immediately, click elsewhere on a row to select it, or double-click a row to open it. -Quitting the opened review returns to the retained history selection and viewport. +Quitting the opened review returns to the retained history selection and viewport. The Commit menu's +**Compare with first parent** and **Compare with parent…** actions compare the selected commit against +an ordered provider-owned parent; they do not navigate the history selection to that parent. | Command id | Does | Default keys | | ---------------------------------------------- | ---------------------------------------------- | ---------------------------- | diff --git a/skills/hunk-extensions/SKILL.md b/skills/hunk-extensions/SKILL.md index 363a36ec7..f4c8ae713 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/skills/hunk-extensions/SKILL.md @@ -111,7 +111,7 @@ bad or duplicate id is skipped with a startup notice. | Reload after an external agent changes reviewed inputs | `ctx.review.requestReload()` in an event | | Read user-supplied settings | `hunk.config` (`[extension.]` table) | | Snapshot stable files and every saved review note | `ctx.review.snapshot()` in a command | -| Branch on the API generation (currently `17`) | `hunk.apiVersion` | +| Branch on the API generation (currently `18`) | `hunk.apiVersion` | Registration is only valid while the factory runs — Hunk seals the API object afterwards. diff --git a/src/app/historyBootstrap.test.ts b/src/app/historyBootstrap.test.ts new file mode 100644 index 000000000..976168daa --- /dev/null +++ b/src/app/historyBootstrap.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { HistoryCommandInput } from "../core/run/commandInputs"; +import type { VcsAdapter, VcsCatalog, VcsHistorySource } from "../core/vcs/types"; +import { loadHistoryBootstrap } from "./historyBootstrap"; + +const input: HistoryCommandInput = { + kind: "history", + color: "never", + format: "compact", + ascii: false, + interactive: true, + vcs: "test", + extensionsEnabled: false, + extensionPaths: [], +}; + +describe("history bootstrap cursor ownership", () => { + test("cancels refresh before opening and closes each active provider cursor once", async () => { + const cwd = mkdtempSync(join(tmpdir(), "hunk-history-bootstrap-")); + const configHome = mkdtempSync(join(tmpdir(), "hunk-history-config-")); + const closeCounts: number[] = []; + let opens = 0; + const makeSource = (): VcsHistorySource => { + const index = opens++; + closeCounts[index] = 0; + return { + async read() { + return { commits: [], done: true }; + }, + async close() { + closeCounts[index]! += 1; + }, + }; + }; + const adapter: VcsAdapter = { + id: "test", + name: "Test", + detect: () => ({ id: "test", repoRoot: cwd }), + operations: {}, + history: { + async open() { + return makeSource(); + }, + async planReview(commit) { + return { kind: "revision-show", revisionId: commit.revisionId }; + }, + }, + }; + const catalog: VcsCatalog = { + adapters: [adapter], + defaultAdapterId: "test", + reservedIds: new Set(["test"]), + }; + + try { + const bootstrap = await loadHistoryBootstrap({ + input, + cwd, + env: { ...process.env, XDG_CONFIG_HOME: configHome }, + baseVcsCatalog: catalog, + }); + const cancelled = new AbortController(); + cancelled.abort(); + await expect(bootstrap.reopenSource(cancelled.signal)).rejects.toThrow(); + expect(opens).toBe(1); + + await bootstrap.reopenSource(); + expect(opens).toBe(2); + expect(closeCounts).toEqual([1, 0]); + await bootstrap.close(); + await bootstrap.close(); + expect(closeCounts).toEqual([1, 1]); + } finally { + rmSync(cwd, { recursive: true, force: true }); + rmSync(configHome, { recursive: true, force: true }); + } + }); +}); diff --git a/src/app/historyBootstrap.ts b/src/app/historyBootstrap.ts index a60d57be0..bd4372416 100644 --- a/src/app/historyBootstrap.ts +++ b/src/app/historyBootstrap.ts @@ -3,6 +3,7 @@ import { collectSessionCustomThemes } from "../core/theme/customThemes"; import type { ExtensionVcsHistoryCommit, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, NamedCustomThemeConfig, } from "../extension-api/types"; import { sanitizeTerminalLine } from "../lib/terminalText"; @@ -31,7 +32,11 @@ export interface HistoryBootstrap { extensions: ExtensionLoadResult; notices: readonly string[]; customThemes: readonly NamedCustomThemeConfig[]; - planReview(commit: ExtensionVcsHistoryCommit): Promise; + planReview( + commit: ExtensionVcsHistoryCommit, + options?: ExtensionVcsHistoryReviewOptions, + ): Promise; + reopenSource(signal?: AbortSignal): Promise; close(): Promise; } @@ -95,24 +100,22 @@ export async function loadHistoryBootstrap({ } const repoRoot = selectedDetection?.repoRoot ?? cwd; + const historyInput = { + ...(input.revision ? { revision: input.revision } : {}), + ...(input.all ? { all: true } : {}), + ...(input.firstParent ? { firstParent: true } : {}), + ...(input.maxCount !== undefined ? { maxCount: input.maxCount } : {}), + ...(input.author !== undefined ? { author: input.author } : {}), + ...(input.grep !== undefined ? { grep: input.grep } : {}), + ...(input.since !== undefined ? { since: input.since } : {}), + ...(input.until !== undefined ? { until: input.until } : {}), + ...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}), + }; + const openSource = (signal?: AbortSignal) => + openVcsHistory(adapter, historyInput, { cwd: repoRoot, signal }, catalog); let source: VcsHistorySource; try { - source = await openVcsHistory( - adapter, - { - ...(input.revision ? { revision: input.revision } : {}), - ...(input.all ? { all: true } : {}), - ...(input.firstParent ? { firstParent: true } : {}), - ...(input.maxCount !== undefined ? { maxCount: input.maxCount } : {}), - ...(input.author !== undefined ? { author: input.author } : {}), - ...(input.grep !== undefined ? { grep: input.grep } : {}), - ...(input.since !== undefined ? { since: input.since } : {}), - ...(input.until !== undefined ? { until: input.until } : {}), - ...(input.pathspecs ? { pathspecs: [...input.pathspecs] } : {}), - }, - { cwd: repoRoot }, - catalog, - ); + source = await openSource(); emitExtensionEvent(resolved.extensions, "startup", { cwd }); } catch (error) { await retireExtensionLoadResult(resolved.extensions); @@ -141,8 +144,22 @@ export async function loadHistoryBootstrap({ ] : []), ], - planReview(commit) { - return planVcsHistoryReview(adapter, commit, { cwd: repoRoot }); + planReview(commit, options) { + return planVcsHistoryReview(adapter, commit, { cwd: repoRoot }, options); + }, + async reopenSource(signal) { + if (closed) throw new Error("History session is closed."); + signal?.throwIfAborted(); + const previous = source; + const replacement = await openSource(signal); + if (closed || source !== previous || signal?.aborted) { + await replacement.close(); + signal?.throwIfAborted(); + throw new Error("History session changed while refreshing."); + } + source = replacement; + await previous.close(); + return replacement; }, async close() { if (closed) return; diff --git a/src/core/vcs/index.ts b/src/core/vcs/index.ts index 2a1e072c2..386b320f3 100644 --- a/src/core/vcs/index.ts +++ b/src/core/vcs/index.ts @@ -5,6 +5,7 @@ import type { ExtensionVcsHistoryCommit, ExtensionVcsHistoryInput, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, } from "../../extension-api/types"; import type { CliInput } from "../run/commandInputs"; import type { @@ -182,13 +183,14 @@ export async function planVcsHistoryReview( adapter: VcsAdapter, commit: ExtensionVcsHistoryCommit, context: VcsLoadContext, + options?: ExtensionVcsHistoryReviewOptions, ): Promise { if (!adapter.history) { throw new HunkUserError(`\`hunk log\` is not supported by ${adapter.name}.`, [ "Use a VCS adapter that implements history browsing.", ]); } - return await adapter.history.planReview(commit, context); + return await adapter.history.planReview(commit, context, options); } /** Build an adapter event plan, falling back to signature polling. */ diff --git a/src/core/vcs/types.ts b/src/core/vcs/types.ts index 062676429..7a110e22a 100644 --- a/src/core/vcs/types.ts +++ b/src/core/vcs/types.ts @@ -3,6 +3,7 @@ import type { ExtensionVcsHistoryInput, ExtensionVcsHistoryPage, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, ExtensionVcsWatchPlan, } from "../../extension-api/types"; import type { DiffFile } from "../changeset/model"; @@ -22,6 +23,7 @@ export interface VcsDetection { export interface VcsLoadContext { cwd: string; + signal?: AbortSignal; } export type VcsReviewInput = VcsDiffCommandInput | VcsShowCommandInput | VcsStashShowCommandInput; @@ -57,6 +59,7 @@ export interface VcsHistoryCapability { planReview( commit: ExtensionVcsHistoryCommit, context: VcsLoadContext, + options?: ExtensionVcsHistoryReviewOptions, ): Promise; } diff --git a/src/extension-api/index.ts b/src/extension-api/index.ts index f2445ad17..225ee7261 100644 --- a/src/extension-api/index.ts +++ b/src/extension-api/index.ts @@ -154,6 +154,7 @@ export type { ExtensionVcsHistoryInput, ExtensionVcsHistoryPage, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, ExtensionVcsHistorySource, ExtensionVcsLoadContext, ExtensionVcsOperation, diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 5184853ae..9f4ccb4f5 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -799,6 +799,12 @@ export interface ExtensionVcsHistorySource { close(): void | Promise; } +/** Optional provider-neutral selection facts for reviewing one history item. */ +export interface ExtensionVcsHistoryReviewOptions { + /** One ordered parent id returned on the commit, when the caller chooses a specific parent. */ + parentRevisionId?: string; +} + /** A provider-owned declaration of how Hunk should review one history item. */ export type ExtensionVcsHistoryReviewAction = | { @@ -826,6 +832,7 @@ export interface ExtensionVcsHistoryCapability { planReview( commit: ExtensionVcsHistoryCommit, context: ExtensionVcsLoadContext, + options?: ExtensionVcsHistoryReviewOptions, ): ExtensionVcsHistoryReviewAction | Promise; } diff --git a/src/extensions/default/vcs/git/history.test.ts b/src/extensions/default/vcs/git/history.test.ts index a5826c30e..e18710b06 100644 --- a/src/extensions/default/vcs/git/history.test.ts +++ b/src/extensions/default/vcs/git/history.test.ts @@ -142,6 +142,21 @@ describe("Git history production", () => { fromRevisionId: "c".repeat(40), toRevisionId: "b".repeat(40), }); + expect( + await history.planReview( + { + ...root, + revisionId: "b".repeat(40), + parentRevisionIds: ["c".repeat(40), "d".repeat(40)], + }, + undefined, + { parentRevisionId: "d".repeat(40) }, + ), + ).toEqual({ + kind: "revision-range", + fromRevisionId: "d".repeat(40), + toRevisionId: "b".repeat(40), + }); }); test("rejects truncated records and invalid SHA object ids", () => { diff --git a/src/extensions/default/vcs/git/index.ts b/src/extensions/default/vcs/git/index.ts index c92204ed6..9f1cda3b4 100644 --- a/src/extensions/default/vcs/git/index.ts +++ b/src/extensions/default/vcs/git/index.ts @@ -284,12 +284,15 @@ export function createGitVcsAdapter({ open(input, { cwd }) { return openGitHistory(input, { cwd, gitExecutable }); }, - planReview(commit) { - const firstParent = commit.parentRevisionIds[0]; - return firstParent + planReview(commit, _context?: unknown, options?: { parentRevisionId?: string }) { + const parent = options?.parentRevisionId ?? commit.parentRevisionIds[0]; + if (parent && !commit.parentRevisionIds.includes(parent)) { + throw new Error("The selected revision is not a parent of this Git commit."); + } + return parent ? { kind: "revision-range" as const, - fromRevisionId: firstParent, + fromRevisionId: parent, toRevisionId: commit.revisionId, } : { kind: "revision-show" as const, revisionId: commit.revisionId }; diff --git a/src/extensions/default/vcs/jujutsu/history.test.ts b/src/extensions/default/vcs/jujutsu/history.test.ts index fb4fb0e32..4c35bb363 100644 --- a/src/extensions/default/vcs/jujutsu/history.test.ts +++ b/src/extensions/default/vcs/jujutsu/history.test.ts @@ -171,7 +171,7 @@ describe("Jujutsu history production", () => { expect(parseJjHistory(raw, true)[0]!.parentRevisionIds).toEqual(["b".repeat(40)]); }); - test("owns ordinary, merge, and root review semantics with revision-show", async () => { + test("owns default and explicitly selected parent review semantics", async () => { const history = createJjVcsAdapter().history!; for (const parentRevisionIds of [[], ["b".repeat(40)], ["b".repeat(40), "c".repeat(40)]]) { const commit = { @@ -188,6 +188,17 @@ describe("Jujutsu history production", () => { kind: "revision-show", revisionId: commit.revisionId, }); + if (parentRevisionIds[0]) { + expect( + await history.planReview(commit, undefined, { + parentRevisionId: parentRevisionIds[0], + }), + ).toEqual({ + kind: "revision-range", + fromRevisionId: parentRevisionIds[0], + toRevisionId: commit.revisionId, + }); + } } }); diff --git a/src/extensions/default/vcs/jujutsu/index.ts b/src/extensions/default/vcs/jujutsu/index.ts index f39cc9f3e..0e0d7e9c7 100644 --- a/src/extensions/default/vcs/jujutsu/index.ts +++ b/src/extensions/default/vcs/jujutsu/index.ts @@ -138,8 +138,18 @@ export function createJjVcsAdapter({ jjExecutable = "jj" }: Readonly { subject: "Child", }; let page = 0; + let plannedParent: string | undefined; const adapter = toInternalVcsAdapter({ id: "opaque", name: "Opaque VCS", @@ -1205,14 +1206,17 @@ describe("toInternalVcsAdapter history boundary", () => { page++ === 0 ? { commits: [child], done: false } : { commits: [root], done: true }, close() {}, }), - planReview: (selected) => - selected.parentRevisionIds.length + planReview: (selected, _context, options) => { + plannedParent = options?.parentRevisionId; + return selected.parentRevisionIds.length ? { kind: "revision-range", - fromRevisionId: `opaque:base-for/${selected.revisionId}`, + fromRevisionId: + options?.parentRevisionId ?? `opaque:base-for/${selected.revisionId}`, toRevisionId: selected.revisionId, } - : { kind: "revision-show", revisionId: `opaque:root-view/${selected.revisionId}` }, + : { kind: "revision-show", revisionId: `opaque:root-view/${selected.revisionId}` }; + }, }, }); @@ -1228,6 +1232,14 @@ describe("toInternalVcsAdapter history boundary", () => { fromRevisionId: `opaque:base-for/${child.revisionId}`, toRevisionId: child.revisionId, }); + await expect( + adapter.history!.planReview(child, { cwd: "/repo" }, { parentRevisionId: root.revisionId }), + ).resolves.toEqual({ + kind: "revision-range", + fromRevisionId: root.revisionId, + toRevisionId: child.revisionId, + }); + expect(plannedParent).toBe(root.revisionId); await expect(adapter.history!.planReview(root, { cwd: "/repo" })).resolves.toEqual({ kind: "revision-show", revisionId: `opaque:root-view/${root.revisionId}`, diff --git a/src/extensions/runExtension.ts b/src/extensions/runExtension.ts index d1619d4ca..8f69b6f7e 100644 --- a/src/extensions/runExtension.ts +++ b/src/extensions/runExtension.ts @@ -562,10 +562,25 @@ export function toInternalVcsAdapter( throw toUserFacingError(error); } }, - async planReview(commit, context) { + async planReview(commit, context, options) { try { + const normalizedCommit = normalizeHistoryCommit(commit); + const parentRevisionId = options?.parentRevisionId; + if ( + parentRevisionId !== undefined && + !normalizedCommit.parentRevisionIds.includes(parentRevisionId) + ) { + throw new Error( + "VCS history parent selection must name one of the commit's parents.", + ); + } return normalizeHistoryReviewAction( - await planHistoryReview.call(history, normalizeHistoryCommit(commit), context), + await planHistoryReview.call( + history, + normalizedCommit, + context, + parentRevisionId === undefined ? undefined : { parentRevisionId }, + ), ); } catch (error) { throw toUserFacingError(error); diff --git a/src/extensions/types.ts b/src/extensions/types.ts index 7d281f052..4bace99a9 100644 --- a/src/extensions/types.ts +++ b/src/extensions/types.ts @@ -82,6 +82,7 @@ export type { ExtensionVcsHistoryInput, ExtensionVcsHistoryPage, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, ExtensionVcsHistorySource, ExtensionWorkspace, ExtensionWorkspaceWriteRequest, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 8cb01ce8d..c845e48a1 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1448,6 +1448,7 @@ export function App({ activeMenuSpec={activeMenuSpec} activeMenuWidth={activeMenuWidth} top={showMenuBar ? 1 : 0} + terminalHeight={terminal.height} terminalWidth={terminal.width} theme={baseTheme} onHoverItem={setActiveMenuItemIndex} diff --git a/src/ui/AppHost.extensions.test.tsx b/src/ui/AppHost.extensions.test.tsx index ed5523487..8eff9e09a 100644 --- a/src/ui/AppHost.extensions.test.tsx +++ b/src/ui/AppHost.extensions.test.tsx @@ -1050,7 +1050,11 @@ describe("mounted lifecycle ordering", () => { expect(events).toContain("result:ok"); expect(events).toContain("trailing:ok"); expect(events.filter((event) => event === "reload:extension")).toHaveLength(2); - expect(setup.captureCharFrame()).toContain("four"); + await flushUntil( + setup, + () => setup.captureCharFrame().includes("four"), + "the successor extension reload frame to render", + ); }); }); diff --git a/src/ui/AppHost.file-views.test.tsx b/src/ui/AppHost.file-views.test.tsx index 2e032db09..266e6d016 100644 --- a/src/ui/AppHost.file-views.test.tsx +++ b/src/ui/AppHost.file-views.test.tsx @@ -429,7 +429,11 @@ describe("AppHost file views", () => { await act(async () => setup.mockInput.pressKey("F10")); await waitForFrame(setup, (frame) => frame.includes("Toggle files/filter focus")); - await act(async () => setup.mockInput.pressArrow("right")); + await act(async () => { + await setup.mockInput.pressArrow("right"); + // Height-clamped dropdowns window around the active item; wrap to the final action. + await setup.mockInput.pressArrow("up"); + }); const menu = await waitForFrame(setup, (frame) => frame.includes("Apply “Bulk preview” to all matching files"), ); diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index 3dcfc9808..b025d5ffa 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -1,5 +1,5 @@ import type { AppCommand } from "../../lib/appCommands"; -import { buildHelpSections } from "../../lib/helpContent"; +import { buildHelpSections, type HelpSection } from "../../lib/helpContent"; import { fitText, padText } from "../../lib/text"; import type { AppTheme } from "../../themes"; import { ModalFrame } from "./ModalFrame"; @@ -12,20 +12,22 @@ import { ModalFrame } from "./ModalFrame"; */ export function HelpDialog({ commands, + sections: suppliedSections, terminalHeight, terminalWidth, theme, onClose, }: { - commands: readonly AppCommand[]; + commands?: readonly AppCommand[]; + sections?: readonly HelpSection[]; terminalHeight: number; terminalWidth: number; theme: AppTheme; onClose: () => void; }) { - const sections = buildHelpSections(commands); + const sections = suppliedSections ? [...suppliedSections] : buildHelpSections(commands ?? []); - const width = Math.min(74, Math.max(56, terminalWidth - 8)); + const width = Math.max(1, Math.min(74, Math.max(56, terminalWidth - 8), terminalWidth - 2)); const bodyWidth = Math.max(1, width - 4); const rows = sections.flatMap((section) => section.rows); const longestKeys = Math.max(0, ...rows.map((row) => row.keys.length)); @@ -33,8 +35,14 @@ export function HelpDialog({ // Key text is user-controlled once bindings are, so the column is measured // rather than guessed — but descriptions are given the room they need first, // since a truncated key is still recognizable and a truncated sentence is not. - const keyWidth = Math.max(12, Math.min(longestKeys + 1, bodyWidth - longestDescription)); - const descriptionWidth = Math.max(1, bodyWidth - keyWidth); + const keyWidth = Math.max( + 1, + Math.min( + bodyWidth, + Math.max(Math.min(12, bodyWidth), Math.min(longestKeys + 1, bodyWidth - longestDescription)), + ), + ); + const descriptionWidth = Math.max(0, bodyWidth - keyWidth); const sectionSpacerRowCount = Math.max(0, sections.length - 1); const contentRowCount = sections.reduce((rowCount, section) => rowCount + 1 + section.rows.length, 0) + @@ -42,7 +50,7 @@ export function HelpDialog({ // ModalFrame contributes the border rows, title row, padding, and one blank spacer row. const modalFrameChromeRowCount = 6; const requiredModalHeight = contentRowCount + modalFrameChromeRowCount; - const modalHeight = Math.min(requiredModalHeight, Math.max(8, terminalHeight - 2)); + const modalHeight = Math.max(1, Math.min(requiredModalHeight, terminalHeight - 2)); const shouldScroll = modalHeight < requiredModalHeight; const content = ( @@ -77,7 +85,7 @@ export function HelpDialog({ onClose={onClose} > {shouldScroll ? ( - + {content} ) : ( diff --git a/src/ui/components/chrome/MenuBar.tsx b/src/ui/components/chrome/MenuBar.tsx index 9857feaad..fed171f30 100644 --- a/src/ui/components/chrome/MenuBar.tsx +++ b/src/ui/components/chrome/MenuBar.tsx @@ -1,6 +1,6 @@ import type { AppTheme } from "../../themes"; import { fitText } from "../../lib/text"; -import { menuBarTitleWidth, type MenuId, type MenuSpec } from "./menu"; +import { menuBarTitleWidth, responsiveMenuSpecs, type MenuId, type MenuSpec } from "./menu"; /** Render the top menu bar and the current changeset title. */ export function MenuBar({ @@ -20,6 +20,25 @@ export function MenuBar({ onHoverMenu: (menuId: MenuId) => void; onToggleMenu: (menuId: MenuId) => void; }) { + const responsive = responsiveMenuSpecs(menuSpecs, terminalWidth); + const visibleMenuSpecs = responsive.visible; + const hiddenMenuIds = new Set(responsive.hidden.map((menu) => menu.id)); + const activeHiddenIndex = responsive.hidden.findIndex((menu) => menu.id === activeMenuId); + const activeHiddenMenu = + activeHiddenIndex >= 0 ? responsive.hidden[activeHiddenIndex] : undefined; + const overflowTarget = + activeHiddenIndex >= 0 + ? responsive.hidden[(activeHiddenIndex + 1) % responsive.hidden.length] + : responsive.hidden[0]; + const overflowHoverTarget = activeHiddenMenu ?? responsive.hidden[0]; + const titleSpecs = + responsive.overflowLeft === null + ? visibleMenuSpecs + : [ + ...visibleMenuSpecs, + { id: "help" as const, left: responsive.overflowLeft, width: 3, label: "…" }, + ]; + const title = visibleMenuSpecs.length === 0 ? "F10 menu" : topTitle; return ( // The outer row paints the app background so the bar keeps the same // one-column gutter the body panes have; only the inner band is chrome. @@ -42,7 +61,7 @@ export function MenuBar({ alignItems: "center", }} > - {menuSpecs.map((menu) => { + {visibleMenuSpecs.map((menu) => { const active = activeMenuId === menu.id; return ( ); })} + {overflowTarget && responsive.overflowLeft !== null ? ( + onToggleMenu(overflowTarget.id)} + onMouseOver={() => { + if (activeMenuId && overflowHoverTarget) onHoverMenu(overflowHoverTarget.id); + }} + > + + {" … "} + + + ) : null} {` ${fitText(topTitle, menuBarTitleWidth(menuSpecs, terminalWidth))}`} + >{` ${fitText(title, menuBarTitleWidth(titleSpecs, terminalWidth))}`} diff --git a/src/ui/components/chrome/MenuDropdown.tsx b/src/ui/components/chrome/MenuDropdown.tsx index 53f1d0cf1..5a883b56c 100644 --- a/src/ui/components/chrome/MenuDropdown.tsx +++ b/src/ui/components/chrome/MenuDropdown.tsx @@ -23,7 +23,7 @@ function renderMenuLine( style={{ width: "100%", height: 1, flexDirection: "row", justifyContent: "space-between" }} > - {padText(text, leftWidth)} + {padText(text, leftWidth)} {hint ? ( @@ -42,6 +42,7 @@ export function MenuDropdown({ activeMenuSpec, activeMenuWidth, top = 1, + terminalHeight = Number.MAX_SAFE_INTEGER, terminalWidth, theme, onHoverItem, @@ -53,13 +54,23 @@ export function MenuDropdown({ activeMenuSpec: MenuSpec; activeMenuWidth: number; top?: number; + terminalHeight?: number; terminalWidth: number; theme: AppTheme; onHoverItem: (index: number) => void; onSelectItem: (entry: Extract) => void; }) { - const clampedWidth = Math.min(activeMenuWidth, Math.max(22, terminalWidth - 2)); - const clampedLeft = Math.max(1, Math.min(activeMenuSpec.left, terminalWidth - clampedWidth - 1)); + const clampedWidth = Math.max(1, Math.min(activeMenuWidth, terminalWidth - 2)); + const clampedLeft = Math.max(0, Math.min(activeMenuSpec.left, terminalWidth - clampedWidth)); + const visibleRowCount = Math.max(1, terminalHeight - top - 2); + const windowStart = Math.max( + 0, + Math.min( + Math.max(0, activeMenuEntries.length - visibleRowCount), + activeMenuItemIndex - Math.floor(visibleRowCount / 2), + ), + ); + const visibleEntries = activeMenuEntries.slice(windowStart, windowStart + visibleRowCount); return ( - {activeMenuEntries.map((entry, index) => - entry.kind === "separator" ? ( + {visibleEntries.map((entry, offset) => { + const index = windowStart + offset; + return entry.kind === "separator" ? ( - {padText("-".repeat(clampedWidth - 4), clampedWidth - 2)} + + {padText("-".repeat(Math.max(0, clampedWidth - 4)), Math.max(0, clampedWidth - 2))} + ) : ( { + if (!entry.disabled) onHoverItem(index); + }} + onMouseUp={() => { + if (!entry.disabled) onSelectItem(entry); }} - onMouseOver={() => onHoverItem(index)} - onMouseUp={() => onSelectItem(entry)} > - {renderMenuLine(entry, clampedWidth - 2, theme, activeMenuItemIndex === index)} + {renderMenuLine( + entry, + Math.max(1, clampedWidth - 2), + theme, + activeMenuItemIndex === index && !entry.disabled, + )} - ), - )} + ); + })} ); } diff --git a/src/ui/components/chrome/ThemeSelectorDialog.tsx b/src/ui/components/chrome/ThemeSelectorDialog.tsx index f88131502..7d6495456 100644 --- a/src/ui/components/chrome/ThemeSelectorDialog.tsx +++ b/src/ui/components/chrome/ThemeSelectorDialog.tsx @@ -68,11 +68,17 @@ export function ThemeSelectorDialog({ onClose: () => void; onPreviewItem: (index: number) => void; }) { - const width = Math.min(82, Math.max(56, terminalWidth - 8)); - const modalHeight = Math.min(Math.max(12, terminalHeight - 4), 28); + const width = Math.max(1, Math.min(82, Math.max(56, terminalWidth - 8), terminalWidth - 2)); + const modalHeight = Math.max( + 1, + Math.min(28, Math.max(12, terminalHeight - 4), terminalHeight - 2), + ); const bodyWidth = Math.max(1, width - 4); // ModalFrame contributes border/title/padding; reserve help/footer rows inside the body. - const visibleRows = Math.max(4, modalHeight - 7); + const visibleRows = Math.max( + 1, + Math.min(Math.max(4, modalHeight - 7), Math.max(1, modalHeight - 5)), + ); const [windowState, setWindowState] = useState(() => ({ itemCount: items.length, selectedIndex, @@ -123,9 +129,12 @@ export function ThemeSelectorDialog({ ); const visibleItems = items.slice(windowStart, windowStart + visibleRows); - const markerWidth = 3; - const descriptionWidth = 12; - const labelWidth = Math.max(8, bodyWidth - markerWidth - descriptionWidth - 2); + const markerWidth = Math.min(3, bodyWidth); + const descriptionWidth = bodyWidth >= 28 ? 12 : 0; + const labelWidth = Math.max( + 0, + bodyWidth - markerWidth - descriptionWidth - (descriptionWidth ? 2 : 0), + ); return ( void; } | { @@ -36,6 +38,7 @@ const MENU_LABELS: Record = { file: "File", view: "View", navigate: "Navigate", + commit: "Commit", agent: "Agent", extensions: "Extensions", help: "Help", @@ -74,6 +77,26 @@ export function buildMenuSpecs(menus: AppMenus) { ); } +/** Fit a shared ordered menu model into one bar and retain hidden menus behind overflow. */ +export function responsiveMenuSpecs(menuSpecs: readonly MenuSpec[], terminalWidth: number) { + const rightEdge = Math.max(1, terminalWidth - 1); + const allVisible = menuSpecs.filter((menu) => menu.left + menu.width <= rightEdge); + if (allVisible.length === menuSpecs.length) { + return { visible: allVisible, hidden: [] as MenuSpec[], overflowLeft: null }; + } + + const overflowWidth = 3; + const visible = menuSpecs.filter((menu) => menu.left + menu.width + overflowWidth <= rightEdge); + const visibleIds = new Set(visible.map((menu) => menu.id)); + const hidden = menuSpecs.filter((menu) => !visibleIds.has(menu.id)); + const previous = visible.at(-1); + return { + visible, + hidden, + overflowLeft: previous ? previous.left + previous.width : 1, + }; +} + /** Find the next selectable menu item, skipping separators. */ export function nextMenuItemIndex(entries: MenuEntry[], currentIndex: number, delta: number) { if (entries.length === 0) { @@ -84,7 +107,7 @@ export function nextMenuItemIndex(entries: MenuEntry[], currentIndex: number, de for (let remaining = entries.length; remaining > 0; remaining -= 1) { candidate = (candidate + delta + entries.length) % entries.length; const entry = entries[candidate]; - if (entry?.kind === "item") { + if (entry?.kind === "item" && !entry.disabled) { return candidate; } } diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 74d9acd5c..a26214f2c 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -3,6 +3,7 @@ import type { ScrollBoxRenderable } from "@opentui/core"; import { MouseButtons } from "@opentui/core/testing"; import { testRender } from "@opentui/react/test-utils"; import { act, createRef, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import type { MenuId } from "./chrome/menu"; import type { AppBootstrap } from "../../core/bootstrap"; import type { DiffFile } from "../../core/changeset/model"; import { createTestVcsAppBootstrap } from "../../../test/helpers/app-bootstrap"; @@ -27,9 +28,11 @@ const { AppHost } = await import("../AppHost"); const { toReadOnlyFileViews } = await import("../../extensions/events"); const { FlexFileSidebar } = await import("../../extensions/default/ui/sidebar"); const { HelpDialog } = await import("./chrome/HelpDialog"); +const { ThemeSelectorDialog } = await import("./chrome/ThemeSelectorDialog"); const { AgentCard } = await import("./panes/AgentCard"); const { AgentInlineNote, measureAgentInlineNoteHeight } = await import("./panes/AgentInlineNote"); const { DiffPane, storedReviewNoteActions } = await import("./panes/DiffPane"); +const { MenuBar } = await import("./chrome/MenuBar"); const { MenuDropdown } = await import("./chrome/MenuDropdown"); const { StatusBar } = await import("./chrome/StatusBar"); const { DiffFileHeaderRow } = await import("./panes/DiffFileHeaderRow"); @@ -3470,6 +3473,69 @@ describe("UI components", () => { expect(frame).toContain("Second rationale."); }); + test("MenuBar renders a responsive overflow for hidden top menus", async () => { + const theme = resolveTheme("github-dark-default", null); + const frame = await captureFrame( + {}} + onToggleMenu={() => {}} + />, + 20, + 2, + ); + expect(frame).toContain("File"); + expect(frame).toContain("…"); + expect(frame).not.toContain("Navigate"); + }); + + test("MenuBar overflow cycles through every hidden menu by mouse", async () => { + const theme = resolveTheme("github-dark-default", null); + const toggled: string[] = []; + const clickOverflow = async (activeMenuId: MenuId | null) => { + const setup = await testRender( + {}} + onToggleMenu={(id) => toggled.push(id)} + />, + { width: 20, height: 2 }, + ); + try { + await act(async () => { + await setup.renderOnce(); + await setup.mockMouse.click(14, 0); + }); + } finally { + setup.renderer.destroy(); + } + }; + await clickOverflow(null); + await clickOverflow("navigate"); + await clickOverflow("commit"); + expect(toggled).toEqual(["navigate", "commit", "help"]); + }); + test("MenuDropdown renders checked items and key hints", async () => { const theme = resolveTheme("github-dark-default", null); const frame = await captureFrame( @@ -3533,6 +3599,33 @@ describe("UI components", () => { expect(frame).toContain("┘"); }); + test("MenuDropdown windows the active item inside a narrow terminal", async () => { + const theme = resolveTheme("github-dark-default", null); + const entries = Array.from({ length: 8 }, (_, index) => ({ + kind: "item" as const, + label: `Item ${index}`, + action: () => {}, + })); + const frame = await captureFrame( + {}} + onSelectItem={() => {}} + />, + 16, + 6, + ); + expect(frame).toContain("Item 7"); + expect(frame.split("\n").every((line) => line.length <= 16)).toBe(true); + }); + test("StatusBar renders filter mode affordance", async () => { const theme = resolveTheme("github-dark-default", null); const frame = await captureFrame( @@ -3751,6 +3844,60 @@ describe("UI components", () => { expect(frame).not.toContain("linese/Awrapt/smetadata"); }); + test("HelpDialog renders surface-supplied sections without review controls", async () => { + const theme = resolveTheme("github-dark-default", null); + const frame = await captureFrame( + {}} + />, + 60, + 16, + ); + + expect(frame).toContain("Commit"); + expect(frame).toContain("Enter"); + expect(frame).toContain("open commit"); + expect(frame).not.toContain("Review"); + }); + + test("shared help and theme dialogs clamp into narrow terminals", async () => { + const theme = resolveTheme("github-dark-default", null); + const help = await captureFrame( + {}} + />, + 20, + 8, + ); + const selector = await captureFrame( + {}} + onClose={() => {}} + onPreviewItem={() => {}} + />, + 20, + 8, + ); + expect(help).toContain("Commit"); + expect(selector).toContain("Dark"); + expect([...help.split("\n"), ...selector.split("\n")].every((line) => line.length <= 20)).toBe( + true, + ); + }); + test("HelpDialog shows the keys a remapped command actually answers to", async () => { const theme = resolveTheme("github-dark-default", null); const { keys } = resolveCommandKeys({ diff --git a/src/ui/history/runInteractiveHistory.test.ts b/src/ui/history/runInteractiveHistory.test.ts index 8d29616d4..9d91e6cad 100644 --- a/src/ui/history/runInteractiveHistory.test.ts +++ b/src/ui/history/runInteractiveHistory.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { PassThrough } from "node:stream"; -import type { HistoryRuntime } from "./types"; -import { historyReviewArgs, runInteractiveHistory } from "./runInteractiveHistory"; +import { historyReviewArgs } from "./runInteractiveHistory"; describe("history review child arguments", () => { test("encodes provider-owned opaque actions without exposing ids to CLI option parsing", () => { @@ -25,79 +23,3 @@ describe("history review child arguments", () => { }); }); }); - -describe("interactive history loading", () => { - test("q interrupts an exhaustive read and closes the provider", async () => { - const stdin = new PassThrough() as unknown as NodeJS.ReadStream; - const stdout = new PassThrough() as unknown as NodeJS.WriteStream; - Object.assign(stdin, { isTTY: true, setRawMode() {} }); - Object.assign(stdout, { isTTY: true, columns: 80, rows: 12 }); - let reads = 0; - let readAborted = false; - let closed = false; - const runtime: HistoryRuntime = { - input: { - kind: "history", - color: "never", - format: "compact", - ascii: false, - interactive: true, - extensionsEnabled: false, - extensionPaths: [], - }, - source: { - async read({ signal }) { - reads += 1; - if (reads === 1) { - return { - commits: [ - { - revisionId: "a".repeat(40), - displayId: "aaaaaaaa", - parentRevisionIds: [], - subject: "First", - authorName: "Ada", - authoredAt: "2026-01-01T00:00:00Z", - decorations: [], - }, - ], - done: false, - }; - } - return await new Promise((_, reject) => { - signal?.addEventListener( - "abort", - () => { - readAborted = true; - reject(signal.reason); - }, - { once: true }, - ); - }); - }, - async close() {}, - }, - providerId: "test", - providerName: "Test", - repoRoot: "/repo", - notices: [], - customThemes: [], - async planReview() { - return { kind: "revision-show", revisionId: "a".repeat(40) }; - }, - async close() { - closed = true; - }, - }; - - const running = runInteractiveHistory(runtime, { stdin, stdout }); - while (reads < 1) await Bun.sleep(1); - // Terminals may coalesce the exhaustive-navigation key and quit. - stdin.write("Gq"); - await running; - expect(reads).toBe(2); - - expect(readAborted).toBe(true); - expect(closed).toBe(true); - }); -}); diff --git a/src/ui/history/runInteractiveHistory.ts b/src/ui/history/runInteractiveHistory.ts index ca46ad514..bf698746d 100644 --- a/src/ui/history/runInteractiveHistory.ts +++ b/src/ui/history/runInteractiveHistory.ts @@ -1,364 +1,3 @@ -import { spawn } from "node:child_process"; -import { resolve } from "node:path"; -import { createHistoryLaneCheckpoint, planHistoryPage } from "../../core/history/lanePlanner"; -import type { HistoryGraphRow, HistoryLaneCheckpoint } from "../../core/history/types"; -import { resolveCurrentHunkCommand } from "../../core/process/relaunch"; -import { HunkUserError } from "../../core/run/errors"; -import { sanitizeTerminalLine } from "../../lib/terminalText"; -import { fitText } from "../lib/text"; -import { - background, - foreground, - getHistoryCommitIdBounds, - projectHistoryRow, - resolveHistoryColor, - resolveHistoryTheme, -} from "./staticProjection"; -import { TerminalInputReader } from "./terminalInput"; -import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; -import type { HistoryRuntime } from "./types"; - -const ENTER_ALT = "\x1b[?1049h\x1b[?25l\x1b[?1000h\x1b[?1006h"; -const LEAVE_ALT = "\x1b[?1006l\x1b[?1000l\x1b[?25h\x1b[?1049l"; - -/** Convert a provider-owned review declaration into one child Hunk invocation. */ -export function historyReviewArgs(action: ExtensionVcsHistoryReviewAction) { - const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64url"); - return [action.kind === "revision-range" ? "diff" : "show", "--history-review", payload]; -} - -/** Run one provider-planned child Hunk review after yielding terminal ownership. */ -async function openCommitReview( - bootstrap: HistoryRuntime, - action: ExtensionVcsHistoryReviewAction, -) { - const current = resolveCurrentHunkCommand(); - const extensionArgs = bootstrap.input.extensionPaths.flatMap((path) => [ - "--extension", - resolve(path), - ]); - const reviewArgs = historyReviewArgs(action); - const args = [ - ...current.args, - ...reviewArgs, - "--vcs", - bootstrap.providerId, - ...(bootstrap.input.extensionsEnabled ? extensionArgs : ["--no-extensions"]), - ]; - const child = spawn(current.command, args, { - cwd: bootstrap.repoRoot, - env: { ...process.env, HUNK_RETURN_TO_HISTORY: "1" }, - stdio: "inherit", - }); - return await new Promise((resolveExit, reject) => { - child.once("error", reject); - child.once("exit", (code, signal) => resolveExit(signal ? 1 : (code ?? 1))); - }); -} - -/** Browse history as one minimal graph list and open immutable commits in ordinary Hunk review. */ -export async function runInteractiveHistory( - bootstrap: HistoryRuntime, - { - stdin = process.stdin, - stdout = process.stdout, - }: { - stdin?: NodeJS.ReadStream; - stdout?: NodeJS.WriteStream; - } = {}, -) { - if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { - await bootstrap.close(); - throw new HunkUserError("`hunk log --interactive` requires a terminal.", [ - "Use plain `hunk log` for pipes and redirected output.", - ]); - } - - const input = new TerminalInputReader(stdin); - const abort = new AbortController(); - const rows: HistoryGraphRow[] = []; - let checkpoint: HistoryLaneCheckpoint = createHistoryLaneCheckpoint(); - let historyDone = false; - let selected = 0; - let top = 0; - let search = ""; - let notice = bootstrap.notices[0] ? sanitizeTerminalLine(bootstrap.notices[0]) : ""; - let lastClick = { index: -1, at: 0 }; - let active = false; - let stopped = false; - let loading = false; - const theme = resolveHistoryTheme(bootstrap.input.theme, bootstrap.customThemes); - - /** Read one page while letting quit interrupt an exhaustive traversal. */ - const readInterruptibly = async () => { - const pending = bootstrap.source.read({ limit: 256, signal: abort.signal }); - const settled = pending.then( - (page) => ({ kind: "page" as const, page }), - (error: unknown) => ({ kind: "error" as const, error }), - ); - const deferredKeys: string[] = []; - for (;;) { - const keyWait = new AbortController(); - // Put input first so a queued `q` wins when a fast provider page and - // coalesced `Gq` input are both already ready. - const result = await Promise.race([ - input.next(keyWait.signal).then((key) => ({ kind: "key" as const, key })), - settled, - ]); - if (result.kind === "page") { - keyWait.abort(); - input.prepend(deferredKeys); - return result.page; - } - if (result.kind === "error") { - keyWait.abort(); - input.prepend(deferredKeys); - throw result.error; - } - if (result.key === "q" || result.key === "\x03") { - cleanup(); - await settled; - return undefined; - } - deferredKeys.push(result.key); - } - }; - - /** Fetch one bounded continuation page and preserve graph state across it. */ - const loadMore = async (interruptible = false) => { - if (historyDone || loading || stopped) return; - loading = true; - try { - const page = interruptible - ? await readInterruptibly() - : await bootstrap.source.read({ limit: 256, signal: abort.signal }); - if (!page) return; - if (!page.done && page.commits.length === 0) - throw new Error("VCS history returned an empty page before EOF."); - const planned = planHistoryPage(page.commits, checkpoint); - rows.push(...planned.rows); - checkpoint = planned.checkpoint; - historyDone = page.done; - if (rows.length === 0 && historyDone) notice = "No commits found."; - } finally { - loading = false; - } - }; - const loadAll = async () => { - while (!historyDone && !stopped) await loadMore(true); - }; - - const terminalWidth = () => (stdout.columns && stdout.columns > 0 ? stdout.columns : 80); - const terminalHeight = () => (stdout.rows && stdout.rows > 0 ? stdout.rows : 24); - const enterTerminal = () => { - stdin.setRawMode?.(true); - input.resume(); - stdout.write(ENTER_ALT); - active = true; - }; - const leaveTerminal = () => { - if (!active) return; - active = false; - input.pause(); - stdout.write(LEAVE_ALT); - stdin.setRawMode?.(false); - }; - const clampViewport = () => { - selected = Math.max(0, Math.min(Math.max(0, rows.length - 1), selected)); - const height = Math.max(1, terminalHeight() - 1); - if (selected < top) top = selected; - if (selected >= top + height) top = selected - height + 1; - top = Math.max(0, Math.min(top, Math.max(0, rows.length - height))); - }; - const render = () => { - if (!active) return; - clampViewport(); - const width = Math.max(1, terminalWidth()); - const height = Math.max(1, terminalHeight() - 1); - const visible = rows.slice(top, top + height); - const color = resolveHistoryColor({ - mode: bootstrap.input.color, - stdoutIsTTY: true, - env: process.env, - }); - const lines = visible.map((row, offset) => { - const isSelected = top + offset === selected; - const text = projectHistoryRow(row, { - ascii: bootstrap.input.ascii || process.env.TERM === "dumb", - color: color && !isSelected, - theme, - width, - }); - return isSelected && color - ? `${background(theme.selectedHunk)}${foreground(theme.text)}${text}\x1b[0m` - : isSelected - ? `\x1b[7m${text}\x1b[0m` - : text; - }); - while (lines.length < height) lines.push(""); - const footer = search - ? `/${search}` - : notice || - `↑↓/jk move / search n/N match y copy enter open q quit${historyDone ? "" : " ↓ load more"}`; - const footerText = fitText(footer, width, "…"); - const styledFooter = color - ? `${background(theme.panelAlt)}${foreground(theme.muted)}${footerText}\x1b[0m` - : `\x1b[7m${footerText}\x1b[0m`; - stdout.write(`\x1b[H\x1b[2J${lines.join("\n")}\n${styledFooter}`); - }; - const findMatch = async (direction: 1 | -1) => { - if (!search || rows.length === 0) return; - await loadAll(); - const needle = search.toLocaleLowerCase(); - for (let step = 1; step <= rows.length; step += 1) { - const index = (selected + direction * step + rows.length) % rows.length; - const commit = rows[index]!.commit; - const haystack = [ - commit.revisionId, - commit.displayId, - commit.subject, - commit.body ?? "", - commit.authorName, - commit.authorEmail ?? "", - ...commit.decorations.map((entry) => entry.label), - ] - .join(" ") - .toLocaleLowerCase(); - if (haystack.includes(needle)) { - selected = index; - notice = ""; - return; - } - } - notice = `No match for ${sanitizeTerminalLine(search)}`; - }; - const editSearch = async () => { - let draft = search; - for (;;) { - search = draft; - render(); - const key = await input.next(); - if (key === "\r" || key === "\n") { - search = draft; - await findMatch(1); - return; - } - if (key === "\x1b") return; - if (key === "\x03") { - cleanup(); - return; - } - if (key === "\x7f") draft = Array.from(draft).slice(0, -1).join(""); - else if (/^[^\x00-\x1f\x7f]+$/u.test(key)) draft += key; - } - }; - /** Open one provider-planned review while yielding terminal ownership. */ - const openRowReview = async (row: HistoryGraphRow) => { - const reviewAction = await bootstrap.planReview(row.commit); - input.discardPending(); - leaveTerminal(); - const code = await openCommitReview(bootstrap, reviewAction); - if (!stopped) enterTerminal(); - notice = code === 0 ? "" : `Could not open ${row.commit.displayId}`; - }; - const cleanup = () => { - if (stopped) return; - stopped = true; - abort.abort(new Error("History browser stopped.")); - leaveTerminal(); - }; - const onResize = () => { - if (active) render(); - }; - const stopForSignal = (exitCode: number) => { - cleanup(); - process.exitCode = exitCode; - input.close(); - }; - const onInterrupt = () => stopForSignal(130); - const onHangup = () => stopForSignal(129); - const onTerminate = () => stopForSignal(143); - - process.once("SIGINT", onInterrupt); - process.once("SIGHUP", onHangup); - process.once("SIGTERM", onTerminate); - stdout.on("resize", onResize); - try { - await loadMore(); - if (stopped) return; - enterTerminal(); - render(); - while (!stopped) { - const key = await input.next(); - const height = Math.max(1, terminalHeight() - 1); - if (key === "q" || key === "\x03") break; - if (key === "\x1b[B" || key === "j") { - if (selected + 1 >= rows.length && !historyDone) await loadMore(); - selected += 1; - } else if (key === "\x1b[A" || key === "k") selected -= 1; - else if (key === "\x1b[6~") { - while (selected + height >= rows.length && !historyDone) await loadMore(); - selected += height; - } else if (key === "\x1b[5~") selected -= height; - else if (["\x1b[H", "\x1b[1~", "\x1bOH", "g"].includes(key)) selected = 0; - else if (["\x1b[F", "\x1b[4~", "\x1bOF", "G"].includes(key)) { - await loadAll(); - selected = rows.length - 1; - } else if (key === "/") await editSearch(); - else if (key === "n") await findMatch(1); - else if (key === "N") await findMatch(-1); - else if (key === "y" && rows[selected]) { - stdout.write( - `\x1b]52;c;${Buffer.from(rows[selected]!.commit.revisionId).toString("base64")}\x07`, - ); - notice = `Copied ${rows[selected]!.commit.displayId}`; - } else if ((key === "\r" || key === "\n") && rows[selected]) { - await openRowReview(rows[selected]!); - if (stopped) break; - } else { - const mouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(key); - if (mouse) { - const button = Number(mouse[1]); - const screenColumn = Number(mouse[2]) - 1; - const screenRow = Number(mouse[3]) - 1; - const visibleCount = Math.min(height, rows.length - top); - if (button === 64) selected -= 3; - else if (button === 65) { - if (selected + 3 >= rows.length && !historyDone) await loadMore(); - selected += 3; - } else if ( - button === 0 && - mouse[4] === "M" && - screenRow >= 0 && - screenRow < visibleCount - ) { - const index = top + screenRow; - const row = rows[index]!; - selected = index; - const idBounds = getHistoryCommitIdBounds( - row, - bootstrap.input.ascii || process.env.TERM === "dumb", - ); - const clickedCommitId = screenColumn >= idBounds.start && screenColumn < idBounds.end; - const now = Date.now(); - if (clickedCommitId || (lastClick.index === index && now - lastClick.at < 400)) { - await openRowReview(row); - } - lastClick = { index, at: now }; - } - } - } - render(); - } - } catch (error) { - if (!stopped) throw error; - } finally { - cleanup(); - input.close(); - stdout.off("resize", onResize); - process.off("SIGINT", onInterrupt); - process.off("SIGHUP", onHangup); - process.off("SIGTERM", onTerminate); - await bootstrap.close(); - } -} +/** Preserve the original import seam while interactive history moves into the log feature folder. */ +export { historyReviewArgs } from "../log/reviewLaunch"; +export { runInteractiveLog as runInteractiveHistory } from "../log/runInteractiveLog"; diff --git a/src/ui/history/runStaticHistory.test.ts b/src/ui/history/runStaticHistory.test.ts index d2e5a6f41..cd2b53214 100644 --- a/src/ui/history/runStaticHistory.test.ts +++ b/src/ui/history/runStaticHistory.test.ts @@ -28,6 +28,9 @@ function runtime(commits: HistoryCommit[], maxCount?: number) { async planReview(commit) { return { kind: "revision-show", revisionId: commit.revisionId }; }, + async reopenSource() { + return value.source; + }, source: { async read({ limit }) { reads += 1; diff --git a/src/ui/history/terminalInput.test.ts b/src/ui/history/terminalInput.test.ts deleted file mode 100644 index 0d18822e4..000000000 --- a/src/ui/history/terminalInput.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { PassThrough } from "node:stream"; -import { TerminalInputReader, TerminalInputTokenizer } from "./terminalInput"; - -describe("TerminalInputTokenizer", () => { - test("queues multiple navigation and action keys from one chunk", () => { - const tokenizer = new TerminalInputTokenizer(); - - expect(tokenizer.push("\x1b[B\r")).toEqual(["\x1b[B", "\r"]); - }); - - test("retains split CSI and mouse sequences until they are complete", () => { - const tokenizer = new TerminalInputTokenizer(); - - expect(tokenizer.push("\x1b[")).toEqual([]); - expect(tokenizer.push("B/fi")).toEqual(["\x1b[B", "/", "f", "i"]); - expect(tokenizer.push("\x1b[<0;12;")).toEqual([]); - expect(tokenizer.push("4Mq")).toEqual(["\x1b[<0;12;4M", "q"]); - }); - - test("preserves UTF-8 characters split across byte chunks", () => { - const tokenizer = new TerminalInputTokenizer(); - const bytes = Buffer.from("猫"); - - expect(tokenizer.push(bytes.subarray(0, 2))).toEqual([]); - expect(tokenizer.push(bytes.subarray(2))).toEqual(["猫"]); - }); - - test("flushes a standalone escape without consuming the next action", () => { - const tokenizer = new TerminalInputTokenizer(); - - expect(tokenizer.push("\x1b")).toEqual([]); - expect(tokenizer.hasStandaloneEscape()).toBe(true); - expect(tokenizer.flushStandaloneEscape()).toEqual(["\x1b"]); - expect(tokenizer.push("q")).toEqual(["q"]); - }); -}); - -describe("TerminalInputReader", () => { - test("cancels a temporary wait without losing restored input", async () => { - const stream = new PassThrough() as unknown as NodeJS.ReadStream; - const reader = new TerminalInputReader(stream); - const abort = new AbortController(); - const waiting = reader.next(abort.signal); - - abort.abort(new Error("stop waiting")); - await expect(waiting).rejects.toThrow("stop waiting"); - reader.prepend(["j", "q"]); - expect(await reader.next()).toBe("j"); - expect(await reader.next()).toBe("q"); - reader.close(); - }); -}); diff --git a/src/ui/history/terminalInput.ts b/src/ui/history/terminalInput.ts deleted file mode 100644 index 52c5b395c..000000000 --- a/src/ui/history/terminalInput.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { StringDecoder } from "node:string_decoder"; - -const ESCAPE = "\x1b"; - -/** Splits raw terminal bytes into complete key and mouse tokens across arbitrary chunks. */ -export class TerminalInputTokenizer { - private readonly decoder = new StringDecoder("utf8"); - private buffered = ""; - - /** Add one raw input chunk and return every complete token now available. */ - push(chunk: Buffer | string) { - this.buffered += typeof chunk === "string" ? chunk : this.decoder.write(chunk); - return this.takeCompleteTokens(); - } - - /** Return whether a lone Escape is waiting for a possible sequence suffix. */ - hasStandaloneEscape() { - return this.buffered === ESCAPE; - } - - /** Resolve a lone buffered Escape after the terminal's sequence grace period. */ - flushStandaloneEscape() { - if (!this.hasStandaloneEscape()) return []; - this.buffered = ""; - return [ESCAPE]; - } - - /** Flush decoder state and expose any remaining input when the stream closes. */ - finish() { - this.buffered += this.decoder.end(); - const tokens = this.takeCompleteTokens(); - if (this.buffered) { - tokens.push(...Array.from(this.buffered)); - this.buffered = ""; - } - return tokens; - } - - /** Consume complete characters, CSI sequences, and SS3 sequences from the buffer. */ - private takeCompleteTokens() { - const tokens: string[] = []; - while (this.buffered) { - if (!this.buffered.startsWith(ESCAPE)) { - const token = String.fromCodePoint(this.buffered.codePointAt(0)!); - tokens.push(token); - this.buffered = this.buffered.slice(token.length); - continue; - } - - if (this.buffered.length === 1) break; - const prefix = this.buffered[1]; - if (prefix === "[") { - let finalIndex = -1; - for (let index = 2; index < this.buffered.length; index += 1) { - const code = this.buffered.charCodeAt(index); - if (code >= 0x40 && code <= 0x7e) { - finalIndex = index; - break; - } - } - if (finalIndex < 0) break; - tokens.push(this.buffered.slice(0, finalIndex + 1)); - this.buffered = this.buffered.slice(finalIndex + 1); - continue; - } - - if (prefix === "O") { - if (this.buffered.length < 3) break; - tokens.push(this.buffered.slice(0, 3)); - this.buffered = this.buffered.slice(3); - continue; - } - - // Hunk has no Alt-key bindings here, so preserve Escape as its own action. - tokens.push(ESCAPE); - this.buffered = this.buffered.slice(1); - } - return tokens; - } -} - -/** Queues tokenized terminal input while allowing terminal ownership to pause for child review. */ -export class TerminalInputReader { - private readonly tokenizer = new TerminalInputTokenizer(); - private readonly queued: string[] = []; - private readonly waiting: Array<{ - resolve: (token: string) => void; - reject: (error: Error) => void; - }> = []; - private escapeTimer: ReturnType | undefined; - private endedError: Error | undefined; - - constructor(private readonly stream: NodeJS.ReadStream) { - stream.on("data", this.onData); - stream.on("end", this.onEnd); - stream.on("error", this.onError); - } - - /** Resume delivery from the caller-owned terminal stream. */ - resume() { - this.stream.resume(); - } - - /** Pause delivery while another process owns the terminal. */ - pause() { - this.clearEscapeTimer(); - this.stream.pause(); - } - - /** Return the next complete token, allowing a temporary caller to cancel its wait. */ - next(signal?: AbortSignal) { - const token = this.queued.shift(); - if (token !== undefined) return Promise.resolve(token); - if (this.endedError) return Promise.reject(this.endedError); - if (signal?.aborted) { - return Promise.reject(signal.reason ?? new Error("Terminal input wait aborted.")); - } - return new Promise((resolve, reject) => { - const waiter = { - resolve: (value: string) => { - signal?.removeEventListener("abort", onAbort); - resolve(value); - }, - reject: (error: Error) => { - signal?.removeEventListener("abort", onAbort); - reject(error); - }, - }; - const onAbort = () => { - const index = this.waiting.indexOf(waiter); - if (index >= 0) this.waiting.splice(index, 1); - waiter.reject(signal?.reason ?? new Error("Terminal input wait aborted.")); - }; - this.waiting.push(waiter); - signal?.addEventListener("abort", onAbort, { once: true }); - }); - } - - /** Restore temporarily consumed tokens to the front of the input queue. */ - prepend(tokens: readonly string[]) { - this.queued.unshift(...tokens); - } - - /** Drop typeahead before transferring terminal ownership to a child process. */ - discardPending() { - this.queued.length = 0; - } - - /** Detach listeners and reject any pending read. */ - close(error = new Error("Terminal input closed.")) { - this.finish(error, false); - } - - private readonly onData = (chunk: Buffer | string) => { - this.clearEscapeTimer(); - this.enqueue(this.tokenizer.push(chunk)); - if (this.tokenizer.hasStandaloneEscape()) { - this.escapeTimer = setTimeout(() => { - this.escapeTimer = undefined; - this.enqueue(this.tokenizer.flushStandaloneEscape()); - }, 25); - this.escapeTimer.unref?.(); - } - }; - - private readonly onEnd = () => this.finish(new Error("Terminal input closed."), true); - private readonly onError = (error: Error) => this.finish(error, true); - - private enqueue(tokens: string[]) { - for (const token of tokens) { - const waiter = this.waiting.shift(); - if (waiter) waiter.resolve(token); - else this.queued.push(token); - } - } - - private finish(error: Error, flush: boolean) { - if (this.endedError) return; - this.clearEscapeTimer(); - if (flush) this.enqueue(this.tokenizer.finish()); - this.endedError = error; - this.stream.off("data", this.onData); - this.stream.off("end", this.onEnd); - this.stream.off("error", this.onError); - this.stream.pause(); - for (const waiter of this.waiting.splice(0)) waiter.reject(error); - } - - private clearEscapeTimer() { - if (this.escapeTimer) clearTimeout(this.escapeTimer); - this.escapeTimer = undefined; - } -} diff --git a/src/ui/history/types.ts b/src/ui/history/types.ts index 5f63a1f98..fa808bae7 100644 --- a/src/ui/history/types.ts +++ b/src/ui/history/types.ts @@ -3,6 +3,7 @@ import type { VcsHistorySource } from "../../core/vcs/types"; import type { ExtensionVcsHistoryCommit, ExtensionVcsHistoryReviewAction, + ExtensionVcsHistoryReviewOptions, NamedCustomThemeConfig, } from "../../extension-api/types"; @@ -15,6 +16,11 @@ export interface HistoryRuntime { repoRoot: string; notices: readonly string[]; customThemes: readonly NamedCustomThemeConfig[]; - planReview(commit: ExtensionVcsHistoryCommit): Promise; + planReview( + commit: ExtensionVcsHistoryCommit, + options?: ExtensionVcsHistoryReviewOptions, + ): Promise; + /** Replace the current provider cursor for an explicit interactive refresh. */ + reopenSource(signal?: AbortSignal): Promise; close(): Promise; } diff --git a/src/ui/hooks/useMenuController.test.tsx b/src/ui/hooks/useMenuController.test.tsx index 3d78b15cc..d5ab11cbf 100644 --- a/src/ui/hooks/useMenuController.test.tsx +++ b/src/ui/hooks/useMenuController.test.tsx @@ -159,6 +159,60 @@ describe("useMenuController", () => { } }); + test("skips disabled entries and refuses direct activation", async () => { + let controller!: ReturnType; + const ran: string[] = []; + const menus: AppMenus = { + commit: [ + { kind: "item", label: "Root parent", disabled: true, action: () => ran.push("disabled") }, + { kind: "item", label: "Open diff", action: () => ran.push("open") }, + ], + }; + + function Probe() { + controller = useMenuController(menus); + return null; + } + + const setup = await testRender(, { width: 80, height: 24 }); + try { + await act(async () => { + await setup.renderOnce(); + controller.openMenu("commit"); + }); + expect(controller.activeMenuItemIndex).toBe(1); + await act(async () => controller.activateCurrentMenuItem()); + expect(ran).toEqual(["open"]); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("keeps rapid menu switch and activation sequential before a rerender", async () => { + let controller!: ReturnType; + const ran: string[] = []; + const menus: AppMenus = { + file: [{ kind: "item", label: "Open", action: () => ran.push("file") }], + view: [{ kind: "item", label: "Theme", action: () => ran.push("view") }], + }; + function Probe() { + controller = useMenuController(menus); + return null; + } + const setup = await testRender(, { width: 80, height: 24 }); + try { + await act(async () => setup.renderOnce()); + await act(async () => { + controller.openMenu("file"); + controller.switchMenu(1); + controller.activateCurrentMenuItem(); + }); + expect(ran).toEqual(["view"]); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + test("cycling skips menus the session does not show", async () => { let controller!: ReturnType; diff --git a/src/ui/hooks/useMenuController.ts b/src/ui/hooks/useMenuController.ts index c1c949422..b5e874d7a 100644 --- a/src/ui/hooks/useMenuController.ts +++ b/src/ui/hooks/useMenuController.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { buildMenuSpecs, menuEntries, @@ -12,6 +12,14 @@ import { export function useMenuController(menus: AppMenus) { const [activeMenuId, setActiveMenuId] = useState(null); const [activeMenuItemIndex, setActiveMenuItemIndex] = useState(0); + // OpenTUI can deliver several decoded keys before React commits a render. + // These mirrors preserve sequential menu semantics within that input burst. + const liveMenuId = useRef(activeMenuId); + const liveItemIndex = useRef(activeMenuItemIndex); + const liveMenus = useRef(menus); + liveMenuId.current = activeMenuId; + liveItemIndex.current = activeMenuItemIndex; + liveMenus.current = menus; // Plain derivation, not a memo: `menus` is rebuilt every render on purpose // (hints and checked marks must stay live), so a memo keyed on it never hits. @@ -35,12 +43,16 @@ export function useMenuController(menus: AppMenus) { }, [activeMenuId, openMenuId]); const closeMenu = () => { + liveMenuId.current = null; setActiveMenuId(null); }; const openMenu = (menuId: MenuId) => { + const firstIndex = nextMenuItemIndex(menuEntries(liveMenus.current, menuId), -1, 1); + liveMenuId.current = menuId; + liveItemIndex.current = firstIndex; setActiveMenuId(menuId); - setActiveMenuItemIndex(nextMenuItemIndex(menuEntries(menus, menuId), -1, 1)); + setActiveMenuItemIndex(firstIndex); }; const toggleMenu = (menuId: MenuId) => { @@ -59,9 +71,10 @@ export function useMenuController(menus: AppMenus) { return; } + const currentMenuId = liveMenuId.current; const currentIndex = Math.max( 0, - openMenuId ? menuSpecs.findIndex((menu) => menu.id === openMenuId) : 0, + currentMenuId ? menuSpecs.findIndex((menu) => menu.id === currentMenuId) : 0, ); const nextIndex = (currentIndex + delta + menuSpecs.length) % menuSpecs.length; openMenu(menuSpecs[nextIndex]!.id); @@ -76,7 +89,7 @@ export function useMenuController(menus: AppMenus) { // so the visible highlight and Enter always agree on a real entry. const resolveItemIndex = (index: number) => { const entry = activeMenuEntries[index]; - return entry?.kind === "item" + return entry?.kind === "item" && !entry.disabled ? index : nextMenuItemIndex(activeMenuEntries, Math.min(index, activeMenuEntries.length) - 1, 1); }; @@ -87,19 +100,24 @@ export function useMenuController(menus: AppMenus) { // highlight visibly is, not from the stale position. useEffect(() => { if (openMenuId !== null && openMenuItemIndex !== activeMenuItemIndex) { + liveItemIndex.current = openMenuItemIndex; setActiveMenuItemIndex(openMenuItemIndex); } }, [openMenuId, openMenuItemIndex, activeMenuItemIndex]); const moveMenuItem = (delta: number) => { - setActiveMenuItemIndex((current) => - nextMenuItemIndex(activeMenuEntries, resolveItemIndex(current), delta), - ); + const menuId = liveMenuId.current; + const entries = menuId ? menuEntries(liveMenus.current, menuId) : []; + const next = nextMenuItemIndex(entries, liveItemIndex.current, delta); + liveItemIndex.current = next; + setActiveMenuItemIndex(next); }; const activateCurrentMenuItem = () => { - const entry = activeMenuEntries[openMenuItemIndex]; - if (!entry || entry.kind !== "item") { + const menuId = liveMenuId.current; + const entries = menuId ? menuEntries(liveMenus.current, menuId) : []; + const entry = entries[liveItemIndex.current]; + if (!entry || entry.kind !== "item" || entry.disabled) { return; } @@ -118,6 +136,7 @@ export function useMenuController(menus: AppMenus) { activeMenuWidth, activateCurrentMenuItem, closeMenu, + getActiveMenuId: () => liveMenuId.current, menuSpecs, moveMenuItem, openMenu, diff --git a/src/ui/hooks/useThemeSelectorController.test.tsx b/src/ui/hooks/useThemeSelectorController.test.tsx index f69dc8e3e..7a0253242 100644 --- a/src/ui/hooks/useThemeSelectorController.test.tsx +++ b/src/ui/hooks/useThemeSelectorController.test.tsx @@ -149,8 +149,10 @@ describe("useThemeSelectorController", () => { test("pointer and keyboard acceptance commit atomically and preserve notices", async () => { const notices: string[] = []; + const committed: string[] = []; const harness = await renderThemeSelectorController({ initialTheme: "github-dark-default", + onThemeCommitted: (themeId) => committed.push(themeId), onTransientNotice: (notice) => notices.push(notice), transparentBackground: false, }); @@ -175,6 +177,7 @@ describe("useThemeSelectorController", () => { expect(harness.controller.themeId).toBe(keyboardItem.id); expect(harness.controller.baseTheme.id).toBe(keyboardItem.id); expect(notices.at(-1)).toBe(`Theme: ${keyboardItem.label}`); + expect(committed).toEqual([pointerItem.id, keyboardItem.id]); } finally { await destroyController(harness.setup); } diff --git a/src/ui/hooks/useThemeSelectorController.ts b/src/ui/hooks/useThemeSelectorController.ts index 7db6223da..a41d6ed5e 100644 --- a/src/ui/hooks/useThemeSelectorController.ts +++ b/src/ui/hooks/useThemeSelectorController.ts @@ -16,6 +16,8 @@ export interface UseThemeSelectorControllerOptions { initialTheme?: string; initialThemeMode?: TerminalThemeMode | null; onTransientNotice: (text: string) => void; + /** Observe committed choices so a remounting surface can retain them. */ + onThemeCommitted?: (themeId: string) => void; transparentBackground: boolean; } @@ -25,6 +27,7 @@ export function useThemeSelectorController({ initialTheme, initialThemeMode, onTransientNotice, + onThemeCommitted, transparentBackground, }: UseThemeSelectorControllerOptions) { // Startup detection is launch state. Soft bootstrap reloads may replace the @@ -177,9 +180,10 @@ export function useThemeSelectorController({ previewThemeId: null, selectedThemeId: item.id, })); + onThemeCommitted?.(item.id); onTransientNotice(`Theme: ${item.label}`); }, - [onTransientNotice], + [onThemeCommitted, onTransientNotice], ); /** Commit one pointer-selected item when its current catalog entry is valid. */ diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index 0614b6c00..6a48df299 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -137,7 +137,7 @@ export function buildAppMenus({ showMenuBar, wrapLines, }: BuildAppMenusOptions): AppMenus { - const specs: Record, MenuEntrySpec[]> = { + const specs: Record, MenuEntrySpec[]> = { file: [ { commandId: "hunk.app.toggleFocusArea", label: "Toggle files/filter focus" }, { commandId: "hunk.review.focusFilter", label: "Focus filter" }, diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 1292f255d..79be9ed18 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -9,6 +9,7 @@ import { menuBoxHeight, menuWidth, nextMenuItemIndex, + responsiveMenuSpecs, type MenuEntry, } from "../components/chrome/menu"; import { createVisibleAgentNote } from "./agentAnnotations"; @@ -124,11 +125,11 @@ describe("ui helpers", () => { ]); }); - test("nextMenuItemIndex skips separators in both directions", () => { + test("nextMenuItemIndex skips separators and disabled items in both directions", () => { const entries: MenuEntry[] = [ { kind: "separator" }, { kind: "item", label: "One", action: () => {} }, - { kind: "separator" }, + { kind: "item", label: "Unavailable", disabled: true, action: () => {} }, { kind: "item", label: "Two", action: () => {} }, ]; @@ -171,6 +172,21 @@ describe("ui helpers", () => { expect(menuWidth(wide)).toBe(menuWidth(ascii) + 10); }); + test("responsive menus keep hidden commands reachable behind a visible overflow", () => { + const item: MenuEntry = { kind: "item", label: "One", action: () => {} }; + const specs = buildMenuSpecs({ + file: [item], + view: [item], + navigate: [item], + commit: [item], + help: [item], + }); + const layout = responsiveMenuSpecs(specs, 20); + expect(layout.visible.map((spec) => spec.id)).toEqual(["file", "view"]); + expect(layout.hidden.map((spec) => spec.id)).toEqual(["navigate", "commit", "help"]); + expect(layout.overflowLeft).toBe(13); + }); + test("menuBarTitleWidth cedes title space to the menus the bar shows", () => { const item: MenuEntry = { kind: "item", label: "One", action: () => {} }; const base = { file: [item], view: [item], navigate: [item], agent: [item], help: [item] }; diff --git a/src/ui/log/LogApp.tsx b/src/ui/log/LogApp.tsx new file mode 100644 index 000000000..4b8d0a0dd --- /dev/null +++ b/src/ui/log/LogApp.tsx @@ -0,0 +1,549 @@ +import type { KeyEvent, MouseEvent as TuiMouseEvent } from "@opentui/core"; +import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; +import { basename } from "node:path"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; +import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { HelpDialog } from "../components/chrome/HelpDialog"; +import { MenuBar } from "../components/chrome/MenuBar"; +import { MenuDropdown } from "../components/chrome/MenuDropdown"; +import type { AppMenus, MenuEntry } from "../components/chrome/menu"; +import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog"; +import { useMenuController } from "../hooks/useMenuController"; +import { useThemeSelectorController } from "../hooks/useThemeSelectorController"; +import { fitText } from "../lib/text"; +import { formatHistoryDecorations, renderHistoryGraph } from "../history/staticProjection"; +import type { HistoryRuntime } from "../history/types"; +import type { LogController } from "./controller"; +import { LOG_HELP_SECTIONS } from "./logHelp"; +import { + isLogCommandEnabled, + logCommand, + logCommandHint, + matchLogCommand, + type LogCommandId, +} from "./commands"; +import { ParentSelectorDialog } from "./ParentSelectorDialog"; +import { monochromeLogTheme } from "./colorPolicy"; + +export type LogAppOutcome = + | { kind: "quit"; exitCode?: number } + | { kind: "open-review"; action: ExtensionVcsHistoryReviewAction; themeId: string }; + +/** Render the bounded history list inside Hunk's shared desktop chrome. */ +export function LogApp({ + controller, + runtime, + onOutcome, + useColor, +}: { + controller: LogController; + runtime: HistoryRuntime; + onOutcome: (outcome: LogAppOutcome) => void; + useColor: boolean; +}) { + const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); + const terminal = useTerminalDimensions(); + const renderer = useRenderer(); + const [showHelp, setShowHelp] = useState(false); + const [parentSelectorIndex, setParentSelectorIndex] = useState(null); + const [transientNotice, setTransientNotice] = useState(""); + const lastClick = useRef({ index: -1, at: 0 }); + // Lock synchronously before awaiting provider planning so coalesced Enter+q input cannot + // quit the log or leak the trailing command into the child review. + const reviewPending = useRef(false); + const themeController = useThemeSelectorController({ + customThemes: runtime.customThemes, + initialTheme: snapshot.themeId, + initialThemeMode: renderer.themeMode, + onTransientNotice: setTransientNotice, + onThemeCommitted: (id) => controller.setTheme(id), + transparentBackground: false, + }); + const terminalThemeMode = renderer.themeMode ?? "dark"; + const theme = useColor + ? themeController.activeTheme + : monochromeLogTheme(themeController.activeTheme, terminalThemeMode); + const chromeTheme = useColor + ? themeController.baseTheme + : monochromeLogTheme(themeController.baseTheme, terminalThemeMode); + const selectedRow = snapshot.rows[snapshot.selected]; + const detailHeight = + snapshot.presentation.format === "medium" && selectedRow && terminal.height >= 9 ? 5 : 0; + const viewportHeight = Math.max(1, terminal.height - 2 - detailHeight); + + const copySelected = () => { + const currentRow = controller.getSelectedRow(); + if (!currentRow) return; + if (renderer.isOsc52Supported?.() && typeof renderer.copyToClipboardOSC52 === "function") { + renderer.copyToClipboardOSC52(currentRow.commit.revisionId); + setTransientNotice(`Copied ${currentRow.commit.displayId}`); + } else { + setTransientNotice("Clipboard is unavailable in this terminal."); + } + }; + const openSelected = async (parentRevisionId?: string) => { + if (reviewPending.current) return; + const planned = controller.planSelectedReview(parentRevisionId); + if (!planned) return; + reviewPending.current = true; + try { + onOutcome({ + kind: "open-review", + action: await planned, + themeId: themeController.themeId, + }); + } catch (error) { + reviewPending.current = false; + controller.setNotice(error instanceof Error ? error.message : String(error)); + } + }; + + useEffect(() => { + if (!transientNotice) return; + const timeout = setTimeout(() => setTransientNotice(""), 2500); + return () => clearTimeout(timeout); + }, [transientNotice]); + + const clearTransientNotice = () => setTransientNotice(""); + const executeCommand = (id: LogCommandId, exitCode?: number) => { + clearTransientNotice(); + if (!isLogCommandEnabled(id, controller.getSnapshot())) return; + switch (id) { + case "open": + void openSelected(); + break; + case "copy": + copySelected(); + break; + case "refresh": + void controller.refresh(); + break; + case "quit": + onOutcome({ kind: "quit", ...(exitCode === undefined ? {} : { exitCode }) }); + break; + case "theme": + themeController.openThemeSelector(); + break; + case "format-medium": + controller.setFormat("medium"); + break; + case "format-compact": + controller.setFormat("compact"); + break; + case "toggle-graph": + controller.togglePresentation("graph"); + break; + case "toggle-unicode": + controller.togglePresentation("unicode"); + break; + case "toggle-author": + controller.togglePresentation("author"); + break; + case "toggle-date": + controller.togglePresentation("date"); + break; + case "toggle-decorations": + controller.togglePresentation("decorations"); + break; + case "previous": + void controller.move(-1, viewportHeight); + break; + case "next": + void controller.move(1, viewportHeight); + break; + case "page-up": + void controller.page(-1, viewportHeight); + break; + case "page-down": + void controller.page(1, viewportHeight); + break; + case "first": + void controller.first(viewportHeight); + break; + case "last": + void controller.last(viewportHeight); + break; + case "search": + controller.beginSearch(); + break; + case "next-match": + void controller.findMatch(1, viewportHeight); + break; + case "previous-match": + void controller.findMatch(-1, viewportHeight); + break; + case "open-first-parent": { + const parent = controller.getSelectedRow()?.commit.parentRevisionIds[0]; + if (parent) void openSelected(parent); + break; + } + case "open-parent": + setParentSelectorIndex(0); + break; + case "help": + setShowHelp(true); + break; + case "about": + setTransientNotice("Hunk · terminal-native code review"); + break; + } + }; + const commandItem = ( + id: LogCommandId, + options: Pick, "checked"> = {}, + ): Extract => { + const definition = logCommand(id); + return { + kind: "item", + commandId: `hunk.log.${id}`, + label: definition.label, + ...(logCommandHint(id) ? { hint: logCommandHint(id) } : {}), + disabled: !isLogCommandEnabled(id, snapshot), + action: () => executeCommand(id), + ...options, + }; + }; + const menus: AppMenus = { + file: [ + commandItem("open"), + commandItem("copy"), + commandItem("refresh"), + { kind: "separator" }, + commandItem("quit"), + ], + view: [ + commandItem("theme"), + { kind: "separator" }, + commandItem("format-medium", { checked: snapshot.presentation.format === "medium" }), + commandItem("format-compact", { checked: snapshot.presentation.format === "compact" }), + { kind: "separator" }, + commandItem("toggle-graph", { checked: snapshot.presentation.graph }), + commandItem("toggle-unicode", { checked: snapshot.presentation.unicode }), + commandItem("toggle-author", { checked: snapshot.presentation.author }), + commandItem("toggle-date", { checked: snapshot.presentation.date }), + commandItem("toggle-decorations", { checked: snapshot.presentation.decorations }), + ], + navigate: [ + commandItem("previous"), + commandItem("next"), + commandItem("page-up"), + commandItem("page-down"), + commandItem("first"), + commandItem("last"), + { kind: "separator" }, + commandItem("search"), + commandItem("next-match"), + commandItem("previous-match"), + ], + commit: [ + commandItem("open"), + commandItem("copy"), + { kind: "separator" }, + commandItem("open-first-parent"), + commandItem("open-parent"), + ], + help: [commandItem("help"), commandItem("about")], + }; + const menu = useMenuController(menus); + + useEffect(() => { + void controller.loadMore(); + }, [controller]); + useEffect(() => { + controller.clampViewport(viewportHeight); + if (snapshot.top + viewportHeight + 8 >= snapshot.rows.length && !snapshot.historyDone) { + void controller.loadMore(); + } + }, [controller, snapshot.historyDone, snapshot.rows.length, snapshot.top, viewportHeight]); + + useKeyboard((key: KeyEvent) => { + clearTransientNotice(); + const consume = () => { + key.preventDefault(); + key.stopPropagation(); + }; + if (reviewPending.current) { + consume(); + return; + } + const name = key.name; + const sequence = key.sequence ?? ""; + if (parentSelectorIndex !== null) { + const parents = controller.getSelectedRow()?.commit.parentRevisionIds ?? []; + if (name === "escape") setParentSelectorIndex(null); + else if (name === "up") + setParentSelectorIndex((parentSelectorIndex - 1 + parents.length) % parents.length); + else if (name === "down" || name === "tab") + setParentSelectorIndex( + (parentSelectorIndex + (key.shift ? -1 : 1) + parents.length) % parents.length, + ); + else if (name === "return" || name === "enter") { + const parent = parents[parentSelectorIndex]; + setParentSelectorIndex(null); + if (parent) void openSelected(parent); + } else return; + consume(); + return; + } + if (themeController.themeSelectorOpen) { + if (name === "escape") themeController.closeThemeSelector(); + else if (name === "up") themeController.moveThemeSelector(-1); + else if (name === "down" || name === "tab") + themeController.moveThemeSelector(key.shift ? -1 : 1); + else if (name === "return" || name === "enter") themeController.acceptThemeSelector(); + else return; + consume(); + return; + } + if (showHelp) { + if (name === "escape" || name === "q" || sequence === "q") setShowHelp(false); + else return; + consume(); + return; + } + if (menu.getActiveMenuId()) { + if (name === "escape") menu.closeMenu(); + else if (name === "left") menu.switchMenu(-1); + else if (name === "right" || name === "tab") menu.switchMenu(1); + else if (name === "up") menu.moveMenuItem(-1); + else if (name === "down") menu.moveMenuItem(1); + else if (name === "return" || name === "enter") menu.activateCurrentMenuItem(); + else { + const command = matchLogCommand(key); + if (!command) return; + menu.closeMenu(); + executeCommand(command, key.ctrl && key.name === "c" ? 130 : undefined); + } + consume(); + return; + } + if (snapshot.searchEditing) { + if ((key.ctrl && name === "c") || name === "escape") controller.cancelSearch(); + else if (name === "return" || name === "enter") + void controller.finishSearch(1, viewportHeight); + else if (name === "backspace") controller.backspaceSearch(); + else if (/^[^\x00-\x1f\x7f]+$/u.test(sequence)) controller.appendSearch(sequence); + else return; + consume(); + return; + } + if (name === "f10") { + menu.openMenu("file"); + consume(); + return; + } + const command = matchLogCommand(key); + if (!command) return; + executeCommand(command, key.ctrl && key.name === "c" ? 130 : undefined); + consume(); + }); + + const visible = snapshot.rows.slice(snapshot.top, snapshot.top + viewportHeight); + const rowWidth = Math.max(1, terminal.width - 2); + const statusHint = terminal.width >= 48 ? "↑↓ move · Enter open · / search · F10 menu" : ""; + const statusTextWidth = Math.max(1, terminal.width - (statusHint ? 42 : 2)); + return ( + + { + if (menu.activeMenuId) menu.openMenu(id); + }} + onToggleMenu={menu.toggleMenu} + /> + menu.closeMenu()} + onMouseScroll={(event: TuiMouseEvent) => { + menu.closeMenu(); + const direction = event.scroll?.direction; + if (direction === "up") controller.move(-3, viewportHeight); + else if (direction === "down") controller.move(3, viewportHeight); + }} + > + {visible.map((row, offset) => { + const index = snapshot.top + offset; + const selected = index === snapshot.selected; + const graph = snapshot.presentation.graph + ? `${renderHistoryGraph(row, !snapshot.presentation.unicode)} ` + : ""; + const decorations = snapshot.presentation.decorations + ? formatHistoryDecorations(row) + : ""; + const author = snapshot.presentation.author + ? ` ${sanitizeTerminalLine(row.commit.authorName)}` + : ""; + const date = snapshot.presentation.date + ? ` ${sanitizeTerminalLine(row.commit.authoredAt).slice(0, 10)}` + : ""; + const subject = fitText( + `${sanitizeTerminalLine(row.commit.subject)}${decorations}${author}${date}`, + Math.max(1, rowWidth - graph.length - row.commit.displayId.length - 2), + ); + return ( + { + clearTransientNotice(); + const now = Date.now(); + const shouldOpen = + lastClick.current.index === index && now - lastClick.current.at < 400; + void controller.select(index, viewportHeight).then(() => { + if (shouldOpen) void openSelected(); + }); + lastClick.current = { index, at: now }; + }} + > + {graph ? {graph} : null} + { + event.stopPropagation(); + clearTransientNotice(); + void controller.select(index, viewportHeight).then(() => openSelected()); + }} + > + {row.commit.displayId} + + {` ${subject}`} + + ); + })} + + {detailHeight && selectedRow ? ( + + {fitText(selectedRow.commit.subject, rowWidth)} + + {fitText( + sanitizeTerminalLine( + `${selectedRow.commit.authorName}${selectedRow.commit.authorEmail ? ` <${selectedRow.commit.authorEmail}>` : ""}`, + ), + rowWidth, + )} + + + {fitText(sanitizeTerminalLine(selectedRow.commit.authoredAt), rowWidth)} + + + {fitText( + sanitizeTerminalLine((selectedRow.commit.body ?? "").replaceAll("\n", " ")), + rowWidth, + )} + + + {fitText(sanitizeTerminalLine(selectedRow.commit.revisionId), rowWidth)} + + + ) : null} + + + {fitText( + snapshot.searchEditing + ? `/${snapshot.search}` + : transientNotice || + snapshot.notice || + `${runtime.providerName} · ${snapshot.rows.length}${snapshot.historyDone ? " commits" : "+ commits"}`, + statusTextWidth, + )} + + {statusHint ? {statusHint} : null} + + {menu.activeMenuId && menu.activeMenuSpec ? ( + ) => { + if (!entry.disabled) entry.action(); + menu.closeMenu(); + }} + /> + ) : null} + {parentSelectorIndex !== null && selectedRow ? ( + { + const parent = selectedRow.commit.parentRevisionIds[index]; + setParentSelectorIndex(null); + if (parent) void openSelected(parent); + }} + onClose={() => setParentSelectorIndex(null)} + onSelect={setParentSelectorIndex} + /> + ) : null} + {themeController.themeSelectorOpen ? ( + + ) : null} + {showHelp ? ( + setShowHelp(false)} + /> + ) : null} + + ); +} diff --git a/src/ui/log/ParentSelectorDialog.test.tsx b/src/ui/log/ParentSelectorDialog.test.tsx new file mode 100644 index 000000000..b306c9df1 --- /dev/null +++ b/src/ui/log/ParentSelectorDialog.test.tsx @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { resolveTheme } from "../themes"; +import { ParentSelectorDialog } from "./ParentSelectorDialog"; + +describe("ParentSelectorDialog", () => { + test("uses every available body row and moves the window with the mouse wheel", async () => { + const selected: number[] = []; + const setup = await testRender( + {}} + onClose={() => {}} + onSelect={(index) => selected.push(index)} + />, + { width: 40, height: 10 }, + ); + try { + await act(async () => { + await setup.renderOnce(); + }); + const frame = setup.captureCharFrame(); + expect(frame).toContain("parent-1"); + expect(frame).toContain("parent-2"); + + await act(async () => { + await setup.mockMouse.scroll(20, 5, "down"); + await setup.renderOnce(); + }); + expect(selected).toEqual([1]); + } finally { + setup.renderer.destroy(); + } + }); +}); diff --git a/src/ui/log/ParentSelectorDialog.tsx b/src/ui/log/ParentSelectorDialog.tsx new file mode 100644 index 000000000..969a7aa73 --- /dev/null +++ b/src/ui/log/ParentSelectorDialog.tsx @@ -0,0 +1,79 @@ +import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; +import { fitText } from "../lib/text"; +import type { AppTheme } from "../themes"; +import { ModalFrame } from "../components/chrome/ModalFrame"; + +/** Let a caller choose one ordered opaque parent without interpreting provider syntax. */ +export function ParentSelectorDialog({ + parentRevisionIds, + selectedIndex, + terminalHeight, + terminalWidth, + theme, + onAccept, + onClose, + onSelect, +}: { + parentRevisionIds: readonly string[]; + selectedIndex: number; + terminalHeight: number; + terminalWidth: number; + theme: AppTheme; + onAccept: (index: number) => void; + onClose: () => void; + onSelect: (index: number) => void; +}) { + const width = Math.max(1, Math.min(70, terminalWidth - 2)); + // Borders, title padding, help row, and bottom padding consume six rows. + const height = Math.max(1, Math.min(parentRevisionIds.length + 6, terminalHeight - 2)); + const bodyWidth = Math.max(1, width - 4); + const visibleRows = Math.max(1, height - 6); + const start = Math.max( + 0, + Math.min(parentRevisionIds.length - visibleRows, selectedIndex - Math.floor(visibleRows / 2)), + ); + return ( + { + const direction = event.scroll?.direction; + if (direction === "up") onSelect(Math.max(0, selectedIndex - 1)); + else if (direction === "down") + onSelect(Math.min(parentRevisionIds.length - 1, selectedIndex + 1)); + }} + > + + {fitText("Enter/click open · Esc cancel", bodyWidth)} + + {parentRevisionIds.slice(start, start + visibleRows).map((parentId, offset) => { + const index = start + offset; + const selected = index === selectedIndex; + return ( + onSelect(index)} + onMouseUp={(event: TuiMouseEvent) => { + event.stopPropagation(); + onAccept(index); + }} + > + + {fitText(`${index + 1}. ${parentId}`, bodyWidth)} + + + ); + })} + + ); +} diff --git a/src/ui/log/colorPolicy.test.ts b/src/ui/log/colorPolicy.test.ts new file mode 100644 index 000000000..390c48256 --- /dev/null +++ b/src/ui/log/colorPolicy.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { resolveTheme } from "../themes"; +import { interactiveLogUsesColor, monochromeLogTheme } from "./colorPolicy"; + +describe("interactive log color policy", () => { + test("honors explicit color precedence and terminal conventions", () => { + expect(interactiveLogUsesColor("always", { NO_COLOR: "", TERM: "dumb" })).toBe(true); + expect(interactiveLogUsesColor("never", {})).toBe(false); + expect(interactiveLogUsesColor("auto", { NO_COLOR: "" })).toBe(false); + expect(interactiveLogUsesColor("auto", { TERM: "dumb" })).toBe(false); + expect(interactiveLogUsesColor("auto", { TERM: "xterm-256color" })).toBe(true); + }); + + test("does not expose selected theme colors when color is disabled", () => { + const selected = resolveTheme("github-dark-default", null); + const neutral = monochromeLogTheme(selected, "dark"); + expect(neutral.id).toBe("terminal-monochrome"); + expect(neutral.accent).toBe("#ffffff"); + expect(neutral.background).toBe("#000000"); + expect(neutral.accent).not.toBe(selected.accent); + }); +}); diff --git a/src/ui/log/colorPolicy.ts b/src/ui/log/colorPolicy.ts new file mode 100644 index 000000000..eed8977d9 --- /dev/null +++ b/src/ui/log/colorPolicy.ts @@ -0,0 +1,37 @@ +import type { ThemeMode } from "@opentui/core"; +import type { HistoryColorMode } from "../../core/run/commandInputs"; +import type { AppTheme } from "../themes"; +import { resolveHistoryColor } from "../history/staticProjection"; + +/** Resolve whether interactive history may apply the selected Hunk palette. */ +export function interactiveLogUsesColor( + mode: HistoryColorMode, + env: NodeJS.ProcessEnv, + stdoutIsTTY = true, +) { + return resolveHistoryColor({ mode, env, stdoutIsTTY }); +} + +/** Replace theme-specific chrome colors with a stable monochrome terminal palette. */ +export function monochromeLogTheme(theme: AppTheme, terminalMode: ThemeMode): AppTheme { + const light = terminalMode === "light"; + const background = light ? "#ffffff" : "#000000"; + const foreground = light ? "#000000" : "#ffffff"; + const selection = light ? "#d0d0d0" : "#404040"; + return { + ...theme, + id: "terminal-monochrome", + label: "Terminal monochrome", + appearance: light ? "light" : "dark", + background, + panel: background, + panelAlt: background, + border: foreground, + accent: foreground, + accentMuted: selection, + text: foreground, + muted: foreground, + selectedHunk: selection, + badgeNeutral: foreground, + }; +} diff --git a/src/ui/log/commands.test.ts b/src/ui/log/commands.test.ts new file mode 100644 index 000000000..e00d524ee --- /dev/null +++ b/src/ui/log/commands.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import type { KeyEvent } from "@opentui/core"; +import type { LogSnapshot } from "./controller"; +import { + buildLogHelpSections, + isLogCommandEnabled, + logCommand, + logCommandHint, + matchLogCommand, +} from "./commands"; + +const key = (name: string, sequence = name, ctrl = false) => ({ name, sequence, ctrl }) as KeyEvent; + +const snapshot = (parents: string[] = []): LogSnapshot => ({ + rows: [ + { + commit: { + revisionId: "commit", + displayId: "commit", + parentRevisionIds: parents, + subject: "subject", + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + }, + lane: 0, + lanesBefore: [], + lanesAfter: [], + cells: [], + parentLanes: [], + convergences: [], + }, + ], + selected: 0, + top: 0, + search: "", + searchEditing: false, + historyDone: true, + loading: false, + notice: "", + presentation: { + format: "compact", + graph: true, + unicode: true, + author: true, + date: true, + decorations: true, + }, +}); + +describe("log command authority", () => { + test("drives keyboard dispatch, menu hints, and help from one definition", () => { + expect(matchLogCommand(key("down", ""))).toBe("next"); + expect(matchLogCommand(key("x", "j"))).toBe("next"); + expect(matchLogCommand(key("c", "\x03", true))).toBe("quit"); + expect(logCommandHint("next")).toBe("↓ / j"); + expect(logCommand("open-first-parent").label).toBe("Compare with first parent"); + expect(logCommand("open-parent").label).toBe("Compare with parent…"); + expect(buildLogHelpSections().flatMap((section) => section.rows)).toContainEqual({ + keys: "↓ / j", + description: "next commit", + }); + }); + + test("derives parent and search enabled state from current snapshot", () => { + expect(isLogCommandEnabled("open-first-parent", snapshot())).toBe(false); + expect(isLogCommandEnabled("open-first-parent", snapshot(["p1", "p2"]))).toBe(true); + expect(isLogCommandEnabled("open-parent", snapshot(["p1", "p2"]))).toBe(true); + expect(isLogCommandEnabled("next-match", snapshot())).toBe(false); + }); +}); diff --git a/src/ui/log/commands.ts b/src/ui/log/commands.ts new file mode 100644 index 000000000..aeceb57b5 --- /dev/null +++ b/src/ui/log/commands.ts @@ -0,0 +1,228 @@ +import type { KeyEvent } from "@opentui/core"; +import type { HelpSection } from "../lib/helpContent"; +import type { MenuId } from "../components/chrome/menu"; +import type { LogSnapshot } from "./controller"; + +export type LogCommandId = + | "open" + | "copy" + | "refresh" + | "quit" + | "theme" + | "format-medium" + | "format-compact" + | "toggle-graph" + | "toggle-unicode" + | "toggle-author" + | "toggle-date" + | "toggle-decorations" + | "previous" + | "next" + | "page-up" + | "page-down" + | "first" + | "last" + | "search" + | "next-match" + | "previous-match" + | "open-first-parent" + | "open-parent" + | "help" + | "about"; + +type Shortcut = { token: string; display: string }; + +export interface LogCommandDefinition { + id: LogCommandId; + label: string; + menu: MenuId; + shortcuts?: readonly Shortcut[]; + helpSection?: "Navigation" | "Commit" | "Application"; +} + +/** Define log labels and bindings once for keyboard dispatch, menus, and help. */ +export const LOG_COMMANDS: readonly LogCommandDefinition[] = [ + { + id: "open", + label: "Open selected commit", + menu: "file", + shortcuts: [{ token: "name:enter", display: "Enter" }], + helpSection: "Commit", + }, + { + id: "copy", + label: "Copy commit ID", + menu: "file", + shortcuts: [{ token: "sequence:y", display: "y" }], + helpSection: "Commit", + }, + { + id: "refresh", + label: "Refresh history", + menu: "file", + shortcuts: [{ token: "sequence:r", display: "r" }], + helpSection: "Application", + }, + { + id: "quit", + label: "Quit", + menu: "file", + shortcuts: [ + { token: "sequence:q", display: "q" }, + { token: "ctrl:c", display: "Ctrl-C" }, + ], + helpSection: "Application", + }, + { id: "theme", label: "Theme…", menu: "view" }, + { id: "format-medium", label: "Medium format", menu: "view" }, + { id: "format-compact", label: "Compact format", menu: "view" }, + { id: "toggle-graph", label: "Graph", menu: "view" }, + { id: "toggle-unicode", label: "Unicode graph", menu: "view" }, + { id: "toggle-author", label: "Show author", menu: "view" }, + { id: "toggle-date", label: "Show date", menu: "view" }, + { id: "toggle-decorations", label: "Show decorations", menu: "view" }, + { + id: "previous", + label: "Previous commit", + menu: "navigate", + shortcuts: [ + { token: "name:up", display: "↑ / k" }, + { token: "sequence:k", display: "↑ / k" }, + ], + helpSection: "Navigation", + }, + { + id: "next", + label: "Next commit", + menu: "navigate", + shortcuts: [ + { token: "name:down", display: "↓ / j" }, + { token: "sequence:j", display: "↓ / j" }, + ], + helpSection: "Navigation", + }, + { + id: "page-up", + label: "Page up", + menu: "navigate", + shortcuts: [{ token: "name:pageup", display: "PgUp" }], + helpSection: "Navigation", + }, + { + id: "page-down", + label: "Page down", + menu: "navigate", + shortcuts: [{ token: "name:pagedown", display: "PgDn" }], + helpSection: "Navigation", + }, + { + id: "first", + label: "First commit", + menu: "navigate", + shortcuts: [ + { token: "name:home", display: "Home / g" }, + { token: "sequence:g", display: "Home / g" }, + ], + helpSection: "Navigation", + }, + { + id: "last", + label: "Last commit", + menu: "navigate", + shortcuts: [ + { token: "name:end", display: "End / G" }, + { token: "sequence:G", display: "End / G" }, + ], + helpSection: "Navigation", + }, + { + id: "search", + label: "Search…", + menu: "navigate", + shortcuts: [{ token: "sequence:/", display: "/" }], + helpSection: "Navigation", + }, + { + id: "next-match", + label: "Next match", + menu: "navigate", + shortcuts: [{ token: "sequence:n", display: "n" }], + helpSection: "Navigation", + }, + { + id: "previous-match", + label: "Previous match", + menu: "navigate", + shortcuts: [{ token: "sequence:N", display: "N" }], + helpSection: "Navigation", + }, + { + id: "open-first-parent", + label: "Compare with first parent", + menu: "commit", + }, + { id: "open-parent", label: "Compare with parent…", menu: "commit" }, + { + id: "help", + label: "Keyboard shortcuts", + menu: "help", + shortcuts: [{ token: "sequence:?", display: "?" }], + helpSection: "Application", + }, + { id: "about", label: "About Hunk", menu: "help" }, +]; + +const BY_ID = new Map(LOG_COMMANDS.map((command) => [command.id, command])); + +/** Return the canonical definition for one command. */ +export function logCommand(id: LogCommandId) { + return BY_ID.get(id)!; +} + +/** Derive one menu/help hint from the same shortcuts used for dispatch. */ +export function logCommandHint(id: LogCommandId) { + return logCommand(id).shortcuts?.[0]?.display; +} + +/** Resolve a keyboard event to the canonical log command. */ +export function matchLogCommand(key: KeyEvent): LogCommandId | null { + const tokens = [ + key.ctrl ? `ctrl:${key.name}` : "", + `name:${key.name === "return" ? "enter" : key.name}`, + key.sequence ? `sequence:${key.sequence}` : "", + ]; + return ( + LOG_COMMANDS.find((command) => + command.shortcuts?.some((shortcut) => tokens.includes(shortcut.token)), + )?.id ?? null + ); +} + +/** Apply context-sensitive availability consistently to keyboard and menus. */ +export function isLogCommandEnabled(id: LogCommandId, snapshot: LogSnapshot) { + const selected = snapshot.rows[snapshot.selected]; + if (["open", "copy"].includes(id)) return Boolean(selected); + if (id === "previous" || id === "page-up" || id === "first") return snapshot.selected > 0; + if (id === "next" || id === "page-down" || id === "last") + return !(snapshot.historyDone && snapshot.selected >= snapshot.rows.length - 1); + if (id === "next-match" || id === "previous-match") return Boolean(snapshot.search); + if (id === "open-first-parent") return Boolean(selected?.commit.parentRevisionIds.length); + if (id === "open-parent") return (selected?.commit.parentRevisionIds.length ?? 0) > 1; + return true; +} + +/** Build log help from the same labels and bindings used by dispatch and menus. */ +export function buildLogHelpSections(): readonly HelpSection[] { + const order: NonNullable[] = [ + "Navigation", + "Commit", + "Application", + ]; + return order.map((title) => ({ + title, + rows: LOG_COMMANDS.filter((command) => command.helpSection === title).map((command) => ({ + keys: command.shortcuts?.[0]?.display ?? "", + description: command.label.toLocaleLowerCase(), + })), + })); +} diff --git a/src/ui/log/controller.test.ts b/src/ui/log/controller.test.ts new file mode 100644 index 000000000..1762ffce8 --- /dev/null +++ b/src/ui/log/controller.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, test } from "bun:test"; +import type { HistoryRuntime } from "../history/types"; +import { LogController } from "./controller"; + +function createRuntime(subjects = ["first", "second", "third"]) { + let cursor = 0; + let closeCount = 0; + const makeSource = () => ({ + async read({ limit }: { limit: number; signal?: AbortSignal }) { + const selected = subjects.slice(cursor, cursor + Math.min(limit, 2)); + cursor += selected.length; + return { + commits: selected.map((subject) => ({ + revisionId: subject, + displayId: subject.slice(0, 8), + parentRevisionIds: [], + subject, + authorName: "Ada", + authoredAt: "2026-01-01T00:00:00Z", + decorations: [], + })), + done: cursor >= subjects.length, + }; + }, + async close() {}, + }); + let source = makeSource(); + const runtime: HistoryRuntime = { + input: { + kind: "history", + color: "never", + format: "compact", + ascii: false, + interactive: true, + extensionsEnabled: false, + extensionPaths: [], + }, + source, + providerId: "test", + providerName: "Test", + repoRoot: "/repo", + notices: [], + customThemes: [], + async planReview(commit) { + return { kind: "revision-show", revisionId: commit.revisionId }; + }, + async reopenSource() { + cursor = 0; + source = makeSource(); + return source; + }, + async close() { + closeCount += 1; + }, + }; + return { runtime, closeCount: () => closeCount }; +} + +describe("LogController", () => { + test("loads bounded pages and retains navigation/search state", async () => { + const { runtime } = createRuntime(); + const controller = new LogController(runtime); + await controller.loadMore(); + expect(controller.getSnapshot().rows.map((row) => row.commit.subject)).toEqual([ + "first", + "second", + ]); + controller.move(1, 1); + expect(controller.getSnapshot().selected).toBe(1); + controller.setSearch(""); + controller.appendSearch("th"); + controller.appendSearch("ird"); + expect(controller.getSnapshot().search).toBe("third"); + controller.backspaceSearch(); + controller.appendSearch("d"); + await controller.findMatch(1); + expect(controller.getSnapshot().rows).toHaveLength(3); + expect(controller.getSnapshot().selected).toBe(2); + await controller.close(); + }); + + test("forces ASCII graph presentation for TERM=dumb", async () => { + const previous = process.env.TERM; + process.env.TERM = "dumb"; + try { + const { runtime } = createRuntime(); + const controller = new LogController(runtime); + expect(controller.getSnapshot().presentation.unicode).toBe(false); + await controller.close(); + } finally { + if (previous === undefined) delete process.env.TERM; + else process.env.TERM = previous; + } + }); + + test("initializes format from CLI input and loads enough pages for navigation", async () => { + const { runtime } = createRuntime(["one", "two", "three", "four", "five"]); + runtime.input.format = "medium"; + const controller = new LogController(runtime); + expect(controller.getSnapshot().presentation.format).toBe("medium"); + await controller.loadMore(); + await controller.page(1, 4); + expect(controller.getSnapshot().selected).toBe(4); + expect(controller.getSnapshot().historyDone).toBe(true); + await controller.close(); + }); + + test("preserves rapid navigation targets while bounded continuation is loading", async () => { + const { runtime } = createRuntime(["one", "two", "three", "four"]); + const controller = new LogController(runtime); + await controller.loadMore(); + await Promise.all([controller.move(1, 1), controller.move(1, 1), controller.move(1, 1)]); + expect(controller.getSnapshot().selected).toBe(3); + await controller.close(); + }); + + test("search reveals its match and refresh preserves immutable selection viewport offset", async () => { + const { runtime } = createRuntime(["one", "two", "three", "four"]); + const controller = new LogController(runtime); + await controller.loadMore(); + await controller.select(2, 2); + expect(controller.getSnapshot().top).toBe(1); + controller.setSearch("four"); + await controller.findMatch(1, 2); + expect(controller.getSnapshot()).toMatchObject({ selected: 3, top: 2 }); + await controller.refresh(); + expect( + controller.getSnapshot().rows[controller.getSnapshot().selected]?.commit.revisionId, + ).toBe("four"); + expect(controller.getSnapshot().selected - controller.getSnapshot().top).toBe(1); + await controller.close(); + }); + + test("closes a replacement cursor when quit wins a refresh race", async () => { + let resolveReplacement!: (source: HistoryRuntime["source"]) => void; + let replacementCloseCount = 0; + let reopenSignal: AbortSignal | undefined; + const { runtime } = createRuntime(["one"]); + runtime.reopenSource = (signal) => { + reopenSignal = signal; + return new Promise((resolve) => { + resolveReplacement = resolve; + }); + }; + const controller = new LogController(runtime); + await controller.loadMore(); + const refresh = controller.refresh(); + await Promise.resolve(); + const close = controller.close(); + resolveReplacement({ + async read() { + return { commits: [], done: true }; + }, + async close() { + replacementCloseCount += 1; + }, + }); + await Promise.all([refresh, close]); + expect(reopenSignal?.aborted).toBe(true); + expect(replacementCloseCount).toBe(1); + }); + + test("refreshes through the provider-owned cursor factory and closes once", async () => { + const { runtime, closeCount } = createRuntime(["first"]); + const controller = new LogController(runtime); + await controller.loadMore(); + controller.setTheme("github-dark"); + await controller.refresh(); + expect(controller.getSnapshot().rows).toHaveLength(1); + expect(controller.getSnapshot().themeId).toBe("github-dark"); + await controller.close(); + await controller.close(); + expect(closeCount()).toBe(1); + }); +}); diff --git a/src/ui/log/controller.ts b/src/ui/log/controller.ts new file mode 100644 index 000000000..494cd6575 --- /dev/null +++ b/src/ui/log/controller.ts @@ -0,0 +1,346 @@ +import { createHistoryLaneCheckpoint, planHistoryPage } from "../../core/history/lanePlanner"; +import type { HistoryGraphRow, HistoryLaneCheckpoint } from "../../core/history/types"; +import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; +import { sanitizeTerminalLine } from "../../lib/terminalText"; +import type { HistoryRuntime } from "../history/types"; + +export interface LogPresentation { + format: "compact" | "medium"; + graph: boolean; + unicode: boolean; + author: boolean; + date: boolean; + decorations: boolean; +} + +export interface LogSnapshot { + rows: readonly HistoryGraphRow[]; + selected: number; + top: number; + search: string; + searchEditing: boolean; + historyDone: boolean; + loading: boolean; + notice: string; + themeId?: string; + presentation: LogPresentation; +} + +/** Retain interactive log state independently of renderer mount/unmount cycles. */ +export class LogController { + private source: HistoryRuntime["source"]; + private checkpoint: HistoryLaneCheckpoint = createHistoryLaneCheckpoint(); + private listeners = new Set<() => void>(); + private generation = 0; + private abort = new AbortController(); + private loadingPromise: Promise | null = null; + private refreshPromise: Promise | null = null; + private navigationTarget: number | null = null; + private closed = false; + private viewportHeight = 1; + private noticeTimer: ReturnType | null = null; + private snapshot: LogSnapshot; + + constructor(private readonly runtime: HistoryRuntime) { + this.source = runtime.source; + this.snapshot = { + rows: [], + selected: 0, + top: 0, + search: "", + searchEditing: false, + historyDone: false, + loading: false, + notice: runtime.notices[0] ?? "", + themeId: runtime.input.theme, + presentation: { + format: runtime.input.format, + graph: true, + unicode: !runtime.input.ascii && process.env.TERM !== "dumb", + author: true, + date: true, + decorations: true, + }, + }; + } + + /** Return the immutable render snapshot. */ + getSnapshot = () => this.snapshot; + + /** Subscribe one mounted surface to controller changes. */ + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + private publish(patch: Partial) { + this.snapshot = { ...this.snapshot, ...patch }; + for (const listener of this.listeners) listener(); + } + + /** Load one bounded page, preserving symbolic graph state across page boundaries. */ + async loadMore() { + if (this.loadingPromise) return this.loadingPromise; + if (this.closed || this.snapshot.historyDone) return; + const generation = this.generation; + this.publish({ loading: true }); + const loading = (async () => { + try { + const page = await this.source.read({ limit: 256, signal: this.abort.signal }); + if (generation !== this.generation || this.closed) return; + if (!page.done && page.commits.length === 0) + throw new Error("VCS history returned an empty page before EOF."); + const planned = planHistoryPage(page.commits, this.checkpoint); + this.checkpoint = planned.checkpoint; + const rows = [...this.snapshot.rows, ...planned.rows]; + this.publish({ + rows, + historyDone: page.done, + notice: rows.length === 0 && page.done ? "No commits found." : this.snapshot.notice, + }); + } catch (error) { + if (!this.abort.signal.aborted && generation === this.generation) { + this.publish({ + historyDone: true, + notice: sanitizeTerminalLine(error instanceof Error ? error.message : String(error)), + }); + } + } finally { + if (generation === this.generation && !this.closed) this.publish({ loading: false }); + } + })(); + this.loadingPromise = loading; + try { + await loading; + } finally { + if (this.loadingPromise === loading) this.loadingPromise = null; + } + } + + /** Keep the selected row visible in a viewport of fixed-height compact rows. */ + clampViewport(height: number) { + const safeHeight = Math.max(1, height); + this.viewportHeight = safeHeight; + const selected = Math.max( + 0, + Math.min(Math.max(0, this.snapshot.rows.length - 1), this.snapshot.selected), + ); + let top = this.snapshot.top; + if (selected < top) top = selected; + if (selected >= top + safeHeight) top = selected - safeHeight + 1; + top = Math.max(0, Math.min(top, Math.max(0, this.snapshot.rows.length - safeHeight))); + if (selected !== this.snapshot.selected || top !== this.snapshot.top) + this.publish({ selected, top }); + } + + /** Select a target, loading bounded continuation pages until it exists or EOF is known. */ + async select(index: number, viewportHeight: number) { + this.clearNotice(); + const target = Math.max(0, index); + this.navigationTarget = target; + while (target >= this.snapshot.rows.length && !this.snapshot.historyDone && !this.closed) { + await this.loadMore(); + } + if (this.closed) return; + this.publish({ selected: Math.max(0, Math.min(this.snapshot.rows.length - 1, target)) }); + this.clampViewport(viewportHeight); + if (this.navigationTarget === target) this.navigationTarget = null; + if (target + viewportHeight >= this.snapshot.rows.length && !this.snapshot.historyDone) + void this.loadMore(); + } + + move(delta: number, viewportHeight: number) { + return this.select((this.navigationTarget ?? this.snapshot.selected) + delta, viewportHeight); + } + + page(delta: number, viewportHeight: number) { + return this.move(delta * Math.max(1, viewportHeight), viewportHeight); + } + + first(viewportHeight: number) { + return this.select(0, viewportHeight); + } + + async last(viewportHeight: number) { + while (!this.snapshot.historyDone && !this.closed) await this.loadMore(); + await this.select(this.snapshot.rows.length - 1, viewportHeight); + } + + /** Enter or update the focused search editor without filtering topology. */ + setSearch(search: string, editing = this.snapshot.searchEditing) { + this.publish({ search, searchEditing: editing }); + } + + appendSearch(text: string) { + this.publish({ search: this.snapshot.search + text }); + } + + backspaceSearch() { + this.publish({ search: Array.from(this.snapshot.search).slice(0, -1).join("") }); + } + + beginSearch() { + this.clearNotice(); + this.publish({ searchEditing: true }); + } + + cancelSearch() { + this.publish({ searchEditing: false }); + } + + async finishSearch(direction: 1 | -1 = 1, viewportHeight = this.viewportHeight) { + this.publish({ searchEditing: false }); + await this.findMatch(direction, viewportHeight); + } + + async findMatch(direction: 1 | -1, viewportHeight = this.viewportHeight) { + const needle = this.snapshot.search.toLocaleLowerCase(); + if (!needle) return; + while (!this.snapshot.historyDone && !this.closed) await this.loadMore(); + const rows = this.snapshot.rows; + for (let step = 1; step <= rows.length; step += 1) { + const index = (this.snapshot.selected + direction * step + rows.length) % rows.length; + const commit = rows[index]!.commit; + const haystack = [ + commit.revisionId, + commit.displayId, + commit.subject, + commit.body ?? "", + commit.authorName, + commit.authorEmail ?? "", + ...commit.decorations.map((entry) => entry.label), + ] + .join(" ") + .toLocaleLowerCase(); + if (haystack.includes(needle)) { + this.publish({ selected: index, notice: "" }); + this.clampViewport(viewportHeight); + return; + } + } + this.setNotice(`No match for ${this.snapshot.search}`); + } + + private clearNotice() { + if (this.noticeTimer) clearTimeout(this.noticeTimer); + this.noticeTimer = null; + if (this.snapshot.notice) this.publish({ notice: "" }); + } + + setNotice(notice: string) { + if (this.noticeTimer) clearTimeout(this.noticeTimer); + const safe = sanitizeTerminalLine(notice); + this.publish({ notice: safe }); + if (safe) { + this.noticeTimer = setTimeout(() => { + this.noticeTimer = null; + if (!this.closed && this.snapshot.notice === safe) this.publish({ notice: "" }); + }, 2500); + this.noticeTimer.unref?.(); + } + } + + setTheme(themeId: string) { + this.publish({ themeId }); + } + + togglePresentation(key: keyof Omit) { + this.publish({ + presentation: { ...this.snapshot.presentation, [key]: !this.snapshot.presentation[key] }, + }); + } + + setFormat(format: LogPresentation["format"]) { + this.publish({ presentation: { ...this.snapshot.presentation, format } }); + } + + /** Refresh the provider cursor while reconciling selection by immutable revision id. */ + async refresh() { + if (this.refreshPromise) return this.refreshPromise; + const refreshing = this.performRefresh(); + this.refreshPromise = refreshing; + try { + await refreshing; + } finally { + if (this.refreshPromise === refreshing) this.refreshPromise = null; + } + } + + private async performRefresh() { + if (this.closed) return; + const selectedId = this.snapshot.rows[this.snapshot.selected]?.commit.revisionId; + const viewportOffset = this.snapshot.selected - this.snapshot.top; + this.generation += 1; + const generation = this.generation; + this.abort.abort(); + await this.loadingPromise; + this.loadingPromise = null; + this.abort = new AbortController(); + let replacement: HistoryRuntime["source"]; + try { + replacement = await this.runtime.reopenSource(this.abort.signal); + } catch (error) { + if (!this.closed && generation === this.generation) { + this.setNotice(error instanceof Error ? error.message : String(error)); + } + return; + } + if (this.closed || generation !== this.generation) { + await replacement.close(); + return; + } + this.source = replacement; + this.checkpoint = createHistoryLaneCheckpoint(); + this.publish({ + rows: [], + selected: 0, + top: 0, + historyDone: false, + loading: false, + notice: "", + }); + await this.loadMore(); + if (selectedId) { + while ( + !this.snapshot.historyDone && + !this.snapshot.rows.some((row) => row.commit.revisionId === selectedId) + ) { + await this.loadMore(); + } + const index = this.snapshot.rows.findIndex((row) => row.commit.revisionId === selectedId); + if (index >= 0) { + const maxTop = Math.max(0, this.snapshot.rows.length - this.viewportHeight); + this.publish({ + selected: index, + top: Math.min(maxTop, Math.max(0, index - viewportOffset)), + }); + } + } + this.setNotice("History refreshed."); + } + + /** Ask the selected provider to describe the review without interpreting revision syntax. */ + getSelectedRow() { + return this.snapshot.rows[this.snapshot.selected]; + } + + planSelectedReview(parentRevisionId?: string): Promise | null { + const commit = this.getSelectedRow()?.commit; + return commit + ? this.runtime.planReview( + commit, + parentRevisionId === undefined ? undefined : { parentRevisionId }, + ) + : null; + } + + async close() { + if (this.closed) return; + this.closed = true; + this.generation += 1; + this.abort.abort(); + if (this.noticeTimer) clearTimeout(this.noticeTimer); + this.noticeTimer = null; + await this.runtime.close(); + } +} diff --git a/src/ui/log/logHelp.ts b/src/ui/log/logHelp.ts new file mode 100644 index 000000000..e950ef872 --- /dev/null +++ b/src/ui/log/logHelp.ts @@ -0,0 +1,4 @@ +import { buildLogHelpSections } from "./commands"; + +/** Describe only the canonical controls owned by the interactive log surface. */ +export const LOG_HELP_SECTIONS = buildLogHelpSections(); diff --git a/src/ui/log/reviewLaunch.ts b/src/ui/log/reviewLaunch.ts new file mode 100644 index 000000000..d0d60a39e --- /dev/null +++ b/src/ui/log/reviewLaunch.ts @@ -0,0 +1,41 @@ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { resolveCurrentHunkCommand } from "../../core/process/relaunch"; +import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; +import type { HistoryRuntime } from "../history/types"; + +/** Convert a provider-owned review declaration into one option-safe child invocation. */ +export function historyReviewArgs(action: ExtensionVcsHistoryReviewAction) { + const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64url"); + return [action.kind === "revision-range" ? "diff" : "show", "--history-review", payload]; +} + +/** Run one provider-planned child Hunk review after the log renderer yields the terminal. */ +export async function launchHistoryReview( + runtime: HistoryRuntime, + action: ExtensionVcsHistoryReviewAction, + themeId?: string, +) { + const current = resolveCurrentHunkCommand(); + const extensionArgs = runtime.input.extensionPaths.flatMap((path) => [ + "--extension", + resolve(path), + ]); + const args = [ + ...current.args, + ...historyReviewArgs(action), + "--vcs", + runtime.providerId, + ...(themeId ? ["--theme", themeId] : []), + ...(runtime.input.extensionsEnabled ? extensionArgs : ["--no-extensions"]), + ]; + const child = spawn(current.command, args, { + cwd: runtime.repoRoot, + env: { ...process.env, HUNK_RETURN_TO_HISTORY: "1" }, + stdio: "inherit", + }); + return await new Promise((resolveExit, reject) => { + child.once("error", reject); + child.once("exit", (code, signal) => resolveExit(signal ? 1 : (code ?? 1))); + }); +} diff --git a/src/ui/log/runInteractiveLog.test.ts b/src/ui/log/runInteractiveLog.test.ts new file mode 100644 index 000000000..10159d1fe --- /dev/null +++ b/src/ui/log/runInteractiveLog.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, test } from "bun:test"; +import { logSignalExitCode } from "./runInteractiveLog"; + +describe("interactive log lifecycle", () => { + test("preserves conventional signal exit codes after cleanup", () => { + expect(logSignalExitCode("SIGINT")).toBe(130); + expect(logSignalExitCode("SIGHUP")).toBe(129); + expect(logSignalExitCode("SIGTERM")).toBe(143); + }); +}); diff --git a/src/ui/log/runInteractiveLog.tsx b/src/ui/log/runInteractiveLog.tsx new file mode 100644 index 000000000..e4f1bc875 --- /dev/null +++ b/src/ui/log/runInteractiveLog.tsx @@ -0,0 +1,136 @@ +import { createCliRenderer } from "@opentui/core"; +import { createRoot } from "@opentui/react"; +import { + installJobControlInterruptSupport, + installJobControlSuspendSupport, + type JobControlInterruptSupport, + type JobControlSuspendSupport, +} from "../../core/process/jobControl"; +import { shutdownSession } from "../../core/process/shutdown"; +import { HunkUserError } from "../../core/run/errors"; +import { + installTerminalDisconnectSupport, + type TerminalDisconnectSupport, +} from "../../core/process/terminal"; +import { LogApp, type LogAppOutcome } from "./LogApp"; +import { LogController } from "./controller"; +import { launchHistoryReview } from "./reviewLaunch"; +import type { HistoryRuntime } from "../history/types"; +import { interactiveLogUsesColor } from "./colorPolicy"; + +const LOG_SHUTDOWN_SIGNALS: NodeJS.Signals[] = + process.platform === "win32" + ? ["SIGINT", "SIGTERM", "SIGBREAK"] + : ["SIGINT", "SIGTERM", "SIGHUP"]; + +/** Translate terminal shutdown signals to conventional shell exit codes. */ +export function logSignalExitCode(signal: NodeJS.Signals) { + return signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143; +} + +/** Mount one OpenTUI log surface and resolve only after it requests quit or review. */ +async function mountLogSurface( + controller: LogController, + runtime: HistoryRuntime, + stdin: NodeJS.ReadStream, + stdout: NodeJS.WriteStream, +) { + const renderer = await createCliRenderer({ + stdin, + stdout, + useMouse: true, + screenMode: "alternate-screen", + exitOnCtrlC: false, + exitSignals: [], + openConsoleOnError: true, + }); + let root: ReturnType; + try { + root = createRoot(renderer); + } catch (error) { + renderer.destroy(); + throw error; + } + let settled = false; + let settle!: (outcome: LogAppOutcome) => void; + const outcome = new Promise((resolve) => { + settle = resolve; + }); + const finish = (value: LogAppOutcome) => { + if (settled) return; + settled = true; + settle(value); + }; + const requestQuit = () => finish({ kind: "quit" }); + const requestInterrupt = () => finish({ kind: "quit", exitCode: 130 }); + const signalHandlers = new Map void>( + LOG_SHUTDOWN_SIGNALS.map((signal) => [ + signal, + () => + finish({ + kind: "quit", + exitCode: logSignalExitCode(signal), + }), + ]), + ); + for (const [signal, handler] of signalHandlers) process.once(signal, handler); + let interrupt: JobControlInterruptSupport = { dispose: () => undefined }; + let suspend: JobControlSuspendSupport = { dispose: () => undefined }; + let disconnect: TerminalDisconnectSupport = { dispose: () => undefined }; + try { + interrupt = installJobControlInterruptSupport(renderer, requestInterrupt); + suspend = installJobControlSuspendSupport(renderer); + disconnect = installTerminalDisconnectSupport(stdin, requestQuit); + root.render( + , + ); + return await outcome; + } finally { + for (const [signal, handler] of signalHandlers) process.off(signal, handler); + interrupt.dispose(); + suspend.dispose(); + disconnect.dispose(); + shutdownSession({ root, renderer, exit: () => undefined }); + } +} + +/** Browse history in shared desktop chrome, yielding fully to each child review. */ +export async function runInteractiveLog( + runtime: HistoryRuntime, + { + stdin = process.stdin, + stdout = process.stdout, + }: { stdin?: NodeJS.ReadStream; stdout?: NodeJS.WriteStream } = {}, +) { + if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { + await runtime.close(); + throw new HunkUserError("`hunk log --interactive` requires a terminal.", [ + "Use plain `hunk log` for pipes and redirected output.", + ]); + } + + const controller = new LogController(runtime); + try { + await controller.loadMore(); + for (;;) { + const outcome = await mountLogSurface(controller, runtime, stdin, stdout); + if (outcome.kind === "quit") { + if (outcome.exitCode !== undefined) process.exitCode = outcome.exitCode; + return; + } + try { + const code = await launchHistoryReview(runtime, outcome.action, outcome.themeId); + controller.setNotice(code === 0 ? "" : "Could not open the selected commit."); + } catch (error) { + controller.setNotice(error instanceof Error ? error.message : String(error)); + } + } + } finally { + await controller.close(); + } +} diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index 56b3e956b..b03dd34ef 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -39,6 +39,32 @@ function createHistoryRepo() { return cwd; } +/** Create a merge whose second-parent comparison exposes only the main-side file. */ +function createMergeHistoryRepo() { + const cwd = mkdtempSync(join(tmpdir(), "hunk-log-merge-pty-")); + tempDirs.push(cwd); + git(cwd, ["init", "-q"]); + writeFileSync(join(cwd, "base.ts"), "export const base = true;\n"); + git(cwd, ["add", "base.ts"]); + git(cwd, ["commit", "-qm", "Root"]); + const defaultBranch = Bun.spawnSync(["git", "branch", "--show-current"], { + cwd, + stdout: "pipe", + }) + .stdout.toString() + .trim(); + git(cwd, ["checkout", "-qb", "side"]); + writeFileSync(join(cwd, "side.ts"), "export const side = true;\n"); + git(cwd, ["add", "side.ts"]); + git(cwd, ["commit", "-qm", "Side"]); + git(cwd, ["checkout", "-q", defaultBranch]); + writeFileSync(join(cwd, "main.ts"), "export const main = true;\n"); + git(cwd, ["add", "main.ts"]); + git(cwd, ["commit", "-qm", "Main"]); + git(cwd, ["merge", "--no-ff", "-qm", "Merge side", "side"]); + return cwd; +} + afterEach(() => { harness.cleanup(); for (const path of tempDirs.splice(0)) rmSync(path, { recursive: true, force: true }); @@ -59,11 +85,22 @@ describe("interactive hunk log", () => { timeout: 15_000, }); expect(history).toContain("First history commit"); - expect(history).toContain("enter open"); + expect(history).toContain("File View Navigate Commit Help"); + expect(history).toContain("Enter open"); - // The first compact row starts with one graph cell plus two spaces, so x=4 - // lands inside its visible commit id. One press opens without a double-click. - session.writeRaw("\x1b[<0;5;1M"); + // Mouse and keyboard share the same menu model and actions. + session.writeRaw("\x1b[<0;2;1M\x1b[<0;2;1m"); + await session.waitForText(/Open selected commit/, { timeout: 5_000 }); + await session.press("right"); + await session.press("enter"); + await session.waitForText(/Theme selector/, { timeout: 5_000 }); + await session.press("down"); + await session.press("enter"); + await session.waitForText(/Second history commit/, { timeout: 5_000 }); + + // The menu occupies row one; x=5 on the first history row lands inside + // its commit id and opens immediately without a double-click. + session.writeRaw("\x1b[<0;5;2M\x1b[<0;5;2m"); const review = await session.waitForText(/historyValue = 'second'/, { timeout: 15_000, }); @@ -73,10 +110,21 @@ describe("interactive hunk log", () => { const returned = await session.waitForText(/Second history commit/, { timeout: 15_000, }); - expect(returned).toContain("enter open"); + expect(returned).toContain("Enter open"); + + // Scrolling the history body dismisses an open dropdown before moving selection. + await session.press("f10"); + await session.waitForText(/Open selected commit/, { timeout: 5_000 }); + session.writeRaw("\x1b[<65;50;5M"); + await harness.waitForSnapshot( + session, + (text) => !text.includes("Open selected commit"), + 5_000, + ); - // Terminals may coalesce rapid navigation and activation into one stdin chunk. - session.writeRaw("\x1b[B\r"); + // Clicking outside the id selects the second row without opening it. + session.writeRaw("\x1b[<0;50;3M\x1b[<0;50;3m"); + await session.press("enter"); const rootReview = await session.waitForText(/historyValue = 'first'/, { timeout: 15_000, }); @@ -84,11 +132,93 @@ describe("interactive hunk log", () => { await session.press("q"); await session.waitForText(/First history commit/, { timeout: 15_000 }); - // Opening again without moving proves return restored the immutable-id selection. + // A command key closes an open menu and falls through to canonical dispatch. + await session.press("f10"); + await session.waitForText(/Open selected commit/, { timeout: 5_000 }); + session.writeRaw("k\r"); + await session.waitForText(/historyValue = 'second'/, { timeout: 15_000 }); + await session.press("q"); + await session.waitForText(/Second history commit/, { timeout: 15_000 }); + + // Opening again without moving proves return restored the immutable-id selection. The + // coalesced trailing q must be consumed by the log transition rather than closing the child. + session.writeRaw("\rq"); + await session.waitForText(/historyValue = 'second'/, { timeout: 15_000 }); + await session.press("q"); + await session.waitForText(/Second history commit/, { timeout: 15_000 }); + await session.press("q"); + } finally { + session.close(); + } + }); + + test("uses an ASCII graph in a dumb terminal", async () => { + const cwd = createHistoryRepo(); + const session = await harness.launchHunk({ + args: ["log", "--interactive", "--ascii", "--no-extensions"], + cwd, + cols: 80, + rows: 16, + env: { TERM: "dumb", NO_COLOR: "" }, + }); + try { + const history = await session.waitForText(/Second history commit/, { timeout: 15_000 }); + expect(history).toContain("*"); + expect(history).not.toContain("●"); + await session.press("q"); + } finally { + session.close(); + } + }); + + test("refreshes from a new provider cursor and reveals a new commit", async () => { + const cwd = createHistoryRepo(); + const session = await harness.launchHunk({ + args: ["log", "--interactive", "--color", "never", "--no-extensions"], + cwd, + cols: 90, + rows: 18, + }); + try { + await session.waitForText(/Second history commit/, { timeout: 15_000 }); + writeFileSync(join(cwd, "history.ts"), "export const historyValue = 'third';\n"); + git(cwd, ["commit", "-qam", "Third history commit"]); + await session.press("r"); + const refreshed = await session.waitForText(/Third history commit/, { timeout: 15_000 }); + expect(refreshed).toContain("History refreshed"); + await session.press("q"); + } finally { + session.close(); + } + }); + + test("opens a merge against the provider-selected parent", async () => { + const cwd = createMergeHistoryRepo(); + const session = await harness.launchHunk({ + args: ["log", "--interactive", "--color", "never", "--no-extensions"], + cwd, + cols: 100, + rows: 20, + }); + try { + await session.waitForText(/Merge side/, { timeout: 15_000 }); + await session.press("f10"); + await session.press("right"); + await session.press("right"); + await session.press("right"); + await session.waitForText(/Compare with parent/, { timeout: 5_000 }); + await session.press("down"); + await session.press("down"); + await session.press("down"); + await session.press("enter"); + await session.waitForText(/Compare with parent/, { timeout: 5_000 }); + session.writeRaw("\x1b[<65;50;10M"); + await session.waitIdle(); await session.press("enter"); - await session.waitForText(/historyValue = 'first'/, { timeout: 15_000 }); + const review = await session.waitForText(/main\.ts/, { timeout: 15_000 }); + expect(review).not.toContain("side.ts"); await session.press("q"); - await session.waitForText(/First history commit/, { timeout: 15_000 }); + await session.waitForText(/Merge side/, { timeout: 15_000 }); await session.press("q"); } finally { session.close(); From 1598b0d4f1acb3784a6aa7c5da3937586f7f134a Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:18:07 -0400 Subject: [PATCH 05/36] feat(ui): make history automatically responsive (#987) --- .changeset/responsive-log.md | 5 + README.md | 20 ++-- docs/keybindings.md | 7 +- src/app/cli.test.ts | 12 +- src/app/cli.ts | 22 ++-- src/app/historyBootstrap.test.ts | 2 +- src/app/startup.test.ts | 19 +++- src/app/startup.ts | 20 +++- src/core/run/commandInputs.ts | 5 +- src/ui/history/runStaticHistory.test.ts | 2 +- src/ui/log/LogApp.tsx | 140 +++++++++++------------- src/ui/log/commands.test.ts | 1 - src/ui/log/commands.ts | 4 - src/ui/log/controller.test.ts | 6 +- src/ui/log/controller.ts | 8 +- src/ui/log/responsiveLayout.test.ts | 118 ++++++++++++++++++++ src/ui/log/responsiveLayout.ts | 136 +++++++++++++++++++++++ src/ui/log/runInteractiveLog.tsx | 4 +- test/pty/log-integration.test.ts | 92 ++++++++++++++-- 19 files changed, 492 insertions(+), 131 deletions(-) create mode 100644 .changeset/responsive-log.md create mode 100644 src/ui/log/responsiveLayout.test.ts create mode 100644 src/ui/log/responsiveLayout.ts diff --git a/.changeset/responsive-log.md b/.changeset/responsive-log.md new file mode 100644 index 000000000..d9e685794 --- /dev/null +++ b/.changeset/responsive-log.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Open `hunk log` as an auto-responsive terminal browser with GitHub-inspired rows, right-aligned commit links and copy actions, while preserving static output for pipes and `--static`. diff --git a/README.md b/README.md index 5257ebf5d..c0275f47d 100644 --- a/README.md +++ b/README.md @@ -87,17 +87,19 @@ hunk --fast # experimentally offload eligible syntax highligh hunk diff --watch # auto-reload as the working tree changes hunk show # review the latest commit hunk show HEAD~1 # review an earlier commit -hunk log # print the selected provider's repository history -hunk log --interactive # browse history; Enter opens a commit in Hunk +hunk log # browse history on a terminal; print when redirected +hunk log --static # force static output, paging when needed ``` -`hunk log` is a static-first, read-only history surface, not a repository manager. The selected VCS -adapter owns traversal, filtering, refs, and how a history item opens for review; the bundled Git -and Jujutsu adapters both implement that public capability. Default output keeps full commit, -author, date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact rows, and `--theme` -uses the same palette as Hunk review. Static output remains safe for pipes, redirects, and normal -terminal scrollback. The explicit interactive mode stays a single history list; after opening a -commit, quit its normal Hunk review to return to the same selection. +`hunk log` is one auto-responsive, read-only history surface, not a repository manager. On a +terminal it opens the desktop history browser; pipes and redirects receive shell-native static +records automatically, and `--static` forces static output that pages only when needed. The selected VCS adapter +owns traversal, filtering, refs, and how a history item opens for review; the bundled Git and +Jujutsu adapters both implement that public capability. Static output keeps full commit, author, +date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact records, and +`--theme` uses the same palette as Hunk review. Interactive rows adapt their information density to +the available width and keep commit ids right-aligned and clickable. After opening a commit, quit +its normal Hunk review to return to the same selection. ### Working with Jujutsu and Sapling diff --git a/docs/keybindings.md b/docs/keybindings.md index 39e8e57e8..2a131cead 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -45,12 +45,13 @@ deleted until its replies are removed. The built-in commands and the keys they ship with: -`hunk log --interactive` is a separate, fixed read-only history entry point rather than part of -the configurable review command table. `F10` opens its File, View, Navigate, Commit, and Help menus; +On a terminal, `hunk log` opens its read-only history browser automatically. Its controls are +separate from the configurable review command table. `F10` opens File, View, Navigate, Commit, and Help menus; View includes Hunk's shared theme selector. It uses `Up`/`Down` or `j`/`k` to move, `PageUp`/`PageDown`, `g`/`G` or `Home`/`End` to jump, `/` to search, `n`/`N` for matches, `r` to refresh, `y` to copy the full commit id, `Enter` to open the commit in normal Hunk review, and `q` to quit. With a mouse, click a commit -id to open it immediately, click elsewhere on a row to select it, or double-click a row to open it. +id to open it immediately, click the adjacent copy icon to copy its full immutable id, click elsewhere +on a row to select it, or double-click a row to open it. Quitting the opened review returns to the retained history selection and viewport. The Commit menu's **Compare with first parent** and **Compare with parent…** actions compare the selected commit against an ordered provider-owned parent; they do not navigate the history selection to that parent. diff --git a/src/app/cli.test.ts b/src/app/cli.test.ts index 3c27a99ed..dc26b117a 100644 --- a/src/app/cli.test.ts +++ b/src/app/cli.test.ts @@ -491,7 +491,7 @@ describe("parseCli", () => { }); }); - test("parses static and interactive log options without review flags", async () => { + test("parses automatic and forced-static log options without review flags", async () => { expect( await parseCli([ "bun", @@ -520,10 +520,14 @@ describe("parseCli", () => { color: "never", format: "medium", ascii: true, - interactive: true, + static: false, extensionsEnabled: true, extensionPaths: [], }); + expect(await parseCli(["bun", "hunk", "log", "--static"])).toMatchObject({ + kind: "history", + static: true, + }); }); test("parses compact aliases, themes, and command-local extension disabling", async () => { @@ -1757,6 +1761,10 @@ describe("parseCli command help text", () => { expect(await expectHelp(["patch", "--help"])).toContain("review a patch file"); expect(await expectHelp(["pager", "--help"])).toContain("general Git pager wrapper"); expect(await expectHelp(["difftool", "--help"])).toContain("review Git difftool file pairs"); + const logHelp = await expectHelp(["log", "--help"]); + expect(logHelp).toContain("browse an attractive repository history"); + expect(logHelp).toContain("--static"); + expect(logHelp).not.toContain("--interactive"); }); test("renders the stash command overview and the stash show command help", async () => { diff --git a/src/app/cli.ts b/src/app/cli.ts index 53c0d8bb0..d5edf34c6 100644 --- a/src/app/cli.ts +++ b/src/app/cli.ts @@ -207,10 +207,11 @@ export const CLI_REFERENCE_COMMANDS = { path: "log", // Release preparation enables this when an installable build contains history browsing. publicDocs: false, - summary: "print an attractive repository history", + summary: "browse an attractive repository history", synopsis: ["hunk log [revision-expression] [-- ]"], details: [ - "Static output is the default. Use --interactive for the experimental history browser.", + "A terminal opens the responsive browser; pipes and redirects receive static output.", + "Use --static to force static output, paging when it exceeds the terminal.", "The selected VCS provider defines revision, filtering, and review semantics.", ], options: [ @@ -232,13 +233,13 @@ export const CLI_REFERENCE_COMMANDS = { }, { flag: "--format ", - description: "record format: medium or compact", + description: "static record format: medium or compact", commanderDefault: "medium", }, - { flag: "--oneline", description: "alias for --format compact" }, + { flag: "--oneline", description: "alias for static --format compact" }, { flag: "--theme ", description: "use the same theme as Hunk review" }, { flag: "--ascii", description: "use an ASCII graph" }, - { flag: "--interactive", description: "browse history and open commits in Hunk" }, + { flag: "--static", description: "print static output, paging when needed" }, { flag: "--vcs ", description: "select a VCS history provider" }, { flag: "--extension ", @@ -576,7 +577,7 @@ function renderCliHelp() { " hunk diff --staged [-- ] review staged changes", " hunk diff --files compare two concrete files", " hunk show [target] [-- ] review the last commit or a given target", - " hunk log [target] [-- ] print an attractive repository history", + " hunk log [target] [-- ] browse an attractive repository history", " hunk stash show [ref] review a stash entry (git only)", " hunk patch [file] review a patch file or stdin", " hunk pager general Git pager wrapper with diff detection", @@ -1003,13 +1004,16 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise { const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCliReferenceCommand("log").argument("[revision]"); + const command = createCliReferenceCommand("log") + .argument("[revision]") + // Accept the former opt-in spelling during migration without presenting two experiences. + .addOption(new Option("--interactive").hideHelp()); let revision: string | undefined; let options: Record = {}; @@ -1052,7 +1056,7 @@ async function parseHistoryCommand( color, format, ascii: Boolean(options.ascii), - interactive: Boolean(options.interactive), + static: Boolean(options.static), ...(typeof options.theme === "string" ? { theme: options.theme } : {}), ...(typeof options.vcs === "string" ? { vcs: options.vcs } : {}), extensionsEnabled: extensionsEnabled && options.extensions !== false, diff --git a/src/app/historyBootstrap.test.ts b/src/app/historyBootstrap.test.ts index 976168daa..b8f937161 100644 --- a/src/app/historyBootstrap.test.ts +++ b/src/app/historyBootstrap.test.ts @@ -11,7 +11,7 @@ const input: HistoryCommandInput = { color: "never", format: "compact", ascii: false, - interactive: true, + static: false, vcs: "test", extensionsEnabled: false, extensionPaths: [], diff --git a/src/app/startup.test.ts b/src/app/startup.test.ts index df653c998..647101aec 100644 --- a/src/app/startup.test.ts +++ b/src/app/startup.test.ts @@ -3,7 +3,7 @@ import { createEmptyExtensionLoadResult } from "../extensions/types"; import { resolveExtensionCliCommands } from "../extensions/cliCommands"; import type { HunkConfigResolution } from "../core/run/config"; import { HunkUserError } from "../core/run/errors"; -import { prepareStartupPlan } from "./startup"; +import { prepareStartupPlan, shouldUseInteractiveHistory } from "./startup"; import type { AppBootstrap } from "../core/bootstrap"; import type { CliInput, ParsedCliInput } from "../core/run/commandInputs"; import type { NamedCustomThemeConfig } from "../extension-api/types"; @@ -41,6 +41,23 @@ function createBootstrap(input: CliInput): AppBootstrap { }; } +describe("history surface selection", () => { + test("opens one automatic browser only when both standard streams are terminals", () => { + expect( + shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: true, stdoutIsTTY: true }), + ).toBe(true); + expect( + shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: false, stdoutIsTTY: true }), + ).toBe(false); + expect( + shouldUseInteractiveHistory({ forceStatic: false, stdinIsTTY: true, stdoutIsTTY: false }), + ).toBe(false); + expect( + shouldUseInteractiveHistory({ forceStatic: true, stdinIsTTY: true, stdoutIsTTY: true }), + ).toBe(false); + }); +}); + describe("startup planning", () => { test("runs an extension CLI command and retires its registry before returning", async () => { const invocation = { diff --git a/src/app/startup.ts b/src/app/startup.ts index 253c3d352..a88daea9d 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -161,6 +161,19 @@ function applyDelegatedExtensionFlags( } as ParsedCliInput; } +/** Choose the history surface from terminal ownership unless static output was forced. */ +export function shouldUseInteractiveHistory({ + forceStatic, + stdinIsTTY, + stdoutIsTTY, +}: { + forceStatic: boolean; + stdinIsTTY: boolean; + stdoutIsTTY: boolean; +}) { + return !forceStatic && stdinIsTTY && stdoutIsTTY; +} + /** Normalize startup work so help, pager, and app-bootstrap paths can be tested directly. */ export async function prepareStartupPlan( argv: string[] = process.argv, @@ -399,8 +412,13 @@ export async function prepareStartupPlan( // The runner owns source/extension retirement; unlike ordinary headless plans, history must // retain its provider cursor until every page has been consumed. preloadedExtensions = undefined; + const useInteractiveHistory = shouldUseInteractiveHistory({ + forceStatic: parsedCliInput.static, + stdinIsTTY, + stdoutIsTTY, + }); return { - kind: parsedCliInput.interactive ? "history-interactive" : "history-static", + kind: useInteractiveHistory ? "history-interactive" : "history-static", bootstrap, input: parsedCliInput, }; diff --git a/src/core/run/commandInputs.ts b/src/core/run/commandInputs.ts index b8652415e..4fc50d751 100644 --- a/src/core/run/commandInputs.ts +++ b/src/core/run/commandInputs.ts @@ -113,7 +113,7 @@ export type SessionCommentListType = "live" | "all" | ReviewNoteSource; export type HistoryColorMode = "auto" | "always" | "never"; export type HistoryFormat = "medium" | "compact"; -/** Static-first VCS history invocation, deliberately separate from review view options. */ +/** Auto-responsive VCS history invocation, deliberately separate from review view options. */ export interface HistoryCommandInput { kind: "history"; revision?: string; @@ -128,7 +128,8 @@ export interface HistoryCommandInput { color: HistoryColorMode; format: HistoryFormat; ascii: boolean; - interactive: boolean; + /** Force scrollback output even when stdin and stdout are terminals. */ + static: boolean; theme?: string; vcs?: string; extensionsEnabled: boolean; diff --git a/src/ui/history/runStaticHistory.test.ts b/src/ui/history/runStaticHistory.test.ts index cd2b53214..1ac5a2071 100644 --- a/src/ui/history/runStaticHistory.test.ts +++ b/src/ui/history/runStaticHistory.test.ts @@ -15,7 +15,7 @@ function runtime(commits: HistoryCommit[], maxCount?: number) { color: "never", format: "medium", ascii: false, - interactive: false, + static: true, extensionsEnabled: true, extensionPaths: [], ...(maxCount !== undefined ? { maxCount } : {}), diff --git a/src/ui/log/LogApp.tsx b/src/ui/log/LogApp.tsx index 4b8d0a0dd..e6635ab48 100644 --- a/src/ui/log/LogApp.tsx +++ b/src/ui/log/LogApp.tsx @@ -11,8 +11,7 @@ import type { AppMenus, MenuEntry } from "../components/chrome/menu"; import { ThemeSelectorDialog } from "../components/chrome/ThemeSelectorDialog"; import { useMenuController } from "../hooks/useMenuController"; import { useThemeSelectorController } from "../hooks/useThemeSelectorController"; -import { fitText } from "../lib/text"; -import { formatHistoryDecorations, renderHistoryGraph } from "../history/staticProjection"; +import { fitText, measureTextWidth } from "../lib/text"; import type { HistoryRuntime } from "../history/types"; import type { LogController } from "./controller"; import { LOG_HELP_SECTIONS } from "./logHelp"; @@ -25,6 +24,7 @@ import { } from "./commands"; import { ParentSelectorDialog } from "./ParentSelectorDialog"; import { monochromeLogTheme } from "./colorPolicy"; +import { projectResponsiveLogRow, resolveLogResponsiveLayout } from "./responsiveLayout"; export type LogAppOutcome = | { kind: "quit"; exitCode?: number } @@ -68,12 +68,11 @@ export function LogApp({ ? themeController.baseTheme : monochromeLogTheme(themeController.baseTheme, terminalThemeMode); const selectedRow = snapshot.rows[snapshot.selected]; - const detailHeight = - snapshot.presentation.format === "medium" && selectedRow && terminal.height >= 9 ? 5 : 0; - const viewportHeight = Math.max(1, terminal.height - 2 - detailHeight); + const responsiveLayout = resolveLogResponsiveLayout(terminal.width, terminal.height); + const viewportHeight = responsiveLayout.visibleRows; - const copySelected = () => { - const currentRow = controller.getSelectedRow(); + const copySelected = (row = controller.getSelectedRow()) => { + const currentRow = row; if (!currentRow) return; if (renderer.isOsc52Supported?.() && typeof renderer.copyToClipboardOSC52 === "function") { renderer.copyToClipboardOSC52(currentRow.commit.revisionId); @@ -125,12 +124,6 @@ export function LogApp({ case "theme": themeController.openThemeSelector(); break; - case "format-medium": - controller.setFormat("medium"); - break; - case "format-compact": - controller.setFormat("compact"); - break; case "toggle-graph": controller.togglePresentation("graph"); break; @@ -215,9 +208,6 @@ export function LogApp({ view: [ commandItem("theme"), { kind: "separator" }, - commandItem("format-medium", { checked: snapshot.presentation.format === "medium" }), - commandItem("format-compact", { checked: snapshot.presentation.format === "compact" }), - { kind: "separator" }, commandItem("toggle-graph", { checked: snapshot.presentation.graph }), commandItem("toggle-unicode", { checked: snapshot.presentation.unicode }), commandItem("toggle-author", { checked: snapshot.presentation.author }), @@ -340,9 +330,11 @@ export function LogApp({ }); const visible = snapshot.rows.slice(snapshot.top, snapshot.top + viewportHeight); - const rowWidth = Math.max(1, terminal.width - 2); - const statusHint = terminal.width >= 48 ? "↑↓ move · Enter open · / search · F10 menu" : ""; - const statusTextWidth = Math.max(1, terminal.width - (statusHint ? 42 : 2)); + const statusHint = terminal.width >= 60 ? "↑↓ move · Enter open · / search · F10 menu" : ""; + const statusTextWidth = Math.max( + 1, + terminal.width - measureTextWidth(statusHint) - (statusHint ? 3 : 2), + ); return ( + { const index = snapshot.top + offset; const selected = index === snapshot.selected; - const graph = snapshot.presentation.graph - ? `${renderHistoryGraph(row, !snapshot.presentation.unicode)} ` - : ""; - const decorations = snapshot.presentation.decorations - ? formatHistoryDecorations(row) - : ""; - const author = snapshot.presentation.author - ? ` ${sanitizeTerminalLine(row.commit.authorName)}` - : ""; - const date = snapshot.presentation.date - ? ` ${sanitizeTerminalLine(row.commit.authoredAt).slice(0, 10)}` - : ""; - const subject = fitText( - `${sanitizeTerminalLine(row.commit.subject)}${decorations}${author}${date}`, - Math.max(1, rowWidth - graph.length - row.commit.displayId.length - 2), - ); + const projected = projectResponsiveLogRow({ + row, + presentation: snapshot.presentation, + layout: responsiveLayout, + width: terminal.width, + }); return ( - {graph ? {graph} : null} + {projected.graphWidth ? ( + + {projected.graph} + {Array.from({ length: responsiveLayout.rowHeight - 1 }, (_, line) => ( + + {projected.continuation} + + ))} + + ) : null} + + {projected.title} + {responsiveLayout.showDescription ? ( + {projected.description} + ) : null} + {projected.metadata} + + {projected.columnGap ? : null} { event.stopPropagation(); clearTransientNotice(); - void controller.select(index, viewportHeight).then(() => openSelected()); + const copyIconStart = terminal.width - 1 - measureTextWidth(projected.copyIcon); + void controller.select(index, viewportHeight).then(() => { + if (event.x >= copyIconStart) copySelected(row); + else void openSelected(); + }); }} > - {row.commit.displayId} + + {projected.displayId} + {projected.copyIcon} + + {projected.secondary ? {projected.secondary} : null} - {` ${subject}`} ); })} - {detailHeight && selectedRow ? ( - - {fitText(selectedRow.commit.subject, rowWidth)} - - {fitText( - sanitizeTerminalLine( - `${selectedRow.commit.authorName}${selectedRow.commit.authorEmail ? ` <${selectedRow.commit.authorEmail}>` : ""}`, - ), - rowWidth, - )} - - - {fitText(sanitizeTerminalLine(selectedRow.commit.authoredAt), rowWidth)} - - - {fitText( - sanitizeTerminalLine((selectedRow.commit.body ?? "").replaceAll("\n", " ")), - rowWidth, - )} - - - {fitText(sanitizeTerminalLine(selectedRow.commit.revisionId), rowWidth)} - - - ) : null} ({ loading: false, notice: "", presentation: { - format: "compact", graph: true, unicode: true, author: true, diff --git a/src/ui/log/commands.ts b/src/ui/log/commands.ts index aeceb57b5..94debff4a 100644 --- a/src/ui/log/commands.ts +++ b/src/ui/log/commands.ts @@ -9,8 +9,6 @@ export type LogCommandId = | "refresh" | "quit" | "theme" - | "format-medium" - | "format-compact" | "toggle-graph" | "toggle-unicode" | "toggle-author" @@ -74,8 +72,6 @@ export const LOG_COMMANDS: readonly LogCommandDefinition[] = [ helpSection: "Application", }, { id: "theme", label: "Theme…", menu: "view" }, - { id: "format-medium", label: "Medium format", menu: "view" }, - { id: "format-compact", label: "Compact format", menu: "view" }, { id: "toggle-graph", label: "Graph", menu: "view" }, { id: "toggle-unicode", label: "Unicode graph", menu: "view" }, { id: "toggle-author", label: "Show author", menu: "view" }, diff --git a/src/ui/log/controller.test.ts b/src/ui/log/controller.test.ts index 1762ffce8..9125f979a 100644 --- a/src/ui/log/controller.test.ts +++ b/src/ui/log/controller.test.ts @@ -31,7 +31,7 @@ function createRuntime(subjects = ["first", "second", "third"]) { color: "never", format: "compact", ascii: false, - interactive: true, + static: false, extensionsEnabled: false, extensionPaths: [], }, @@ -93,11 +93,9 @@ describe("LogController", () => { } }); - test("initializes format from CLI input and loads enough pages for navigation", async () => { + test("loads enough bounded pages for responsive navigation", async () => { const { runtime } = createRuntime(["one", "two", "three", "four", "five"]); - runtime.input.format = "medium"; const controller = new LogController(runtime); - expect(controller.getSnapshot().presentation.format).toBe("medium"); await controller.loadMore(); await controller.page(1, 4); expect(controller.getSnapshot().selected).toBe(4); diff --git a/src/ui/log/controller.ts b/src/ui/log/controller.ts index 494cd6575..dbae4c3f7 100644 --- a/src/ui/log/controller.ts +++ b/src/ui/log/controller.ts @@ -5,7 +5,6 @@ import { sanitizeTerminalLine } from "../../lib/terminalText"; import type { HistoryRuntime } from "../history/types"; export interface LogPresentation { - format: "compact" | "medium"; graph: boolean; unicode: boolean; author: boolean; @@ -54,7 +53,6 @@ export class LogController { notice: runtime.notices[0] ?? "", themeId: runtime.input.theme, presentation: { - format: runtime.input.format, graph: true, unicode: !runtime.input.ascii && process.env.TERM !== "dumb", author: true, @@ -244,16 +242,12 @@ export class LogController { this.publish({ themeId }); } - togglePresentation(key: keyof Omit) { + togglePresentation(key: keyof LogPresentation) { this.publish({ presentation: { ...this.snapshot.presentation, [key]: !this.snapshot.presentation[key] }, }); } - setFormat(format: LogPresentation["format"]) { - this.publish({ presentation: { ...this.snapshot.presentation, format } }); - } - /** Refresh the provider cursor while reconciling selection by immutable revision id. */ async refresh() { if (this.refreshPromise) return this.refreshPromise; diff --git a/src/ui/log/responsiveLayout.test.ts b/src/ui/log/responsiveLayout.test.ts new file mode 100644 index 000000000..34ddf1610 --- /dev/null +++ b/src/ui/log/responsiveLayout.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { planHistoryPage } from "../../core/history/lanePlanner"; +import type { HistoryCommit } from "../../core/history/types"; +import { measureTextWidth } from "../lib/text"; +import type { LogPresentation } from "./controller"; +import { projectResponsiveLogRow, resolveLogResponsiveLayout } from "./responsiveLayout"; + +const commit: HistoryCommit = { + revisionId: "a".repeat(40), + displayId: "日本語a1", + parentRevisionIds: ["b".repeat(40), "c".repeat(40)], + subject: "Responsive history title", + body: "A useful description of the selected change.\n\nMore detail.", + authorName: "Ada Lovelace", + authorEmail: "ada@example.com", + authoredAt: "2026-09-05T12:00:00Z", + decorations: [ + { kind: "head", label: "HEAD", attachedLocalBranch: "main" }, + { kind: "local-branch", label: "main" }, + { kind: "tag", label: "v1.0.0" }, + ], +}; +const row = planHistoryPage([commit]).rows[0]!; +const presentation: LogPresentation = { + graph: true, + unicode: true, + author: true, + date: true, + decorations: true, +}; + +describe("responsive log layout", () => { + test("selects one automatic density from actual width", () => { + expect(resolveLogResponsiveLayout(120, 30)).toMatchObject({ + density: "wide", + rowHeight: 4, + visibleRows: 6, + }); + expect(resolveLogResponsiveLayout(80, 24)).toMatchObject({ + density: "medium", + rowHeight: 3, + visibleRows: 7, + }); + expect(resolveLogResponsiveLayout(42, 18)).toMatchObject({ + density: "narrow", + rowHeight: 3, + visibleRows: 5, + }); + }); + + test("keeps GitHub-style left metadata and a display-cell-correct right id", () => { + const wide = projectResponsiveLogRow({ + row, + presentation, + layout: resolveLogResponsiveLayout(120, 30), + width: 120, + }); + expect(wide.title).toBe("Responsive history title"); + expect(wide.description).toContain("useful description"); + expect(wide.metadata).toContain("Ada Lovelace"); + expect(wide.metadata).toContain("2026-09-05"); + expect(wide.metadata).toContain("HEAD -> main"); + expect(wide.metadata).toContain("tag: v1.0.0"); + expect(wide.secondary).toBe("2 parents"); + expect(measureTextWidth(wide.displayId)).toBe(8); + expect(wide.copyIcon).toBe("⧉"); + expect(wide.rightWidth).toBeGreaterThan(measureTextWidth(wide.displayId)); + expect(wide.graphWidth + wide.leftWidth + wide.rightWidth + 2).toBeLessThanOrEqual(118); + }); + + test("bounds many graph lanes while reserving the title and right-aligned id", () => { + const manyLanes = Array.from({ length: 24 }, (_, index) => `lane-${index}`); + const crowdedRow = { + ...row, + cells: manyLanes.map((_, index) => ({ + kind: index === 0 ? ("node" as const) : ("vertical" as const), + })), + lanesBefore: manyLanes, + lanesAfter: manyLanes, + }; + const projected = projectResponsiveLogRow({ + row: crowdedRow, + presentation, + layout: resolveLogResponsiveLayout(42, 18), + width: 42, + }); + expect(projected.graph).toEndWith("…"); + expect(projected.leftWidth).toBeGreaterThanOrEqual(12); + expect( + projected.graphWidth + projected.leftWidth + projected.rightWidth + projected.columnGap, + ).toBeLessThanOrEqual(40); + expect(projected.displayId).not.toBe(""); + expect(projected.copyIcon).toBe("⧉"); + }); + + test("removes description and secondary state as room contracts", () => { + const medium = projectResponsiveLogRow({ + row, + presentation, + layout: resolveLogResponsiveLayout(80, 24), + width: 80, + }); + expect(medium.description).toBe(""); + expect(medium.metadata).toContain("2026-09-05"); + expect(medium.secondary).toBe(""); + + const narrow = projectResponsiveLogRow({ + row, + presentation, + layout: resolveLogResponsiveLayout(42, 18), + width: 42, + }); + expect(narrow.description).toBe(""); + expect(narrow.metadata).not.toContain("2026-09-05"); + expect(measureTextWidth(narrow.title)).toBeLessThanOrEqual(narrow.leftWidth); + expect(measureTextWidth(narrow.displayId)).toBeLessThanOrEqual(narrow.rightWidth); + }); +}); diff --git a/src/ui/log/responsiveLayout.ts b/src/ui/log/responsiveLayout.ts new file mode 100644 index 000000000..534f131ba --- /dev/null +++ b/src/ui/log/responsiveLayout.ts @@ -0,0 +1,136 @@ +import type { HistoryGraphRow } from "../../core/history/types"; +import { sanitizeTerminalLine, sanitizeTerminalText } from "../../lib/terminalText"; +import { + formatHistoryDecorations, + renderHistoryContinuation, + renderHistoryGraph, +} from "../history/staticProjection"; +import { fitText, measureTextWidth } from "../lib/text"; +import type { LogPresentation } from "./controller"; + +export type LogResponsiveDensity = "wide" | "medium" | "narrow"; + +export interface LogResponsiveLayout { + density: LogResponsiveDensity; + rowHeight: 3 | 4; + bodyHeight: number; + visibleRows: number; + showDescription: boolean; + showSecondary: boolean; +} + +export interface LogResponsiveRow { + graph: string; + continuation: string; + graphWidth: number; + leftWidth: number; + rightWidth: number; + columnGap: number; + title: string; + description: string; + metadata: string; + displayId: string; + copyIcon: string; + secondary: string; +} + +/** Derive one information-density policy from the actual terminal dimensions. */ +export function resolveLogResponsiveLayout(width: number, height: number): LogResponsiveLayout { + const safeWidth = Math.max(1, width); + // Reserve one row each for the menu, its breathing room, and the status bar. + const bodyHeight = Math.max(1, height - 3); + const density: LogResponsiveDensity = + safeWidth >= 96 ? "wide" : safeWidth >= 60 ? "medium" : "narrow"; + // Keep one graph-continuation row of breathing room between commit entries. + const rowHeight = density === "wide" ? 4 : 3; + return { + density, + rowHeight, + bodyHeight, + visibleRows: Math.max(1, Math.floor(bodyHeight / rowHeight)), + showDescription: density === "wide", + showSecondary: density === "wide", + }; +} + +/** Return the first safe one-line description after the commit subject. */ +function commitDescription(body: string | undefined) { + if (!body) return ""; + return ( + sanitizeTerminalText(body, { preserveNewlines: true, preserveTabs: false }) + .split("\n") + .map((line) => line.trim()) + .find(Boolean) ?? "" + ); +} + +/** Project one commit into left/right responsive columns using terminal display-cell widths. */ +export function projectResponsiveLogRow({ + row, + presentation, + layout, + width, +}: { + row: HistoryGraphRow; + presentation: LogPresentation; + layout: LogResponsiveLayout; + width: number; +}): LogResponsiveRow { + const contentWidth = Math.max(1, width - 2); + const rawGraph = presentation.graph ? renderHistoryGraph(row, !presentation.unicode) : ""; + const rawContinuation = presentation.graph + ? renderHistoryContinuation(row, !presentation.unicode) + : ""; + const safeId = sanitizeTerminalLine(row.commit.displayId).replaceAll("\t", " "); + const maxIdWidth = Math.max( + 1, + Math.min(measureTextWidth(safeId), Math.floor(contentWidth * 0.35)), + ); + const displayId = fitText(safeId, maxIdWidth, "…"); + const copyIcon = presentation.unicode ? "⧉" : "c"; + const secondary = + layout.showSecondary && row.commit.parentRevisionIds.length > 1 + ? `${row.commit.parentRevisionIds.length} parents` + : ""; + const idActionWidth = measureTextWidth(displayId) + 1 + measureTextWidth(copyIcon); + const rightWidth = Math.max(idActionWidth, measureTextWidth(secondary)); + const minimumLeftWidth = Math.min(12, Math.max(1, contentWidth - rightWidth)); + const desiredGap = contentWidth > rightWidth + minimumLeftWidth ? 2 : 0; + const maximumGraphWidth = Math.max(0, contentWidth - rightWidth - minimumLeftWidth - desiredGap); + const graphContentWidth = Math.max(0, maximumGraphWidth - 2); + const graph = graphContentWidth > 0 ? fitText(rawGraph, graphContentWidth, "…") : ""; + const continuation = + graphContentWidth > 0 ? fitText(rawContinuation, graphContentWidth, "…") : ""; + const graphWidth = graph + ? Math.min( + maximumGraphWidth, + Math.max(measureTextWidth(graph), measureTextWidth(continuation)) + 2, + ) + : 0; + const columnGap = contentWidth > graphWidth + rightWidth ? desiredGap : 0; + const leftWidth = Math.max(1, contentWidth - graphWidth - rightWidth - columnGap); + const decorations = presentation.decorations ? formatHistoryDecorations(row).trim() : ""; + const metadataParts = [ + presentation.author ? sanitizeTerminalLine(row.commit.authorName).replaceAll("\t", " ") : "", + presentation.date && layout.density !== "narrow" + ? sanitizeTerminalLine(row.commit.authoredAt).slice(0, 10) + : "", + decorations, + ].filter(Boolean); + return { + graph, + continuation, + graphWidth, + leftWidth, + rightWidth, + columnGap, + title: fitText(sanitizeTerminalLine(row.commit.subject).replaceAll("\t", " "), leftWidth), + description: layout.showDescription + ? fitText(commitDescription(row.commit.body), leftWidth) + : "", + metadata: fitText(metadataParts.join(" · "), leftWidth), + displayId, + copyIcon, + secondary, + }; +} diff --git a/src/ui/log/runInteractiveLog.tsx b/src/ui/log/runInteractiveLog.tsx index e4f1bc875..7de12522d 100644 --- a/src/ui/log/runInteractiveLog.tsx +++ b/src/ui/log/runInteractiveLog.tsx @@ -109,8 +109,8 @@ export async function runInteractiveLog( ) { if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { await runtime.close(); - throw new HunkUserError("`hunk log --interactive` requires a terminal.", [ - "Use plain `hunk log` for pipes and redirected output.", + throw new HunkUserError("The `hunk log` browser requires a terminal.", [ + "Use `hunk log --static` to force scrollback output.", ]); } diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index b03dd34ef..be633c624 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createPtyHarness } from "./harness"; +import { createPtyHarness, rightmostColumnOf } from "./harness"; const harness = createPtyHarness(); const tempDirs: string[] = []; @@ -35,7 +35,7 @@ function createHistoryRepo() { git(cwd, ["add", "history.ts"]); git(cwd, ["commit", "-qm", "First history commit"]); writeFileSync(join(cwd, "history.ts"), "export const historyValue = 'second';\n"); - git(cwd, ["commit", "-qam", "Second history commit"]); + git(cwd, ["commit", "-qam", "Second history commit", "-m", "Responsive description"]); return cwd; } @@ -74,7 +74,7 @@ describe("interactive hunk log", () => { test("opens the selected immutable commit and returns to the retained history", async () => { const cwd = createHistoryRepo(); const session = await harness.launchHunk({ - args: ["log", "--interactive", "--color", "never", "--no-extensions"], + args: ["log", "--color", "never", "--no-extensions"], cwd, cols: 100, rows: 20, @@ -98,9 +98,16 @@ describe("interactive hunk log", () => { await session.press("enter"); await session.waitForText(/Second history commit/, { timeout: 5_000 }); - // The menu occupies row one; x=5 on the first history row lands inside - // its commit id and opens immediately without a double-click. - session.writeRaw("\x1b[<0;5;2M\x1b[<0;5;2m"); + // The first row's right-aligned commit id opens immediately without a double-click. + const firstRowIndex = history + .split("\n") + .findIndex((line) => line.includes("Second history commit")); + const firstRow = history.split("\n")[firstRowIndex] ?? ""; + const commitColumn = firstRow.search(/[0-9a-f]{8}\s+⧉\s*$/); + expect(commitColumn).toBeGreaterThan(0); + session.writeRaw( + `\x1b[<0;${commitColumn + 1};${firstRowIndex + 1}M\x1b[<0;${commitColumn + 1};${firstRowIndex + 1}m`, + ); const review = await session.waitForText(/historyValue = 'second'/, { timeout: 15_000, }); @@ -112,6 +119,12 @@ describe("interactive hunk log", () => { }); expect(returned).toContain("Enter open"); + // The adjacent icon copies without opening the review. + session.writeRaw( + `\x1b[<0;${commitColumn + 10};${firstRowIndex + 1}M\x1b[<0;${commitColumn + 10};${firstRowIndex + 1}m`, + ); + await session.waitForText(/Copied [0-9a-f]{8}/, { timeout: 5_000 }); + // Scrolling the history body dismisses an open dropdown before moving selection. await session.press("f10"); await session.waitForText(/Open selected commit/, { timeout: 5_000 }); @@ -123,7 +136,7 @@ describe("interactive hunk log", () => { ); // Clicking outside the id selects the second row without opening it. - session.writeRaw("\x1b[<0;50;3M\x1b[<0;50;3m"); + session.writeRaw("\x1b[<0;50;5M\x1b[<0;50;5m"); await session.press("enter"); const rootReview = await session.waitForText(/historyValue = 'first'/, { timeout: 15_000, @@ -152,10 +165,69 @@ describe("interactive hunk log", () => { } }); + test("adapts GitHub-style row density and right-aligned ids on resize", async () => { + const cwd = createHistoryRepo(); + const displayId = Bun.spawnSync(["git", "rev-parse", "--short=8", "HEAD"], { + cwd, + stdout: "pipe", + }) + .stdout.toString() + .trim(); + const session = await harness.launchHunk({ + args: ["log", "--color", "never", "--no-extensions"], + cwd, + cols: 110, + rows: 20, + }); + try { + const wide = await session.waitForText(/Responsive description/, { timeout: 15_000 }); + expect(rightmostColumnOf(wide, displayId)).toBeGreaterThan(95); + session.resize({ cols: 70, rows: 20 }); + await harness.waitForSnapshot( + session, + (text) => + text.includes("Second history commit") && !text.includes("Responsive description"), + 5_000, + ); + const medium = await session.text({ immediate: true }); + expect(medium).toContain("History Tester"); + expect(rightmostColumnOf(medium, displayId)).toBeGreaterThan(55); + session.resize({ cols: 42, rows: 18 }); + await harness.waitForSnapshot( + session, + (text) => text.includes("Second history commit") && !text.includes("2026-"), + 5_000, + ); + const narrow = await session.text({ immediate: true }); + expect(narrow).toContain("History Tester"); + expect(rightmostColumnOf(narrow, displayId)).toBeGreaterThan(30); + await session.press("q"); + } finally { + session.close(); + } + }); + + test("forces static scrollback output on a terminal", async () => { + const cwd = createHistoryRepo(); + const session = await harness.launchHunk({ + args: ["log", "--static", "--color", "never", "--no-extensions"], + cwd, + cols: 80, + rows: 24, + }); + try { + const output = await session.waitForText(/Author: History Tester/, { timeout: 15_000 }); + expect(output).toContain("Second history commit"); + expect(output).not.toContain("File View Navigate"); + } finally { + session.close(); + } + }); + test("uses an ASCII graph in a dumb terminal", async () => { const cwd = createHistoryRepo(); const session = await harness.launchHunk({ - args: ["log", "--interactive", "--ascii", "--no-extensions"], + args: ["log", "--ascii", "--no-extensions"], cwd, cols: 80, rows: 16, @@ -174,7 +246,7 @@ describe("interactive hunk log", () => { test("refreshes from a new provider cursor and reveals a new commit", async () => { const cwd = createHistoryRepo(); const session = await harness.launchHunk({ - args: ["log", "--interactive", "--color", "never", "--no-extensions"], + args: ["log", "--color", "never", "--no-extensions"], cwd, cols: 90, rows: 18, @@ -195,7 +267,7 @@ describe("interactive hunk log", () => { test("opens a merge against the provider-selected parent", async () => { const cwd = createMergeHistoryRepo(); const session = await harness.launchHunk({ - args: ["log", "--interactive", "--color", "never", "--no-extensions"], + args: ["log", "--color", "never", "--no-extensions"], cwd, cols: 100, rows: 20, From e884bca4c8d9c9c38e54adfee5bc03a8162bed6b Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:18:07 -0400 Subject: [PATCH 06/36] fix(ui): prepare history reviews before terminal handoff (#989) --- .changeset/flicker-free-history-review.md | 5 + src/app/startup.test.ts | 29 ++++ src/app/startup.ts | 7 +- src/core/process/terminalHandoff.test.ts | 45 +++++ src/core/process/terminalHandoff.ts | 116 +++++++++++++ src/main.tsx | 25 ++- src/ui/log/LogApp.tsx | 192 +++++++++++++--------- src/ui/log/reviewLaunch.test.ts | 127 ++++++++++++++ src/ui/log/reviewLaunch.ts | 166 +++++++++++++++++-- src/ui/log/runInteractiveLog.tsx | 39 ++++- test/pty/log-integration.test.ts | 4 + 11 files changed, 650 insertions(+), 105 deletions(-) create mode 100644 .changeset/flicker-free-history-review.md create mode 100644 src/core/process/terminalHandoff.test.ts create mode 100644 src/core/process/terminalHandoff.ts create mode 100644 src/ui/log/reviewLaunch.test.ts diff --git a/.changeset/flicker-free-history-review.md b/.changeset/flicker-free-history-review.md new file mode 100644 index 000000000..abb8a3bc2 --- /dev/null +++ b/.changeset/flicker-free-history-review.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep the themed history loading screen visible until a selected commit review is ready to claim the terminal. diff --git a/src/app/startup.test.ts b/src/app/startup.test.ts index 647101aec..ed0edf260 100644 --- a/src/app/startup.test.ts +++ b/src/app/startup.test.ts @@ -749,6 +749,35 @@ describe("startup planning", () => { expect(opened).toBe(1); }); + test("inherits handoff theme mode without querying the parent-owned terminal", async () => { + const cliInput: CliInput = { + kind: "show", + ref: "opaque:id", + options: { theme: "auto" }, + }; + let detected = 0; + + const plan = await prepareStartupPlan(["bun", "hunk", "show", "opaque:id"], { + parseCliImpl: async () => cliInput as ParsedCliInput, + resolveRuntimeCliInputImpl: (input) => input, + resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input), + loadAppBootstrapImpl: async (input) => createBootstrap(input), + detectTerminalThemeModeFromBackgroundImpl: async () => { + detected += 1; + return "light"; + }, + stdinIsTTY: true, + stdoutIsTTY: true, + env: { + HUNK_TERMINAL_HANDOFF: "1", + HUNK_TERMINAL_HANDOFF_THEME_MODE: "dark", + }, + }); + + expect(plan).toMatchObject({ kind: "app", bootstrap: { initialThemeMode: "dark" } }); + expect(detected).toBe(0); + }); + test("opens the controlling terminal for piped patch startup", async () => { const cliInput: CliInput = { kind: "patch", diff --git a/src/app/startup.ts b/src/app/startup.ts index a88daea9d..48f8c0cab 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -5,6 +5,7 @@ import { resolveConfiguredCliInput } from "../core/run/config"; import { HunkUserError } from "../core/run/errors"; import type { loadAppBootstrap } from "../core/changeset/loaders"; import { looksLikePatchInput } from "../core/process/pager"; +import { terminalHandoffThemeMode } from "../core/process/terminalHandoff"; import { sanitizeTerminalText } from "../lib/terminalText"; import { detectTerminalThemeModeFromBackground } from "../core/theme/detection"; import { @@ -543,8 +544,10 @@ export async function prepareStartupPlan( controllingTerminal = openControllingTerminalImpl(); } - let initialThemeMode: AppBootstrap["initialThemeMode"]; - if (cliInput.options.theme === "auto" && stdoutIsTTY) { + // A handoff child inherits the parent's detected mode so bootstrap never queries a terminal + // whose input and renderer are still exclusively owned by the history process. + let initialThemeMode: AppBootstrap["initialThemeMode"] = terminalHandoffThemeMode(env); + if (!initialThemeMode && cliInput.options.theme === "auto" && stdoutIsTTY) { const themeInput = controllingTerminal?.stdin ?? (stdinIsTTY ? process.stdin : null); if (themeInput) { initialThemeMode = diff --git a/src/core/process/terminalHandoff.test.ts b/src/core/process/terminalHandoff.test.ts new file mode 100644 index 000000000..08753d939 --- /dev/null +++ b/src/core/process/terminalHandoff.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { + hasTerminalHandoff, + parseTerminalHandoffMessage, + terminalHandoffEnv, + terminalHandoffMessage, + terminalHandoffThemeMode, +} from "./terminalHandoff"; + +describe("terminal handoff protocol", () => { + test("uses a private marker and inherits only a valid terminal mode", () => { + const env = terminalHandoffEnv({ PATH: "/bin" }, "dark"); + expect(hasTerminalHandoff(env)).toBe(true); + expect(terminalHandoffThemeMode(env)).toBe("dark"); + expect( + terminalHandoffThemeMode({ + HUNK_TERMINAL_HANDOFF: "1", + HUNK_TERMINAL_HANDOFF_THEME_MODE: "blue", + }), + ).toBeUndefined(); + expect(terminalHandoffThemeMode({ HUNK_TERMINAL_HANDOFF_THEME_MODE: "light" })).toBeUndefined(); + }); + + test("accepts only versioned bounded messages", () => { + expect(parseTerminalHandoffMessage(terminalHandoffMessage("ready"))).toEqual({ + protocol: "hunk-terminal-handoff-v1", + kind: "ready", + }); + expect(parseTerminalHandoffMessage({ protocol: "wrong", kind: "ready" })).toBeUndefined(); + expect( + parseTerminalHandoffMessage({ protocol: "hunk-terminal-handoff-v1", kind: "other" }), + ).toBeUndefined(); + expect( + parseTerminalHandoffMessage({ + protocol: "hunk-terminal-handoff-v1", + kind: "failed", + message: "x".repeat(3_000), + }), + ).toEqual({ + protocol: "hunk-terminal-handoff-v1", + kind: "failed", + message: "x".repeat(2_000), + }); + }); +}); diff --git a/src/core/process/terminalHandoff.ts b/src/core/process/terminalHandoff.ts new file mode 100644 index 000000000..0f8cc3956 --- /dev/null +++ b/src/core/process/terminalHandoff.ts @@ -0,0 +1,116 @@ +const HANDOFF_ENV = "HUNK_TERMINAL_HANDOFF"; +const HANDOFF_THEME_MODE_ENV = "HUNK_TERMINAL_HANDOFF_THEME_MODE"; +const PROTOCOL = "hunk-terminal-handoff-v1"; + +export type TerminalHandoffMessage = + | { protocol: typeof PROTOCOL; kind: "ready" } + | { protocol: typeof PROTOCOL; kind: "release" } + | { protocol: typeof PROTOCOL; kind: "failed"; message: string }; + +/** Return whether this process was launched for a coordinated terminal handoff. */ +export function hasTerminalHandoff(env: NodeJS.ProcessEnv = process.env) { + return env[HANDOFF_ENV] === "1"; +} + +/** Read the parent's already-detected terminal mode without querying the owned terminal again. */ +export function terminalHandoffThemeMode( + env: NodeJS.ProcessEnv = process.env, +): "dark" | "light" | undefined { + if (!hasTerminalHandoff(env)) return undefined; + const value = env[HANDOFF_THEME_MODE_ENV]; + return value === "dark" || value === "light" ? value : undefined; +} + +/** Add the private one-shot handoff marker and terminal mode to a child environment. */ +export function terminalHandoffEnv( + env: NodeJS.ProcessEnv, + themeMode: "dark" | "light" | undefined, +): NodeJS.ProcessEnv { + return { + ...env, + [HANDOFF_ENV]: "1", + ...(themeMode ? { [HANDOFF_THEME_MODE_ENV]: themeMode } : {}), + }; +} + +/** Narrow an IPC payload to one bounded handoff protocol message. */ +export function parseTerminalHandoffMessage(value: unknown): TerminalHandoffMessage | undefined { + if (!value || typeof value !== "object") return undefined; + const message = value as Record; + if (message.protocol !== PROTOCOL) return undefined; + if (message.kind === "ready" || message.kind === "release") { + return { protocol: PROTOCOL, kind: message.kind }; + } + if (message.kind === "failed" && typeof message.message === "string") { + return { protocol: PROTOCOL, kind: "failed", message: message.message.slice(0, 2_000) }; + } + return undefined; +} + +/** Build one authenticated-by-inheritance IPC message for the handoff peer. */ +export function terminalHandoffMessage(kind: "ready" | "release"): TerminalHandoffMessage { + return { protocol: PROTOCOL, kind }; +} + +/** Tell the parent startup succeeded, then wait boundedly for exclusive terminal ownership. */ +export async function awaitTerminalHandoffRelease({ + env = process.env, + timeoutMs = 10_000, +}: { + env?: NodeJS.ProcessEnv; + timeoutMs?: number; +} = {}) { + if (!hasTerminalHandoff(env)) return; + if (typeof process.send !== "function" || !process.connected) { + throw new Error("The terminal handoff channel is unavailable."); + } + + await new Promise((resolve, reject) => { + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + process.off("message", onMessage); + process.off("disconnect", onDisconnect); + if (error) reject(error); + else resolve(); + }; + const onMessage = (value: unknown) => { + const message = parseTerminalHandoffMessage(value); + if (message?.kind === "release") finish(); + }; + const onDisconnect = () => finish(new Error("The terminal handoff parent disconnected.")); + const timeout = setTimeout( + () => finish(new Error("Timed out waiting for terminal ownership.")), + timeoutMs, + ); + timeout.unref?.(); + process.on("message", onMessage); + process.once("disconnect", onDisconnect); + process.send!(terminalHandoffMessage("ready"), (error) => { + if (error) finish(error); + }); + }); + process.disconnect?.(); + delete env[HANDOFF_ENV]; + delete env[HANDOFF_THEME_MODE_ENV]; +} + +/** Report a bounded pre-render startup failure to a waiting parent. */ +export async function reportTerminalHandoffFailure( + error: unknown, + env: NodeJS.ProcessEnv = process.env, +) { + if (!hasTerminalHandoff(env) || typeof process.send !== "function" || !process.connected) { + return false; + } + const message = error instanceof Error ? error.message : String(error); + await new Promise((resolve) => { + process.send!({ protocol: PROTOCOL, kind: "failed", message: message.slice(0, 2_000) }, () => + resolve(), + ); + }); + process.disconnect?.(); + return true; +} diff --git a/src/main.tsx b/src/main.tsx index 7137bc4ee..23732524f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,6 +7,10 @@ import { prepareStartupPlan } from "./app/startup"; import { sanitizeTerminalText } from "./lib/terminalText"; import { serveSessionBrokerDaemon } from "./session/broker/brokerServer"; import { runSessionCommand } from "./session/agent/commands"; +import { + awaitTerminalHandoffRelease, + reportTerminalHandoffFailure, +} from "./core/process/terminalHandoff"; async function main() { const startupPlan = await prepareStartupPlan(); @@ -128,11 +132,18 @@ async function main() { } // OpenTUI stays behind the interactive plan so headless commands never materialize its embedded - // native library. The highlighting client starts the compiled worker only when an opted-in, - // eligible diff needs it, so normal sessions do not pay its startup cost. The interactive - // app owns that worker's disposal: this call returns once the app is mounted, not once it exits. + // native library. Load it before declaring a delegated review ready so terminal release is + // followed immediately by renderer creation rather than another module-loading gap. + const { runInteractiveApp } = await import("./ui/runInteractiveApp"); + + // A history parent keeps its loading frame mounted until review bootstrap and renderer code are + // ready. Wait for exclusive terminal ownership before mounting the child renderer. + await awaitTerminalHandoffRelease(); + + // The highlighting client starts the compiled worker only when an opted-in, eligible diff needs + // it, so normal sessions do not pay its startup cost. The interactive app owns that worker's + // disposal: this call returns once the app is mounted, not once it exits. try { - const { runInteractiveApp } = await import("./ui/runInteractiveApp"); await runInteractiveApp(startupPlan); } catch (error) { startupPlan.controllingTerminal?.close(); @@ -143,7 +154,7 @@ async function main() { } } -await main().catch((error) => { - process.stderr.write(formatCliError(error)); - process.exit(1); +await main().catch(async (error) => { + if (!(await reportTerminalHandoffFailure(error))) process.stderr.write(formatCliError(error)); + process.exitCode = 1; }); diff --git a/src/ui/log/LogApp.tsx b/src/ui/log/LogApp.tsx index e6635ab48..38090f7b5 100644 --- a/src/ui/log/LogApp.tsx +++ b/src/ui/log/LogApp.tsx @@ -28,7 +28,12 @@ import { projectResponsiveLogRow, resolveLogResponsiveLayout } from "./responsiv export type LogAppOutcome = | { kind: "quit"; exitCode?: number } - | { kind: "open-review"; action: ExtensionVcsHistoryReviewAction; themeId: string }; + | { + kind: "open-review"; + action: ExtensionVcsHistoryReviewAction; + themeId: string; + themeMode: "dark" | "light"; + }; /** Render the bounded history list inside Hunk's shared desktop chrome. */ export function LogApp({ @@ -39,7 +44,7 @@ export function LogApp({ }: { controller: LogController; runtime: HistoryRuntime; - onOutcome: (outcome: LogAppOutcome) => void; + onOutcome: (outcome: LogAppOutcome) => void | Promise; useColor: boolean; }) { const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot); @@ -48,6 +53,7 @@ export function LogApp({ const [showHelp, setShowHelp] = useState(false); const [parentSelectorIndex, setParentSelectorIndex] = useState(null); const [transientNotice, setTransientNotice] = useState(""); + const [openingCommit, setOpeningCommit] = useState<{ id: string; subject: string } | null>(null); const lastClick = useRef({ index: -1, at: 0 }); // Lock synchronously before awaiting provider planning so coalesced Enter+q input cannot // quit the log or leak the trailing command into the child review. @@ -86,14 +92,25 @@ export function LogApp({ const planned = controller.planSelectedReview(parentRevisionId); if (!planned) return; reviewPending.current = true; + const currentRow = controller.getSelectedRow(); + setOpeningCommit( + currentRow + ? { + id: sanitizeTerminalLine(currentRow.commit.displayId), + subject: sanitizeTerminalLine(currentRow.commit.subject), + } + : null, + ); try { - onOutcome({ + await onOutcome({ kind: "open-review", action: await planned, themeId: themeController.themeId, + themeMode: terminalThemeMode, }); } catch (error) { reviewPending.current = false; + setOpeningCommit(null); controller.setNotice(error instanceof Error ? error.message : String(error)); } }; @@ -372,91 +389,112 @@ export function LogApp({ else if (direction === "down") controller.move(3, viewportHeight); }} > - {visible.map((row, offset) => { - const index = snapshot.top + offset; - const selected = index === snapshot.selected; - const projected = projectResponsiveLogRow({ - row, - presentation: snapshot.presentation, - layout: responsiveLayout, - width: terminal.width, - }); - return ( - { - clearTransientNotice(); - const now = Date.now(); - const shouldOpen = - lastClick.current.index === index && now - lastClick.current.at < 400; - void controller.select(index, viewportHeight).then(() => { - if (shouldOpen) void openSelected(); - }); - lastClick.current = { index, at: now }; - }} - > - {projected.graphWidth ? ( - - {projected.graph} - {Array.from({ length: responsiveLayout.rowHeight - 1 }, (_, line) => ( - - {projected.continuation} - - ))} - - ) : null} - - {projected.title} - {responsiveLayout.showDescription ? ( - {projected.description} - ) : null} - {projected.metadata} - - {projected.columnGap ? : null} + {openingCommit ? ( + + Opening commit + + {fitText( + `${openingCommit.id} · ${openingCommit.subject}`, + Math.max(1, terminal.width - 4), + )} + + Preparing review… + + ) : ( + visible.map((row, offset) => { + const index = snapshot.top + offset; + const selected = index === snapshot.selected; + const projected = projectResponsiveLogRow({ + row, + presentation: snapshot.presentation, + layout: responsiveLayout, + width: terminal.width, + }); + return ( { - event.stopPropagation(); + onMouseUp={() => { clearTransientNotice(); - const copyIconStart = terminal.width - 1 - measureTextWidth(projected.copyIcon); + const now = Date.now(); + const shouldOpen = + lastClick.current.index === index && now - lastClick.current.at < 400; void controller.select(index, viewportHeight).then(() => { - if (event.x >= copyIconStart) copySelected(row); - else void openSelected(); + if (shouldOpen) void openSelected(); }); + lastClick.current = { index, at: now }; }} > - - {projected.displayId} - {projected.copyIcon} + {projected.graphWidth ? ( + + {projected.graph} + {Array.from({ length: responsiveLayout.rowHeight - 1 }, (_, line) => ( + + {projected.continuation} + + ))} + + ) : null} + + {projected.title} + {responsiveLayout.showDescription ? ( + {projected.description} + ) : null} + {projected.metadata} + + {projected.columnGap ? : null} + { + event.stopPropagation(); + clearTransientNotice(); + const copyIconStart = terminal.width - 1 - measureTextWidth(projected.copyIcon); + void controller.select(index, viewportHeight).then(() => { + if (event.x >= copyIconStart) copySelected(row); + else void openSelected(); + }); + }} + > + + {projected.displayId} + {projected.copyIcon} + + {projected.secondary ? {projected.secondary} : null} - {projected.secondary ? {projected.secondary} : null} - - ); - })} + ); + }) + )} void) => { + child.sent.push(message); + callback?.(null); + return true; + }) as ChildProcess["send"]; + child.kill = ((signal: NodeJS.Signals = "SIGTERM") => { + if (signal === "SIGTERM" && ignoreTerm) return true; + child.signalCode = signal; + child.connected = false; + queueMicrotask(() => child.emit("exit", null, signal)); + return true; + }) as ChildProcess["kill"]; + return child; +} + +describe("history review readiness", () => { + test("does not release terminal ownership until the child reports ready", async () => { + const child = createTestChild(); + const preparedPromise = prepareHistoryReview( + createTestRuntime(), + { kind: "revision-show", revisionId: "opaque:id" }, + { + current: { command: "hunk", args: [] }, + spawnImpl: (() => child) as unknown as typeof spawn, + env: {}, + }, + ); + + expect(child.sent).toEqual([]); + child.emit("message", { protocol: "hunk-terminal-handoff-v1", kind: "ready" }); + const prepared = await preparedPromise; + expect(child.sent).toEqual([]); + + const exit = prepared.run(); + expect(child.sent).toEqual([{ protocol: "hunk-terminal-handoff-v1", kind: "release" }]); + child.exitCode = 0; + child.emit("exit", 0, null); + expect(await exit).toBe(0); + }); + + test("observes a signalled exit that happens after readiness but before run", async () => { + const child = createTestChild(); + const preparedPromise = prepareHistoryReview( + createTestRuntime(), + { kind: "revision-show", revisionId: "racy" }, + { + current: { command: "hunk", args: [] }, + spawnImpl: (() => child) as unknown as typeof spawn, + env: {}, + }, + ); + + child.emit("message", { protocol: "hunk-terminal-handoff-v1", kind: "ready" }); + const prepared = await preparedPromise; + child.signalCode = "SIGTERM"; + child.connected = false; + child.emit("exit", null, "SIGTERM"); + expect(await prepared.run()).toBe(1); + }); + + test("surfaces bounded child bootstrap failures without releasing the terminal", async () => { + const child = createTestChild(); + const prepared = prepareHistoryReview( + createTestRuntime(), + { kind: "revision-show", revisionId: "missing" }, + { + current: { command: "hunk", args: [] }, + spawnImpl: (() => child) as unknown as typeof spawn, + env: {}, + }, + ); + child.emit("message", { + protocol: "hunk-terminal-handoff-v1", + kind: "failed", + message: "provider could not resolve revision", + }); + await expect(prepared).rejects.toThrow("provider could not resolve revision"); + expect(child.sent).toEqual([]); + expect(child.signalCode).toBe("SIGTERM"); + }); + + test("escalates when a timed-out child ignores graceful termination", async () => { + const child = createTestChild({ ignoreTerm: true }); + const prepared = prepareHistoryReview( + createTestRuntime(), + { kind: "revision-show", revisionId: "slow" }, + { + current: { command: "hunk", args: [] }, + spawnImpl: (() => child) as unknown as typeof spawn, + env: {}, + readyTimeoutMs: 5, + terminateGraceMs: 5, + }, + ); + await expect(prepared).rejects.toThrow("Timed out while preparing"); + expect(child.signalCode).toBe("SIGKILL"); + }); +}); diff --git a/src/ui/log/reviewLaunch.ts b/src/ui/log/reviewLaunch.ts index d0d60a39e..f982678d9 100644 --- a/src/ui/log/reviewLaunch.ts +++ b/src/ui/log/reviewLaunch.ts @@ -1,22 +1,67 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { resolve } from "node:path"; import { resolveCurrentHunkCommand } from "../../core/process/relaunch"; +import { + parseTerminalHandoffMessage, + terminalHandoffEnv, + terminalHandoffMessage, +} from "../../core/process/terminalHandoff"; import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; import type { HistoryRuntime } from "../history/types"; +const DEFAULT_READY_TIMEOUT_MS = 30_000; +const MAX_BOOTSTRAP_ERROR_BYTES = 16_384; + +export interface PreparedHistoryReview { + /** Release exclusive terminal ownership and wait for the child review to exit. */ + run(): Promise; + /** Stop a child that never received terminal ownership. */ + abort(): Promise; +} + /** Convert a provider-owned review declaration into one option-safe child invocation. */ export function historyReviewArgs(action: ExtensionVcsHistoryReviewAction) { const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64url"); return [action.kind === "revision-range" ? "diff" : "show", "--history-review", payload]; } -/** Run one provider-planned child Hunk review after the log renderer yields the terminal. */ -export async function launchHistoryReview( +/** Collect bounded bootstrap diagnostics without allowing startup output to disturb the log UI. */ +function collectBootstrapError(child: ChildProcess) { + let output = ""; + child.stderr?.setEncoding("utf8"); + child.stderr?.on("data", (chunk: string) => { + if (output.length >= MAX_BOOTSTRAP_ERROR_BYTES) return; + output += chunk.slice(0, MAX_BOOTSTRAP_ERROR_BYTES - output.length); + }); + return () => output.trim(); +} + +/** Spawn and bootstrap one provider-planned review while the log still owns the terminal. */ +export async function prepareHistoryReview( runtime: HistoryRuntime, action: ExtensionVcsHistoryReviewAction, - themeId?: string, -) { - const current = resolveCurrentHunkCommand(); + { + themeId, + themeMode, + signal, + readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS, + terminateGraceMs = 1_000, + spawnImpl = spawn, + current = resolveCurrentHunkCommand(), + env = process.env, + stderr = process.stderr, + }: { + themeId?: string; + themeMode?: "dark" | "light"; + signal?: AbortSignal; + readyTimeoutMs?: number; + terminateGraceMs?: number; + spawnImpl?: typeof spawn; + current?: ReturnType; + env?: NodeJS.ProcessEnv; + stderr?: NodeJS.WritableStream; + } = {}, +): Promise { const extensionArgs = runtime.input.extensionPaths.flatMap((path) => [ "--extension", resolve(path), @@ -29,13 +74,110 @@ export async function launchHistoryReview( ...(themeId ? ["--theme", themeId] : []), ...(runtime.input.extensionsEnabled ? extensionArgs : ["--no-extensions"]), ]; - const child = spawn(current.command, args, { + const child = spawnImpl(current.command, args, { cwd: runtime.repoRoot, - env: { ...process.env, HUNK_RETURN_TO_HISTORY: "1" }, - stdio: "inherit", + env: terminalHandoffEnv({ ...env, HUNK_RETURN_TO_HISTORY: "1" }, themeMode), + stdio: ["inherit", "inherit", "pipe", "ipc"], }); - return await new Promise((resolveExit, reject) => { - child.once("error", reject); - child.once("exit", (code, signal) => resolveExit(signal ? 1 : (code ?? 1))); + const bootstrapError = collectBootstrapError(child); + const exitResult = new Promise<{ code: number; error?: Error }>((resolveExit) => { + let settled = false; + const finish = (result: { code: number; error?: Error }) => { + if (settled) return; + settled = true; + resolveExit(result); + }; + child.once("error", (error) => finish({ code: 1, error })); + child.once("exit", (code, exitSignal) => finish({ code: exitSignal ? 1 : (code ?? 1) })); }); + const childRunning = () => child.exitCode === null && child.signalCode === null; + const terminateChild = async () => { + if (!childRunning()) return; + child.kill("SIGTERM"); + const stopped = await Promise.race([ + exitResult.then(() => true), + new Promise((resolveTimeout) => { + const timer = setTimeout(() => resolveTimeout(false), terminateGraceMs); + timer.unref?.(); + }), + ]); + if (!stopped && childRunning()) { + child.kill("SIGKILL"); + await exitResult; + } + }; + + try { + await new Promise((resolveReady, rejectReady) => { + let settled = false; + const finish = (error?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + child.off("message", onMessage); + child.off("error", onError); + child.off("exit", onExit); + signal?.removeEventListener("abort", onAbort); + if (error) rejectReady(error); + else resolveReady(); + }; + const onMessage = (value: unknown) => { + const message = parseTerminalHandoffMessage(value); + if (message?.kind === "ready") finish(); + if (message?.kind === "failed") finish(new Error(message.message)); + }; + const onError = (error: Error) => finish(error); + const onExit = () => + finish(new Error(bootstrapError() || "The review exited before it was ready.")); + const onAbort = () => finish(new Error("Review launch was cancelled.")); + const timeout = setTimeout( + () => finish(new Error("Timed out while preparing the selected commit.")), + readyTimeoutMs, + ); + timeout.unref?.(); + child.on("message", onMessage); + child.once("error", onError); + child.once("exit", onExit); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } catch (error) { + await terminateChild(); + throw error; + } + + let released = false; + const waitForExit = async () => { + const result = await exitResult; + if (result.error) throw result.error; + return result.code; + }; + + return { + async run() { + if (released || !childRunning()) return await waitForExit(); + child.stderr?.pipe(stderr); + try { + await new Promise((resolveRelease, rejectRelease) => { + if (!child.connected) { + rejectRelease(new Error("The review disconnected before terminal release.")); + return; + } + child.send(terminalHandoffMessage("release"), (error) => { + if (error) rejectRelease(error); + else resolveRelease(); + }); + }); + released = true; + } catch (error) { + if (!childRunning()) return await waitForExit(); + await terminateChild(); + throw error; + } + return await waitForExit(); + }, + async abort() { + if (released || !childRunning()) return; + await terminateChild(); + }, + }; } diff --git a/src/ui/log/runInteractiveLog.tsx b/src/ui/log/runInteractiveLog.tsx index 7de12522d..518d7b532 100644 --- a/src/ui/log/runInteractiveLog.tsx +++ b/src/ui/log/runInteractiveLog.tsx @@ -14,7 +14,7 @@ import { } from "../../core/process/terminal"; import { LogApp, type LogAppOutcome } from "./LogApp"; import { LogController } from "./controller"; -import { launchHistoryReview } from "./reviewLaunch"; +import { prepareHistoryReview, type PreparedHistoryReview } from "./reviewLaunch"; import type { HistoryRuntime } from "../history/types"; import { interactiveLogUsesColor } from "./colorPolicy"; @@ -28,7 +28,11 @@ export function logSignalExitCode(signal: NodeJS.Signals) { return signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143; } -/** Mount one OpenTUI log surface and resolve only after it requests quit or review. */ +type MountedLogOutcome = + | Extract + | { kind: "open-review"; launch: PreparedHistoryReview }; + +/** Mount one OpenTUI log surface and keep it visible while the selected review bootstraps. */ async function mountLogSurface( controller: LogController, runtime: HistoryRuntime, @@ -52,15 +56,35 @@ async function mountLogSurface( throw error; } let settled = false; - let settle!: (outcome: LogAppOutcome) => void; - const outcome = new Promise((resolve) => { + let settle!: (outcome: MountedLogOutcome) => void; + const outcome = new Promise((resolve) => { settle = resolve; }); - const finish = (value: LogAppOutcome) => { + const launchAbort = new AbortController(); + let preparedLaunch: PreparedHistoryReview | undefined; + const finish = (value: MountedLogOutcome) => { if (settled) return; settled = true; settle(value); }; + const handleOutcome = async (value: LogAppOutcome) => { + if (value.kind === "quit") { + launchAbort.abort(); + finish(value); + return; + } + const launch = await prepareHistoryReview(runtime, value.action, { + themeId: value.themeId, + themeMode: value.themeMode, + signal: launchAbort.signal, + }); + if (settled) { + await launch.abort(); + return; + } + preparedLaunch = launch; + finish({ kind: "open-review", launch }); + }; const requestQuit = () => finish({ kind: "quit" }); const requestInterrupt = () => finish({ kind: "quit", exitCode: 130 }); const signalHandlers = new Map void>( @@ -86,11 +110,12 @@ async function mountLogSurface( controller={controller} runtime={runtime} useColor={interactiveLogUsesColor(runtime.input.color, process.env)} - onOutcome={finish} + onOutcome={handleOutcome} />, ); return await outcome; } finally { + if (!preparedLaunch) launchAbort.abort(); for (const [signal, handler] of signalHandlers) process.off(signal, handler); interrupt.dispose(); suspend.dispose(); @@ -124,7 +149,7 @@ export async function runInteractiveLog( return; } try { - const code = await launchHistoryReview(runtime, outcome.action, outcome.themeId); + const code = await outcome.launch.run(); controller.setNotice(code === 0 ? "" : "Could not open the selected commit."); } catch (error) { controller.setNotice(error instanceof Error ? error.message : String(error)); diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index be633c624..8e1528b97 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -108,6 +108,10 @@ describe("interactive hunk log", () => { session.writeRaw( `\x1b[<0;${commitColumn + 1};${firstRowIndex + 1}M\x1b[<0;${commitColumn + 1};${firstRowIndex + 1}m`, ); + const preparing = await session.waitForText(/Opening commit[\s\S]*Preparing review…/, { + timeout: 5_000, + }); + expect(preparing).not.toContain("historyValue = 'second'"); const review = await session.waitForText(/historyValue = 'second'/, { timeout: 15_000, }); From 554cfc04c613f014d5e3044febc9079e534499d4 Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:18:08 -0400 Subject: [PATCH 07/36] refactor(ui): keep history reviews in one renderer (#992) --- .../cancellable-shared-history-extensions.md | 5 + .changeset/persistent-history-review.md | 5 + docs/extensions.md | 22 ++ src/app/extensionBootstrap.test.ts | 60 +++++ src/app/extensionBootstrap.ts | 20 ++ src/app/historyBootstrap.ts | 5 + src/app/session/registration.test.ts | 6 + src/app/session/registration.ts | 3 +- src/app/sessionBootstrap.ts | 7 + src/app/startup.test.ts | 7 +- src/app/startup.ts | 33 ++- src/core/changeset/loaders.test.ts | 13 +- src/core/changeset/loaders.ts | 12 +- src/core/process/terminalHandoff.test.ts | 45 ---- src/core/process/terminalHandoff.ts | 116 --------- src/extension-api/types.ts | 2 + .../default/vcs/asyncProcess.test.ts | 39 +++ src/extensions/default/vcs/asyncProcess.ts | 98 ++++++++ src/extensions/default/vcs/git/commands.ts | 235 ++++++++++++++++++ src/extensions/default/vcs/git/index.ts | 166 ++++++++----- .../default/vcs/jujutsu/commands.ts | 90 +++++++ src/extensions/default/vcs/jujutsu/index.ts | 36 ++- .../default/vcs/sapling/commands.ts | 63 +++++ .../default/vcs/sapling/index.test.ts | 18 +- src/extensions/default/vcs/sapling/index.ts | 17 +- src/main.tsx | 21 +- src/ui/App.tsx | 42 +++- src/ui/AppHost.dynamic-mount.test.tsx | 61 +++++ src/ui/AppHost.keybindings.test.tsx | 46 ++++ src/ui/AppHost.tsx | 38 ++- src/ui/components/panes/DiffPane.tsx | 8 +- src/ui/history/types.ts | 5 + .../useIntermediateRenderAfterMount.test.tsx | 39 +++ .../hooks/useIntermediateRenderAfterMount.ts | 18 ++ src/ui/log/LogApp.tsx | 5 +- src/ui/log/LogSessionHost.tsx | 143 +++++++++++ src/ui/log/embeddedReview.test.ts | 74 ++++++ src/ui/log/reviewLaunch.test.ts | 127 ---------- src/ui/log/reviewLaunch.ts | 178 +------------ src/ui/log/runInteractiveLog.tsx | 165 +++++------- src/ui/runInteractiveApp.tsx | 124 +++++++-- test/pty/log-integration.test.ts | 50 +++- 42 files changed, 1531 insertions(+), 736 deletions(-) create mode 100644 .changeset/cancellable-shared-history-extensions.md create mode 100644 .changeset/persistent-history-review.md delete mode 100644 src/core/process/terminalHandoff.test.ts delete mode 100644 src/core/process/terminalHandoff.ts create mode 100644 src/extensions/default/vcs/asyncProcess.test.ts create mode 100644 src/extensions/default/vcs/asyncProcess.ts create mode 100644 src/ui/AppHost.dynamic-mount.test.tsx create mode 100644 src/ui/hooks/useIntermediateRenderAfterMount.test.tsx create mode 100644 src/ui/hooks/useIntermediateRenderAfterMount.ts create mode 100644 src/ui/log/LogSessionHost.tsx create mode 100644 src/ui/log/embeddedReview.test.ts delete mode 100644 src/ui/log/reviewLaunch.test.ts diff --git a/.changeset/cancellable-shared-history-extensions.md b/.changeset/cancellable-shared-history-extensions.md new file mode 100644 index 000000000..7806a652b --- /dev/null +++ b/.changeset/cancellable-shared-history-extensions.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep commit reviews responsive while bundled VCS commands run, and share one correctly owned extension lifecycle across retained history and embedded reviews. diff --git a/.changeset/persistent-history-review.md b/.changeset/persistent-history-review.md new file mode 100644 index 000000000..8eff3a6d3 --- /dev/null +++ b/.changeset/persistent-history-review.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Keep `hunk log` and opened commit reviews in one terminal renderer so returning never exposes previous terminal output. diff --git a/docs/extensions.md b/docs/extensions.md index d4844dd62..60af74dc1 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -278,6 +278,22 @@ start watchers, processes, connections, and other long-lived resources from `startup`, and release them from `shutdown`. Extension-registry reloads create new instances and run that shutdown/startup pair around the replacement. +An interactive history workspace owns one extension instance for its complete +lifetime. Opening a commit review inside that workspace borrows the same +instance: the factory and `startup` do not run again, and returning to history +does not send `shutdown`. Review-specific `changeset_loaded` events still run +for each opened commit. The owning history workspace sends the one eventual +`shutdown` when it exits. This makes module-local clients and stores safe to +share deliberately between retained history and its commit reviews without a +review closing resources that history still uses. + +Keep mutable review-generation data keyed by the identities in event payloads +or replace it on `changeset_loaded`; module scope is workspace state, not a +fresh namespace per opened commit. An embedded review cannot replace its +borrowed registry; extension replacement remains an operation of the owning +workspace. A true owner-driven extension-registry reload creates a new instance +and retires the replaced instance at that explicit ownership boundary. + ### `hunk.apiVersion` The API generation this Hunk speaks (currently `19`). Branch on it if you want @@ -562,6 +578,12 @@ reports it as unsupported. Jujutsu supplies commit/change identities, bookmarks, and native merge-review semantics without routing through a colocated Git repository. Third-party adapters use exactly the same contract. +Every operation `load` receives `context.signal`. Use asynchronous subprocess +APIs, pass cancellation through, and terminate plus reap provider processes when +it aborts; a synchronous spawn blocks Hunk's renderer and prevents the abort +handler from running. Watch signatures remain synchronous because the watch +runtime calls them as short, noninteractive probes. + A `load` result is patch text plus how to label it. Everything else on it is optional, and each optional field buys one thing: diff --git a/src/app/extensionBootstrap.test.ts b/src/app/extensionBootstrap.test.ts index 692d87fa7..1a45aee54 100644 --- a/src/app/extensionBootstrap.test.ts +++ b/src/app/extensionBootstrap.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from "bun:test"; import type { HunkConfigResolution } from "../core/run/config"; import type { CliInput } from "../core/run/commandInputs"; +import type { HunkExtensionAPI } from "../extension-api/types"; +import { emitExtensionEvent, retireExtensionLoadResult } from "../extensions/events"; +import { loadExtensions } from "../extensions/host"; import { createEmptyExtensionLoadResult } from "../extensions/types"; import { resolveConfiguredExtensions } from "./extensionBootstrap"; import { getBundledVcsCatalog } from "./vcsCatalog"; @@ -16,6 +19,63 @@ function createTestConfig(input: CliInput): HunkConfigResolution { } describe("resolveConfiguredExtensions", () => { + test("borrows one stateful extension instance across repeated review generations", async () => { + const input: CliInput = { kind: "show", ref: "opaque", options: { vcs: "git" } }; + const state = { factories: 0, startups: 0, changesets: 0, shutdowns: 0 }; + const borrowed = await loadExtensions({ + candidates: [{ id: "stateful", path: "/repo/stateful.ts", origin: "flag" }], + cwd: "/repo", + importExtensionModuleImpl: async () => ({ + default(hunk: HunkExtensionAPI) { + state.factories += 1; + hunk.on("startup", () => { + state.startups += 1; + }); + hunk.on("changeset_loaded", () => { + state.changesets += 1; + }); + hunk.on("shutdown", () => { + state.shutdowns += 1; + }); + }, + }), + }); + emitExtensionEvent(borrowed, "startup", { cwd: "/repo" }); + let loaderCalls = 0; + + for (let generation = 0; generation < 2; generation += 1) { + const resolved = await resolveConfiguredExtensions( + { + runtimeInput: input, + configured: createTestConfig(input), + cwd: "/repo", + baseVcsCatalog: getBundledVcsCatalog(), + borrowedLoad: borrowed, + }, + { + loadStartupExtensionsImpl: async () => { + loaderCalls += 1; + throw new Error("borrowed extensions must not reload"); + }, + }, + ); + expect(resolved.extensions).toBe(borrowed); + emitExtensionEvent(resolved.extensions, "changeset_loaded", { + changeset: { + id: `generation-${generation}`, + title: "Stateful review", + files: [], + sourceLabel: `generation-${generation}`, + }, + }); + } + + expect(state).toEqual({ factories: 1, startups: 1, changesets: 2, shutdowns: 0 }); + expect(loaderCalls).toBe(0); + await retireExtensionLoadResult(borrowed); + expect(state.shutdowns).toBe(1); + }); + test("retires provisional authority when the loader rejects before returning it", async () => { const input: CliInput = { kind: "vcs", staged: false, options: { vcs: "git" } }; const provisional = createEmptyExtensionLoadResult("/repo"); diff --git a/src/app/extensionBootstrap.ts b/src/app/extensionBootstrap.ts index 15adc2d84..d833f9de6 100644 --- a/src/app/extensionBootstrap.ts +++ b/src/app/extensionBootstrap.ts @@ -21,6 +21,8 @@ export interface ResolveConfiguredExtensionsOptions { notifications?: ExtensionNotificationHub; /** Registry already loaded by an extension CLI command before built-in delegation. */ previousLoad?: ExtensionLoadResult; + /** Session-owned registry borrowed by an embedded surface without lifecycle authority. */ + borrowedLoad?: ExtensionLoadResult; /** Publish provisional ownership before imports or asynchronous factories can suspend. */ onProvisionalLoad?: (result: ExtensionLoadResult) => void; /** Throw when the caller's lifetime ended so no later staged registry can be created. */ @@ -59,6 +61,24 @@ export async function resolveConfiguredExtensions( vcsCatalog: options.discoveryCatalog ?? options.baseVcsCatalog, }); + if (options.borrowedLoad) { + const adapters = resolveExtensionVcsAdapters( + options.borrowedLoad.registry, + options.baseVcsCatalog, + ).adapters; + const catalog = extendVcsCatalog(options.baseVcsCatalog, adapters); + const projectRoot = findProjectRootCandidateImpl(options.cwd, catalog); + if (projectRoot !== configured.projectRoot) { + configured = resolveConfiguredCliInputImpl(options.runtimeInput, { + cwd: options.cwd, + env: options.env, + vcsCatalog: catalog, + }); + } + options.assertActive?.(); + return { configured, extensions: options.borrowedLoad }; + } + let extensions: ExtensionLoadResult | undefined; let provisionalExtensions: ExtensionLoadResult | undefined; /** Retain loader ownership locally before forwarding it to a caller that may also suspend. */ diff --git a/src/app/historyBootstrap.ts b/src/app/historyBootstrap.ts index bd4372416..1e362c940 100644 --- a/src/app/historyBootstrap.ts +++ b/src/app/historyBootstrap.ts @@ -28,8 +28,11 @@ export interface HistoryBootstrap { source: VcsHistorySource; providerId: string; providerName: string; + startupCwd: string; repoRoot: string; extensions: ExtensionLoadResult; + /** History-owned extension authority borrowed by embedded reviews. */ + extensionSession: ExtensionLoadResult; notices: readonly string[]; customThemes: readonly NamedCustomThemeConfig[]; planReview( @@ -129,8 +132,10 @@ export async function loadHistoryBootstrap({ source, providerId: sanitizeTerminalLine(adapter.id), providerName: sanitizeTerminalLine(adapter.name), + startupCwd: cwd, repoRoot, extensions: resolved.extensions, + extensionSession: resolved.extensions, customThemes: sessionThemes.themes, notices: [ ...(mergeStartupNotices(resolved.configured.startupNotices, resolved.extensions) ?? []).map( diff --git a/src/app/session/registration.test.ts b/src/app/session/registration.test.ts index 1e119b635..6f3d41b3f 100644 --- a/src/app/session/registration.test.ts +++ b/src/app/session/registration.test.ts @@ -211,6 +211,12 @@ describe("session registration", () => { // Intent: the daemon can address every resource of the generation it mirrors, and can // map the renderer file ids the session surface uses onto the semantic keys resources // are addressed by. + test("createSessionRegistration accepts the owning surface's authoritative cwd", () => { + const bootstrap = createBootstrap(); + const registration = createSessionRegistration(bootstrap, publish(bootstrap), "embedded-cwd"); + expect(registration.cwd).toBe("embedded-cwd"); + }); + test("createSessionRegistration advertises the generation's resource catalog", () => { const bootstrap = createBootstrap(); const publication = publish(bootstrap); diff --git a/src/app/session/registration.ts b/src/app/session/registration.ts index bacf67637..748a5c14a 100644 --- a/src/app/session/registration.ts +++ b/src/app/session/registration.ts @@ -81,6 +81,7 @@ function buildReviewCatalog(publication: ReviewPublication): HunkReviewResourceC export function createSessionRegistration( bootstrap: AppBootstrap, publication: ReviewPublication, + cwd = process.cwd(), ): HunkSessionRegistration { const terminal = resolveSessionTerminalMetadata({ tty: ttyname() }); @@ -88,7 +89,7 @@ export function createSessionRegistration( registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, sessionId: randomUUID(), pid: process.pid, - cwd: process.cwd(), + cwd, repoRoot: inferRepoRoot(bootstrap), launchedAt: new Date().toISOString(), terminal, diff --git a/src/app/sessionBootstrap.ts b/src/app/sessionBootstrap.ts index eb36b7ed7..25ad868b5 100644 --- a/src/app/sessionBootstrap.ts +++ b/src/app/sessionBootstrap.ts @@ -30,6 +30,8 @@ export interface SessionBootstrapOptions { loadAppBootstrapImpl?: typeof loadAppBootstrap; /** Base product adapters composed before user extensions are applied. */ baseVcsCatalog?: VcsCatalog; + /** Cancel initial provider-backed loading for an abandoned embedded surface. */ + signal?: AbortSignal; } export interface SessionBootstrapResult { @@ -57,7 +59,9 @@ export async function loadConfiguredSessionBootstrap({ loadAtCwd = false, loadAppBootstrapImpl = loadAppBootstrap, baseVcsCatalog = getBundledVcsCatalog(), + signal, }: SessionBootstrapOptions): Promise { + signal?.throwIfAborted(); const previousFileLanguages = fileLanguageRegistrationSnapshot(); try { @@ -84,8 +88,11 @@ export async function loadConfiguredSessionBootstrap({ ...(loadAtCwd ? { cwd } : {}), customThemes: sessionThemes.themes, vcsCatalog: applied.vcsCatalog, + signal, })) as AppBootstrap; + signal?.throwIfAborted(); bootstrap.changeset = await applyExtensionChangesetTransforms(extensions, bootstrap.changeset); + signal?.throwIfAborted(); bootstrap.initialThemeMode = initialThemeMode ?? bootstrap.initialThemeMode; bootstrap.extensions = extensions; bootstrap.viewPreferencesConfigPath = configured.viewPreferencesConfigPath; diff --git a/src/app/startup.test.ts b/src/app/startup.test.ts index ed0edf260..bc6edbcc1 100644 --- a/src/app/startup.test.ts +++ b/src/app/startup.test.ts @@ -749,7 +749,7 @@ describe("startup planning", () => { expect(opened).toBe(1); }); - test("inherits handoff theme mode without querying the parent-owned terminal", async () => { + test("inherits an embedded renderer theme mode without querying the shared terminal", async () => { const cliInput: CliInput = { kind: "show", ref: "opaque:id", @@ -768,10 +768,7 @@ describe("startup planning", () => { }, stdinIsTTY: true, stdoutIsTTY: true, - env: { - HUNK_TERMINAL_HANDOFF: "1", - HUNK_TERMINAL_HANDOFF_THEME_MODE: "dark", - }, + terminalThemeMode: "dark", }); expect(plan).toMatchObject({ kind: "app", bootstrap: { initialThemeMode: "dark" } }); diff --git a/src/app/startup.ts b/src/app/startup.ts index 48f8c0cab..0459ca847 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -5,7 +5,6 @@ import { resolveConfiguredCliInput } from "../core/run/config"; import { HunkUserError } from "../core/run/errors"; import type { loadAppBootstrap } from "../core/changeset/loaders"; import { looksLikePatchInput } from "../core/process/pager"; -import { terminalHandoffThemeMode } from "../core/process/terminalHandoff"; import { sanitizeTerminalText } from "../lib/terminalText"; import { detectTerminalThemeModeFromBackground } from "../core/theme/detection"; import { @@ -136,6 +135,14 @@ export interface StartupDeps { runExtensionCliCommandImpl?: typeof import("../extensions/cliCommandRuntime").runExtensionCliCommand; env?: NodeJS.ProcessEnv; bunVersion?: string; + /** Override process cwd for an embedded review bootstrap. */ + cwd?: string; + /** Reuse the owning renderer's detected terminal mode. */ + terminalThemeMode?: "dark" | "light"; + /** Cancel provider-backed startup for an abandoned embedded surface. */ + signal?: AbortSignal; + /** Borrow the owning history session's already-loaded extension authority. */ + borrowedExtensionLoad?: import("../extensions/types").ExtensionLoadResult; } /** Carry the invocation's authoritative extension paths into a delegated review input. */ @@ -197,17 +204,20 @@ export async function prepareStartupPlan( const env = deps.env ?? process.env; const bunVersion = deps.bunVersion ?? Bun.version; const loadBaseVcsCatalog = createBundledVcsCatalogLoader(); - const startupCwd = process.cwd(); + const startupCwd = deps.cwd ?? process.cwd(); + deps.signal?.throwIfAborted(); let parsedCliInput = await parseCliImpl(argv); let controllingTerminal: ControllingTerminal | null = null; - let preloadedExtensions: import("../extensions/types").ExtensionLoadResult | undefined; + let preloadedExtensions: import("../extensions/types").ExtensionLoadResult | undefined = + deps.borrowedExtensionLoad; + const ownsPreloadedExtensions = !deps.borrowedExtensionLoad; let delegatedDiscoveryCatalog: VcsCatalog | undefined; let delegatedReview: ExtensionReviewDescriptor | undefined; /** Retire startup-owned extension state before returning a non-app plan. */ const retirePreloadedExtensions = async () => { - if (!preloadedExtensions) return; + if (!preloadedExtensions || !ownsPreloadedExtensions) return; await (await import("../extensions/events")).retireExtensionLoadResult(preloadedExtensions); preloadedExtensions = undefined; }; @@ -544,9 +554,9 @@ export async function prepareStartupPlan( controllingTerminal = openControllingTerminalImpl(); } - // A handoff child inherits the parent's detected mode so bootstrap never queries a terminal - // whose input and renderer are still exclusively owned by the history process. - let initialThemeMode: AppBootstrap["initialThemeMode"] = terminalHandoffThemeMode(env); + // Embedded reviews inherit their owner's detected mode so bootstrap never queries a terminal + // whose input and renderer are already exclusively owned. + let initialThemeMode: AppBootstrap["initialThemeMode"] = deps.terminalThemeMode; if (!initialThemeMode && cliInput.options.theme === "auto" && stdoutIsTTY) { const themeInput = controllingTerminal?.stdin ?? (stdinIsTTY ? process.stdin : null); if (themeInput) { @@ -586,7 +596,9 @@ export async function prepareStartupPlan( env, baseVcsCatalog, discoveryCatalog: delegatedDiscoveryCatalog, - previousLoad: preloadedExtensions, + previousLoad: deps.borrowedExtensionLoad ? undefined : preloadedExtensions, + borrowedLoad: deps.borrowedExtensionLoad, + assertActive: () => deps.signal?.throwIfAborted(), }, { resolveConfiguredCliInputImpl, loadStartupExtensionsImpl }, ); @@ -594,6 +606,10 @@ export async function prepareStartupPlan( cliInput = configured.input; const extensionResult = resolvedExtensions.extensions; preloadedExtensions = extensionResult; + if (deps.signal?.aborted) { + await retirePreloadedExtensions(); + deps.signal.throwIfAborted(); + } let preparedSession: SessionBootstrapResult; try { @@ -604,6 +620,7 @@ export async function prepareStartupPlan( initialThemeMode, loadAppBootstrapImpl, baseVcsCatalog, + signal: deps.signal, }); } catch (error) { controllingTerminal?.close(); diff --git a/src/core/changeset/loaders.test.ts b/src/core/changeset/loaders.test.ts index 9950f19da..adf10ce2d 100644 --- a/src/core/changeset/loaders.test.ts +++ b/src/core/changeset/loaders.test.ts @@ -198,6 +198,14 @@ afterEach(() => { }); describe("loadAppBootstrap", () => { + test("refuses an already-cancelled embedded review load", async () => { + const abort = new AbortController(); + abort.abort(); + await expect( + loadAppBootstrap({ kind: "show", ref: "opaque", options: {} }, { signal: abort.signal }), + ).rejects.toThrow(); + }); + test("synthesizes untracked file diffs an adapter reported by path", async () => { const dir = createTempDir("hunk-adapter-untracked-"); writeFileSync(join(dir, "note.txt"), "hello\n"); @@ -1932,8 +1940,9 @@ describe("loadAppBootstrap source fetcher attachment", () => { mutableBun.spawn = originalSpawn; } - expect(syncCalls.some((call) => call.includes("rev-parse"))).toBe(true); - expect(syncCalls.some((call) => call.includes("diff"))).toBe(true); + expect(syncCalls).toEqual([]); + expect(asyncCalls.some((call) => call.includes("rev-parse"))).toBe(true); + expect(asyncCalls.some((call) => call.includes("diff"))).toBe(true); expect(asyncCalls).toContainEqual([gitExecutable, "show", ":value.txt"]); }); diff --git a/src/core/changeset/loaders.ts b/src/core/changeset/loaders.ts index aece09bc4..ff17a33d4 100644 --- a/src/core/changeset/loaders.ts +++ b/src/core/changeset/loaders.ts @@ -45,6 +45,8 @@ export interface LoadAppBootstrapOptions { customThemes?: readonly NamedCustomThemeConfig[]; /** Complete adapter catalog composed by the app for this session. */ vcsCatalog?: VcsCatalog; + /** Cancel provider-backed review loading before it mutates mounted state. */ + signal?: AbortSignal; } /** Return the final path segment for display-oriented labels. */ @@ -216,10 +218,11 @@ async function loadVcsChangeset( sidecar: SidecarContext | null, cwd: string, vcsCatalog: VcsCatalog, + signal?: AbortSignal, ) { const adapter = getConfiguredVcsAdapter(input.options.vcs, vcsCatalog); const operation = operationFromInput(input); - const result = await loadVcsReview(adapter, operation, { cwd }, vcsCatalog); + const result = await loadVcsReview(adapter, operation, { cwd, signal }, vcsCatalog); const parsedChangeset = changesetFromPatch( result.patchText, result.title, @@ -274,8 +277,9 @@ async function loadPatchChangeset( /** Resolve CLI input into the fully loaded app bootstrap state. */ export async function loadAppBootstrap( input: CliInput, - { cwd = process.cwd(), customThemes, vcsCatalog }: LoadAppBootstrapOptions = {}, + { cwd = process.cwd(), customThemes, vcsCatalog, signal }: LoadAppBootstrapOptions = {}, ): Promise { + signal?.throwIfAborted(); // Capture before loading content so watch mode can detect mutations that race initial loading. let initialWatchSignature: string | undefined; if (input.options.watch) { @@ -289,6 +293,7 @@ export async function loadAppBootstrap( } const sidecar = await loadSidecarContext(input.options.agentContext, { cwd }); + signal?.throwIfAborted(); let changeset: Changeset; let repoRoot: string | undefined; @@ -301,7 +306,7 @@ export async function loadAppBootstrap( if (!vcsCatalog) { throw new Error("VCS-backed reviews require a composed VCS catalog."); } - const result = await loadVcsChangeset(input, sidecar, cwd, vcsCatalog); + const result = await loadVcsChangeset(input, sidecar, cwd, vcsCatalog, signal); changeset = result.changeset; repoRoot = result.repoRoot; } @@ -317,6 +322,7 @@ export async function loadAppBootstrap( break; } + signal?.throwIfAborted(); changeset = { ...changeset, files: orderDiffFiles(changeset.files, sidecar), diff --git a/src/core/process/terminalHandoff.test.ts b/src/core/process/terminalHandoff.test.ts deleted file mode 100644 index 08753d939..000000000 --- a/src/core/process/terminalHandoff.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - hasTerminalHandoff, - parseTerminalHandoffMessage, - terminalHandoffEnv, - terminalHandoffMessage, - terminalHandoffThemeMode, -} from "./terminalHandoff"; - -describe("terminal handoff protocol", () => { - test("uses a private marker and inherits only a valid terminal mode", () => { - const env = terminalHandoffEnv({ PATH: "/bin" }, "dark"); - expect(hasTerminalHandoff(env)).toBe(true); - expect(terminalHandoffThemeMode(env)).toBe("dark"); - expect( - terminalHandoffThemeMode({ - HUNK_TERMINAL_HANDOFF: "1", - HUNK_TERMINAL_HANDOFF_THEME_MODE: "blue", - }), - ).toBeUndefined(); - expect(terminalHandoffThemeMode({ HUNK_TERMINAL_HANDOFF_THEME_MODE: "light" })).toBeUndefined(); - }); - - test("accepts only versioned bounded messages", () => { - expect(parseTerminalHandoffMessage(terminalHandoffMessage("ready"))).toEqual({ - protocol: "hunk-terminal-handoff-v1", - kind: "ready", - }); - expect(parseTerminalHandoffMessage({ protocol: "wrong", kind: "ready" })).toBeUndefined(); - expect( - parseTerminalHandoffMessage({ protocol: "hunk-terminal-handoff-v1", kind: "other" }), - ).toBeUndefined(); - expect( - parseTerminalHandoffMessage({ - protocol: "hunk-terminal-handoff-v1", - kind: "failed", - message: "x".repeat(3_000), - }), - ).toEqual({ - protocol: "hunk-terminal-handoff-v1", - kind: "failed", - message: "x".repeat(2_000), - }); - }); -}); diff --git a/src/core/process/terminalHandoff.ts b/src/core/process/terminalHandoff.ts deleted file mode 100644 index 0f8cc3956..000000000 --- a/src/core/process/terminalHandoff.ts +++ /dev/null @@ -1,116 +0,0 @@ -const HANDOFF_ENV = "HUNK_TERMINAL_HANDOFF"; -const HANDOFF_THEME_MODE_ENV = "HUNK_TERMINAL_HANDOFF_THEME_MODE"; -const PROTOCOL = "hunk-terminal-handoff-v1"; - -export type TerminalHandoffMessage = - | { protocol: typeof PROTOCOL; kind: "ready" } - | { protocol: typeof PROTOCOL; kind: "release" } - | { protocol: typeof PROTOCOL; kind: "failed"; message: string }; - -/** Return whether this process was launched for a coordinated terminal handoff. */ -export function hasTerminalHandoff(env: NodeJS.ProcessEnv = process.env) { - return env[HANDOFF_ENV] === "1"; -} - -/** Read the parent's already-detected terminal mode without querying the owned terminal again. */ -export function terminalHandoffThemeMode( - env: NodeJS.ProcessEnv = process.env, -): "dark" | "light" | undefined { - if (!hasTerminalHandoff(env)) return undefined; - const value = env[HANDOFF_THEME_MODE_ENV]; - return value === "dark" || value === "light" ? value : undefined; -} - -/** Add the private one-shot handoff marker and terminal mode to a child environment. */ -export function terminalHandoffEnv( - env: NodeJS.ProcessEnv, - themeMode: "dark" | "light" | undefined, -): NodeJS.ProcessEnv { - return { - ...env, - [HANDOFF_ENV]: "1", - ...(themeMode ? { [HANDOFF_THEME_MODE_ENV]: themeMode } : {}), - }; -} - -/** Narrow an IPC payload to one bounded handoff protocol message. */ -export function parseTerminalHandoffMessage(value: unknown): TerminalHandoffMessage | undefined { - if (!value || typeof value !== "object") return undefined; - const message = value as Record; - if (message.protocol !== PROTOCOL) return undefined; - if (message.kind === "ready" || message.kind === "release") { - return { protocol: PROTOCOL, kind: message.kind }; - } - if (message.kind === "failed" && typeof message.message === "string") { - return { protocol: PROTOCOL, kind: "failed", message: message.message.slice(0, 2_000) }; - } - return undefined; -} - -/** Build one authenticated-by-inheritance IPC message for the handoff peer. */ -export function terminalHandoffMessage(kind: "ready" | "release"): TerminalHandoffMessage { - return { protocol: PROTOCOL, kind }; -} - -/** Tell the parent startup succeeded, then wait boundedly for exclusive terminal ownership. */ -export async function awaitTerminalHandoffRelease({ - env = process.env, - timeoutMs = 10_000, -}: { - env?: NodeJS.ProcessEnv; - timeoutMs?: number; -} = {}) { - if (!hasTerminalHandoff(env)) return; - if (typeof process.send !== "function" || !process.connected) { - throw new Error("The terminal handoff channel is unavailable."); - } - - await new Promise((resolve, reject) => { - let settled = false; - const finish = (error?: Error) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - process.off("message", onMessage); - process.off("disconnect", onDisconnect); - if (error) reject(error); - else resolve(); - }; - const onMessage = (value: unknown) => { - const message = parseTerminalHandoffMessage(value); - if (message?.kind === "release") finish(); - }; - const onDisconnect = () => finish(new Error("The terminal handoff parent disconnected.")); - const timeout = setTimeout( - () => finish(new Error("Timed out waiting for terminal ownership.")), - timeoutMs, - ); - timeout.unref?.(); - process.on("message", onMessage); - process.once("disconnect", onDisconnect); - process.send!(terminalHandoffMessage("ready"), (error) => { - if (error) finish(error); - }); - }); - process.disconnect?.(); - delete env[HANDOFF_ENV]; - delete env[HANDOFF_THEME_MODE_ENV]; -} - -/** Report a bounded pre-render startup failure to a waiting parent. */ -export async function reportTerminalHandoffFailure( - error: unknown, - env: NodeJS.ProcessEnv = process.env, -) { - if (!hasTerminalHandoff(env) || typeof process.send !== "function" || !process.connected) { - return false; - } - const message = error instanceof Error ? error.message : String(error); - await new Promise((resolve) => { - process.send!({ protocol: PROTOCOL, kind: "failed", message: message.slice(0, 2_000) }, () => - resolve(), - ); - }); - process.disconnect?.(); - return true; -} diff --git a/src/extension-api/types.ts b/src/extension-api/types.ts index 9f4ccb4f5..f716e31a4 100644 --- a/src/extension-api/types.ts +++ b/src/extension-api/types.ts @@ -667,6 +667,8 @@ export interface ExtensionVcsDetection { /** Ambient information an operation may need to shell out. */ export interface ExtensionVcsLoadContext { cwd: string; + /** Abort provider setup or work when the owning host generation ends. */ + signal?: AbortSignal; } /** diff --git a/src/extensions/default/vcs/asyncProcess.test.ts b/src/extensions/default/vcs/asyncProcess.test.ts new file mode 100644 index 000000000..b95d2231d --- /dev/null +++ b/src/extensions/default/vcs/asyncProcess.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { runAbortableCommand } from "./asyncProcess"; + +describe("abortable bundled VCS subprocesses", () => { + test("does not spawn after cancellation already won", async () => { + const abort = new AbortController(); + abort.abort(new Error("cancelled before spawn")); + await expect( + runAbortableCommand([process.execPath, "-e", "process.exit(99)"], { + cwd: process.cwd(), + signal: abort.signal, + }), + ).rejects.toThrow("cancelled before spawn"); + }); + + test("terminates, escalates, and reaps a command that ignores graceful cancellation", async () => { + const abort = new AbortController(); + const startedAt = Date.now(); + const pending = runAbortableCommand( + [ + process.execPath, + "-e", + 'process.on("SIGTERM",()=>{}); process.stdout.write("started\\n"); setTimeout(()=>process.stdout.write("late\\n"),5000)', + ], + { cwd: process.cwd(), signal: abort.signal, terminationGraceMs: 25 }, + ); + setTimeout(() => abort.abort(new Error("provider cancelled")), 20); + await expect(pending).rejects.toThrow("provider cancelled"); + expect(Date.now() - startedAt).toBeLessThan(2_000); + }); + + test("collects output and exit status on normal completion", async () => { + const result = await runAbortableCommand( + [process.execPath, "-e", 'process.stdout.write("ok"); process.stderr.write("note")'], + { cwd: process.cwd() }, + ); + expect(result).toEqual({ stdout: "ok", stderr: "note", exitCode: 0 }); + }); +}); diff --git a/src/extensions/default/vcs/asyncProcess.ts b/src/extensions/default/vcs/asyncProcess.ts new file mode 100644 index 000000000..6dce708f9 --- /dev/null +++ b/src/extensions/default/vcs/asyncProcess.ts @@ -0,0 +1,98 @@ +const DEFAULT_TERMINATION_GRACE_MS = 250; + +export interface AsyncCommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +/** Run one provider command without blocking renderer input and reap it after cancellation. */ +export async function runAbortableCommand( + command: string[], + { + cwd, + env, + signal, + terminationGraceMs = DEFAULT_TERMINATION_GRACE_MS, + }: { + cwd: string; + env?: Record; + signal?: AbortSignal; + terminationGraceMs?: number; + }, +): Promise { + signal?.throwIfAborted(); + const ownsProcessGroup = process.platform !== "win32"; + const proc = Bun.spawn(command, { + cwd, + env, + detached: ownsProcessGroup, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + + let killTimer: ReturnType | undefined; + let terminating = false; + const treeTerminationTasks: Promise[] = []; + const kill = (signal: "SIGTERM" | "SIGKILL") => { + if (ownsProcessGroup) { + try { + process.kill(-proc.pid, signal); + return; + } catch { + // Fall back when the child exited before its process group was signalled. + } + } + if (process.platform === "win32") { + // Bun cannot signal a Windows process group. taskkill owns the complete descendant + // tree so helpers that inherited our pipes cannot keep stream collection pending. + const task = Bun.spawn( + ["taskkill", "/pid", String(proc.pid), "/t", ...(signal === "SIGKILL" ? ["/f"] : [])], + { stdin: "ignore", stdout: "ignore", stderr: "ignore" }, + ); + treeTerminationTasks.push(task.exited.catch(() => undefined)); + return; + } + proc.kill(signal); + }; + const abort = () => { + if (terminating) return; + terminating = true; + try { + kill("SIGTERM"); + } catch { + // The process may already have exited between the abort and this handler. + } + killTimer = setTimeout(() => { + try { + kill("SIGKILL"); + } catch { + // Reaping below remains authoritative when the process already exited. + } + }, terminationGraceMs); + killTimer.unref?.(); + }; + signal?.addEventListener("abort", abort, { once: true }); + // Close the race between the pre-spawn check and listener registration. + if (signal?.aborted) abort(); + + try { + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + signal?.throwIfAborted(); + return { stdout, stderr, exitCode }; + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + throw error; + } finally { + signal?.removeEventListener("abort", abort); + if (killTimer) clearTimeout(killTimer); + // `proc.exited` also reaps a child terminated during stream collection. Windows + // tree-kill helpers are awaited as well so cancellation leaves no owned processes. + await Promise.all([proc.exited.catch(() => undefined), ...treeTerminationTasks]); + } +} diff --git a/src/extensions/default/vcs/git/commands.ts b/src/extensions/default/vcs/git/commands.ts index 161554a8e..82d8ee873 100644 --- a/src/extensions/default/vcs/git/commands.ts +++ b/src/extensions/default/vcs/git/commands.ts @@ -9,6 +9,7 @@ import { import { LARGE_DIFF_FILE_MAX_BYTES, LARGE_DIFF_FILE_MAX_LINES } from "../../../../lib/largeFile"; import { normalizePathForOS } from "../../../../lib/osPath"; import { describeDiffRange, describeDiffTargets } from "../diffRange"; +import { runAbortableCommand } from "../asyncProcess"; /** * Every Git command Hunk runs, and the failures they translate into. @@ -31,6 +32,7 @@ export interface RunGitTextOptions { cwd?: string; gitExecutable?: string; preventOptionalLocks?: boolean; + signal?: AbortSignal; } interface RunGitCommandResult { @@ -513,6 +515,41 @@ export function runGitText(options: RunGitTextOptions) { return runGitCommand(options).stdout; } +/** Run one Git command asynchronously so embedded review preparation remains cancellable. */ +async function runGitCommandAsync({ + input, + args, + cwd = process.cwd(), + gitExecutable = "git", + preventOptionalLocks = false, + signal, + acceptedExitCodes = [0], +}: RunGitCommandOptions): Promise { + let result: Awaited>; + try { + result = await runAbortableCommand([gitExecutable, ...args], { + cwd, + signal, + env: preventOptionalLocks ? { ...process.env, GIT_OPTIONAL_LOCKS: "0" } : undefined, + }); + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + throw translateGitSpawnFailure(input, error, gitExecutable); + } + if (!acceptedExitCodes.includes(result.exitCode)) { + throw translateGitExitFailure( + input, + result.stderr.trim() || `Command failed: ${gitExecutable} ${args.join(" ")}`, + ); + } + return result; +} + +/** Run one Git command asynchronously and return its decoded stdout. */ +export async function runGitTextAsync(options: RunGitTextOptions): Promise { + return (await runGitCommandAsync(options)).stdout; +} + const GIT_BOOLEAN_TRUE_VALUES = new Set(["true", "yes", "on", "1", "always"]); const GIT_BOOLEAN_FALSE_VALUES = new Set(["false", "no", "off", "0", "never"]); @@ -579,6 +616,27 @@ export function resolveGitColorMovedOptions( }; } +/** Resolve moved-line configuration without blocking an embedded renderer. */ +export async function resolveGitColorMovedOptionsAsync( + input: GitBackedInput, + options: Omit = {}, +): Promise { + const readConfig = async (key: string) => { + const result = await runGitCommandAsync({ + input, + args: ["config", "--get", key], + ...options, + acceptedExitCodes: [0, 1], + }); + return result.exitCode === 0 ? result.stdout.trim() || undefined : undefined; + }; + const gitMode = normalizeGitColorMovedMode(await readConfig("diff.colorMoved")); + if (gitMode === null) return null; + const mode = gitMode ?? (input.options.colorMoved ? "zebra" : undefined); + if (!mode) return null; + return { mode, whitespaceMode: await readConfig("diff.colorMovedWS") }; +} + /** * Return whether one `hunk diff` input still compares against the live working tree. * @@ -753,6 +811,63 @@ export function listGitUntrackedFiles( ); } +/** Return untracked files without blocking review preparation. */ +export async function listGitUntrackedFilesAsync( + input: ExtensionVcsDiffInput, + { + cwd = process.cwd(), + repoRoot, + gitExecutable = "git", + preventOptionalLocks = false, + signal, + }: Omit & { repoRoot?: string } = {}, +) { + if (input.staged || input.options.excludeUntracked === true) return []; + const range = requireGitDiffRangeArg(input); + if (range) { + const revs = ( + await runGitTextAsync({ + input, + args: ["rev-parse", "--revs-only", requireGitRevisionArg(input, range)], + cwd: repoRoot ?? cwd, + gitExecutable, + preventOptionalLocks, + signal, + }) + ) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + if ( + revs.filter((line) => !line.startsWith("^")).length !== 1 || + revs.some((line) => line.startsWith("^")) + ) { + return []; + } + } + const statusText = await runGitTextAsync({ + input, + args: buildGitStatusArgs(input), + cwd, + gitExecutable, + preventOptionalLocks, + signal, + }); + const untrackedFiles = parseUntrackedFilePaths(statusText); + if (untrackedFiles.length === 0) return []; + const normalizedRepoRoot = + repoRoot ?? + (await resolveGitRepoRootAsync(input, { + cwd, + gitExecutable, + preventOptionalLocks, + signal, + })); + return untrackedFiles.filter((filePath) => + isReviewableUntrackedPath(normalizedRepoRoot, filePath), + ); +} + export interface GitMetadata { repoRoot: string; gitDir: string; @@ -992,3 +1107,123 @@ export function resolveGitDiffEndpoints( // from the wrong revision. return null; } + +/** Resolve a Git repository root without blocking renderer input. */ +export async function resolveGitRepoRootAsync( + input: GitBackedInput, + options: Omit = {}, +) { + return normalizePathForOS( + (await runGitTextAsync({ input, args: ["rev-parse", "--show-toplevel"], ...options })).trim(), + ); +} + +/** Resolve one exact commit ref without blocking renderer input. */ +export async function resolveGitCommitRefAsync( + input: GitBackedInput, + ref: string, + options: Omit = {}, +) { + return ( + await runGitTextAsync({ + input, + args: ["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`], + ...options, + }) + ) + .split("\n")[0]! + .trim(); +} + +/** Resolve old/new Git endpoints asynchronously for review-load source capabilities. */ +export async function resolveGitDiffEndpointsAsync( + input: ExtensionVcsDiffInput, + { + cwd = process.cwd(), + gitExecutable = "git", + repoRoot, + signal, + }: Omit & { repoRoot?: string } = {}, +): Promise { + const range = requireGitDiffRangeArg(input); + const commandCwd = repoRoot ?? cwd; + const resolveRef = (ref: string) => + resolveGitCommitRefAsync(input, ref, { cwd: commandCwd, gitExecutable, signal }); + const resolveRevisions = async (value: string) => { + const revs = ( + await runGitTextAsync({ + input, + args: ["rev-parse", "--revs-only", requireGitRevisionArg(input, value)], + cwd: commandCwd, + gitExecutable, + signal, + }) + ) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + return { + positives: revs.filter((rev) => !rev.startsWith("^")), + negatives: revs.filter((rev) => rev.startsWith("^")).map((rev) => rev.slice(1)), + }; + }; + + if (input.staged) { + if (!range) { + const result = await runGitCommandAsync({ + input, + args: ["rev-parse", "--verify", "--end-of-options", "HEAD^{commit}"], + cwd: commandCwd, + gitExecutable, + signal, + acceptedExitCodes: [0, 1, 128], + }); + const headRef = result.exitCode === 0 ? result.stdout.split("\n")[0]!.trim() : null; + if (!headRef && !isUnknownRevisionMessage(result.stderr)) { + throw translateGitExitFailure( + input, + result.stderr.trim() || "Could not resolve Git ref HEAD.", + ); + } + return { + old: headRef ? { kind: "git-ref", ref: headRef } : { kind: "none" }, + new: { kind: "index" }, + }; + } + const { positives, negatives } = await resolveRevisions(range); + return positives.length === 1 && negatives.length === 0 + ? { old: { kind: "git-ref", ref: positives[0]! }, new: { kind: "index" } } + : null; + } + if (!range) return { old: { kind: "index" }, new: { kind: "worktree" } }; + const symmetric = parseSymmetricDiffRange(range); + if (symmetric) { + const mergeBase = ( + await runGitTextAsync({ + input, + args: ["merge-base", symmetric.left, symmetric.right], + cwd: commandCwd, + gitExecutable, + signal, + }) + ) + .split("\n")[0] + ?.trim(); + if (!mergeBase) return null; + return { + old: { kind: "git-ref", ref: mergeBase }, + new: { kind: "git-ref", ref: await resolveRef(symmetric.right) }, + }; + } + const { positives, negatives } = await resolveRevisions(range); + if (positives.length === 1 && negatives.length === 0) { + return { old: { kind: "git-ref", ref: positives[0]! }, new: { kind: "worktree" } }; + } + if (positives.length === 1 && negatives.length === 1) { + return { + old: { kind: "git-ref", ref: negatives[0]! }, + new: { kind: "git-ref", ref: positives[0]! }, + }; + } + return null; +} diff --git a/src/extensions/default/vcs/git/index.ts b/src/extensions/default/vcs/git/index.ts index 9f1cda3b4..a6489daa1 100644 --- a/src/extensions/default/vcs/git/index.ts +++ b/src/extensions/default/vcs/git/index.ts @@ -8,13 +8,17 @@ import { buildGitStashShowArgs, listGitIgnoredDirectoryRoots, listGitUntrackedFiles, + listGitUntrackedFilesAsync, parseGitNumstat, - resolveGitColorMovedOptions, - resolveGitCommitRef, + resolveGitColorMovedOptionsAsync, + resolveGitCommitRefAsync, resolveGitDiffEndpoints, + resolveGitDiffEndpointsAsync, resolveGitMetadata, resolveGitRepoRoot, + resolveGitRepoRootAsync, runGitText, + runGitTextAsync, shouldSkipLargeTrackedDiff, type GitBackedInput, type GitDiffEndpoints, @@ -87,13 +91,19 @@ interface GitSourceCapability { sourceCacheKey: string; } -/** Hash semantic index entries so filesystem-stat refreshes do not defeat cache reuse. */ -function gitIndexCacheKey(input: GitBackedInput, repoRoot: string, gitExecutable: string) { - const entries = runGitText({ +/** Hash index entries without blocking an embedded renderer. */ +async function gitIndexCacheKeyAsync( + input: GitBackedInput, + repoRoot: string, + gitExecutable: string, + signal?: AbortSignal, +) { + const entries = await runGitTextAsync({ input, args: ["ls-files", "--stage", "-z"], cwd: repoRoot, gitExecutable, + signal, }); return createHash("sha256").update(entries).digest("hex"); } @@ -109,22 +119,40 @@ function gitEndpointCacheKey(endpoint: GitDiffEndpoints["old"], indexCacheKey: s return endpoint.kind; } -/** - * Build a source reader that answers from exact Git old/new endpoints. - * - * The endpoint key omits worktree contents because the per-file patch fingerprint - * already describes their delta. Immutable refs and the index hash identify the - * base, so unchanged files can safely retain highlighting across watch reloads. - */ -function createGitSourceCapability( +/** Build a pinned revision source capability without blocking renderer input. */ +async function createGitRevisionSourceCapabilityAsync( + input: GitBackedInput, + ref: string, + repoRoot: string, + gitExecutable: string, + signal?: AbortSignal, +): Promise { + const newRef = await resolveGitCommitRefAsync(input, ref, { + cwd: repoRoot, + gitExecutable, + signal, + }); + return createGitSourceCapabilityAsync( + input, + repoRoot, + { old: { kind: "git-ref", ref: `${newRef}^` }, new: { kind: "git-ref", ref: newRef } }, + gitExecutable, + signal, + ); +} + +/** Build exact source capability data while asynchronously hashing a possible index. */ +async function createGitSourceCapabilityAsync( input: GitBackedInput, repoRoot: string, endpoints: GitDiffEndpoints, gitExecutable: string, -): GitSourceCapability { + signal?: AbortSignal, +): Promise { const needsIndex = endpoints.old.kind === "index" || endpoints.new.kind === "index"; - const indexCacheKey = needsIndex ? gitIndexCacheKey(input, repoRoot, gitExecutable) : "unused"; - + const indexCacheKey = needsIndex + ? await gitIndexCacheKeyAsync(input, repoRoot, gitExecutable, signal) + : "unused"; return { sourceCacheKey: [ "git-source-v1", @@ -132,19 +160,14 @@ function createGitSourceCapability( gitEndpointCacheKey(endpoints.new, indexCacheKey), ].join(":"), readFileSource: ({ path, previousPath, changeType, side }) => { - // An added file has no old side and a deleted one has no new side; asking - // Git for either would just be a failed lookup. if (side === "old") { return changeType === "new" ? Promise.resolve(null) : readGitFileSource( gitEndpointSourceSpec(endpoints.old, repoRoot, previousPath ?? path), - { - gitExecutable, - }, + { gitExecutable }, ); } - return changeType === "deleted" ? Promise.resolve(null) : readGitFileSource(gitEndpointSourceSpec(endpoints.new, repoRoot, path), { @@ -154,38 +177,22 @@ function createGitSourceCapability( }; } -/** Build a pinned source capability for a single-revision review. */ -function createGitRevisionSourceCapability( - input: GitBackedInput, - ref: string, - repoRoot: string, - gitExecutable: string, -): GitSourceCapability { - const newRef = resolveGitCommitRef(input, ref, { cwd: repoRoot, gitExecutable }); - return createGitSourceCapability( - input, - repoRoot, - { old: { kind: "git-ref", ref: `${newRef}^` }, new: { kind: "git-ref", ref: newRef } }, - gitExecutable, - ); -} - -/** - * Build a source capability for a working-tree review, when both sides are exact. - * - * Ranges that do not reduce to one old/new pair — octopus merges, multi-positive - * revision sets — deliberately get no reader at all rather than a guess, so - * expanded rows are never read from the wrong revision. - */ -function createGitDiffSourceCapability( +/** Build working-tree source capability without blocking renderer input. */ +async function createGitDiffSourceCapabilityAsync( input: ExtensionVcsDiffInput, repoRoot: string, cwd: string, gitExecutable: string, -): GitSourceCapability | undefined { - const endpoints = resolveGitDiffEndpoints(input, { cwd, repoRoot, gitExecutable }); + signal?: AbortSignal, +): Promise { + const endpoints = await resolveGitDiffEndpointsAsync(input, { + cwd, + repoRoot, + gitExecutable, + signal, + }); return endpoints - ? createGitSourceCapability(input, repoRoot, endpoints, gitExecutable) + ? createGitSourceCapabilityAsync(input, repoRoot, endpoints, gitExecutable, signal) : undefined; } @@ -300,8 +307,8 @@ export function createGitVcsAdapter({ }, operations: { "working-tree-diff": { - async load(input, { cwd }) { - const repoRoot = resolveGitRepoRoot(input, { cwd, gitExecutable }); + async load(input, { cwd, signal }) { + const repoRoot = await resolveGitRepoRootAsync(input, { cwd, gitExecutable, signal }); const repoName = basename(repoRoot); const range = describeDiffRange(input); const title = input.staged @@ -311,22 +318,40 @@ export function createGitVcsAdapter({ : `${repoName} working tree`; // Ask for stats before the patch so files too large to render can be // excluded from the diff instead of generating output nobody reads. - const largeTrackedFiles = parseGitNumstat( - runGitText({ input, args: buildGitDiffNumstatArgs(input), cwd, gitExecutable }), - ).filter((file) => shouldSkipLargeTrackedDiff(file, repoRoot)); - const colorMoved = resolveGitColorMovedOptions(input, { cwd, gitExecutable }); - const sourceCapability = createGitDiffSourceCapability( + const numstat = await runGitTextAsync({ + input, + args: buildGitDiffNumstatArgs(input), + cwd, + gitExecutable, + signal, + }); + const colorMoved = await resolveGitColorMovedOptionsAsync(input, { + cwd, + gitExecutable, + signal, + }); + const sourceCapability = await createGitDiffSourceCapabilityAsync( input, repoRoot, cwd, gitExecutable, + signal, + ); + const untrackedPaths = await listGitUntrackedFilesAsync(input, { + cwd, + repoRoot, + gitExecutable, + signal, + }); + const largeTrackedFiles = parseGitNumstat(numstat).filter((file) => + shouldSkipLargeTrackedDiff(file, repoRoot), ); return { repoRoot, sourceLabel: repoRoot, title, - patchText: runGitText({ + patchText: await runGitTextAsync({ input, args: buildGitDiffArgs( input, @@ -335,6 +360,7 @@ export function createGitVcsAdapter({ ), cwd, gitExecutable, + signal, }), ...sourceCapability, extraFiles: largeTrackedFiles.map( @@ -350,7 +376,7 @@ export function createGitVcsAdapter({ // diff in-process. Rendering them through `git diff --no-index` // instead costs one subprocess per file, which made working-tree // review scale with the untracked file count. - untrackedPaths: listGitUntrackedFiles(input, { cwd, repoRoot, gitExecutable }), + untrackedPaths, }; }, watchPlan(input, { cwd }) { @@ -379,28 +405,30 @@ export function createGitVcsAdapter({ }, }, "revision-show": { - async load(input, { cwd }) { - const repoRoot = resolveGitRepoRoot(input, { cwd, gitExecutable }); + async load(input, { cwd, signal }) { + const repoRoot = await resolveGitRepoRootAsync(input, { cwd, gitExecutable, signal }); const repoName = basename(repoRoot); - const sourceCapability = createGitRevisionSourceCapability( + const sourceCapability = await createGitRevisionSourceCapabilityAsync( input, input.ref ?? "HEAD", repoRoot, gitExecutable, + signal, ); return { repoRoot, sourceLabel: repoRoot, title: input.ref ? `${repoName} show ${input.ref}` : `${repoName} show HEAD`, - patchText: runGitText({ + patchText: await runGitTextAsync({ input, args: buildGitShowArgs( input, - resolveGitColorMovedOptions(input, { cwd, gitExecutable }), + await resolveGitColorMovedOptionsAsync(input, { cwd, gitExecutable, signal }), ), cwd, gitExecutable, + signal, }), ...sourceCapability, }; @@ -419,28 +447,30 @@ export function createGitVcsAdapter({ }, }, "stash-show": { - async load(input, { cwd }) { - const repoRoot = resolveGitRepoRoot(input, { cwd, gitExecutable }); + async load(input, { cwd, signal }) { + const repoRoot = await resolveGitRepoRootAsync(input, { cwd, gitExecutable, signal }); const repoName = basename(repoRoot); - const sourceCapability = createGitRevisionSourceCapability( + const sourceCapability = await createGitRevisionSourceCapabilityAsync( input, input.ref ?? "stash@{0}", repoRoot, gitExecutable, + signal, ); return { repoRoot, sourceLabel: repoRoot, title: input.ref ? `${repoName} stash ${input.ref}` : `${repoName} stash`, - patchText: runGitText({ + patchText: await runGitTextAsync({ input, args: buildGitStashShowArgs( input, - resolveGitColorMovedOptions(input, { cwd, gitExecutable }), + await resolveGitColorMovedOptionsAsync(input, { cwd, gitExecutable, signal }), ), cwd, gitExecutable, + signal, }), ...sourceCapability, }; diff --git a/src/extensions/default/vcs/jujutsu/commands.ts b/src/extensions/default/vcs/jujutsu/commands.ts index 39c2272a4..daa3989c6 100644 --- a/src/extensions/default/vcs/jujutsu/commands.ts +++ b/src/extensions/default/vcs/jujutsu/commands.ts @@ -6,6 +6,7 @@ import { } from "hunkdiff/extension"; import { normalizePathForOS } from "../../../../lib/osPath"; import { describeDiffTargets } from "../diffRange"; +import { runAbortableCommand } from "../asyncProcess"; export type JjBackedInput = ExtensionVcsDiffInput | ExtensionVcsShowInput; @@ -14,6 +15,7 @@ export interface RunJjTextOptions { args: string[]; cwd?: string; jjExecutable?: string; + signal?: AbortSignal; } /** Identifies the reviewed new commit and every commit JJ used to build the old side. */ @@ -250,6 +252,31 @@ export function runJjText(options: RunJjTextOptions) { return runJjCommand(options).stdout; } +/** Run a Jujutsu command asynchronously so embedded review preparation stays cancellable. */ +export async function runJjTextAsync({ + input, + args, + cwd = process.cwd(), + jjExecutable = "jj", + signal, +}: RunJjTextOptions): Promise { + const command = [jjExecutable, "--no-pager", "--color", "never", ...args]; + let result: Awaited>; + try { + result = await runAbortableCommand(command, { cwd, signal }); + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + throw translateJjSpawnFailure(input, error, jjExecutable); + } + if (result.exitCode !== 0) { + throw translateJjExitFailure( + input, + result.stderr.trim() || `Command failed: ${command.join(" ")}`, + ); + } + return result.stdout; +} + /** * Resolve a JJ revset once so the patch and later source reads use the same commit. * @@ -332,6 +359,61 @@ export function resolveJjRangeEndpoints( return { newCommitId: toCommitIds[0]!, oldCommitIds: [fromCommitIds[0]!] }; } +/** Resolve immutable JJ endpoints without blocking renderer input. */ +export async function resolveJjDiffEndpointsAsync( + input: JjBackedInput, + revset: string, + options: Omit = {}, +): Promise { + const commitIds = parseJjCommitIds( + await runJjTextAsync({ + input, + args: ["log", "--no-graph", "-r", revset, "-T", JjCommitIdTemplate], + ...options, + }), + ); + if (commitIds.length !== 1) return undefined; + const commitId = commitIds[0]!; + const parentCommitIds = parseJjCommitIds( + await runJjTextAsync({ + input, + args: [ + "log", + "--no-graph", + "--ignore-working-copy", + "-r", + `${commitId}-`, + "-T", + JjCommitIdTemplate, + ], + ...options, + }), + ); + return { newCommitId: commitId, oldCommitIds: parentCommitIds.sort() }; +} + +/** Resolve two immutable JJ range endpoints without blocking renderer input. */ +export async function resolveJjRangeEndpointsAsync( + input: ExtensionVcsDiffInput, + endpoints: ExtensionVcsRangeEndpoints, + options: Omit = {}, +): Promise { + const from = requireJjRevisionArg(input, endpoints.from); + const to = requireJjRevisionArg(input, endpoints.to); + const resolveOne = async (revset: string) => + parseJjCommitIds( + await runJjTextAsync({ + input, + args: ["log", "--no-graph", "-r", revset, "-T", JjCommitIdTemplate], + ...options, + }), + ); + const fromCommitIds = await resolveOne(from); + const toCommitIds = await resolveOne(to); + if (fromCommitIds.length !== 1 || toCommitIds.length !== 1) return undefined; + return { newCommitId: toCommitIds[0]!, oldCommitIds: [fromCommitIds[0]!] }; +} + export function resolveJjRepoRoot( input: JjBackedInput, options: Omit = {}, @@ -343,3 +425,11 @@ export function resolveJjRepoRoot( }).trim(); return normalizePathForOS(repoRoot); } + +/** Resolve the JJ repository root without blocking renderer input. */ +export async function resolveJjRepoRootAsync( + input: JjBackedInput, + options: Omit = {}, +) { + return normalizePathForOS((await runJjTextAsync({ input, args: ["root"], ...options })).trim()); +} diff --git a/src/extensions/default/vcs/jujutsu/index.ts b/src/extensions/default/vcs/jujutsu/index.ts index 0e0d7e9c7..cc065bc65 100644 --- a/src/extensions/default/vcs/jujutsu/index.ts +++ b/src/extensions/default/vcs/jujutsu/index.ts @@ -4,10 +4,11 @@ import { buildJjDiffArgs, buildJjShowArgs, createJjStagedError, - resolveJjDiffEndpoints, - resolveJjRangeEndpoints, - resolveJjRepoRoot, + resolveJjDiffEndpointsAsync, + resolveJjRangeEndpointsAsync, + resolveJjRepoRootAsync, runJjText, + runJjTextAsync, type JjDiffEndpoints, } from "./commands"; import { openJjHistory } from "./history"; @@ -154,15 +155,23 @@ export function createJjVcsAdapter({ jjExecutable = "jj" }: Readonly { + const command = [slExecutable, "--noninteractive", "--color", "never", ...args]; + let result: Awaited>; + try { + result = await runAbortableCommand(command, { cwd, signal }); + } catch (error) { + if (signal?.aborted) signal.throwIfAborted(); + throw translateSlSpawnFailure(input, error, slExecutable); + } + if (result.exitCode !== 0) { + throw translateSlExitFailure( + input, + result.stderr.trim() || `Command failed: ${command.join(" ")}`, + ); + } + return result.stdout; +} + /** Return whether working-copy review should synthesize unknown Sapling files into the patch stream. */ function shouldIncludeUntrackedFiles(input: ExtensionVcsDiffInput) { return !input.staged && !input.rangeEndpoints && input.options.excludeUntracked !== true; @@ -309,6 +336,34 @@ export function listSlUntrackedFiles( ); } +/** Return unknown files without blocking review preparation. */ +export async function listSlUntrackedFilesAsync( + input: ExtensionVcsDiffInput, + { + cwd = process.cwd(), + repoRoot, + slExecutable = "sl", + signal, + }: Omit & { repoRoot?: string } = {}, +) { + validateSlDiffEndpoints(input); + if (!shouldIncludeUntrackedFiles(input)) return []; + const statusText = await runSlTextAsync({ + input, + args: buildSlStatusArgs(input), + cwd, + slExecutable, + signal, + }); + const untrackedFiles = parseUntrackedFilePaths(statusText); + if (untrackedFiles.length === 0) return []; + const normalizedRepoRoot = + repoRoot ?? (await resolveSlRepoRootAsync(input, { cwd, slExecutable, signal })); + return untrackedFiles.filter((filePath) => + isReviewableUntrackedPath(normalizedRepoRoot, filePath), + ); +} + /** Resolve the repo root by running `sl root`. */ export function resolveSlRepoRoot( input: SlBackedInput, @@ -321,3 +376,11 @@ export function resolveSlRepoRoot( }).trim(); return normalizePathForOS(repoRoot); } + +/** Resolve the Sapling repository root without blocking renderer input. */ +export async function resolveSlRepoRootAsync( + input: SlBackedInput, + options: Omit = {}, +) { + return normalizePathForOS((await runSlTextAsync({ input, args: ["root"], ...options })).trim()); +} diff --git a/src/extensions/default/vcs/sapling/index.test.ts b/src/extensions/default/vcs/sapling/index.test.ts index 04e0031e2..20dad2254 100644 --- a/src/extensions/default/vcs/sapling/index.test.ts +++ b/src/extensions/default/vcs/sapling/index.test.ts @@ -165,14 +165,29 @@ describe("SaplingVcsAdapter without the sl binary", () => { test("adapter range loads do not probe working-copy unknown files", async () => { const repo = createTempDir("hunk-sl-adapter-range-untracked-"); const commands: string[][] = []; - const mutableBun = Bun as unknown as { spawnSync: typeof Bun.spawnSync }; + const mutableBun = Bun as unknown as { + spawnSync: typeof Bun.spawnSync; + spawn: typeof Bun.spawn; + }; const originalSpawnSync = mutableBun.spawnSync; + const originalSpawn = mutableBun.spawn; mutableBun.spawnSync = ((command: string[]) => { commands.push(command); const operation = command.slice(4); const stdout = operation[0] === "root" ? `${repo}\n` : ""; return { exitCode: 0, stdout: Buffer.from(stdout), stderr: Buffer.from("") }; }) as typeof Bun.spawnSync; + mutableBun.spawn = ((command: string[]) => { + commands.push(command); + const operation = command.slice(4); + const stdout = operation[0] === "root" ? `${repo}\n` : ""; + return { + stdout: new Blob([stdout]).stream(), + stderr: new Blob([""]).stream(), + exited: Promise.resolve(0), + kill() {}, + }; + }) as unknown as typeof Bun.spawn; try { const input = { @@ -192,6 +207,7 @@ describe("SaplingVcsAdapter without the sl binary", () => { ).toBe(true); } finally { mutableBun.spawnSync = originalSpawnSync; + mutableBun.spawn = originalSpawn; } }); diff --git a/src/extensions/default/vcs/sapling/index.ts b/src/extensions/default/vcs/sapling/index.ts index 37eacb1df..fcfe201ff 100644 --- a/src/extensions/default/vcs/sapling/index.ts +++ b/src/extensions/default/vcs/sapling/index.ts @@ -5,8 +5,11 @@ import { buildSlShowArgs, createSlStagedError, listSlUntrackedFiles, + listSlUntrackedFilesAsync, resolveSlRepoRoot, + resolveSlRepoRootAsync, runSlText, + runSlTextAsync, } from "./commands"; import { describeDiffRange } from "../diffRange"; import { @@ -76,20 +79,20 @@ export const SaplingVcsAdapter = { detectionPriority: HUNK_VCS_DETECTION_BASELINE_PRIORITY + 100, operations: { "working-tree-diff": { - async load(input, { cwd }) { + async load(input, { cwd, signal }) { if (input.staged) { throw createSlStagedError(input); } const diffArgs = buildSlDiffArgs(input); - const repoRoot = resolveSlRepoRoot(input, { cwd }); + const repoRoot = await resolveSlRepoRootAsync(input, { cwd, signal }); const repoName = basename(repoRoot); const range = describeDiffRange(input); return { repoRoot, sourceLabel: repoRoot, title: range ? `${repoName} ${range}` : `${repoName} working copy`, - patchText: runSlText({ input, args: diffArgs, cwd }), - untrackedPaths: listSlUntrackedFiles(input, { cwd, repoRoot }), + patchText: await runSlTextAsync({ input, args: diffArgs, cwd, signal }), + untrackedPaths: await listSlUntrackedFilesAsync(input, { cwd, repoRoot, signal }), }; }, watchSignature(input, { cwd }) { @@ -102,15 +105,15 @@ export const SaplingVcsAdapter = { }, }, "revision-show": { - async load(input, { cwd }) { - const repoRoot = resolveSlRepoRoot(input, { cwd }); + async load(input, { cwd, signal }) { + const repoRoot = await resolveSlRepoRootAsync(input, { cwd, signal }); const repoName = basename(repoRoot); const revset = input.ref ?? "."; return { repoRoot, sourceLabel: repoRoot, title: `${repoName} show ${revset}`, - patchText: runSlText({ input, args: buildSlShowArgs(input), cwd }), + patchText: await runSlTextAsync({ input, args: buildSlShowArgs(input), cwd, signal }), }; }, watchSignature(input, { cwd }) { diff --git a/src/main.tsx b/src/main.tsx index 23732524f..9ebf7fa7f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -7,10 +7,6 @@ import { prepareStartupPlan } from "./app/startup"; import { sanitizeTerminalText } from "./lib/terminalText"; import { serveSessionBrokerDaemon } from "./session/broker/brokerServer"; import { runSessionCommand } from "./session/agent/commands"; -import { - awaitTerminalHandoffRelease, - reportTerminalHandoffFailure, -} from "./core/process/terminalHandoff"; async function main() { const startupPlan = await prepareStartupPlan(); @@ -132,17 +128,10 @@ async function main() { } // OpenTUI stays behind the interactive plan so headless commands never materialize its embedded - // native library. Load it before declaring a delegated review ready so terminal release is - // followed immediately by renderer creation rather than another module-loading gap. + // native library. The highlighting client starts the compiled worker only when an opted-in, + // eligible diff needs it, so normal sessions do not pay its startup cost. The interactive app + // owns that worker's disposal: this call returns once the app is mounted, not once it exits. const { runInteractiveApp } = await import("./ui/runInteractiveApp"); - - // A history parent keeps its loading frame mounted until review bootstrap and renderer code are - // ready. Wait for exclusive terminal ownership before mounting the child renderer. - await awaitTerminalHandoffRelease(); - - // The highlighting client starts the compiled worker only when an opted-in, eligible diff needs - // it, so normal sessions do not pay its startup cost. The interactive app owns that worker's - // disposal: this call returns once the app is mounted, not once it exits. try { await runInteractiveApp(startupPlan); } catch (error) { @@ -154,7 +143,7 @@ async function main() { } } -await main().catch(async (error) => { - if (!(await reportTerminalHandoffFailure(error))) process.stderr.write(formatCliError(error)); +await main().catch((error) => { + process.stderr.write(formatCliError(error)); process.exitCode = 1; }); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c845e48a1..1f3ad5030 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -53,6 +53,7 @@ import { resolveCodeViewportWidth, } from "./diff/codeColumns"; import { useAppKeyboardShortcuts } from "./hooks/useAppKeyboardShortcuts"; +import { useIntermediateRenderAfterMount } from "./hooks/useIntermediateRenderAfterMount"; import { useCurrentReviewRefreshController } from "./hooks/useCurrentReviewRefreshController"; import { useExtensionCommandRunner } from "./hooks/useExtensionCommandRunner"; import { useExtensionDialogController } from "./hooks/useExtensionDialogController"; @@ -134,22 +135,29 @@ function clamp(value: number, min: number, max: number) { /** Orchestrate global app state, layout, navigation, and pane coordination. */ export function App({ bootstrap, + canReloadExtensions = true, hostClient, noticeText, onQuit = () => process.exit(0), + onFirstFrameReady, onRegisterWorkspaceRefreshRequest, onReloadSession, onRequestExtensionReviewReload, onWorkspaceWriteCompleted, reviewProducer, runWorkspaceWrite, + returnToHistory = process.env.HUNK_RETURN_TO_HISTORY === "1", watchRuntime, workspaceFileWriter, }: { bootstrap: AppBootstrap; + /** Whether this surface may replace the session-owned extension registry. */ + canReloadExtensions?: boolean; hostClient?: HunkSessionBrokerClient; noticeText?: string | null; onQuit?: () => void; + /** Report once OpenTUI has committed the review's first requested frame. */ + onFirstFrameReady?: () => void; /** Register the mounted review descriptor AppHost should reconcile after a completed write. */ onRegisterWorkspaceRefreshRequest: (request: WorkspaceRefreshRequest) => () => void; onReloadSession: ( @@ -166,6 +174,8 @@ export function App({ reviewProducer?: ReviewProducer; /** Start and track one irreversible write, or refuse it once graceful shutdown begins. */ runWorkspaceWrite: WorkspaceWriteRunner; + /** Present quit as returning to the owning history surface. */ + returnToHistory?: boolean; watchRuntime?: WatchedInputRuntime; workspaceFileWriter?: WorkspaceFileWriter; }) { @@ -362,8 +372,7 @@ export function App({ configPath: bootstrap.viewPreferencesConfigPath, pagerMode, promptSaveViewPreferences: - bootstrap.input.options.promptSaveViewPreferences !== false && - process.env.HUNK_RETURN_TO_HISTORY !== "1", + bootstrap.input.options.promptSaveViewPreferences !== false && !returnToHistory, transientViewPreferences: extensionSessionOptions.transientViewPreferences, onQuit, showNotice: showSessionNotice, @@ -742,11 +751,27 @@ export function App({ ), [diffContentWidth, maxLineNumberDigits, resolvedLayout, showLineNumbers], ); + // Redraw subsequent geometry changes without clearing a review mounted into an existing root. + useIntermediateRenderAfterMount( + renderer, + [renderSidebar, resolvedLayout, terminal.height, terminal.width, wrapLines], + Boolean(onFirstFrameReady), + ); + const firstFrameReportedRef = useRef(false); useEffect(() => { - // Force an intermediate redraw when app geometry or row-wrapping changes so pane relayout - // feels immediate after toggling split/stack or line wrapping. - renderer.intermediateRender(); - }, [renderer, renderSidebar, resolvedLayout, terminal.height, terminal.width, wrapLines]); + if (!onFirstFrameReady || firstFrameReportedRef.current) return; + let active = true; + renderer.requestRender(); + void renderer.idle().then(() => { + if (active && !firstFrameReportedRef.current) { + firstFrameReportedRef.current = true; + onFirstFrameReady(); + } + }); + return () => { + active = false; + }; + }, [onFirstFrameReady, renderer]); /** Scroll the main review pane by line steps, viewport fractions, or whole-content jumps. */ const scrollDiff = ( @@ -896,7 +921,7 @@ export function App({ extensionTrustPromptRoot, trustRepoExtensions, } = useExtensionTrustController({ - canRefreshCurrentInput, + canRefreshCurrentInput: canRefreshCurrentInput && canReloadExtensions, pagerMode, pendingRepoRoot: pendingTrustRepoRoot, refreshCurrentInput, @@ -1079,7 +1104,7 @@ export function App({ triggerEditSelectedFile, triggerRefreshCurrentInput, }).map((command) => - process.env.HUNK_RETURN_TO_HISTORY === "1" && command.id === "hunk.app.quit" + returnToHistory && command.id === "hunk.app.quit" ? { ...command, title: "Back to history" } : command, ), @@ -1354,6 +1379,7 @@ export function App({ pagerMode={pagerMode} screenTop={diffPaneScreenTop} showTopChrome={showMenuBar} + skipInitialIntermediateRender={Boolean(onFirstFrameReady)} headerLabelWidth={diffHeaderLabelWidth} headerStatsWidth={diffHeaderStatsWidth} layout={resolvedLayout} diff --git a/src/ui/AppHost.dynamic-mount.test.tsx b/src/ui/AppHost.dynamic-mount.test.tsx new file mode 100644 index 000000000..b6461df9b --- /dev/null +++ b/src/ui/AppHost.dynamic-mount.test.tsx @@ -0,0 +1,61 @@ +import { expect, mock, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act, useState } from "react"; +import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap"; +import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { AppHost } from "./AppHost"; + +mock.restore(); + +let mountReview: (() => void) | undefined; + +/** Mount AppHost after another surface has already committed into the same stable root. */ +function DynamicReviewHost({ onReady }: { onReady: () => void }) { + const [mounted, setMounted] = useState(false); + mountReview = () => setMounted(true); + if (!mounted) return History surface; + return ( + + ); +} + +test("AppHost paints and reports readiness when mounted after a retained surface", async () => { + const ready = mock(() => undefined); + const setup = await testRender(, { + width: 100, + height: 18, + }); + try { + await act(async () => { + await setup.renderOnce(); + }); + expect(setup.captureCharFrame()).toContain("History surface"); + + act(() => mountReview?.()); + await Bun.sleep(20); + await setup.renderOnce(); + await Bun.sleep(20); + await setup.renderOnce(); + + const frame = setup.captureCharFrame(); + expect(frame).toContain("dynamic.ts"); + expect(ready).toHaveBeenCalledTimes(1); + } finally { + setup.renderer.destroy(); + mountReview = undefined; + } +}); diff --git a/src/ui/AppHost.keybindings.test.tsx b/src/ui/AppHost.keybindings.test.tsx index 1416daf55..6e03fd1f1 100644 --- a/src/ui/AppHost.keybindings.test.tsx +++ b/src/ui/AppHost.keybindings.test.tsx @@ -10,6 +10,7 @@ import { resolveConfiguredCliInput } from "../core/run/config"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; import { loadAppBootstrap } from "../core/changeset/loaders"; import type { AppBootstrap } from "../core/bootstrap"; +import { retireExtensionLoadResult } from "../extensions/events"; import { createEmptyExtensionLoadResult } from "../extensions/types"; import { AppHost } from "./AppHost"; @@ -97,6 +98,7 @@ async function withAppHost( bootstrap: AppBootstrap, body: (setup: Awaited>, quits: () => number) => Promise, externalQuitSignal?: AbortSignal, + extensionOwnership: "owned" | "borrowed" = "owned", ) { let quitCount = 0; const setup = await testRender( @@ -104,6 +106,7 @@ async function withAppHost( bootstrap={bootstrap} externalQuitSignal={externalQuitSignal} onQuit={() => (quitCount += 1)} + extensionOwnership={extensionOwnership} />, { width: 120, height: 24 }, ); @@ -322,6 +325,49 @@ describe("user keybindings", () => { }); }); + test("borrows one history extension authority across repeated review mounts", async () => { + const repo = createTestRepo("hunk-keybindings-borrowed-extensions-"); + const bootstrap = await launchWithConfig(repo, ""); + const extensions = createEmptyExtensionLoadResult(repo); + const seen: string[] = []; + extensions.registry.eventHandlers.startup.push({ + extensionId: "stateful", + handler: () => { + seen.push("startup"); + }, + }); + extensions.registry.eventHandlers.changeset_loaded.push({ + extensionId: "stateful", + handler: () => { + seen.push("changeset"); + }, + }); + extensions.registry.eventHandlers.shutdown.push({ + extensionId: "stateful", + handler: () => { + seen.push("shutdown"); + }, + }); + bootstrap.extensions = extensions; + + for (let generation = 0; generation < 2; generation += 1) { + await withAppHost( + bootstrap, + async (setup, quits) => { + await act(async () => setup.mockInput.typeText("q")); + await flush(setup); + expect(quits()).toBe(1); + }, + undefined, + "borrowed", + ); + } + + expect(seen).toEqual(["changeset", "changeset"]); + await retireExtensionLoadResult(extensions); + expect(seen).toEqual(["changeset", "changeset", "shutdown"]); + }); + test("retires extensions before an external terminal interrupt quits", async () => { const repo = createTestRepo("hunk-keybindings-interrupt-shutdown-"); const bootstrap = await launchWithConfig(repo, ""); diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index 9c83727c2..959973c4b 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -62,6 +62,9 @@ export function AppHost({ hostClient, onQuit = () => process.exit(0), onActiveBootstrapChange, + onFirstFrameReady, + returnToHistory = false, + extensionOwnership = "owned", reviewProducer, startupNoticeResolver, watchRuntime, @@ -75,6 +78,12 @@ export function AppHost({ onQuit?: () => void; /** Observe the bootstrap after its matching App commit; used by mounted host tests. */ onActiveBootstrapChange?: (bootstrap: AppBootstrap) => void; + /** Report once the dynamically mounted review has committed its first requested frame. */ + onFirstFrameReady?: () => void; + /** Present quit as returning to an owning history surface. */ + returnToHistory?: boolean; + /** Whether this surface may retire and restart its initial extension authority. */ + extensionOwnership?: "owned" | "borrowed"; /** * The producer whose generations this host publishes. Supplied by the process that * built the initial registration from its first publication; a host mounted without one @@ -111,9 +120,14 @@ export function AppHost({ }), ); const [appVersion, setAppVersion] = useState(0); - // Extensions outlive App remounts, and a trust grant can replace the whole - // load result mid-session, so the host owns them rather than the bootstrap. + // Extensions outlive App remounts. Standalone hosts own replacement and shutdown; + // embedded reviews borrow the history workspace's initial authority. const extensionsRef = useRef(initialBootstrap.extensions as ExtensionLoadResult | undefined); + const borrowedExtensionRegistryRef = useRef( + extensionOwnership === "borrowed" + ? (initialBootstrap.extensions as ExtensionLoadResult | undefined)?.registry + : undefined, + ); // Experimental capabilities are launch authority: remote/watch reloads may replace content, // but opting in or out requires starting a new Hunk process. const launchExperimental = initialBootstrap.input.options.experimental === true; @@ -172,9 +186,11 @@ export function AppHost({ // leases are live here; passive UI events still wait until this order lands. if (initialExtensionStartupPendingRef.current) { initialExtensionStartupPendingRef.current = false; - emitExtensionEvent(extensionsRef.current, "startup", { - cwd: initialBootstrap.reloadContext.cwd, - }); + if (extensionOwnership === "owned") { + emitExtensionEvent(extensionsRef.current, "startup", { + cwd: initialBootstrap.reloadContext.cwd, + }); + } emitExtensionEvent(extensionsRef.current, "changeset_loaded", { changeset: initialBootstrap.changeset, }); @@ -197,7 +213,7 @@ export function AppHost({ reason: pending.reason, }); pending.resolveMounted(); - }, [activeBootstrap, initialBootstrap.reloadContext.cwd]); + }, [activeBootstrap, extensionOwnership, initialBootstrap.reloadContext.cwd]); /** Track one prepared registry until it is either adopted or fully retired. */ const trackPreparedExtensionReplacement = useCallback((result: ExtensionLoadResult) => { @@ -206,6 +222,7 @@ export function AppHost({ /** Track every registry retirement until its shared shutdown completion settles. */ const retireOwnedExtensionLoadResult = useCallback((result: ExtensionLoadResult | undefined) => { + if (result?.registry === borrowedExtensionRegistryRef.current) return Promise.resolve(); const retirement = retireExtensionLoadResult(result); pendingExtensionRetirementsRef.current.add(retirement); void retirement.then( @@ -297,7 +314,10 @@ export function AppHost({ } let replacementExtensions: ExtensionLoadResult | undefined; - if (options?.reloadExtensions || cwd !== extensionsCwdRef.current) { + if ( + extensionOwnership === "owned" && + (options?.reloadExtensions || cwd !== extensionsCwdRef.current) + ) { try { const resolvedExtensions = await resolveConfiguredExtensions({ runtimeInput, @@ -460,6 +480,7 @@ export function AppHost({ }, [ adoptPreparedExtensionReplacement, + extensionOwnership, hostClient, launchExperimental, launchFast, @@ -610,9 +631,12 @@ export function AppHost({ { - renderer.intermediateRender(); - }, [renderer, pinnedHeaderFileId]); + useIntermediateRenderAfterMount(renderer, [pinnedHeaderFileId], skipInitialIntermediateRender); const fullFileRenderItems = useMemo( (): FileRenderWindowItem[] => diff --git a/src/ui/history/types.ts b/src/ui/history/types.ts index fa808bae7..045b9916d 100644 --- a/src/ui/history/types.ts +++ b/src/ui/history/types.ts @@ -1,5 +1,6 @@ import type { HistoryCommandInput } from "../../core/run/commandInputs"; import type { VcsHistorySource } from "../../core/vcs/types"; +import type { ExtensionLoadResult } from "../../extensions/types"; import type { ExtensionVcsHistoryCommit, ExtensionVcsHistoryReviewAction, @@ -13,9 +14,13 @@ export interface HistoryRuntime { source: VcsHistorySource; providerId: string; providerName: string; + /** Invocation cwd used to resolve explicit extension paths for embedded reviews. */ + startupCwd?: string; repoRoot: string; notices: readonly string[]; customThemes: readonly NamedCustomThemeConfig[]; + /** History-owned extension authority borrowed by embedded reviews. */ + extensionSession?: ExtensionLoadResult; planReview( commit: ExtensionVcsHistoryCommit, options?: ExtensionVcsHistoryReviewOptions, diff --git a/src/ui/hooks/useIntermediateRenderAfterMount.test.tsx b/src/ui/hooks/useIntermediateRenderAfterMount.test.tsx new file mode 100644 index 000000000..02015641e --- /dev/null +++ b/src/ui/hooks/useIntermediateRenderAfterMount.test.tsx @@ -0,0 +1,39 @@ +import { expect, mock, test } from "bun:test"; +import { act, useState } from "react"; +import { testRender } from "@opentui/react/test-utils"; +import { useRenderer } from "@opentui/react"; +import { useIntermediateRenderAfterMount } from "./useIntermediateRenderAfterMount"; + +mock.restore(); + +let changeGeometry: (() => void) | undefined; + +/** Exercise the post-mount redraw hook through a committed geometry change. */ +function TestPostMountRender() { + const renderer = useRenderer(); + const [geometry, setGeometry] = useState(1); + changeGeometry = () => setGeometry((value) => value + 1); + useIntermediateRenderAfterMount(renderer, [geometry]); + return {geometry}; +} + +test("post-mount intermediate rendering skips initial dynamic mount and redraws later geometry", async () => { + const setup = await testRender(, { width: 20, height: 4 }); + const intermediateRender = mock(() => undefined); + setup.renderer.intermediateRender = intermediateRender; + try { + await act(async () => { + await setup.renderOnce(); + }); + expect(intermediateRender).not.toHaveBeenCalled(); + + await act(async () => { + changeGeometry?.(); + await setup.renderOnce(); + }); + expect(intermediateRender).toHaveBeenCalledTimes(1); + } finally { + setup.renderer.destroy(); + changeGeometry = undefined; + } +}); diff --git a/src/ui/hooks/useIntermediateRenderAfterMount.ts b/src/ui/hooks/useIntermediateRenderAfterMount.ts new file mode 100644 index 000000000..a04118320 --- /dev/null +++ b/src/ui/hooks/useIntermediateRenderAfterMount.ts @@ -0,0 +1,18 @@ +import type { CliRenderer } from "@opentui/core"; +import { useLayoutEffect, useRef, type DependencyList } from "react"; + +/** Request an intermediate redraw only after a component's initial committed layout. */ +export function useIntermediateRenderAfterMount( + renderer: Pick, + dependencies: DependencyList, + skipInitial = true, +) { + const mountedRef = useRef(false); + useLayoutEffect(() => { + if (!mountedRef.current) { + mountedRef.current = true; + if (skipInitial) return; + } + renderer.intermediateRender(); + }, [renderer, skipInitial, ...dependencies]); +} diff --git a/src/ui/log/LogApp.tsx b/src/ui/log/LogApp.tsx index 38090f7b5..b03bc09e6 100644 --- a/src/ui/log/LogApp.tsx +++ b/src/ui/log/LogApp.tsx @@ -102,9 +102,12 @@ export function LogApp({ : null, ); try { + const action = await planned; + // Commit the loading surface before in-process provider startup performs synchronous probes. + await new Promise((resolve) => setImmediate(resolve)); await onOutcome({ kind: "open-review", - action: await planned, + action, themeId: themeController.themeId, themeMode: terminalThemeMode, }); diff --git a/src/ui/log/LogSessionHost.tsx b/src/ui/log/LogSessionHost.tsx new file mode 100644 index 000000000..86c91bc07 --- /dev/null +++ b/src/ui/log/LogSessionHost.tsx @@ -0,0 +1,143 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { retireExtensionLoadResult } from "../../extensions/events"; +import { resolveStartupUpdateNotice } from "../../core/process/updateNotice"; +import type { HistoryRuntime } from "../history/types"; +import { AppHost } from "../AppHost"; +import { + createReviewSessionRuntime, + prepareEmbeddedHistoryReview, + type EmbeddedHistoryReview, + type ReviewSessionRuntime, +} from "../runInteractiveApp"; +import { interactiveLogUsesColor } from "./colorPolicy"; +import { LogApp, type LogAppOutcome } from "./LogApp"; +import { LogController } from "./controller"; + +interface MountedReview { + plan: EmbeddedHistoryReview; + runtime: ReviewSessionRuntime; + instanceId: number; +} + +/** Route history and fresh review sessions through one stable React and terminal renderer root. */ +export function LogSessionHost({ + controller, + runtime, + externalQuitSignal, + onQuit, +}: { + controller: LogController; + runtime: HistoryRuntime; + externalQuitSignal: AbortSignal; + onQuit: (exitCode?: number) => void; +}) { + const [review, setReview] = useState(null); + const reviewRef = useRef(review); + reviewRef.current = review; + const [preparing, setPreparing] = useState(false); + const preparingRef = useRef(preparing); + preparingRef.current = preparing; + const nextInstanceRef = useRef(1); + const preparationControllerRef = useRef(null); + + const retireReview = useCallback(() => { + const current = reviewRef.current; + if (!current) return; + reviewRef.current = null; + current.runtime.stop(); + setReview(null); + if (externalQuitSignal.aborted) onQuit(); + }, [externalQuitSignal, onQuit]); + + const handleLogOutcome = async (outcome: LogAppOutcome) => { + if (outcome.kind === "quit") { + preparationControllerRef.current?.abort( + new Error("History review preparation was cancelled."), + ); + onQuit(outcome.exitCode); + return; + } + if (preparingRef.current || reviewRef.current) return; + preparingRef.current = true; + setPreparing(true); + const preparationController = new AbortController(); + preparationControllerRef.current = preparationController; + const preparationSignal = AbortSignal.any([externalQuitSignal, preparationController.signal]); + let plan: EmbeddedHistoryReview | undefined; + try { + plan = await prepareEmbeddedHistoryReview(runtime, outcome.action, { + themeId: outcome.themeId, + themeMode: outcome.themeMode, + signal: preparationSignal, + }); + preparationSignal.throwIfAborted(); + const reviewRuntime = createReviewSessionRuntime( + plan.bootstrap, + runtime.startupCwd ?? runtime.repoRoot, + ); + const mounted = { + plan, + runtime: reviewRuntime, + instanceId: nextInstanceRef.current++, + } satisfies MountedReview; + reviewRef.current = mounted; + setReview(mounted); + } catch (error) { + if (plan && !plan.borrowsExtensions && !reviewRef.current) { + await retireExtensionLoadResult(plan.bootstrap.extensions); + } + if (preparationSignal.aborted) { + if (externalQuitSignal.aborted) onQuit(); + } else throw error; + } finally { + if (preparationControllerRef.current === preparationController) { + preparationControllerRef.current = null; + } + preparingRef.current = false; + setPreparing(false); + } + }; + + useEffect( + () => () => { + preparationControllerRef.current?.abort( + new Error("History review host unmounted during preparation."), + ); + }, + [], + ); + + useEffect(() => { + if (reviewRef.current || preparing) return; + const requestQuit = () => onQuit(); + if (externalQuitSignal.aborted) requestQuit(); + else externalQuitSignal.addEventListener("abort", requestQuit, { once: true }); + return () => externalQuitSignal.removeEventListener("abort", requestQuit); + }, [externalQuitSignal, onQuit, preparing, review]); + + if (review) { + return ( + undefined} + returnToHistory + extensionOwnership={review.plan.borrowsExtensions ? "borrowed" : "owned"} + reviewProducer={review.runtime.reviewProducer} + startupNoticeResolver={resolveStartupUpdateNotice} + /> + ); + } + + return ( + + ); +} diff --git a/src/ui/log/embeddedReview.test.ts b/src/ui/log/embeddedReview.test.ts new file mode 100644 index 000000000..539a776fc --- /dev/null +++ b/src/ui/log/embeddedReview.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import type { HistoryRuntime } from "../history/types"; +import { prepareEmbeddedHistoryReview } from "../runInteractiveApp"; + +/** Provide only the provider-neutral fields embedded review startup consumes. */ +function createTestRuntime() { + const extensionSession = { registry: {} }; + return { + repoRoot: resolve("repository"), + startupCwd: resolve("invocation"), + providerId: "opaque-vcs", + input: { extensionPaths: ["extensions/provider.ts"], extensionsEnabled: true }, + extensionSession, + } as unknown as HistoryRuntime; +} + +describe("embedded history review bootstrap", () => { + test("preserves opaque actions, invocation-relative extensions, cwd, theme, and signal", async () => { + const abort = new AbortController(); + let captured: { argv: string[]; deps: Record } | undefined; + const runtime = createTestRuntime(); + const result = await prepareEmbeddedHistoryReview( + runtime, + { kind: "revision-show", revisionId: "--opaque:id" }, + { + themeId: "github-dark", + themeMode: "dark", + signal: abort.signal, + env: {}, + prepareStartupPlanImpl: (async (argv: string[], deps: Record) => { + captured = { argv, deps }; + return { + kind: "app", + bootstrap: { extensions: runtime.extensionSession }, + cliInput: {}, + controllingTerminal: null, + }; + }) as never, + }, + ); + + expect(result.bootstrap).toBeDefined(); + expect(captured?.argv).toContain(resolve("invocation", "extensions/provider.ts")); + expect(captured?.deps).toMatchObject({ + cwd: resolve("invocation"), + terminalThemeMode: "dark", + signal: abort.signal, + }); + expect(captured?.deps.borrowedExtensionLoad).toBe(runtime.extensionSession); + expect(result.borrowsExtensions).toBe(true); + expect(captured?.argv.join(" ")).not.toContain("--opaque:id"); + }); + + test("refuses an already-cancelled bootstrap before startup", async () => { + const abort = new AbortController(); + abort.abort(); + let called = false; + await expect( + prepareEmbeddedHistoryReview( + createTestRuntime(), + { kind: "revision-show", revisionId: "opaque" }, + { + signal: abort.signal, + prepareStartupPlanImpl: (async () => { + called = true; + return { kind: "help", text: "unexpected" }; + }) as never, + }, + ), + ).rejects.toThrow(); + expect(called).toBe(false); + }); +}); diff --git a/src/ui/log/reviewLaunch.test.ts b/src/ui/log/reviewLaunch.test.ts deleted file mode 100644 index f4f9e5d17..000000000 --- a/src/ui/log/reviewLaunch.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { ChildProcess, spawn } from "node:child_process"; -import { EventEmitter } from "node:events"; -import { PassThrough } from "node:stream"; -import type { HistoryRuntime } from "../history/types"; -import { prepareHistoryReview } from "./reviewLaunch"; - -/** Provide the launch fields used by the process adapter without constructing a repository cursor. */ -function createTestRuntime() { - return { - repoRoot: "/repo", - providerId: "opaque-vcs", - input: { extensionPaths: [], extensionsEnabled: false }, - } as unknown as HistoryRuntime; -} - -/** Emulate the bounded ChildProcess surface used by the handoff orchestration. */ -function createTestChild({ ignoreTerm = false }: { ignoreTerm?: boolean } = {}) { - const child = new EventEmitter() as ChildProcess & { - sent: unknown[]; - exitCode: number | null; - signalCode: NodeJS.Signals | null; - connected: boolean; - }; - child.sent = []; - child.exitCode = null; - child.signalCode = null; - child.connected = true; - child.stderr = new PassThrough(); - child.send = ((message: unknown, callback?: (error: Error | null) => void) => { - child.sent.push(message); - callback?.(null); - return true; - }) as ChildProcess["send"]; - child.kill = ((signal: NodeJS.Signals = "SIGTERM") => { - if (signal === "SIGTERM" && ignoreTerm) return true; - child.signalCode = signal; - child.connected = false; - queueMicrotask(() => child.emit("exit", null, signal)); - return true; - }) as ChildProcess["kill"]; - return child; -} - -describe("history review readiness", () => { - test("does not release terminal ownership until the child reports ready", async () => { - const child = createTestChild(); - const preparedPromise = prepareHistoryReview( - createTestRuntime(), - { kind: "revision-show", revisionId: "opaque:id" }, - { - current: { command: "hunk", args: [] }, - spawnImpl: (() => child) as unknown as typeof spawn, - env: {}, - }, - ); - - expect(child.sent).toEqual([]); - child.emit("message", { protocol: "hunk-terminal-handoff-v1", kind: "ready" }); - const prepared = await preparedPromise; - expect(child.sent).toEqual([]); - - const exit = prepared.run(); - expect(child.sent).toEqual([{ protocol: "hunk-terminal-handoff-v1", kind: "release" }]); - child.exitCode = 0; - child.emit("exit", 0, null); - expect(await exit).toBe(0); - }); - - test("observes a signalled exit that happens after readiness but before run", async () => { - const child = createTestChild(); - const preparedPromise = prepareHistoryReview( - createTestRuntime(), - { kind: "revision-show", revisionId: "racy" }, - { - current: { command: "hunk", args: [] }, - spawnImpl: (() => child) as unknown as typeof spawn, - env: {}, - }, - ); - - child.emit("message", { protocol: "hunk-terminal-handoff-v1", kind: "ready" }); - const prepared = await preparedPromise; - child.signalCode = "SIGTERM"; - child.connected = false; - child.emit("exit", null, "SIGTERM"); - expect(await prepared.run()).toBe(1); - }); - - test("surfaces bounded child bootstrap failures without releasing the terminal", async () => { - const child = createTestChild(); - const prepared = prepareHistoryReview( - createTestRuntime(), - { kind: "revision-show", revisionId: "missing" }, - { - current: { command: "hunk", args: [] }, - spawnImpl: (() => child) as unknown as typeof spawn, - env: {}, - }, - ); - child.emit("message", { - protocol: "hunk-terminal-handoff-v1", - kind: "failed", - message: "provider could not resolve revision", - }); - await expect(prepared).rejects.toThrow("provider could not resolve revision"); - expect(child.sent).toEqual([]); - expect(child.signalCode).toBe("SIGTERM"); - }); - - test("escalates when a timed-out child ignores graceful termination", async () => { - const child = createTestChild({ ignoreTerm: true }); - const prepared = prepareHistoryReview( - createTestRuntime(), - { kind: "revision-show", revisionId: "slow" }, - { - current: { command: "hunk", args: [] }, - spawnImpl: (() => child) as unknown as typeof spawn, - env: {}, - readyTimeoutMs: 5, - terminateGraceMs: 5, - }, - ); - await expect(prepared).rejects.toThrow("Timed out while preparing"); - expect(child.signalCode).toBe("SIGKILL"); - }); -}); diff --git a/src/ui/log/reviewLaunch.ts b/src/ui/log/reviewLaunch.ts index f982678d9..ddbcd01cd 100644 --- a/src/ui/log/reviewLaunch.ts +++ b/src/ui/log/reviewLaunch.ts @@ -1,183 +1,7 @@ -import { spawn, type ChildProcess } from "node:child_process"; -import { resolve } from "node:path"; -import { resolveCurrentHunkCommand } from "../../core/process/relaunch"; -import { - parseTerminalHandoffMessage, - terminalHandoffEnv, - terminalHandoffMessage, -} from "../../core/process/terminalHandoff"; import type { ExtensionVcsHistoryReviewAction } from "../../extension-api/types"; -import type { HistoryRuntime } from "../history/types"; -const DEFAULT_READY_TIMEOUT_MS = 30_000; -const MAX_BOOTSTRAP_ERROR_BYTES = 16_384; - -export interface PreparedHistoryReview { - /** Release exclusive terminal ownership and wait for the child review to exit. */ - run(): Promise; - /** Stop a child that never received terminal ownership. */ - abort(): Promise; -} - -/** Convert a provider-owned review declaration into one option-safe child invocation. */ +/** Convert a provider-owned review declaration into one option-safe internal invocation. */ export function historyReviewArgs(action: ExtensionVcsHistoryReviewAction) { const payload = Buffer.from(JSON.stringify(action), "utf8").toString("base64url"); return [action.kind === "revision-range" ? "diff" : "show", "--history-review", payload]; } - -/** Collect bounded bootstrap diagnostics without allowing startup output to disturb the log UI. */ -function collectBootstrapError(child: ChildProcess) { - let output = ""; - child.stderr?.setEncoding("utf8"); - child.stderr?.on("data", (chunk: string) => { - if (output.length >= MAX_BOOTSTRAP_ERROR_BYTES) return; - output += chunk.slice(0, MAX_BOOTSTRAP_ERROR_BYTES - output.length); - }); - return () => output.trim(); -} - -/** Spawn and bootstrap one provider-planned review while the log still owns the terminal. */ -export async function prepareHistoryReview( - runtime: HistoryRuntime, - action: ExtensionVcsHistoryReviewAction, - { - themeId, - themeMode, - signal, - readyTimeoutMs = DEFAULT_READY_TIMEOUT_MS, - terminateGraceMs = 1_000, - spawnImpl = spawn, - current = resolveCurrentHunkCommand(), - env = process.env, - stderr = process.stderr, - }: { - themeId?: string; - themeMode?: "dark" | "light"; - signal?: AbortSignal; - readyTimeoutMs?: number; - terminateGraceMs?: number; - spawnImpl?: typeof spawn; - current?: ReturnType; - env?: NodeJS.ProcessEnv; - stderr?: NodeJS.WritableStream; - } = {}, -): Promise { - const extensionArgs = runtime.input.extensionPaths.flatMap((path) => [ - "--extension", - resolve(path), - ]); - const args = [ - ...current.args, - ...historyReviewArgs(action), - "--vcs", - runtime.providerId, - ...(themeId ? ["--theme", themeId] : []), - ...(runtime.input.extensionsEnabled ? extensionArgs : ["--no-extensions"]), - ]; - const child = spawnImpl(current.command, args, { - cwd: runtime.repoRoot, - env: terminalHandoffEnv({ ...env, HUNK_RETURN_TO_HISTORY: "1" }, themeMode), - stdio: ["inherit", "inherit", "pipe", "ipc"], - }); - const bootstrapError = collectBootstrapError(child); - const exitResult = new Promise<{ code: number; error?: Error }>((resolveExit) => { - let settled = false; - const finish = (result: { code: number; error?: Error }) => { - if (settled) return; - settled = true; - resolveExit(result); - }; - child.once("error", (error) => finish({ code: 1, error })); - child.once("exit", (code, exitSignal) => finish({ code: exitSignal ? 1 : (code ?? 1) })); - }); - const childRunning = () => child.exitCode === null && child.signalCode === null; - const terminateChild = async () => { - if (!childRunning()) return; - child.kill("SIGTERM"); - const stopped = await Promise.race([ - exitResult.then(() => true), - new Promise((resolveTimeout) => { - const timer = setTimeout(() => resolveTimeout(false), terminateGraceMs); - timer.unref?.(); - }), - ]); - if (!stopped && childRunning()) { - child.kill("SIGKILL"); - await exitResult; - } - }; - - try { - await new Promise((resolveReady, rejectReady) => { - let settled = false; - const finish = (error?: Error) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - child.off("message", onMessage); - child.off("error", onError); - child.off("exit", onExit); - signal?.removeEventListener("abort", onAbort); - if (error) rejectReady(error); - else resolveReady(); - }; - const onMessage = (value: unknown) => { - const message = parseTerminalHandoffMessage(value); - if (message?.kind === "ready") finish(); - if (message?.kind === "failed") finish(new Error(message.message)); - }; - const onError = (error: Error) => finish(error); - const onExit = () => - finish(new Error(bootstrapError() || "The review exited before it was ready.")); - const onAbort = () => finish(new Error("Review launch was cancelled.")); - const timeout = setTimeout( - () => finish(new Error("Timed out while preparing the selected commit.")), - readyTimeoutMs, - ); - timeout.unref?.(); - child.on("message", onMessage); - child.once("error", onError); - child.once("exit", onExit); - signal?.addEventListener("abort", onAbort, { once: true }); - }); - } catch (error) { - await terminateChild(); - throw error; - } - - let released = false; - const waitForExit = async () => { - const result = await exitResult; - if (result.error) throw result.error; - return result.code; - }; - - return { - async run() { - if (released || !childRunning()) return await waitForExit(); - child.stderr?.pipe(stderr); - try { - await new Promise((resolveRelease, rejectRelease) => { - if (!child.connected) { - rejectRelease(new Error("The review disconnected before terminal release.")); - return; - } - child.send(terminalHandoffMessage("release"), (error) => { - if (error) rejectRelease(error); - else resolveRelease(); - }); - }); - released = true; - } catch (error) { - if (!childRunning()) return await waitForExit(); - await terminateChild(); - throw error; - } - return await waitForExit(); - }, - async abort() { - if (released || !childRunning()) return; - await terminateChild(); - }, - }; -} diff --git a/src/ui/log/runInteractiveLog.tsx b/src/ui/log/runInteractiveLog.tsx index 518d7b532..bdb59d737 100644 --- a/src/ui/log/runInteractiveLog.tsx +++ b/src/ui/log/runInteractiveLog.tsx @@ -12,11 +12,10 @@ import { installTerminalDisconnectSupport, type TerminalDisconnectSupport, } from "../../core/process/terminal"; -import { LogApp, type LogAppOutcome } from "./LogApp"; -import { LogController } from "./controller"; -import { prepareHistoryReview, type PreparedHistoryReview } from "./reviewLaunch"; +import { disposeHighlightWorker } from "../diff/worker"; import type { HistoryRuntime } from "../history/types"; -import { interactiveLogUsesColor } from "./colorPolicy"; +import { LogController } from "./controller"; +import { LogSessionHost } from "./LogSessionHost"; const LOG_SHUTDOWN_SIGNALS: NodeJS.Signals[] = process.platform === "win32" @@ -28,134 +27,86 @@ export function logSignalExitCode(signal: NodeJS.Signals) { return signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143; } -type MountedLogOutcome = - | Extract - | { kind: "open-review"; launch: PreparedHistoryReview }; - -/** Mount one OpenTUI log surface and keep it visible while the selected review bootstraps. */ -async function mountLogSurface( - controller: LogController, +/** Browse history and fresh commit reviews inside one renderer and one stable React root. */ +export async function runInteractiveLog( runtime: HistoryRuntime, - stdin: NodeJS.ReadStream, - stdout: NodeJS.WriteStream, + { + stdin = process.stdin, + stdout = process.stdout, + }: { stdin?: NodeJS.ReadStream; stdout?: NodeJS.WriteStream } = {}, ) { - const renderer = await createCliRenderer({ - stdin, - stdout, - useMouse: true, - screenMode: "alternate-screen", - exitOnCtrlC: false, - exitSignals: [], - openConsoleOnError: true, - }); - let root: ReturnType; - try { - root = createRoot(renderer); - } catch (error) { - renderer.destroy(); - throw error; + if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { + await runtime.close(); + throw new HunkUserError("The `hunk log` browser requires a terminal.", [ + "Use `hunk log --static` to force scrollback output.", + ]); } + + const controller = new LogController(runtime); + const quitController = new AbortController(); + let renderer: Awaited> | undefined; + let root: ReturnType | undefined; + let interrupt: JobControlInterruptSupport = { dispose: () => undefined }; + let suspend: JobControlSuspendSupport = { dispose: () => undefined }; + let disconnect: TerminalDisconnectSupport = { dispose: () => undefined }; let settled = false; - let settle!: (outcome: MountedLogOutcome) => void; - const outcome = new Promise((resolve) => { - settle = resolve; + let finish!: (exitCode?: number) => void; + const outcome = new Promise((resolve) => { + finish = (exitCode) => { + if (settled) return; + settled = true; + resolve(exitCode); + }; }); - const launchAbort = new AbortController(); - let preparedLaunch: PreparedHistoryReview | undefined; - const finish = (value: MountedLogOutcome) => { - if (settled) return; - settled = true; - settle(value); - }; - const handleOutcome = async (value: LogAppOutcome) => { - if (value.kind === "quit") { - launchAbort.abort(); - finish(value); - return; - } - const launch = await prepareHistoryReview(runtime, value.action, { - themeId: value.themeId, - themeMode: value.themeMode, - signal: launchAbort.signal, - }); - if (settled) { - await launch.abort(); - return; - } - preparedLaunch = launch; - finish({ kind: "open-review", launch }); + const requestQuit = () => quitController.abort(); + const requestInterrupt = () => { + process.exitCode = 130; + quitController.abort(); }; - const requestQuit = () => finish({ kind: "quit" }); - const requestInterrupt = () => finish({ kind: "quit", exitCode: 130 }); const signalHandlers = new Map void>( LOG_SHUTDOWN_SIGNALS.map((signal) => [ signal, - () => - finish({ - kind: "quit", - exitCode: logSignalExitCode(signal), - }), + () => { + process.exitCode = logSignalExitCode(signal); + quitController.abort(); + }, ]), ); - for (const [signal, handler] of signalHandlers) process.once(signal, handler); - let interrupt: JobControlInterruptSupport = { dispose: () => undefined }; - let suspend: JobControlSuspendSupport = { dispose: () => undefined }; - let disconnect: TerminalDisconnectSupport = { dispose: () => undefined }; + try { + await controller.loadMore(); + renderer = await createCliRenderer({ + stdin, + stdout, + useMouse: true, + screenMode: "alternate-screen", + exitOnCtrlC: false, + exitSignals: [], + openConsoleOnError: true, + }); + root = createRoot(renderer); interrupt = installJobControlInterruptSupport(renderer, requestInterrupt); suspend = installJobControlSuspendSupport(renderer); disconnect = installTerminalDisconnectSupport(stdin, requestQuit); + for (const [signal, handler] of signalHandlers) process.once(signal, handler); root.render( - , ); - return await outcome; + const exitCode = await outcome; + if (exitCode !== undefined) process.exitCode = exitCode; } finally { - if (!preparedLaunch) launchAbort.abort(); for (const [signal, handler] of signalHandlers) process.off(signal, handler); interrupt.dispose(); suspend.dispose(); disconnect.dispose(); - shutdownSession({ root, renderer, exit: () => undefined }); - } -} - -/** Browse history in shared desktop chrome, yielding fully to each child review. */ -export async function runInteractiveLog( - runtime: HistoryRuntime, - { - stdin = process.stdin, - stdout = process.stdout, - }: { stdin?: NodeJS.ReadStream; stdout?: NodeJS.WriteStream } = {}, -) { - if (!stdin.isTTY || !stdout.isTTY || typeof stdin.setRawMode !== "function") { - await runtime.close(); - throw new HunkUserError("The `hunk log` browser requires a terminal.", [ - "Use `hunk log --static` to force scrollback output.", - ]); - } - - const controller = new LogController(runtime); - try { - await controller.loadMore(); - for (;;) { - const outcome = await mountLogSurface(controller, runtime, stdin, stdout); - if (outcome.kind === "quit") { - if (outcome.exitCode !== undefined) process.exitCode = outcome.exitCode; - return; - } - try { - const code = await outcome.launch.run(); - controller.setNotice(code === 0 ? "" : "Could not open the selected commit."); - } catch (error) { - controller.setNotice(error instanceof Error ? error.message : String(error)); - } - } - } finally { + disposeHighlightWorker(); + if (root && renderer) shutdownSession({ root, renderer, exit: () => undefined }); + else renderer?.destroy(); await controller.close(); } } diff --git a/src/ui/runInteractiveApp.tsx b/src/ui/runInteractiveApp.tsx index ed1a7e00c..05af1108f 100644 --- a/src/ui/runInteractiveApp.tsx +++ b/src/ui/runInteractiveApp.tsx @@ -1,6 +1,7 @@ import { createNativeSessionBrokerLifecycleClock } from "@hunk/session-broker"; import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; +import { resolve } from "node:path"; import { installJobControlInterruptSupport, installJobControlSuspendSupport, @@ -16,6 +17,7 @@ import { } from "../core/process/terminal"; import type { AppBootstrap } from "../core/bootstrap"; import { resolveStartupUpdateNotice } from "../core/process/updateNotice"; +import { prepareStartupPlan } from "../app/startup"; import { ReviewProducer } from "../app/review/producer"; import { createInitialSessionSnapshot, @@ -23,6 +25,9 @@ import { } from "../app/session/registration"; import { SessionBrokerClient } from "../session/broker/brokerClient"; import { reportHunkSessionBrokerLifecycleDefect } from "../session/broker/lifecycleDefect"; +import type { ExtensionVcsHistoryReviewAction } from "../extension-api/types"; +import type { HistoryRuntime } from "./history/types"; +import { historyReviewArgs } from "./log/reviewLaunch"; import { AppHost } from "./AppHost"; import { disposeHighlightWorker } from "./diff/worker"; import { retireExtensionLoadResult } from "../extensions/events"; @@ -33,20 +38,23 @@ export interface InteractiveAppInput { controllingTerminal: ControllingTerminal | null; } -// Leave fatal process faults to their default OS disposition. -const APP_SHUTDOWN_SIGNALS: NodeJS.Signals[] = - process.platform === "win32" - ? ["SIGINT", "SIGTERM", "SIGBREAK"] - : ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT", "SIGPIPE"]; +export interface ReviewSessionRuntime { + hostClient: SessionBrokerClient; + reviewProducer: ReviewProducer; + stop(): void; +} -/** Load and run the OpenTUI review app after startup has selected an interactive plan. */ -export async function runInteractiveApp({ - bootstrap, - controllingTerminal, -}: InteractiveAppInput): Promise { - // One producer owns this review's generations for the life of the process: the - // registration and the first snapshot are projections of its first publication, and every - // reload publishes the next one through the same object. +export interface EmbeddedHistoryReview { + bootstrap: AppBootstrap; + /** The history runtime owns this bootstrap's extension registry. */ + borrowsExtensions: boolean; +} + +/** Create broker and producer resources for one independently mountable review surface. */ +export function createReviewSessionRuntime( + bootstrap: AppBootstrap, + cwd = process.cwd(), +): ReviewSessionRuntime { const reviewProducer = new ReviewProducer({ files: bootstrap.changeset.files, sourceLabel: bootstrap.changeset.sourceLabel, @@ -54,11 +62,93 @@ export async function runInteractiveApp({ const publication = reviewProducer.getPublication(); const lifecycleClock = createNativeSessionBrokerLifecycleClock(); const hostClient = new SessionBrokerClient( - createSessionRegistration(bootstrap, publication), + createSessionRegistration(bootstrap, publication, cwd), createInitialSessionSnapshot(bootstrap, publication), { lifecycleClock, onDefect: reportHunkSessionBrokerLifecycleDefect }, ); hostClient.start(); + let stopped = false; + return { + hostClient, + reviewProducer, + stop() { + if (stopped) return; + stopped = true; + hostClient.stop(); + }, + }; +} + +/** Bootstrap one provider-planned history review without creating or claiming a renderer. */ +export async function prepareEmbeddedHistoryReview( + runtime: HistoryRuntime, + action: ExtensionVcsHistoryReviewAction, + { + themeId, + themeMode, + signal, + env = process.env, + prepareStartupPlanImpl = prepareStartupPlan, + }: { + themeId?: string; + themeMode?: "dark" | "light"; + signal?: AbortSignal; + env?: NodeJS.ProcessEnv; + prepareStartupPlanImpl?: typeof prepareStartupPlan; + } = {}, +): Promise { + signal?.throwIfAborted(); + const startupCwd = runtime.startupCwd ?? runtime.repoRoot; + const extensionArgs = runtime.input.extensionPaths.flatMap((path) => [ + "--extension", + resolve(startupCwd, path), + ]); + const args = [ + ...historyReviewArgs(action), + "--vcs", + runtime.providerId, + ...(themeId ? ["--theme", themeId] : []), + ...(runtime.input.extensionsEnabled ? extensionArgs : ["--no-extensions"]), + ]; + const plan = await prepareStartupPlanImpl(["hunk", "hunk", ...args], { + cwd: startupCwd, + env, + signal, + borrowedExtensionLoad: runtime.extensionSession, + stdinIsTTY: true, + stdoutIsTTY: true, + terminalThemeMode: themeMode, + }); + if (signal?.aborted && plan.kind === "app") { + plan.controllingTerminal?.close(); + if (plan.bootstrap.extensions !== runtime.extensionSession) { + await retireExtensionLoadResult(plan.bootstrap.extensions); + } + signal.throwIfAborted(); + } + if (plan.kind !== "app") { + throw new Error("The selected commit did not produce an interactive review."); + } + plan.controllingTerminal?.close(); + return { + bootstrap: plan.bootstrap as AppBootstrap, + borrowsExtensions: plan.bootstrap.extensions === runtime.extensionSession, + }; +} + +// Leave fatal process faults to their default OS disposition. +const APP_SHUTDOWN_SIGNALS: NodeJS.Signals[] = + process.platform === "win32" + ? ["SIGINT", "SIGTERM", "SIGBREAK"] + : ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT", "SIGPIPE"]; + +/** Load and run the OpenTUI review app after startup has selected an interactive plan. */ +export async function runInteractiveApp({ + bootstrap, + controllingTerminal, +}: InteractiveAppInput): Promise { + const reviewSession = createReviewSessionRuntime(bootstrap, bootstrap.reloadContext.cwd); + const { hostClient, reviewProducer } = reviewSession; // Keep OpenTUI's platform-safe threading default (enabled on macOS, disabled on Linux). const rendererStdin = controllingTerminal?.stdin ?? process.stdin; @@ -78,7 +168,7 @@ export async function runInteractiveApp({ onDestroy: () => controllingTerminal?.close(), }); } catch (error) { - hostClient.stop(); + reviewSession.stop(); controllingTerminal?.close(); await retireExtensionLoadResult(bootstrap.extensions); throw error; @@ -89,7 +179,7 @@ export async function runInteractiveApp({ try { root = createRoot(appRenderer); } catch (error) { - hostClient.stop(); + reviewSession.stop(); appRenderer.destroy(); controllingTerminal?.close(); await retireExtensionLoadResult(bootstrap.extensions); @@ -119,7 +209,7 @@ export async function runInteractiveApp({ jobControlInterruptSupport.dispose(); jobControlSuspendSupport.dispose(); terminalDisconnectSupport.dispose(); - hostClient.stop(); + reviewSession.stop(); // Release the syntax worker here rather than from the executable entrypoint: this function // returns once the app is mounted, so an entrypoint-side dispose would fire before the first // eligible diff ever asked for the worker. diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index 8e1528b97..75c789572 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createPtyHarness, rightmostColumnOf } from "./harness"; @@ -71,6 +71,46 @@ afterEach(() => { }); describe("interactive hunk log", () => { + test("cancels a slow bundled Git review without blocking terminal input", async () => { + const cwd = createHistoryRepo(); + const binDir = mkdtempSync(join(tmpdir(), "hunk-slow-git-bin-")); + tempDirs.push(binDir); + const gitPath = Bun.which("git"); + if (!gitPath) throw new Error("Git is required for the PTY history fixture."); + const wrapper = join(binDir, "git"); + writeFileSync( + wrapper, + `#!/bin/sh\ncase " $* " in *" show "*|*" diff "*) sleep 10 ;; esac\nexec ${JSON.stringify(gitPath)} "$@"\n`, + ); + chmodSync(wrapper, 0o755); + const session = await harness.launchHunk({ + args: ["log", "--color", "never", "--no-extensions"], + cwd, + cols: 100, + rows: 20, + env: { PATH: `${binDir}:${process.env.PATH ?? ""}` }, + }); + + try { + await session.waitForText(/Second history commit/, { timeout: 15_000 }); + await session.press("enter"); + await session.waitForText(/Preparing review/, { timeout: 5_000 }); + const cancelledAt = Date.now(); + session.writeRaw("\x03"); + while ( + !session.getRawOutput().slice(-2_000).includes("\x1b[?1049l") && + Date.now() - cancelledAt < 3_000 + ) { + await Bun.sleep(25); + } + expect(Date.now() - cancelledAt).toBeLessThan(3_000); + expect(session.getRawOutput().slice(-2_000)).toContain("\x1b[?1049l"); + expect(session.getRawOutput()).not.toContain("historyValue = 'second'"); + } finally { + session.writeRaw("\x03"); + } + }); + test("opens the selected immutable commit and returns to the retained history", async () => { const cwd = createHistoryRepo(); const session = await harness.launchHunk({ @@ -105,23 +145,23 @@ describe("interactive hunk log", () => { const firstRow = history.split("\n")[firstRowIndex] ?? ""; const commitColumn = firstRow.search(/[0-9a-f]{8}\s+⧉\s*$/); expect(commitColumn).toBeGreaterThan(0); + const transitionOutputStart = session.getRawOutput().length; session.writeRaw( `\x1b[<0;${commitColumn + 1};${firstRowIndex + 1}M\x1b[<0;${commitColumn + 1};${firstRowIndex + 1}m`, ); - const preparing = await session.waitForText(/Opening commit[\s\S]*Preparing review…/, { - timeout: 5_000, - }); - expect(preparing).not.toContain("historyValue = 'second'"); const review = await session.waitForText(/historyValue = 'second'/, { timeout: 15_000, }); expect(review).toContain("history.ts"); + expect(session.getRawOutput().slice(transitionOutputStart)).not.toContain("\x1b[?1049l"); + const returnOutputStart = session.getRawOutput().length; await session.press("q"); const returned = await session.waitForText(/Second history commit/, { timeout: 15_000, }); expect(returned).toContain("Enter open"); + expect(session.getRawOutput().slice(returnOutputStart)).not.toContain("\x1b[?1049l"); // The adjacent icon copies without opening the review. session.writeRaw( From e76b003991689617ea484027cc3e0792b6300120 Mon Sep 17 00:00:00 2001 From: Ben Vinegar <2153+benvinegar@users.noreply.github.com> Date: Sat, 5 Sep 2026 21:40:13 -0400 Subject: [PATCH 08/36] refactor: move hunkdiff into packages/hunk (#997) --- .changeset/README.md | 9 +- .changeset/package-first-phase-one.md | 2 + .dependency-cruiser.cjs | 111 +++--- .github/workflows/ci.yml | 2 +- .github/workflows/pr-ci.yml | 2 +- AGENTS.md | 26 +- CONTRIBUTING.md | 4 +- benchmarks/bootstrap-load.ts | 4 +- benchmarks/changeset-parse.ts | 6 +- benchmarks/compact-highlight-payload.ts | 8 +- benchmarks/geometry-memory.ts | 4 +- benchmarks/highlight-cache-layers.ts | 8 +- benchmarks/highlight-prefetch.ts | 6 +- benchmarks/huge-stream.ts | 2 +- benchmarks/interaction-latency.ts | 2 +- benchmarks/large-stream-fixture.ts | 4 +- benchmarks/large-stream-profile.ts | 8 +- benchmarks/large-stream.ts | 4 +- benchmarks/memory.ts | 8 +- benchmarks/navigation-memory.ts | 2 +- benchmarks/non-ascii-stream.ts | 2 +- benchmarks/render-layout.ts | 8 +- benchmarks/resize-memory.ts | 2 +- benchmarks/run.ts | 4 +- benchmarks/terminal-width.ts | 2 +- benchmarks/worker-highlight-cache.ts | 4 +- benchmarks/working-tree-load.ts | 4 +- benchmarks/wrapped-cjk.ts | 12 +- bun.lock | 43 ++- docs/agent-workflows.md | 2 +- docs/browser-review-rebuild.md | 12 +- docs/browser-review-seam-audit.md | 42 +-- docs/changelog-on-hunk-dev.md | 2 +- docs/extension-architecture.md | 116 +++---- docs/extension-system-exploration.md | 10 +- docs/extensions.md | 6 +- docs/module-boundaries.md | 56 +-- docs/opentui-component.md | 2 +- docs/session-broker-sdk.md | 2 +- docs/source-architecture.md | 44 +-- docs/watch-benchmark-final.md | 4 +- examples/7-opentui-component/README.md | 2 +- examples/7-opentui-component/from-files.tsx | 2 +- examples/7-opentui-component/from-patch.tsx | 2 +- examples/7-opentui-component/support.tsx | 6 +- examples/8-opentui-primitives/README.md | 2 +- .../8-opentui-primitives/primitives-demo.tsx | 4 +- examples/extensions/cli-tools/README.md | 4 +- examples/extensions/github-pr/README.md | 2 +- examples/extensions/inline-edit/README.md | 2 +- .../jsx-file-view-gallery/README.md | 6 +- .../jsx-file-view-gallery/mixed-review/run.ts | 9 +- examples/extensions/jsx-file-view/README.md | 2 +- examples/extensions/pane-layout/README.md | 2 +- .../extensions/rendered-markdown/README.md | 2 +- .../review-note-navigator/README.md | 2 +- .../review-snapshot-export/README.md | 2 +- .../review-snapshot-export/index.test.ts | 2 +- examples/extensions/review-triage/README.md | 2 +- examples/extensions/vim-navigation/README.md | 2 +- knip.json | 37 +- nix/bun.lock.nix | 1 + nix/package.nix | 6 +- package.json | 89 +---- packages/hunk/LICENSE | 21 ++ packages/hunk/README.md | 323 ++++++++++++++++++ {bin => packages/hunk/bin}/hunk.cjs | 0 packages/hunk/package.json | 92 +++++ .../hunk/skills}/hunk-extensions/SKILL.md | 18 +- .../hunk/skills}/hunk-review/SKILL.md | 0 {src => packages/hunk/src}/app/cli.test.ts | 0 {src => packages/hunk/src}/app/cli.ts | 0 .../hunk/src}/app/delegatedReview.test.ts | 0 .../hunk/src}/app/delegatedReview.ts | 0 .../hunk/src}/app/extensionBootstrap.test.ts | 0 .../hunk/src}/app/extensionBootstrap.ts | 0 .../src}/app/extensionCliBootstrap.test.ts | 0 .../hunk/src}/app/extensionCliBootstrap.ts | 0 .../hunk/src}/app/historyBootstrap.test.ts | 0 .../hunk/src}/app/historyBootstrap.ts | 0 .../hunk/src}/app/review/capability.ts | 2 +- .../hunk/src}/app/review/producer.test.ts | 2 +- .../hunk/src}/app/review/producer.ts | 0 .../hunk/src}/app/review/publication.ts | 4 +- .../hunk/src}/app/review/resourceStore.ts | 0 .../hunk/src}/app/session/bridge.test.ts | 0 .../hunk/src}/app/session/bridge.ts | 0 .../src}/app/session/registration.test.ts | 2 +- .../hunk/src}/app/session/registration.ts | 0 .../src}/app/session/reloadBounds.test.ts | 0 .../hunk/src}/app/session/reloadBounds.ts | 0 .../src}/app/session/reviewCommands.test.ts | 2 +- .../hunk/src}/app/session/reviewCommands.ts | 4 +- .../hunk/src}/app/sessionBootstrap.test.ts | 0 .../hunk/src}/app/sessionBootstrap.ts | 0 .../hunk/src}/app/sessionSelector.test.ts | 0 .../hunk/src}/app/sessionSelector.ts | 0 .../hunk/src}/app/startup.test.ts | 0 {src => packages/hunk/src}/app/startup.ts | 0 .../src}/app/startup.vcsExtensions.test.ts | 0 {src => packages/hunk/src}/app/types.ts | 0 .../hunk/src}/app/vcsCatalog.test.ts | 0 {src => packages/hunk/src}/app/vcsCatalog.ts | 0 {src => packages/hunk/src}/core/bootstrap.ts | 2 +- .../hunk/src}/core/changeset/binary.test.ts | 0 .../hunk/src}/core/changeset/binary.ts | 0 .../hunk/src}/core/changeset/diffFile.test.ts | 0 .../hunk/src}/core/changeset/diffFile.ts | 0 .../hunk/src}/core/changeset/diffPaths.ts | 0 .../src}/core/changeset/fileLanguage.test.ts | 0 .../hunk/src}/core/changeset/fileLanguage.ts | 0 .../src}/core/changeset/fileLanguageLookup.ts | 0 .../src}/core/changeset/fileSource.test.ts | 0 .../hunk/src}/core/changeset/fileSource.ts | 0 .../hunk/src}/core/changeset/fromPatch.ts | 0 .../src}/core/changeset/hunkHeader.test.ts | 0 .../hunk/src}/core/changeset/hunkHeader.ts | 0 .../src}/core/changeset/hunkSummary.test.ts | 4 +- .../hunk/src}/core/changeset/hunkSummary.ts | 0 .../core/changeset/loaders.ordering.test.ts | 2 +- .../hunk/src}/core/changeset/loaders.test.ts | 0 .../hunk/src}/core/changeset/loaders.ts | 0 .../hunk/src}/core/changeset/model.ts | 0 .../hunk/src}/core/changeset/sidecar.test.ts | 0 .../hunk/src}/core/changeset/sidecar.ts | 2 +- .../src}/core/history/lanePlanner.test.ts | 0 .../hunk/src}/core/history/lanePlanner.ts | 0 .../hunk/src}/core/history/types.ts | 0 .../src}/core/install/installSource.test.ts | 0 .../hunk/src}/core/install/installSource.ts | 0 .../src}/core/install/latestRelease.test.ts | 0 .../hunk/src}/core/install/latestRelease.ts | 0 .../hunk/src}/core/install/selfUpdate.test.ts | 0 .../hunk/src}/core/install/selfUpdate.ts | 0 .../hunk/src}/core/liveComments.test.ts | 2 +- .../hunk/src}/core/liveComments.ts | 2 +- .../hunk/src}/core/patch/chunks.test.ts | 0 .../hunk/src}/core/patch/chunks.ts | 0 .../hunk/src}/core/patch/gitFormat.test.ts | 0 .../hunk/src}/core/patch/gitFormat.ts | 0 .../hunk/src}/core/patch/gitLog.test.ts | 0 .../hunk/src}/core/patch/gitLog.ts | 0 .../hunk/src}/core/patch/sanitize.test.ts | 0 .../hunk/src}/core/patch/sanitize.ts | 0 .../hunk/src}/core/patch/singleFile.ts | 0 .../src}/core/process/appStateFile.test.ts | 0 .../hunk/src}/core/process/appStateFile.ts | 0 .../hunk/src}/core/process/jobControl.test.ts | 0 .../hunk/src}/core/process/jobControl.ts | 0 .../hunk/src}/core/process/pager.test.ts | 0 .../hunk/src}/core/process/pager.ts | 0 .../src}/core/process/projectRoot.test.ts | 0 .../hunk/src}/core/process/projectRoot.ts | 0 .../hunk/src}/core/process/relaunch.test.ts | 0 .../hunk/src}/core/process/relaunch.ts | 0 .../hunk/src}/core/process/shutdown.test.ts | 0 .../hunk/src}/core/process/shutdown.ts | 0 .../hunk/src}/core/process/startupNotice.ts | 0 .../hunk/src}/core/process/stdout.test.ts | 0 .../hunk/src}/core/process/stdout.ts | 0 .../hunk/src}/core/process/terminal.test.ts | 0 .../hunk/src}/core/process/terminal.ts | 0 .../src}/core/process/updateNotice.test.ts | 0 .../hunk/src}/core/process/updateNotice.ts | 0 .../hunk/src}/core/review/actions.ts | 0 .../hunk/src}/core/review/anchors.test.ts | 0 .../hunk/src}/core/review/anchors.ts | 0 .../hunk/src}/core/review/annotations.ts | 0 .../src}/core/review/canonicalFile.test.ts | 2 +- .../hunk/src}/core/review/canonicalFile.ts | 0 .../src}/core/review/contentManifest.test.ts | 2 +- .../hunk/src}/core/review/contentManifest.ts | 0 .../hunk/src}/core/review/document.test.ts | 2 +- .../hunk/src}/core/review/document.ts | 0 .../hunk/src}/core/review/expansion.test.ts | 2 +- .../hunk/src}/core/review/expansion.ts | 0 .../src}/core/review/generationOrder.test.ts | 0 .../hunk/src}/core/review/generationOrder.ts | 0 .../hunk/src}/core/review/geometry.test.ts | 0 .../hunk/src}/core/review/geometry.ts | 0 .../hunk/src}/core/review/identity.test.ts | 0 .../hunk/src}/core/review/identity.ts | 0 .../hunk/src}/core/review/intents.test.ts | 2 +- .../hunk/src}/core/review/intents.ts | 2 +- .../hunk/src}/core/review/navigation.test.ts | 0 .../hunk/src}/core/review/navigation.ts | 0 .../hunk/src}/core/review/noteSize.test.ts | 0 .../hunk/src}/core/review/noteSize.ts | 0 .../hunk/src}/core/review/reducer.test.ts | 2 +- .../hunk/src}/core/review/reducer.ts | 0 .../src}/core/review/resourceAssembly.test.ts | 0 .../hunk/src}/core/review/resourceAssembly.ts | 0 .../hunk/src}/core/review/resources.test.ts | 0 .../hunk/src}/core/review/resources.ts | 0 .../hunk/src}/core/review/selectors.test.ts | 4 +- .../hunk/src}/core/review/selectors.ts | 0 .../hunk/src}/core/review/state.test.ts | 2 +- .../hunk/src}/core/review/state.ts | 0 .../hunk/src}/core/review/stml.test.ts | 0 .../hunk/src}/core/review/stml.ts | 0 .../hunk/src}/core/review/store.test.ts | 2 +- .../hunk/src}/core/review/store.ts | 0 .../hunk/src}/core/review/types.ts | 0 .../hunk/src}/core/review/validation.test.ts | 0 .../hunk/src}/core/review/validation.ts | 0 .../hunk/src}/core/reviewDescriptor.test.ts | 0 .../hunk/src}/core/reviewDescriptor.ts | 0 .../hunk/src}/core/reviewDigest.ts | 0 .../hunk/src}/core/run/cliCommandNames.ts | 0 .../hunk/src}/core/run/commandCatalog.test.ts | 2 +- .../hunk/src}/core/run/commandCatalog.ts | 2 +- .../hunk/src}/core/run/commandInputs.ts | 0 .../hunk/src}/core/run/config.test.ts | 0 {src => packages/hunk/src}/core/run/config.ts | 2 +- .../hunk/src}/core/run/errors.test.ts | 0 {src => packages/hunk/src}/core/run/errors.ts | 0 .../hunk/src}/core/run/experimental.test.ts | 5 +- .../hunk/src}/core/run/experimental.ts | 0 .../hunk/src}/core/run/inputReload.ts | 0 .../hunk/src}/core/run/paths.test.ts | 0 {src => packages/hunk/src}/core/run/paths.ts | 4 +- .../hunk/src}/core/run/reviewGap.test.ts | 0 .../hunk/src}/core/run/reviewGap.ts | 0 .../hunk/src}/core/run/tabWidth.ts | 0 .../hunk/src}/core/run/version.ts | 0 .../hunk/src}/core/theme/catalog.test.ts | 0 .../hunk/src}/core/theme/catalog.ts | 0 .../hunk/src}/core/theme/customThemes.test.ts | 0 .../hunk/src}/core/theme/customThemes.ts | 0 .../hunk/src}/core/theme/detection.test.ts | 0 .../hunk/src}/core/theme/detection.ts | 0 .../core/theme/legacySyntaxScopes.test.ts | 0 .../src}/core/theme/legacySyntaxScopes.ts | 0 .../hunk/src}/core/vcs/index.test.ts | 0 {src => packages/hunk/src}/core/vcs/index.ts | 0 {src => packages/hunk/src}/core/vcs/types.ts | 0 .../hunk/src}/core/vcs/untracked.test.ts | 0 .../hunk/src}/core/vcs/untracked.ts | 0 .../hunk/src}/core/watch/controller.test.ts | 0 .../hunk/src}/core/watch/controller.ts | 0 .../hunk/src}/core/watch/observer.fs.test.ts | 0 .../hunk/src}/core/watch/observer.test.ts | 2 +- .../hunk/src}/core/watch/observer.ts | 0 .../hunk/src}/core/watch/plan.test.ts | 0 {src => packages/hunk/src}/core/watch/plan.ts | 0 .../hunk/src}/core/watch/runtime.test.ts | 0 .../hunk/src}/core/watch/runtime.ts | 0 .../hunk/src}/core/watch/signature.test.ts | 0 .../hunk/src}/core/watch/signature.ts | 0 .../hunk/src}/extension-api/index.ts | 0 .../hunk/src}/extension-api/keys.test.ts | 2 +- .../hunk/src}/extension-api/keys.ts | 0 .../hunk/src}/extension-api/types.ts | 0 .../hunk/src}/extensions/apply.test.ts | 2 +- .../hunk/src}/extensions/apply.ts | 0 .../src}/extensions/cliCommandRuntime.test.ts | 0 .../hunk/src}/extensions/cliCommandRuntime.ts | 0 .../hunk/src}/extensions/cliCommands.test.ts | 0 .../hunk/src}/extensions/cliCommands.ts | 0 .../src}/extensions/default/ui/index.test.ts | 0 .../hunk/src}/extensions/default/ui/index.ts | 0 .../default/ui/reviewInfo/index.test.tsx | 2 +- .../default/ui/reviewInfo/index.tsx | 0 .../ui/reviewInfo/presentation.test.ts | 0 .../default/ui/reviewInfo/presentation.ts | 0 .../default/ui/sidebar/FileSidebars.tsx | 0 .../default/ui/sidebar/index.test.tsx | 0 .../extensions/default/ui/sidebar/index.tsx | 0 .../default/vcs/asyncProcess.test.ts | 0 .../extensions/default/vcs/asyncProcess.ts | 0 .../src}/extensions/default/vcs/diffRange.ts | 0 .../default/vcs/git/commands.test.ts | 0 .../extensions/default/vcs/git/commands.ts | 47 +-- .../default/vcs/git/history.test.ts | 0 .../extensions/default/vcs/git/history.ts | 0 .../extensions/default/vcs/git/index.test.ts | 0 .../src}/extensions/default/vcs/git/index.ts | 2 +- .../extensions/default/vcs/git/source.test.ts | 0 .../src}/extensions/default/vcs/git/source.ts | 0 .../src}/extensions/default/vcs/index.test.ts | 0 .../hunk/src}/extensions/default/vcs/index.ts | 0 .../default/vcs/jujutsu/commands.test.ts | 0 .../default/vcs/jujutsu/commands.ts | 12 - .../default/vcs/jujutsu/history.test.ts | 0 .../extensions/default/vcs/jujutsu/history.ts | 0 .../default/vcs/jujutsu/index.test.ts | 0 .../extensions/default/vcs/jujutsu/index.ts | 0 .../default/vcs/jujutsu/source.test.ts | 0 .../extensions/default/vcs/jujutsu/source.ts | 0 .../default/vcs/sapling/commands.test.ts | 0 .../default/vcs/sapling/commands.ts | 0 .../default/vcs/sapling/index.test.ts | 0 .../extensions/default/vcs/sapling/index.ts | 0 .../hunk/src}/extensions/discovery.test.ts | 0 .../hunk/src}/extensions/discovery.ts | 0 .../hunk/src}/extensions/events.test.ts | 2 +- .../hunk/src}/extensions/events.ts | 0 .../hunk/src}/extensions/extensionIds.ts | 2 +- .../hunk/src}/extensions/host.test.ts | 0 {src => packages/hunk/src}/extensions/host.ts | 0 .../extensions/hostRuntimeModules.test.ts | 0 .../src}/extensions/hostRuntimeModules.ts | 0 .../hunk/src}/extensions/manage/cli.ts | 0 .../src}/extensions/manage/install.test.ts | 0 .../hunk/src}/extensions/manage/install.ts | 0 .../hunk/src}/extensions/manage/records.ts | 0 .../src}/extensions/manage/source.test.ts | 0 .../hunk/src}/extensions/manage/source.ts | 0 .../src}/extensions/notifications.test.ts | 0 .../hunk/src}/extensions/notifications.ts | 0 .../hunk/src}/extensions/panes.ts | 0 .../extensions/publicApiRobustness.test.ts | 2 +- .../src}/extensions/reviewSnapshot.test.ts | 2 +- .../hunk/src}/extensions/reviewSnapshot.ts | 0 .../hunk/src}/extensions/runExtension.test.ts | 0 .../hunk/src}/extensions/runExtension.ts | 2 +- .../hunk/src}/extensions/startup.test.ts | 0 .../hunk/src}/extensions/startup.ts | 0 .../hunk/src}/extensions/trust.test.ts | 0 .../hunk/src}/extensions/trust.ts | 0 .../hunk/src}/extensions/types.ts | 4 +- .../src}/extensions/vcsPatchResult.test.ts | 0 .../hunk/src}/extensions/vcsPatchResult.ts | 0 .../hunk/src}/highlightWorkerClient.ts | 0 .../hunk/src}/highlightWorkerEntry.ts | 0 .../src}/hunk-review/skillDocument.test.ts | 12 +- .../hunk/src}/hunk-review/skillDocument.ts | 2 +- .../hunk/src}/lib/commandKeys.test.ts | 2 +- {src => packages/hunk/src}/lib/commandKeys.ts | 2 +- {src => packages/hunk/src}/lib/largeFile.ts | 0 {src => packages/hunk/src}/lib/osPath.test.ts | 0 {src => packages/hunk/src}/lib/osPath.ts | 0 {src => packages/hunk/src}/lib/patchPath.ts | 0 .../hunk/src}/lib/sourceText.test.ts | 0 {src => packages/hunk/src}/lib/sourceText.ts | 0 .../hunk/src}/lib/terminalText.test.ts | 0 .../hunk/src}/lib/terminalText.ts | 0 {src => packages/hunk/src}/main.tsx | 0 .../hunk/src}/opentui/HunkDiffBody.tsx | 0 .../hunk/src}/opentui/HunkDiffFileHeader.tsx | 0 .../hunk/src}/opentui/HunkDiffView.test.tsx | 0 .../hunk/src}/opentui/HunkDiffView.tsx | 0 .../hunk/src}/opentui/HunkFileNav.tsx | 0 .../hunk/src}/opentui/HunkReviewStream.tsx | 0 {src => packages/hunk/src}/opentui/index.ts | 0 {src => packages/hunk/src}/opentui/model.ts | 12 +- {src => packages/hunk/src}/opentui/themes.ts | 0 {src => packages/hunk/src}/opentui/types.ts | 0 .../hunk/src}/session/agent/cliClient.test.ts | 2 +- .../hunk/src}/session/agent/cliClient.ts | 0 .../session/agent/commands.daemon.test.ts | 2 +- .../hunk/src}/session/agent/commands.test.ts | 2 +- .../hunk/src}/session/agent/commands.ts | 0 .../hunk/src}/session/agent/errors.test.ts | 0 .../hunk/src}/session/agent/errors.ts | 2 +- .../hunk/src}/session/agent/surface.test.ts | 0 .../hunk/src}/session/agent/surface.ts | 8 +- .../hunk/src}/session/broker/appContract.ts | 0 .../src}/session/broker/brokerClient.test.ts | 4 +- .../hunk/src}/session/broker/brokerClient.ts | 0 .../src}/session/broker/brokerConfig.test.ts | 0 .../hunk/src}/session/broker/brokerConfig.ts | 0 .../session/broker/brokerLauncher.test.ts | 2 +- .../src}/session/broker/brokerLauncher.ts | 0 .../broker/brokerServer.helpers.test.ts | 2 +- .../src}/session/broker/brokerServer.test.ts | 2 +- .../hunk/src}/session/broker/brokerServer.ts | 0 .../browserReviewServer.integration.test.ts | 4 +- .../session/broker/browserReviewServer.ts | 0 .../src}/session/broker/credentials.test.ts | 0 .../hunk/src}/session/broker/credentials.ts | 0 .../session/broker/lifecycleDefect.test.ts | 0 .../src}/session/broker/lifecycleDefect.ts | 0 .../src}/session/broker/projections.test.ts | 2 +- .../hunk/src}/session/broker/projections.ts | 0 .../session/broker/protocolParsers.test.ts | 0 .../src}/session/broker/protocolParsers.ts | 0 .../src}/session/broker/reviewMirror.test.ts | 0 .../hunk/src}/session/broker/reviewMirror.ts | 0 .../broker/reviewResourceCache.test.ts | 0 .../session/broker/reviewResourceCache.ts | 0 .../reviewResources.integration.test.ts | 2 +- .../hunk/src}/session/broker/state.ts | 0 .../hunk/src}/session/broker/wire.test.ts | 0 .../hunk/src}/session/broker/wire.ts | 0 .../src}/session/client/capabilities.test.ts | 0 .../hunk/src}/session/client/capabilities.ts | 0 .../src}/session/client/daemonHttp.test.ts | 0 .../hunk/src}/session/client/daemonHttp.ts | 0 .../hunk/src}/session/protocol.ts | 0 .../hunk/src}/session/protocolSchemas.test.ts | 2 +- .../hunk/src}/session/protocolSchemas.ts | 0 .../src}/session/reviewErrorCatalog.test.ts | 0 .../hunk/src}/session/reviewErrorCatalog.ts | 2 +- .../src}/session/reviewEventProtocol.test.ts | 0 .../hunk/src}/session/reviewEventProtocol.ts | 0 .../src}/session/reviewHttpProtocol.test.ts | 0 .../hunk/src}/session/reviewHttpProtocol.ts | 2 +- .../hunk/src}/session/reviewProtocol.test.ts | 0 .../hunk/src}/session/reviewProtocol.ts | 4 +- {src => packages/hunk/src}/session/types.ts | 0 .../App.extension-command-controls.test.tsx | 4 +- .../src}/ui/App.extension-runtime.test.tsx | 4 +- .../hunk/src}/ui/App.extension-trust.test.tsx | 4 +- {src => packages/hunk/src}/ui/App.tsx | 0 .../hunk/src}/ui/AppHost.cursor-line.test.tsx | 4 +- .../src}/ui/AppHost.dynamic-mount.test.tsx | 4 +- .../src}/ui/AppHost.edit-in-editor.test.tsx | 4 +- .../ui/AppHost.extension-dialogs.test.tsx | 2 +- .../ui/AppHost.extension-navigation.test.tsx | 2 +- .../ui/AppHost.extension-sidebar.test.tsx | 2 +- .../hunk/src}/ui/AppHost.extensions.test.tsx | 2 +- .../src}/ui/AppHost.file-view-modes.test.tsx | 6 +- .../hunk/src}/ui/AppHost.file-views.test.tsx | 9 +- .../src}/ui/AppHost.interactions.test.tsx | 9 +- .../hunk/src}/ui/AppHost.key-routing.test.tsx | 4 +- .../hunk/src}/ui/AppHost.keybindings.test.tsx | 2 +- .../src}/ui/AppHost.keyboard-modes.test.tsx | 4 +- .../hunk/src}/ui/AppHost.reload.test.tsx | 2 +- .../hunk/src}/ui/AppHost.responsive.test.tsx | 4 +- .../src}/ui/AppHost.review-metadata.test.tsx | 2 +- .../ui/AppHost.scroll-regression.test.tsx | 4 +- .../hunk/src}/ui/AppHost.selection.test.tsx | 4 +- .../src}/ui/AppHost.sidebar-resize.test.tsx | 7 +- .../ui/AppHost.sidebar-visibility.test.tsx | 7 +- {src => packages/hunk/src}/ui/AppHost.tsx | 0 .../hunk/src}/ui/AppHost.watch.test.tsx | 4 +- .../hunk/src}/ui/AppHost.workspace.test.tsx | 2 +- .../ui/components/chrome/AgentSkillDialog.tsx | 0 .../ui/components/chrome/ConfirmDialog.tsx | 0 .../ui/components/chrome/ExtensionDialog.tsx | 0 .../ui/components/chrome/ExtensionToast.tsx | 0 .../src}/ui/components/chrome/HelpDialog.tsx | 0 .../src}/ui/components/chrome/MenuBar.tsx | 0 .../ui/components/chrome/MenuDropdown.tsx | 0 .../src}/ui/components/chrome/ModalFrame.tsx | 0 .../src}/ui/components/chrome/StatusBar.tsx | 0 .../components/chrome/ThemeSelectorDialog.tsx | 0 .../hunk/src}/ui/components/chrome/menu.ts | 0 .../src}/ui/components/panes/AgentCard.tsx | 0 .../components/panes/AgentInlineNote.test.tsx | 0 .../ui/components/panes/AgentInlineNote.tsx | 0 .../ui/components/panes/DiffFileHeaderRow.tsx | 0 .../ui/components/panes/DiffPane.test.tsx | 0 .../src}/ui/components/panes/DiffPane.tsx | 0 .../src}/ui/components/panes/DiffSection.tsx | 0 .../components/panes/ExtensionPane.test.tsx | 2 +- .../ui/components/panes/ExtensionPane.tsx | 0 .../src}/ui/components/panes/FileListItem.tsx | 0 .../ui/components/panes/FileView.test.tsx | 2 +- .../src}/ui/components/panes/FileView.tsx | 0 .../src}/ui/components/panes/PaneDivider.tsx | 0 .../ui/components/panes/copySelection.test.ts | 0 .../src}/ui/components/panes/copySelection.ts | 0 .../scrollbar/VerticalScrollbar.test.tsx | 2 +- .../scrollbar/VerticalScrollbar.tsx | 0 .../src}/ui/components/ui-components.test.tsx | 6 +- .../hunk/src}/ui/currentReviewRefresh.test.ts | 0 .../hunk/src}/ui/currentReviewRefresh.ts | 0 .../hunk/src}/ui/diff/CodeCellView.test.tsx | 2 +- .../hunk/src}/ui/diff/CodeCellView.tsx | 0 .../hunk/src}/ui/diff/CodeRowView.test.tsx | 2 +- .../hunk/src}/ui/diff/CodeRowView.tsx | 0 .../hunk/src}/ui/diff/DiffMetaRowView.tsx | 0 .../hunk/src}/ui/diff/DiffRowView.tsx | 0 .../hunk/src}/ui/diff/DiffSectionBody.tsx | 0 .../hunk/src}/ui/diff/codeColumns.test.ts | 0 .../hunk/src}/ui/diff/codeColumns.ts | 0 .../hunk/src}/ui/diff/codeRowAffordance.ts | 0 .../hunk/src}/ui/diff/codeRowLayout.test.ts | 0 .../hunk/src}/ui/diff/codeRowLayout.ts | 0 .../hunk/src}/ui/diff/cursorHighlight.test.ts | 0 .../hunk/src}/ui/diff/cursorHighlight.ts | 0 .../hunk/src}/ui/diff/diffRowModel.ts | 0 .../hunk/src}/ui/diff/diffRows.test.ts | 4 +- .../hunk/src}/ui/diff/diffRows.ts | 0 .../src}/ui/diff/diffSectionGeometry.test.ts | 2 +- .../hunk/src}/ui/diff/diffSectionGeometry.ts | 0 .../hunk/src}/ui/diff/diffSectionRowPlan.ts | 0 .../src}/ui/diff/expandCollapsedRows.test.ts | 0 .../hunk/src}/ui/diff/expandCollapsedRows.ts | 0 .../src}/ui/diff/highlightedDiffCache.test.ts | 0 .../hunk/src}/ui/diff/highlightedDiffCache.ts | 0 .../src}/ui/diff/lineHighlightPaint.test.ts | 2 +- .../hunk/src}/ui/diff/lineHighlightPaint.ts | 0 .../hunk/src}/ui/diff/plannedRowText.ts | 0 .../src}/ui/diff/reviewRenderPlan.test.ts | 0 .../hunk/src}/ui/diff/reviewRenderPlan.ts | 0 .../src}/ui/diff/reviewRowGeometry.test.ts | 0 .../hunk/src}/ui/diff/reviewRowGeometry.ts | 0 .../hunk/src}/ui/diff/rowMouseActions.ts | 0 .../hunk/src}/ui/diff/rowStyle.test.ts | 0 .../hunk/src}/ui/diff/rowStyle.ts | 0 .../hunk/src}/ui/diff/rowWindowing.test.ts | 0 .../hunk/src}/ui/diff/rowWindowing.ts | 0 .../ui/diff/sourceBackedHighlight.test.ts | 0 .../src}/ui/diff/sourceBackedHighlight.ts | 0 .../hunk/src}/ui/diff/styledSpanLayout.ts | 0 .../src}/ui/diff/syntaxHighlightTheme.test.ts | 2 +- .../hunk/src}/ui/diff/syntaxHighlightTheme.ts | 0 .../src}/ui/diff/useHighlightedDiff.test.ts | 5 +- .../hunk/src}/ui/diff/useHighlightedDiff.ts | 0 .../hunk/src}/ui/diff/useHighlightedSource.ts | 0 .../ui/diff/worker/highlightCompact.test.ts | 2 +- .../src}/ui/diff/worker/highlightCompact.ts | 0 .../src}/ui/diff/worker/highlightContext.ts | 0 .../hunk/src}/ui/diff/worker/highlightHast.ts | 0 .../src}/ui/diff/worker/highlightWorker.ts | 0 .../diff/worker/highlightWorkerCache.test.ts | 0 .../ui/diff/worker/highlightWorkerCache.ts | 0 .../diff/worker/highlightWorkerClient.test.ts | 2 +- .../ui/diff/worker/highlightWorkerClient.ts | 0 .../worker/highlightWorkerIdentity.test.ts | 0 .../ui/diff/worker/highlightWorkerIdentity.ts | 0 .../hunk/src}/ui/diff/worker/index.ts | 0 .../src}/ui/fileViews/availability.test.ts | 0 .../hunk/src}/ui/fileViews/availability.ts | 0 .../hunk/src}/ui/fileViews/geometry.test.ts | 0 .../hunk/src}/ui/fileViews/geometry.ts | 0 .../hunk/src}/ui/fileViews/host.test.ts | 2 +- .../hunk/src}/ui/fileViews/host.ts | 0 .../hunk/src}/ui/fileViews/layout.test.ts | 0 .../hunk/src}/ui/fileViews/layout.ts | 0 .../hunk/src}/ui/fileViews/mode.test.ts | 0 .../hunk/src}/ui/fileViews/mode.ts | 0 .../hunk/src}/ui/fileViews/renderPlan.test.ts | 0 .../hunk/src}/ui/fileViews/renderPlan.ts | 0 .../hunk/src}/ui/fileViews/state.test.ts | 0 .../hunk/src}/ui/fileViews/state.ts | 2 +- .../hunk/src}/ui/fileViews/types.ts | 0 .../useFilePresentationController.test.tsx | 2 +- .../useFilePresentationController.ts | 0 .../useFilePresentationRendering.test.tsx | 2 +- .../fileViews/useFilePresentationRendering.ts | 0 .../src}/ui/fileViews/useFileViews.test.tsx | 5 +- .../hunk/src}/ui/fileViews/useFileViews.ts | 0 .../hunk/src}/ui/highlights/merge.test.ts | 0 .../hunk/src}/ui/highlights/merge.ts | 0 .../hunk/src}/ui/highlights/reconcile.test.ts | 2 +- .../hunk/src}/ui/highlights/reconcile.ts | 0 .../hunk/src}/ui/highlights/state.ts | 2 +- .../ui/highlights/useLineHighlights.test.tsx | 2 +- .../src}/ui/highlights/useLineHighlights.ts | 0 .../useLineHighlightsController.test.tsx | 2 +- .../highlights/useLineHighlightsController.ts | 0 .../hunk/src}/ui/highlights/validate.test.ts | 0 .../hunk/src}/ui/highlights/validate.ts | 0 .../ui/history/runInteractiveHistory.test.ts | 0 .../src}/ui/history/runInteractiveHistory.ts | 0 .../src}/ui/history/runStaticHistory.test.ts | 0 .../hunk/src}/ui/history/runStaticHistory.ts | 0 .../src}/ui/history/staticProjection.test.ts | 0 .../hunk/src}/ui/history/staticProjection.ts | 8 - .../hunk/src}/ui/history/types.ts | 0 .../src}/ui/hooks/useAppKeyboardShortcuts.ts | 0 ...useCurrentReviewRefreshController.test.tsx | 2 +- .../useCurrentReviewRefreshController.ts | 0 .../hooks/useExtensionCommandRunner.test.tsx | 0 .../ui/hooks/useExtensionCommandRunner.ts | 0 .../useExtensionDialogController.test.tsx | 0 .../ui/hooks/useExtensionDialogController.ts | 0 .../useExtensionEventContextProvider.test.tsx | 0 .../hooks/useExtensionEventContextProvider.ts | 0 .../hooks/useExtensionNotifications.test.tsx | 0 .../ui/hooks/useExtensionNotifications.ts | 0 .../hooks/useExtensionPaneController.test.tsx | 0 .../ui/hooks/useExtensionPaneController.ts | 0 .../hooks/useExtensionReviewEvents.test.tsx | 2 +- .../src}/ui/hooks/useExtensionReviewEvents.ts | 0 .../hooks/useExtensionRuntimeBridge.test.tsx | 6 +- .../ui/hooks/useExtensionRuntimeBridge.ts | 0 .../useExtensionTrustController.test.tsx | 0 .../ui/hooks/useExtensionTrustController.ts | 0 .../useExtensionWorkspaceControls.test.tsx | 0 .../ui/hooks/useExtensionWorkspaceControls.ts | 0 .../src}/ui/hooks/useHunkSessionBridge.ts | 0 .../useIntermediateRenderAfterMount.test.tsx | 0 .../hooks/useIntermediateRenderAfterMount.ts | 0 .../src}/ui/hooks/useMenuController.test.tsx | 0 .../hunk/src}/ui/hooks/useMenuController.ts | 0 .../src}/ui/hooks/useStartupNotices.test.tsx | 0 .../hunk/src}/ui/hooks/useStartupNotices.ts | 0 .../src}/ui/hooks/useTerminalReview.test.tsx | 2 +- .../hunk/src}/ui/hooks/useTerminalReview.ts | 2 +- .../hooks/useThemeSelectorController.test.tsx | 0 .../ui/hooks/useThemeSelectorController.ts | 0 .../src}/ui/hooks/useTimedNotice.test.tsx | 0 .../hunk/src}/ui/hooks/useTimedNotice.ts | 0 .../ui/hooks/useUserNoteComposer.test.tsx | 0 .../hunk/src}/ui/hooks/useUserNoteComposer.ts | 0 .../useViewPreferenceQuitController.test.tsx | 0 .../hooks/useViewPreferenceQuitController.ts | 0 .../hunk/src}/ui/hooks/useWatchedInput.ts | 0 .../hunk/src}/ui/keyboardModes/mode.test.ts | 0 .../hunk/src}/ui/keyboardModes/mode.ts | 0 .../useKeyboardModeController.test.tsx | 0 .../useKeyboardModeController.ts | 0 .../ui/lib/adaptiveScrollOverscan.test.ts | 0 .../src}/ui/lib/adaptiveScrollOverscan.ts | 0 .../hunk/src}/ui/lib/agentAnnotations.test.ts | 2 +- .../hunk/src}/ui/lib/agentAnnotations.ts | 0 .../src}/ui/lib/agentNoteGeometry.test.ts | 0 .../hunk/src}/ui/lib/agentNoteGeometry.ts | 0 .../hunk/src}/ui/lib/agentPopover.ts | 0 .../hunk/src}/ui/lib/appCommands.test.ts | 5 +- .../hunk/src}/ui/lib/appCommands.ts | 0 .../hunk/src}/ui/lib/appMenus.test.ts | 0 {src => packages/hunk/src}/ui/lib/appMenus.ts | 0 {src => packages/hunk/src}/ui/lib/color.ts | 0 .../hunk/src}/ui/lib/diffSpatial.ts | 0 .../ui/lib/extensionCapabilityLease.test.ts | 0 .../src}/ui/lib/extensionCapabilityLease.ts | 0 .../ui/lib/extensionCommandControls.test.ts | 0 .../src}/ui/lib/extensionCommandControls.ts | 0 .../src}/ui/lib/extensionCommands.test.ts | 0 .../hunk/src}/ui/lib/extensionCommands.ts | 0 .../src}/ui/lib/extensionCurrentLine.test.ts | 2 +- .../hunk/src}/ui/lib/extensionCurrentLine.tsx | 0 .../ui/lib/extensionDialogGeometry.test.ts | 0 .../src}/ui/lib/extensionDialogGeometry.ts | 0 .../hunk/src}/ui/lib/extensionDialogs.test.ts | 0 .../hunk/src}/ui/lib/extensionDialogs.ts | 0 .../src}/ui/lib/extensionDocumentReader.ts | 0 .../src}/ui/lib/extensionKeyEvent.test.ts | 0 .../hunk/src}/ui/lib/extensionKeyEvent.ts | 0 .../src}/ui/lib/extensionNavigation.test.ts | 0 .../hunk/src}/ui/lib/extensionNavigation.ts | 0 .../ui/lib/extensionNotifications.test.ts | 0 .../src}/ui/lib/extensionNotifications.ts | 0 .../hunk/src}/ui/lib/extensionPaintTheme.ts | 0 .../hunk/src}/ui/lib/extensionPanes.test.ts | 0 .../hunk/src}/ui/lib/extensionPanes.ts | 0 .../src}/ui/lib/extensionReviewReload.test.ts | 0 .../hunk/src}/ui/lib/extensionReviewReload.ts | 0 .../src}/ui/lib/extensionSelection.test.ts | 2 +- .../hunk/src}/ui/lib/extensionSelection.ts | 0 .../hunk/src}/ui/lib/extensionTrustPrompt.ts | 0 .../src}/ui/lib/extensionWorkspace.test.ts | 0 .../hunk/src}/ui/lib/extensionWorkspace.ts | 0 .../hunk/src}/ui/lib/fileHeader.test.ts | 2 +- .../hunk/src}/ui/lib/fileHeader.ts | 0 .../hunk/src}/ui/lib/fileRenderWindow.test.ts | 0 .../hunk/src}/ui/lib/fileRenderWindow.ts | 0 .../src}/ui/lib/fileSectionLayout.test.ts | 0 .../hunk/src}/ui/lib/fileSectionLayout.ts | 0 .../hunk/src}/ui/lib/files.test.ts | 2 +- {src => packages/hunk/src}/ui/lib/files.ts | 0 .../hunk/src}/ui/lib/helpContent.test.ts | 0 .../hunk/src}/ui/lib/helpContent.ts | 0 .../hunk/src}/ui/lib/hunkScroll.test.ts | 0 .../hunk/src}/ui/lib/hunkScroll.ts | 0 {src => packages/hunk/src}/ui/lib/ids.ts | 0 .../hunk/src}/ui/lib/keyRouting.test.ts | 0 .../hunk/src}/ui/lib/keyRouting.ts | 0 {src => packages/hunk/src}/ui/lib/keyboard.ts | 0 .../hunk/src}/ui/lib/keymap.test.ts | 0 {src => packages/hunk/src}/ui/lib/keymap.ts | 0 .../hunk/src}/ui/lib/lineCursors.test.ts | 2 +- .../hunk/src}/ui/lib/lineCursors.ts | 0 .../hunk/src}/ui/lib/listWindow.test.ts | 0 .../hunk/src}/ui/lib/listWindow.ts | 0 .../hunk/src}/ui/lib/modalGeometry.test.ts | 0 .../hunk/src}/ui/lib/modalGeometry.ts | 0 .../hunk/src}/ui/lib/mouseCapture.ts | 0 .../hunk/src}/ui/lib/openInEditor.test.ts | 2 +- .../hunk/src}/ui/lib/openInEditor.ts | 0 .../hunk/src}/ui/lib/responsive.test.ts | 0 .../hunk/src}/ui/lib/responsive.ts | 0 .../src}/ui/lib/reviewNoteMapping.test.ts | 5 +- .../hunk/src}/ui/lib/reviewNoteMapping.ts | 2 +- .../hunk/src}/ui/lib/reviewState.test.ts | 5 +- .../hunk/src}/ui/lib/reviewState.ts | 0 .../hunk/src}/ui/lib/scopedEpochs.test.ts | 0 .../hunk/src}/ui/lib/scopedEpochs.ts | 0 .../hunk/src}/ui/lib/scrollAcceleration.ts | 0 {src => packages/hunk/src}/ui/lib/sidebar.ts | 0 .../src}/ui/lib/sidebarRenderWindow.test.ts | 0 .../hunk/src}/ui/lib/sidebarRenderWindow.ts | 0 {src => packages/hunk/src}/ui/lib/stml/cli.ts | 0 .../hunk/src}/ui/lib/stml/colors.test.ts | 0 .../hunk/src}/ui/lib/stml/colors.ts | 0 .../hunk/src}/ui/lib/stml/guide.test.ts | 0 .../hunk/src}/ui/lib/stml/guide.ts | 0 .../hunk/src}/ui/lib/stml/layout.test.ts | 0 .../hunk/src}/ui/lib/stml/layout.ts | 0 .../hunk/src}/ui/lib/stml/parse.test.ts | 0 .../hunk/src}/ui/lib/stml/parse.ts | 0 .../hunk/src}/ui/lib/stml/render.test.ts | 0 .../hunk/src}/ui/lib/stml/render.ts | 0 .../ui/lib/synchronousExtensionCallback.ts | 0 .../hunk/src}/ui/lib/syntheticKeyEvent.ts | 0 {src => packages/hunk/src}/ui/lib/text.ts | 0 .../hunk/src}/ui/lib/ui-lib.test.ts | 2 +- .../hunk/src}/ui/lib/viewportAnchor.test.ts | 2 +- .../hunk/src}/ui/lib/viewportAnchor.ts | 0 .../src}/ui/lib/viewportSelection.test.ts | 2 +- .../hunk/src}/ui/lib/viewportSelection.ts | 0 .../hunk/src}/ui/lib/viewportTiming.test.ts | 0 .../hunk/src}/ui/lib/viewportTiming.ts | 0 .../src}/ui/lib/workspaceWriteGuard.test.ts | 0 .../hunk/src}/ui/lib/workspaceWriteGuard.ts | 0 {src => packages/hunk/src}/ui/log/LogApp.tsx | 0 .../hunk/src}/ui/log/LogSessionHost.tsx | 0 .../src}/ui/log/ParentSelectorDialog.test.tsx | 0 .../hunk/src}/ui/log/ParentSelectorDialog.tsx | 0 .../hunk/src}/ui/log/colorPolicy.test.ts | 0 .../hunk/src}/ui/log/colorPolicy.ts | 0 .../hunk/src}/ui/log/commands.test.ts | 0 {src => packages/hunk/src}/ui/log/commands.ts | 0 .../hunk/src}/ui/log/controller.test.ts | 0 .../hunk/src}/ui/log/controller.ts | 0 .../hunk/src}/ui/log/embeddedReview.test.ts | 0 {src => packages/hunk/src}/ui/log/logHelp.ts | 0 .../hunk/src}/ui/log/responsiveLayout.test.ts | 0 .../hunk/src}/ui/log/responsiveLayout.ts | 0 .../hunk/src}/ui/log/reviewLaunch.ts | 0 .../src}/ui/log/runInteractiveLog.test.ts | 0 .../hunk/src}/ui/log/runInteractiveLog.tsx | 0 .../hunk/src}/ui/runInteractiveApp.tsx | 0 .../hunk/src}/ui/staticDiffPager.test.ts | 0 .../hunk/src}/ui/staticDiffPager.ts | 0 {src => packages/hunk/src}/ui/themes.test.ts | 2 +- {src => packages/hunk/src}/ui/themes.ts | 0 {src => packages/hunk/src}/ui/themes/types.ts | 0 packages/session-broker/README.md | 6 +- scripts/build-bin.ts | 14 +- scripts/build-npm.ts | 48 ++- scripts/build-prebuilt-artifact.test.ts | 22 +- scripts/build-prebuilt-artifact.ts | 4 +- scripts/changeset-version.test.ts | 77 +++++ scripts/changeset-version.ts | 61 ++++ scripts/check-comparison-catalog.test.ts | 2 +- scripts/check-extension-catalog.test.ts | 4 +- scripts/check-pack.ts | 31 +- scripts/check-prebuilt-pack.ts | 2 +- scripts/check-release-version.ts | 10 +- scripts/compare-release-benchmarks.ts | 4 +- scripts/daemon-memory-check.ts | 4 +- scripts/extension-consumer-check.ts | 2 +- scripts/generate-docs.test.ts | 10 +- scripts/generate-docs.ts | 15 +- scripts/generate-skill.ts | 16 +- scripts/generate-theme-diff-colors.test.ts | 20 +- scripts/generate-theme-diff-colors.ts | 14 +- scripts/inline-edit-extension.test.ts | 8 +- scripts/install-bin.ts | 10 +- scripts/jsx-file-view-gallery.test.ts | 6 +- scripts/launch-video/capture.ts | 2 +- scripts/nix-package.test.ts | 17 + scripts/package-paths.ts | 7 + scripts/packed-public-consumer-check.ts | 102 ++++++ scripts/probe-terminal-theme.ts | 2 +- scripts/rendered-markdown-extension.test.ts | 2 +- scripts/review-vocabulary.test.ts | 17 +- scripts/run-test-suite.ts | 1 - scripts/smoke-prebuilt-install.ts | 5 +- scripts/source-boundaries.test.ts | 78 +++-- scripts/stage-prebuilt-npm.ts | 17 +- scripts/test-large-untracked-render.tsx | 4 +- scripts/verify-pr-release-notes.test.ts | 36 +- scripts/verify-pr-release-notes.ts | 6 +- test/README.md | 4 +- test/cli/compiled-headless-native-lib.test.ts | 4 +- test/cli/entrypoint.test.ts | 67 ++-- .../compiled-highlight-worker-control.ts | 2 +- .../prepare-daemon-upgrade-fixtures.ts | 10 +- test/cli/install-vm/prepare-fixtures.test.ts | 83 +++-- test/cli/install-vm/prepare-fixtures.ts | 15 +- test/cli/install-vm/repo-layout.ts | 21 ++ .../cli/install-vm/validate-release-result.ts | 5 +- test/cli/log.test.ts | 2 +- test/cli/non-interactive-stdin.test.ts | 27 +- test/cli/pager-pipe-output.test.ts | 2 +- test/cli/startup-graph.test.ts | 4 +- test/cli/update.test.ts | 6 +- test/helpers/app-bootstrap.ts | 9 +- test/helpers/diff-helpers.ts | 12 +- test/helpers/review-session-harness.ts | 21 +- test/helpers/review-store-helpers.ts | 10 +- test/helpers/session-daemon-fixtures.ts | 2 +- test/helpers/theme-helpers.ts | 5 +- test/helpers/watchTest.ts | 4 +- test/pty/chrome.test.ts | 2 +- test/pty/extensions-integration.test.ts | 2 +- test/pty/harness.ts | 2 +- test/pty/key-routing.test.ts | 2 +- test/pty/moved-lines.test.ts | 2 +- .../pty/session-attention-integration.test.ts | 2 +- test/review-conformance/conformance.test.ts | 15 +- .../consumers/brokerMirror.ts | 11 +- .../consumers/browserReviewSurface.ts | 12 +- .../review-conformance/consumers/coreModel.ts | 11 +- .../consumers/coreOrdering.ts | 2 +- .../consumers/extensionReviewSnapshot.ts | 2 +- .../consumers/intentPlanner.ts | 18 +- .../consumers/reviewEventProtocol.ts | 4 +- .../consumers/reviewProducer.ts | 12 +- .../consumers/reviewWire.ts | 7 +- .../consumers/terminalRenderPlan.ts | 16 +- .../consumers/terminalReview.ts | 19 +- test/review-conformance/eventFixtures.ts | 9 +- test/review-conformance/geometryFixtures.ts | 2 +- test/review-conformance/navigationFixtures.ts | 2 +- test/review-conformance/noteSize.ts | 6 +- test/review-conformance/orderingFixtures.ts | 4 +- test/review-conformance/types.ts | 14 +- .../connectionFixture.ts | 7 +- test/session/broker-e2e.test.ts | 4 +- test/session/cli.test.ts | 4 +- test/session/daemon.test.ts | 2 +- test/smoke/tty.test.ts | 2 +- tsconfig.extension.json | 6 +- tsconfig.json | 6 +- tsconfig.opentui.json | 7 +- website/MEDIA.md | 2 +- website/scripts/capture-media.ts | 4 +- .../src/components/marketing/ThemeShot.astro | 2 +- .../content/docs/docs/agents/review-skill.md | 4 +- .../docs/docs/extend/custom-sidebars.md | 2 +- .../content/docs/docs/extend/file-previews.md | 2 +- .../src/content/docs/docs/reference/config.md | 2 +- website/src/data/comparisons.ts | 2 +- 822 files changed, 1941 insertions(+), 984 deletions(-) create mode 100644 .changeset/package-first-phase-one.md create mode 100644 packages/hunk/LICENSE create mode 100644 packages/hunk/README.md rename {bin => packages/hunk/bin}/hunk.cjs (100%) create mode 100644 packages/hunk/package.json rename {skills => packages/hunk/skills}/hunk-extensions/SKILL.md (95%) rename {skills => packages/hunk/skills}/hunk-review/SKILL.md (100%) rename {src => packages/hunk/src}/app/cli.test.ts (100%) rename {src => packages/hunk/src}/app/cli.ts (100%) rename {src => packages/hunk/src}/app/delegatedReview.test.ts (100%) rename {src => packages/hunk/src}/app/delegatedReview.ts (100%) rename {src => packages/hunk/src}/app/extensionBootstrap.test.ts (100%) rename {src => packages/hunk/src}/app/extensionBootstrap.ts (100%) rename {src => packages/hunk/src}/app/extensionCliBootstrap.test.ts (100%) rename {src => packages/hunk/src}/app/extensionCliBootstrap.ts (100%) rename {src => packages/hunk/src}/app/historyBootstrap.test.ts (100%) rename {src => packages/hunk/src}/app/historyBootstrap.ts (100%) rename {src => packages/hunk/src}/app/review/capability.ts (95%) rename {src => packages/hunk/src}/app/review/producer.test.ts (99%) rename {src => packages/hunk/src}/app/review/producer.ts (100%) rename {src => packages/hunk/src}/app/review/publication.ts (96%) rename {src => packages/hunk/src}/app/review/resourceStore.ts (100%) rename {src => packages/hunk/src}/app/session/bridge.test.ts (100%) rename {src => packages/hunk/src}/app/session/bridge.ts (100%) rename {src => packages/hunk/src}/app/session/registration.test.ts (99%) rename {src => packages/hunk/src}/app/session/registration.ts (100%) rename {src => packages/hunk/src}/app/session/reloadBounds.test.ts (100%) rename {src => packages/hunk/src}/app/session/reloadBounds.ts (100%) rename {src => packages/hunk/src}/app/session/reviewCommands.test.ts (99%) rename {src => packages/hunk/src}/app/session/reviewCommands.ts (97%) rename {src => packages/hunk/src}/app/sessionBootstrap.test.ts (100%) rename {src => packages/hunk/src}/app/sessionBootstrap.ts (100%) rename {src => packages/hunk/src}/app/sessionSelector.test.ts (100%) rename {src => packages/hunk/src}/app/sessionSelector.ts (100%) rename {src => packages/hunk/src}/app/startup.test.ts (100%) rename {src => packages/hunk/src}/app/startup.ts (100%) rename {src => packages/hunk/src}/app/startup.vcsExtensions.test.ts (100%) rename {src => packages/hunk/src}/app/types.ts (100%) rename {src => packages/hunk/src}/app/vcsCatalog.test.ts (100%) rename {src => packages/hunk/src}/app/vcsCatalog.ts (100%) rename {src => packages/hunk/src}/core/bootstrap.ts (96%) rename {src => packages/hunk/src}/core/changeset/binary.test.ts (100%) rename {src => packages/hunk/src}/core/changeset/binary.ts (100%) rename {src => packages/hunk/src}/core/changeset/diffFile.test.ts (100%) rename {src => packages/hunk/src}/core/changeset/diffFile.ts (100%) rename {src => packages/hunk/src}/core/changeset/diffPaths.ts (100%) rename {src => packages/hunk/src}/core/changeset/fileLanguage.test.ts (100%) rename {src => packages/hunk/src}/core/changeset/fileLanguage.ts (100%) rename {src => packages/hunk/src}/core/changeset/fileLanguageLookup.ts (100%) rename {src => packages/hunk/src}/core/changeset/fileSource.test.ts (100%) rename {src => packages/hunk/src}/core/changeset/fileSource.ts (100%) rename {src => packages/hunk/src}/core/changeset/fromPatch.ts (100%) rename {src => packages/hunk/src}/core/changeset/hunkHeader.test.ts (100%) rename {src => packages/hunk/src}/core/changeset/hunkHeader.ts (100%) rename {src => packages/hunk/src}/core/changeset/hunkSummary.test.ts (95%) rename {src => packages/hunk/src}/core/changeset/hunkSummary.ts (100%) rename {src => packages/hunk/src}/core/changeset/loaders.ordering.test.ts (96%) rename {src => packages/hunk/src}/core/changeset/loaders.test.ts (100%) rename {src => packages/hunk/src}/core/changeset/loaders.ts (100%) rename {src => packages/hunk/src}/core/changeset/model.ts (100%) rename {src => packages/hunk/src}/core/changeset/sidecar.test.ts (100%) rename {src => packages/hunk/src}/core/changeset/sidecar.ts (98%) rename {src => packages/hunk/src}/core/history/lanePlanner.test.ts (100%) rename {src => packages/hunk/src}/core/history/lanePlanner.ts (100%) rename {src => packages/hunk/src}/core/history/types.ts (100%) rename {src => packages/hunk/src}/core/install/installSource.test.ts (100%) rename {src => packages/hunk/src}/core/install/installSource.ts (100%) rename {src => packages/hunk/src}/core/install/latestRelease.test.ts (100%) rename {src => packages/hunk/src}/core/install/latestRelease.ts (100%) rename {src => packages/hunk/src}/core/install/selfUpdate.test.ts (100%) rename {src => packages/hunk/src}/core/install/selfUpdate.ts (100%) rename {src => packages/hunk/src}/core/liveComments.test.ts (98%) rename {src => packages/hunk/src}/core/liveComments.ts (97%) rename {src => packages/hunk/src}/core/patch/chunks.test.ts (100%) rename {src => packages/hunk/src}/core/patch/chunks.ts (100%) rename {src => packages/hunk/src}/core/patch/gitFormat.test.ts (100%) rename {src => packages/hunk/src}/core/patch/gitFormat.ts (100%) rename {src => packages/hunk/src}/core/patch/gitLog.test.ts (100%) rename {src => packages/hunk/src}/core/patch/gitLog.ts (100%) rename {src => packages/hunk/src}/core/patch/sanitize.test.ts (100%) rename {src => packages/hunk/src}/core/patch/sanitize.ts (100%) rename {src => packages/hunk/src}/core/patch/singleFile.ts (100%) rename {src => packages/hunk/src}/core/process/appStateFile.test.ts (100%) rename {src => packages/hunk/src}/core/process/appStateFile.ts (100%) rename {src => packages/hunk/src}/core/process/jobControl.test.ts (100%) rename {src => packages/hunk/src}/core/process/jobControl.ts (100%) rename {src => packages/hunk/src}/core/process/pager.test.ts (100%) rename {src => packages/hunk/src}/core/process/pager.ts (100%) rename {src => packages/hunk/src}/core/process/projectRoot.test.ts (100%) rename {src => packages/hunk/src}/core/process/projectRoot.ts (100%) rename {src => packages/hunk/src}/core/process/relaunch.test.ts (100%) rename {src => packages/hunk/src}/core/process/relaunch.ts (100%) rename {src => packages/hunk/src}/core/process/shutdown.test.ts (100%) rename {src => packages/hunk/src}/core/process/shutdown.ts (100%) rename {src => packages/hunk/src}/core/process/startupNotice.ts (100%) rename {src => packages/hunk/src}/core/process/stdout.test.ts (100%) rename {src => packages/hunk/src}/core/process/stdout.ts (100%) rename {src => packages/hunk/src}/core/process/terminal.test.ts (100%) rename {src => packages/hunk/src}/core/process/terminal.ts (100%) rename {src => packages/hunk/src}/core/process/updateNotice.test.ts (100%) rename {src => packages/hunk/src}/core/process/updateNotice.ts (100%) rename {src => packages/hunk/src}/core/review/actions.ts (100%) rename {src => packages/hunk/src}/core/review/anchors.test.ts (100%) rename {src => packages/hunk/src}/core/review/anchors.ts (100%) rename {src => packages/hunk/src}/core/review/annotations.ts (100%) rename {src => packages/hunk/src}/core/review/canonicalFile.test.ts (97%) rename {src => packages/hunk/src}/core/review/canonicalFile.ts (100%) rename {src => packages/hunk/src}/core/review/contentManifest.test.ts (96%) rename {src => packages/hunk/src}/core/review/contentManifest.ts (100%) rename {src => packages/hunk/src}/core/review/document.test.ts (99%) rename {src => packages/hunk/src}/core/review/document.ts (100%) rename {src => packages/hunk/src}/core/review/expansion.test.ts (99%) rename {src => packages/hunk/src}/core/review/expansion.ts (100%) rename {src => packages/hunk/src}/core/review/generationOrder.test.ts (100%) rename {src => packages/hunk/src}/core/review/generationOrder.ts (100%) rename {src => packages/hunk/src}/core/review/geometry.test.ts (100%) rename {src => packages/hunk/src}/core/review/geometry.ts (100%) rename {src => packages/hunk/src}/core/review/identity.test.ts (100%) rename {src => packages/hunk/src}/core/review/identity.ts (100%) rename {src => packages/hunk/src}/core/review/intents.test.ts (99%) rename {src => packages/hunk/src}/core/review/intents.ts (99%) rename {src => packages/hunk/src}/core/review/navigation.test.ts (100%) rename {src => packages/hunk/src}/core/review/navigation.ts (100%) rename {src => packages/hunk/src}/core/review/noteSize.test.ts (100%) rename {src => packages/hunk/src}/core/review/noteSize.ts (100%) rename {src => packages/hunk/src}/core/review/reducer.test.ts (99%) rename {src => packages/hunk/src}/core/review/reducer.ts (100%) rename {src => packages/hunk/src}/core/review/resourceAssembly.test.ts (100%) rename {src => packages/hunk/src}/core/review/resourceAssembly.ts (100%) rename {src => packages/hunk/src}/core/review/resources.test.ts (100%) rename {src => packages/hunk/src}/core/review/resources.ts (100%) rename {src => packages/hunk/src}/core/review/selectors.test.ts (98%) rename {src => packages/hunk/src}/core/review/selectors.ts (100%) rename {src => packages/hunk/src}/core/review/state.test.ts (96%) rename {src => packages/hunk/src}/core/review/state.ts (100%) rename {src => packages/hunk/src}/core/review/stml.test.ts (100%) rename {src => packages/hunk/src}/core/review/stml.ts (100%) rename {src => packages/hunk/src}/core/review/store.test.ts (97%) rename {src => packages/hunk/src}/core/review/store.ts (100%) rename {src => packages/hunk/src}/core/review/types.ts (100%) rename {src => packages/hunk/src}/core/review/validation.test.ts (100%) rename {src => packages/hunk/src}/core/review/validation.ts (100%) rename {src => packages/hunk/src}/core/reviewDescriptor.test.ts (100%) rename {src => packages/hunk/src}/core/reviewDescriptor.ts (100%) rename {src => packages/hunk/src}/core/reviewDigest.ts (100%) rename {src => packages/hunk/src}/core/run/cliCommandNames.ts (100%) rename {src => packages/hunk/src}/core/run/commandCatalog.test.ts (99%) rename {src => packages/hunk/src}/core/run/commandCatalog.ts (99%) rename {src => packages/hunk/src}/core/run/commandInputs.ts (100%) rename {src => packages/hunk/src}/core/run/config.test.ts (100%) rename {src => packages/hunk/src}/core/run/config.ts (99%) rename {src => packages/hunk/src}/core/run/errors.test.ts (100%) rename {src => packages/hunk/src}/core/run/errors.ts (100%) rename {src => packages/hunk/src}/core/run/experimental.test.ts (92%) rename {src => packages/hunk/src}/core/run/experimental.ts (100%) rename {src => packages/hunk/src}/core/run/inputReload.ts (100%) rename {src => packages/hunk/src}/core/run/paths.test.ts (100%) rename {src => packages/hunk/src}/core/run/paths.ts (96%) rename {src => packages/hunk/src}/core/run/reviewGap.test.ts (100%) rename {src => packages/hunk/src}/core/run/reviewGap.ts (100%) rename {src => packages/hunk/src}/core/run/tabWidth.ts (100%) rename {src => packages/hunk/src}/core/run/version.ts (100%) rename {src => packages/hunk/src}/core/theme/catalog.test.ts (100%) rename {src => packages/hunk/src}/core/theme/catalog.ts (100%) rename {src => packages/hunk/src}/core/theme/customThemes.test.ts (100%) rename {src => packages/hunk/src}/core/theme/customThemes.ts (100%) rename {src => packages/hunk/src}/core/theme/detection.test.ts (100%) rename {src => packages/hunk/src}/core/theme/detection.ts (100%) rename {src => packages/hunk/src}/core/theme/legacySyntaxScopes.test.ts (100%) rename {src => packages/hunk/src}/core/theme/legacySyntaxScopes.ts (100%) rename {src => packages/hunk/src}/core/vcs/index.test.ts (100%) rename {src => packages/hunk/src}/core/vcs/index.ts (100%) rename {src => packages/hunk/src}/core/vcs/types.ts (100%) rename {src => packages/hunk/src}/core/vcs/untracked.test.ts (100%) rename {src => packages/hunk/src}/core/vcs/untracked.ts (100%) rename {src => packages/hunk/src}/core/watch/controller.test.ts (100%) rename {src => packages/hunk/src}/core/watch/controller.ts (100%) rename {src => packages/hunk/src}/core/watch/observer.fs.test.ts (100%) rename {src => packages/hunk/src}/core/watch/observer.test.ts (98%) rename {src => packages/hunk/src}/core/watch/observer.ts (100%) rename {src => packages/hunk/src}/core/watch/plan.test.ts (100%) rename {src => packages/hunk/src}/core/watch/plan.ts (100%) rename {src => packages/hunk/src}/core/watch/runtime.test.ts (100%) rename {src => packages/hunk/src}/core/watch/runtime.ts (100%) rename {src => packages/hunk/src}/core/watch/signature.test.ts (100%) rename {src => packages/hunk/src}/core/watch/signature.ts (100%) rename {src => packages/hunk/src}/extension-api/index.ts (100%) rename {src => packages/hunk/src}/extension-api/keys.test.ts (98%) rename {src => packages/hunk/src}/extension-api/keys.ts (100%) rename {src => packages/hunk/src}/extension-api/types.ts (100%) rename {src => packages/hunk/src}/extensions/apply.test.ts (99%) rename {src => packages/hunk/src}/extensions/apply.ts (100%) rename {src => packages/hunk/src}/extensions/cliCommandRuntime.test.ts (100%) rename {src => packages/hunk/src}/extensions/cliCommandRuntime.ts (100%) rename {src => packages/hunk/src}/extensions/cliCommands.test.ts (100%) rename {src => packages/hunk/src}/extensions/cliCommands.ts (100%) rename {src => packages/hunk/src}/extensions/default/ui/index.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/ui/index.ts (100%) rename {src => packages/hunk/src}/extensions/default/ui/reviewInfo/index.test.tsx (97%) rename {src => packages/hunk/src}/extensions/default/ui/reviewInfo/index.tsx (100%) rename {src => packages/hunk/src}/extensions/default/ui/reviewInfo/presentation.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/ui/reviewInfo/presentation.ts (100%) rename {src => packages/hunk/src}/extensions/default/ui/sidebar/FileSidebars.tsx (100%) rename {src => packages/hunk/src}/extensions/default/ui/sidebar/index.test.tsx (100%) rename {src => packages/hunk/src}/extensions/default/ui/sidebar/index.tsx (100%) rename {src => packages/hunk/src}/extensions/default/vcs/asyncProcess.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/asyncProcess.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/diffRange.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/git/commands.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/git/commands.ts (96%) rename {src => packages/hunk/src}/extensions/default/vcs/git/history.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/git/history.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/git/index.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/git/index.ts (99%) rename {src => packages/hunk/src}/extensions/default/vcs/git/source.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/git/source.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/index.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/index.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/jujutsu/commands.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/jujutsu/commands.ts (98%) rename {src => packages/hunk/src}/extensions/default/vcs/jujutsu/history.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/jujutsu/history.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/jujutsu/index.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/jujutsu/index.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/jujutsu/source.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/jujutsu/source.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/sapling/commands.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/sapling/commands.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/sapling/index.test.ts (100%) rename {src => packages/hunk/src}/extensions/default/vcs/sapling/index.ts (100%) rename {src => packages/hunk/src}/extensions/discovery.test.ts (100%) rename {src => packages/hunk/src}/extensions/discovery.ts (100%) rename {src => packages/hunk/src}/extensions/events.test.ts (99%) rename {src => packages/hunk/src}/extensions/events.ts (100%) rename {src => packages/hunk/src}/extensions/extensionIds.ts (95%) rename {src => packages/hunk/src}/extensions/host.test.ts (100%) rename {src => packages/hunk/src}/extensions/host.ts (100%) rename {src => packages/hunk/src}/extensions/hostRuntimeModules.test.ts (100%) rename {src => packages/hunk/src}/extensions/hostRuntimeModules.ts (100%) rename {src => packages/hunk/src}/extensions/manage/cli.ts (100%) rename {src => packages/hunk/src}/extensions/manage/install.test.ts (100%) rename {src => packages/hunk/src}/extensions/manage/install.ts (100%) rename {src => packages/hunk/src}/extensions/manage/records.ts (100%) rename {src => packages/hunk/src}/extensions/manage/source.test.ts (100%) rename {src => packages/hunk/src}/extensions/manage/source.ts (100%) rename {src => packages/hunk/src}/extensions/notifications.test.ts (100%) rename {src => packages/hunk/src}/extensions/notifications.ts (100%) rename {src => packages/hunk/src}/extensions/panes.ts (100%) rename {src => packages/hunk/src}/extensions/publicApiRobustness.test.ts (99%) rename {src => packages/hunk/src}/extensions/reviewSnapshot.test.ts (99%) rename {src => packages/hunk/src}/extensions/reviewSnapshot.ts (100%) rename {src => packages/hunk/src}/extensions/runExtension.test.ts (100%) rename {src => packages/hunk/src}/extensions/runExtension.ts (99%) rename {src => packages/hunk/src}/extensions/startup.test.ts (100%) rename {src => packages/hunk/src}/extensions/startup.ts (100%) rename {src => packages/hunk/src}/extensions/trust.test.ts (100%) rename {src => packages/hunk/src}/extensions/trust.ts (100%) rename {src => packages/hunk/src}/extensions/types.ts (98%) rename {src => packages/hunk/src}/extensions/vcsPatchResult.test.ts (100%) rename {src => packages/hunk/src}/extensions/vcsPatchResult.ts (100%) rename {src => packages/hunk/src}/highlightWorkerClient.ts (100%) rename {src => packages/hunk/src}/highlightWorkerEntry.ts (100%) rename {src => packages/hunk/src}/hunk-review/skillDocument.test.ts (90%) rename {src => packages/hunk/src}/hunk-review/skillDocument.ts (99%) rename {src => packages/hunk/src}/lib/commandKeys.test.ts (94%) rename {src => packages/hunk/src}/lib/commandKeys.ts (96%) rename {src => packages/hunk/src}/lib/largeFile.ts (100%) rename {src => packages/hunk/src}/lib/osPath.test.ts (100%) rename {src => packages/hunk/src}/lib/osPath.ts (100%) rename {src => packages/hunk/src}/lib/patchPath.ts (100%) rename {src => packages/hunk/src}/lib/sourceText.test.ts (100%) rename {src => packages/hunk/src}/lib/sourceText.ts (100%) rename {src => packages/hunk/src}/lib/terminalText.test.ts (100%) rename {src => packages/hunk/src}/lib/terminalText.ts (100%) rename {src => packages/hunk/src}/main.tsx (100%) rename {src => packages/hunk/src}/opentui/HunkDiffBody.tsx (100%) rename {src => packages/hunk/src}/opentui/HunkDiffFileHeader.tsx (100%) rename {src => packages/hunk/src}/opentui/HunkDiffView.test.tsx (100%) rename {src => packages/hunk/src}/opentui/HunkDiffView.tsx (100%) rename {src => packages/hunk/src}/opentui/HunkFileNav.tsx (100%) rename {src => packages/hunk/src}/opentui/HunkReviewStream.tsx (100%) rename {src => packages/hunk/src}/opentui/index.ts (100%) rename {src => packages/hunk/src}/opentui/model.ts (88%) rename {src => packages/hunk/src}/opentui/themes.ts (100%) rename {src => packages/hunk/src}/opentui/types.ts (100%) rename {src => packages/hunk/src}/session/agent/cliClient.test.ts (99%) rename {src => packages/hunk/src}/session/agent/cliClient.ts (100%) rename {src => packages/hunk/src}/session/agent/commands.daemon.test.ts (98%) rename {src => packages/hunk/src}/session/agent/commands.test.ts (99%) rename {src => packages/hunk/src}/session/agent/commands.ts (100%) rename {src => packages/hunk/src}/session/agent/errors.test.ts (100%) rename {src => packages/hunk/src}/session/agent/errors.ts (97%) rename {src => packages/hunk/src}/session/agent/surface.test.ts (100%) rename {src => packages/hunk/src}/session/agent/surface.ts (98%) rename {src => packages/hunk/src}/session/broker/appContract.ts (100%) rename {src => packages/hunk/src}/session/broker/brokerClient.test.ts (99%) rename {src => packages/hunk/src}/session/broker/brokerClient.ts (100%) rename {src => packages/hunk/src}/session/broker/brokerConfig.test.ts (100%) rename {src => packages/hunk/src}/session/broker/brokerConfig.ts (100%) rename {src => packages/hunk/src}/session/broker/brokerLauncher.test.ts (99%) rename {src => packages/hunk/src}/session/broker/brokerLauncher.ts (100%) rename {src => packages/hunk/src}/session/broker/brokerServer.helpers.test.ts (99%) rename {src => packages/hunk/src}/session/broker/brokerServer.test.ts (99%) rename {src => packages/hunk/src}/session/broker/brokerServer.ts (100%) rename {src => packages/hunk/src}/session/broker/browserReviewServer.integration.test.ts (99%) rename {src => packages/hunk/src}/session/broker/browserReviewServer.ts (100%) rename {src => packages/hunk/src}/session/broker/credentials.test.ts (100%) rename {src => packages/hunk/src}/session/broker/credentials.ts (100%) rename {src => packages/hunk/src}/session/broker/lifecycleDefect.test.ts (100%) rename {src => packages/hunk/src}/session/broker/lifecycleDefect.ts (100%) rename {src => packages/hunk/src}/session/broker/projections.test.ts (98%) rename {src => packages/hunk/src}/session/broker/projections.ts (100%) rename {src => packages/hunk/src}/session/broker/protocolParsers.test.ts (100%) rename {src => packages/hunk/src}/session/broker/protocolParsers.ts (100%) rename {src => packages/hunk/src}/session/broker/reviewMirror.test.ts (100%) rename {src => packages/hunk/src}/session/broker/reviewMirror.ts (100%) rename {src => packages/hunk/src}/session/broker/reviewResourceCache.test.ts (100%) rename {src => packages/hunk/src}/session/broker/reviewResourceCache.ts (100%) rename {src => packages/hunk/src}/session/broker/reviewResources.integration.test.ts (99%) rename {src => packages/hunk/src}/session/broker/state.ts (100%) rename {src => packages/hunk/src}/session/broker/wire.test.ts (100%) rename {src => packages/hunk/src}/session/broker/wire.ts (100%) rename {src => packages/hunk/src}/session/client/capabilities.test.ts (100%) rename {src => packages/hunk/src}/session/client/capabilities.ts (100%) rename {src => packages/hunk/src}/session/client/daemonHttp.test.ts (100%) rename {src => packages/hunk/src}/session/client/daemonHttp.ts (100%) rename {src => packages/hunk/src}/session/protocol.ts (100%) rename {src => packages/hunk/src}/session/protocolSchemas.test.ts (99%) rename {src => packages/hunk/src}/session/protocolSchemas.ts (100%) rename {src => packages/hunk/src}/session/reviewErrorCatalog.test.ts (100%) rename {src => packages/hunk/src}/session/reviewErrorCatalog.ts (98%) rename {src => packages/hunk/src}/session/reviewEventProtocol.test.ts (100%) rename {src => packages/hunk/src}/session/reviewEventProtocol.ts (100%) rename {src => packages/hunk/src}/session/reviewHttpProtocol.test.ts (100%) rename {src => packages/hunk/src}/session/reviewHttpProtocol.ts (99%) rename {src => packages/hunk/src}/session/reviewProtocol.test.ts (100%) rename {src => packages/hunk/src}/session/reviewProtocol.ts (99%) rename {src => packages/hunk/src}/session/types.ts (100%) rename {src => packages/hunk/src}/ui/App.extension-command-controls.test.tsx (96%) rename {src => packages/hunk/src}/ui/App.extension-runtime.test.tsx (95%) rename {src => packages/hunk/src}/ui/App.extension-trust.test.tsx (96%) rename {src => packages/hunk/src}/ui/App.tsx (100%) rename {src => packages/hunk/src}/ui/AppHost.cursor-line.test.tsx (97%) rename {src => packages/hunk/src}/ui/AppHost.dynamic-mount.test.tsx (91%) rename {src => packages/hunk/src}/ui/AppHost.edit-in-editor.test.tsx (95%) rename {src => packages/hunk/src}/ui/AppHost.extension-dialogs.test.tsx (99%) rename {src => packages/hunk/src}/ui/AppHost.extension-navigation.test.tsx (99%) rename {src => packages/hunk/src}/ui/AppHost.extension-sidebar.test.tsx (99%) rename {src => packages/hunk/src}/ui/AppHost.extensions.test.tsx (99%) rename {src => packages/hunk/src}/ui/AppHost.file-view-modes.test.tsx (99%) rename {src => packages/hunk/src}/ui/AppHost.file-views.test.tsx (98%) rename {src => packages/hunk/src}/ui/AppHost.interactions.test.tsx (99%) rename {src => packages/hunk/src}/ui/AppHost.key-routing.test.tsx (98%) rename {src => packages/hunk/src}/ui/AppHost.keybindings.test.tsx (99%) rename {src => packages/hunk/src}/ui/AppHost.keyboard-modes.test.tsx (98%) rename {src => packages/hunk/src}/ui/AppHost.reload.test.tsx (99%) rename {src => packages/hunk/src}/ui/AppHost.responsive.test.tsx (98%) rename {src => packages/hunk/src}/ui/AppHost.review-metadata.test.tsx (99%) rename {src => packages/hunk/src}/ui/AppHost.scroll-regression.test.tsx (93%) rename {src => packages/hunk/src}/ui/AppHost.selection.test.tsx (98%) rename {src => packages/hunk/src}/ui/AppHost.sidebar-resize.test.tsx (97%) rename {src => packages/hunk/src}/ui/AppHost.sidebar-visibility.test.tsx (96%) rename {src => packages/hunk/src}/ui/AppHost.tsx (100%) rename {src => packages/hunk/src}/ui/AppHost.watch.test.tsx (97%) rename {src => packages/hunk/src}/ui/AppHost.workspace.test.tsx (99%) rename {src => packages/hunk/src}/ui/components/chrome/AgentSkillDialog.tsx (100%) rename {src => packages/hunk/src}/ui/components/chrome/ConfirmDialog.tsx (100%) rename {src => packages/hunk/src}/ui/components/chrome/ExtensionDialog.tsx (100%) rename {src => packages/hunk/src}/ui/components/chrome/ExtensionToast.tsx (100%) rename {src => packages/hunk/src}/ui/components/chrome/HelpDialog.tsx (100%) rename {src => packages/hunk/src}/ui/components/chrome/MenuBar.tsx (100%) rename {src => packages/hunk/src}/ui/components/chrome/MenuDropdown.tsx (100%) rename {src => packages/hunk/src}/ui/components/chrome/ModalFrame.tsx (100%) rename {src => packages/hunk/src}/ui/components/chrome/StatusBar.tsx (100%) rename {src => packages/hunk/src}/ui/components/chrome/ThemeSelectorDialog.tsx (100%) rename {src => packages/hunk/src}/ui/components/chrome/menu.ts (100%) rename {src => packages/hunk/src}/ui/components/panes/AgentCard.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/AgentInlineNote.test.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/AgentInlineNote.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/DiffFileHeaderRow.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/DiffPane.test.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/DiffPane.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/DiffSection.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/ExtensionPane.test.tsx (99%) rename {src => packages/hunk/src}/ui/components/panes/ExtensionPane.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/FileListItem.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/FileView.test.tsx (99%) rename {src => packages/hunk/src}/ui/components/panes/FileView.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/PaneDivider.tsx (100%) rename {src => packages/hunk/src}/ui/components/panes/copySelection.test.ts (100%) rename {src => packages/hunk/src}/ui/components/panes/copySelection.ts (100%) rename {src => packages/hunk/src}/ui/components/scrollbar/VerticalScrollbar.test.tsx (99%) rename {src => packages/hunk/src}/ui/components/scrollbar/VerticalScrollbar.tsx (100%) rename {src => packages/hunk/src}/ui/components/ui-components.test.tsx (99%) rename {src => packages/hunk/src}/ui/currentReviewRefresh.test.ts (100%) rename {src => packages/hunk/src}/ui/currentReviewRefresh.ts (100%) rename {src => packages/hunk/src}/ui/diff/CodeCellView.test.tsx (99%) rename {src => packages/hunk/src}/ui/diff/CodeCellView.tsx (100%) rename {src => packages/hunk/src}/ui/diff/CodeRowView.test.tsx (96%) rename {src => packages/hunk/src}/ui/diff/CodeRowView.tsx (100%) rename {src => packages/hunk/src}/ui/diff/DiffMetaRowView.tsx (100%) rename {src => packages/hunk/src}/ui/diff/DiffRowView.tsx (100%) rename {src => packages/hunk/src}/ui/diff/DiffSectionBody.tsx (100%) rename {src => packages/hunk/src}/ui/diff/codeColumns.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/codeColumns.ts (100%) rename {src => packages/hunk/src}/ui/diff/codeRowAffordance.ts (100%) rename {src => packages/hunk/src}/ui/diff/codeRowLayout.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/codeRowLayout.ts (100%) rename {src => packages/hunk/src}/ui/diff/cursorHighlight.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/cursorHighlight.ts (100%) rename {src => packages/hunk/src}/ui/diff/diffRowModel.ts (100%) rename {src => packages/hunk/src}/ui/diff/diffRows.test.ts (99%) rename {src => packages/hunk/src}/ui/diff/diffRows.ts (100%) rename {src => packages/hunk/src}/ui/diff/diffSectionGeometry.test.ts (99%) rename {src => packages/hunk/src}/ui/diff/diffSectionGeometry.ts (100%) rename {src => packages/hunk/src}/ui/diff/diffSectionRowPlan.ts (100%) rename {src => packages/hunk/src}/ui/diff/expandCollapsedRows.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/expandCollapsedRows.ts (100%) rename {src => packages/hunk/src}/ui/diff/highlightedDiffCache.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/highlightedDiffCache.ts (100%) rename {src => packages/hunk/src}/ui/diff/lineHighlightPaint.test.ts (99%) rename {src => packages/hunk/src}/ui/diff/lineHighlightPaint.ts (100%) rename {src => packages/hunk/src}/ui/diff/plannedRowText.ts (100%) rename {src => packages/hunk/src}/ui/diff/reviewRenderPlan.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/reviewRenderPlan.ts (100%) rename {src => packages/hunk/src}/ui/diff/reviewRowGeometry.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/reviewRowGeometry.ts (100%) rename {src => packages/hunk/src}/ui/diff/rowMouseActions.ts (100%) rename {src => packages/hunk/src}/ui/diff/rowStyle.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/rowStyle.ts (100%) rename {src => packages/hunk/src}/ui/diff/rowWindowing.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/rowWindowing.ts (100%) rename {src => packages/hunk/src}/ui/diff/sourceBackedHighlight.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/sourceBackedHighlight.ts (100%) rename {src => packages/hunk/src}/ui/diff/styledSpanLayout.ts (100%) rename {src => packages/hunk/src}/ui/diff/syntaxHighlightTheme.test.ts (91%) rename {src => packages/hunk/src}/ui/diff/syntaxHighlightTheme.ts (100%) rename {src => packages/hunk/src}/ui/diff/useHighlightedDiff.test.ts (98%) rename {src => packages/hunk/src}/ui/diff/useHighlightedDiff.ts (100%) rename {src => packages/hunk/src}/ui/diff/useHighlightedSource.ts (100%) rename {src => packages/hunk/src}/ui/diff/worker/highlightCompact.test.ts (98%) rename {src => packages/hunk/src}/ui/diff/worker/highlightCompact.ts (100%) rename {src => packages/hunk/src}/ui/diff/worker/highlightContext.ts (100%) rename {src => packages/hunk/src}/ui/diff/worker/highlightHast.ts (100%) rename {src => packages/hunk/src}/ui/diff/worker/highlightWorker.ts (100%) rename {src => packages/hunk/src}/ui/diff/worker/highlightWorkerCache.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/worker/highlightWorkerCache.ts (100%) rename {src => packages/hunk/src}/ui/diff/worker/highlightWorkerClient.test.ts (98%) rename {src => packages/hunk/src}/ui/diff/worker/highlightWorkerClient.ts (100%) rename {src => packages/hunk/src}/ui/diff/worker/highlightWorkerIdentity.test.ts (100%) rename {src => packages/hunk/src}/ui/diff/worker/highlightWorkerIdentity.ts (100%) rename {src => packages/hunk/src}/ui/diff/worker/index.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/availability.test.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/availability.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/geometry.test.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/geometry.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/host.test.ts (98%) rename {src => packages/hunk/src}/ui/fileViews/host.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/layout.test.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/layout.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/mode.test.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/mode.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/renderPlan.test.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/renderPlan.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/state.test.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/state.ts (98%) rename {src => packages/hunk/src}/ui/fileViews/types.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/useFilePresentationController.test.tsx (99%) rename {src => packages/hunk/src}/ui/fileViews/useFilePresentationController.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/useFilePresentationRendering.test.tsx (98%) rename {src => packages/hunk/src}/ui/fileViews/useFilePresentationRendering.ts (100%) rename {src => packages/hunk/src}/ui/fileViews/useFileViews.test.tsx (99%) rename {src => packages/hunk/src}/ui/fileViews/useFileViews.ts (100%) rename {src => packages/hunk/src}/ui/highlights/merge.test.ts (100%) rename {src => packages/hunk/src}/ui/highlights/merge.ts (100%) rename {src => packages/hunk/src}/ui/highlights/reconcile.test.ts (96%) rename {src => packages/hunk/src}/ui/highlights/reconcile.ts (100%) rename {src => packages/hunk/src}/ui/highlights/state.ts (93%) rename {src => packages/hunk/src}/ui/highlights/useLineHighlights.test.tsx (99%) rename {src => packages/hunk/src}/ui/highlights/useLineHighlights.ts (100%) rename {src => packages/hunk/src}/ui/highlights/useLineHighlightsController.test.tsx (98%) rename {src => packages/hunk/src}/ui/highlights/useLineHighlightsController.ts (100%) rename {src => packages/hunk/src}/ui/highlights/validate.test.ts (100%) rename {src => packages/hunk/src}/ui/highlights/validate.ts (100%) rename {src => packages/hunk/src}/ui/history/runInteractiveHistory.test.ts (100%) rename {src => packages/hunk/src}/ui/history/runInteractiveHistory.ts (100%) rename {src => packages/hunk/src}/ui/history/runStaticHistory.test.ts (100%) rename {src => packages/hunk/src}/ui/history/runStaticHistory.ts (100%) rename {src => packages/hunk/src}/ui/history/staticProjection.test.ts (100%) rename {src => packages/hunk/src}/ui/history/staticProjection.ts (97%) rename {src => packages/hunk/src}/ui/history/types.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useAppKeyboardShortcuts.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useCurrentReviewRefreshController.test.tsx (99%) rename {src => packages/hunk/src}/ui/hooks/useCurrentReviewRefreshController.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionCommandRunner.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionCommandRunner.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionDialogController.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionDialogController.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionEventContextProvider.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionEventContextProvider.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionNotifications.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionNotifications.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionPaneController.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionPaneController.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionReviewEvents.test.tsx (99%) rename {src => packages/hunk/src}/ui/hooks/useExtensionReviewEvents.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionRuntimeBridge.test.tsx (97%) rename {src => packages/hunk/src}/ui/hooks/useExtensionRuntimeBridge.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionTrustController.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionTrustController.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionWorkspaceControls.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useExtensionWorkspaceControls.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useHunkSessionBridge.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useIntermediateRenderAfterMount.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useIntermediateRenderAfterMount.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useMenuController.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useMenuController.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useStartupNotices.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useStartupNotices.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useTerminalReview.test.tsx (99%) rename {src => packages/hunk/src}/ui/hooks/useTerminalReview.ts (99%) rename {src => packages/hunk/src}/ui/hooks/useThemeSelectorController.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useThemeSelectorController.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useTimedNotice.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useTimedNotice.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useUserNoteComposer.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useUserNoteComposer.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useViewPreferenceQuitController.test.tsx (100%) rename {src => packages/hunk/src}/ui/hooks/useViewPreferenceQuitController.ts (100%) rename {src => packages/hunk/src}/ui/hooks/useWatchedInput.ts (100%) rename {src => packages/hunk/src}/ui/keyboardModes/mode.test.ts (100%) rename {src => packages/hunk/src}/ui/keyboardModes/mode.ts (100%) rename {src => packages/hunk/src}/ui/keyboardModes/useKeyboardModeController.test.tsx (100%) rename {src => packages/hunk/src}/ui/keyboardModes/useKeyboardModeController.ts (100%) rename {src => packages/hunk/src}/ui/lib/adaptiveScrollOverscan.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/adaptiveScrollOverscan.ts (100%) rename {src => packages/hunk/src}/ui/lib/agentAnnotations.test.ts (97%) rename {src => packages/hunk/src}/ui/lib/agentAnnotations.ts (100%) rename {src => packages/hunk/src}/ui/lib/agentNoteGeometry.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/agentNoteGeometry.ts (100%) rename {src => packages/hunk/src}/ui/lib/agentPopover.ts (100%) rename {src => packages/hunk/src}/ui/lib/appCommands.test.ts (99%) rename {src => packages/hunk/src}/ui/lib/appCommands.ts (100%) rename {src => packages/hunk/src}/ui/lib/appMenus.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/appMenus.ts (100%) rename {src => packages/hunk/src}/ui/lib/color.ts (100%) rename {src => packages/hunk/src}/ui/lib/diffSpatial.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionCapabilityLease.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionCapabilityLease.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionCommandControls.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionCommandControls.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionCommands.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionCommands.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionCurrentLine.test.ts (98%) rename {src => packages/hunk/src}/ui/lib/extensionCurrentLine.tsx (100%) rename {src => packages/hunk/src}/ui/lib/extensionDialogGeometry.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionDialogGeometry.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionDialogs.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionDialogs.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionDocumentReader.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionKeyEvent.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionKeyEvent.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionNavigation.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionNavigation.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionNotifications.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionNotifications.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionPaintTheme.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionPanes.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionPanes.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionReviewReload.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionReviewReload.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionSelection.test.ts (98%) rename {src => packages/hunk/src}/ui/lib/extensionSelection.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionTrustPrompt.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionWorkspace.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/extensionWorkspace.ts (100%) rename {src => packages/hunk/src}/ui/lib/fileHeader.test.ts (96%) rename {src => packages/hunk/src}/ui/lib/fileHeader.ts (100%) rename {src => packages/hunk/src}/ui/lib/fileRenderWindow.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/fileRenderWindow.ts (100%) rename {src => packages/hunk/src}/ui/lib/fileSectionLayout.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/fileSectionLayout.ts (100%) rename {src => packages/hunk/src}/ui/lib/files.test.ts (99%) rename {src => packages/hunk/src}/ui/lib/files.ts (100%) rename {src => packages/hunk/src}/ui/lib/helpContent.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/helpContent.ts (100%) rename {src => packages/hunk/src}/ui/lib/hunkScroll.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/hunkScroll.ts (100%) rename {src => packages/hunk/src}/ui/lib/ids.ts (100%) rename {src => packages/hunk/src}/ui/lib/keyRouting.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/keyRouting.ts (100%) rename {src => packages/hunk/src}/ui/lib/keyboard.ts (100%) rename {src => packages/hunk/src}/ui/lib/keymap.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/keymap.ts (100%) rename {src => packages/hunk/src}/ui/lib/lineCursors.test.ts (99%) rename {src => packages/hunk/src}/ui/lib/lineCursors.ts (100%) rename {src => packages/hunk/src}/ui/lib/listWindow.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/listWindow.ts (100%) rename {src => packages/hunk/src}/ui/lib/modalGeometry.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/modalGeometry.ts (100%) rename {src => packages/hunk/src}/ui/lib/mouseCapture.ts (100%) rename {src => packages/hunk/src}/ui/lib/openInEditor.test.ts (99%) rename {src => packages/hunk/src}/ui/lib/openInEditor.ts (100%) rename {src => packages/hunk/src}/ui/lib/responsive.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/responsive.ts (100%) rename {src => packages/hunk/src}/ui/lib/reviewNoteMapping.test.ts (97%) rename {src => packages/hunk/src}/ui/lib/reviewNoteMapping.ts (98%) rename {src => packages/hunk/src}/ui/lib/reviewState.test.ts (97%) rename {src => packages/hunk/src}/ui/lib/reviewState.ts (100%) rename {src => packages/hunk/src}/ui/lib/scopedEpochs.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/scopedEpochs.ts (100%) rename {src => packages/hunk/src}/ui/lib/scrollAcceleration.ts (100%) rename {src => packages/hunk/src}/ui/lib/sidebar.ts (100%) rename {src => packages/hunk/src}/ui/lib/sidebarRenderWindow.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/sidebarRenderWindow.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/cli.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/colors.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/colors.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/guide.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/guide.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/layout.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/layout.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/parse.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/parse.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/render.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/stml/render.ts (100%) rename {src => packages/hunk/src}/ui/lib/synchronousExtensionCallback.ts (100%) rename {src => packages/hunk/src}/ui/lib/syntheticKeyEvent.ts (100%) rename {src => packages/hunk/src}/ui/lib/text.ts (100%) rename {src => packages/hunk/src}/ui/lib/ui-lib.test.ts (99%) rename {src => packages/hunk/src}/ui/lib/viewportAnchor.test.ts (97%) rename {src => packages/hunk/src}/ui/lib/viewportAnchor.ts (100%) rename {src => packages/hunk/src}/ui/lib/viewportSelection.test.ts (97%) rename {src => packages/hunk/src}/ui/lib/viewportSelection.ts (100%) rename {src => packages/hunk/src}/ui/lib/viewportTiming.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/viewportTiming.ts (100%) rename {src => packages/hunk/src}/ui/lib/workspaceWriteGuard.test.ts (100%) rename {src => packages/hunk/src}/ui/lib/workspaceWriteGuard.ts (100%) rename {src => packages/hunk/src}/ui/log/LogApp.tsx (100%) rename {src => packages/hunk/src}/ui/log/LogSessionHost.tsx (100%) rename {src => packages/hunk/src}/ui/log/ParentSelectorDialog.test.tsx (100%) rename {src => packages/hunk/src}/ui/log/ParentSelectorDialog.tsx (100%) rename {src => packages/hunk/src}/ui/log/colorPolicy.test.ts (100%) rename {src => packages/hunk/src}/ui/log/colorPolicy.ts (100%) rename {src => packages/hunk/src}/ui/log/commands.test.ts (100%) rename {src => packages/hunk/src}/ui/log/commands.ts (100%) rename {src => packages/hunk/src}/ui/log/controller.test.ts (100%) rename {src => packages/hunk/src}/ui/log/controller.ts (100%) rename {src => packages/hunk/src}/ui/log/embeddedReview.test.ts (100%) rename {src => packages/hunk/src}/ui/log/logHelp.ts (100%) rename {src => packages/hunk/src}/ui/log/responsiveLayout.test.ts (100%) rename {src => packages/hunk/src}/ui/log/responsiveLayout.ts (100%) rename {src => packages/hunk/src}/ui/log/reviewLaunch.ts (100%) rename {src => packages/hunk/src}/ui/log/runInteractiveLog.test.ts (100%) rename {src => packages/hunk/src}/ui/log/runInteractiveLog.tsx (100%) rename {src => packages/hunk/src}/ui/runInteractiveApp.tsx (100%) rename {src => packages/hunk/src}/ui/staticDiffPager.test.ts (100%) rename {src => packages/hunk/src}/ui/staticDiffPager.ts (100%) rename {src => packages/hunk/src}/ui/themes.test.ts (99%) rename {src => packages/hunk/src}/ui/themes.ts (100%) rename {src => packages/hunk/src}/ui/themes/types.ts (100%) create mode 100644 scripts/changeset-version.test.ts create mode 100644 scripts/changeset-version.ts create mode 100644 scripts/nix-package.test.ts create mode 100644 scripts/package-paths.ts create mode 100644 scripts/packed-public-consumer-check.ts create mode 100644 test/cli/install-vm/repo-layout.ts diff --git a/.changeset/README.md b/.changeset/README.md index 8d816bd88..91da40838 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -14,7 +14,9 @@ Select `hunkdiff` and choose the semver bump that matches the shipped CLI/packag - `minor` for new user-facing features - `major` for breaking changes -`package.json` intentionally lists `"."` in `workspaces` so Changesets can discover the root `hunkdiff` package. Keep that entry unless Hunk moves the publishable package out of the repository root. +The private root workspace lists `packages/*`, and Changesets discovers the published `hunkdiff` +package at `packages/hunk/package.json`. Private implementation workspaces remain ignored and are +never published. For maintenance-only PRs that should not appear in release notes, create an empty changeset: @@ -28,6 +30,9 @@ Release prep runs: bun run release:version ``` -That consumes the pending `.changeset/*.md` files, updates `CHANGELOG.md`, and bumps package versions for the release commit. +`CHANGELOG.md` at the repository root is the canonical changelog consumed by the website and +release tooling. The version wrapper temporarily stages that history beside the package for +Changesets, then copies the generated release entry back to the root and removes the temporary +package changelog. It also bumps `packages/hunk/package.json` for the release commit. After the tag release publishes npm packages and GitHub release assets, verify Homebrew through `Homebrew/homebrew-core`. Hunk is on Homebrew's Autobump list, so do not open manual simple version-bump PRs. Wait for the automated `hunk ` PR, confirm it merges, then verify `brew install hunk` resolves to the released version. Use `brew bump-formula-pr hunk --version ` only if Homebrew maintainers ask for a manual bump or Autobump stalls unexpectedly. diff --git a/.changeset/package-first-phase-one.md b/.changeset/package-first-phase-one.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/package-first-phase-one.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index c20aae747..150226b52 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -1,5 +1,5 @@ /** - * Enforces module boundaries on the production import graph (`src/` plus `packages/`). + * Enforces module boundaries on the production import graph (`packages/hunk/src/` plus `packages/`). * * Each rule names one boundary of the target architecture described in * docs/module-boundaries.md. Pre-existing violations live in @@ -8,27 +8,27 @@ * `bun run deps:check` fails on any violation not in the baseline. */ -// UI files allowed to couple to src/app and src/session: the composition shell, the two +// UI files allowed to couple to packages/hunk/src/app and packages/hunk/src/session: the composition shell, the two // named session adapter hooks, and the session-navigation resolution helper those hooks -// share. Everything else in src/ui stays presentation-only. +// share. Everything else in packages/hunk/src/ui stays presentation-only. const UI_SESSION_ADAPTERS = [ - "^src/ui/App\\.tsx$", - "^src/ui/AppHost\\.tsx$", - "^src/ui/runInteractiveApp\\.tsx$", - "^src/ui/hooks/useHunkSessionBridge\\.ts$", - "^src/ui/hooks/useTerminalReview\\.ts$", - "^src/ui/lib/reviewState\\.ts$", + "^packages/hunk/src/ui/App\\.tsx$", + "^packages/hunk/src/ui/AppHost\\.tsx$", + "^packages/hunk/src/ui/runInteractiveApp\\.tsx$", + "^packages/hunk/src/ui/hooks/useHunkSessionBridge\\.ts$", + "^packages/hunk/src/ui/hooks/useTerminalReview\\.ts$", + "^packages/hunk/src/ui/lib/reviewState\\.ts$", ]; // Every way the shipped product is entered: the CLI, the highlight worker thread, the two -// published facades, and the skill generator. A module under src/ that no entry reaches, +// published facades, and the skill generator. A module under packages/hunk/src/ that no entry reaches, // directly or transitively, is not in the product. const PRODUCTION_ENTRY_POINTS = [ - "^src/main\\.tsx$", - "^src/highlightWorkerEntry\\.ts$", - "^src/opentui/index\\.ts$", - "^src/extension-api/index\\.ts$", - "^src/hunk-review/skillDocument\\.ts$", + "^packages/hunk/src/main\\.tsx$", + "^packages/hunk/src/highlightWorkerEntry\\.ts$", + "^packages/hunk/src/opentui/index\\.ts$", + "^packages/hunk/src/extension-api/index\\.ts$", + "^packages/hunk/src/hunk-review/skillDocument\\.ts$", ]; // Modules kept alive by tests alone. The cruise excludes tests, so these look unreachable @@ -37,12 +37,12 @@ const PRODUCTION_ENTRY_POINTS = [ // without the coverage to justify it. const TEST_ONLY_MODULES = [ // Note-height measurement exercised by the review-conformance corpus. - "^src/core/review/noteSize\\.ts$", + "^packages/hunk/src/core/review/noteSize\\.ts$", // The floating agent-note popover and its measurement helper. Nothing renders them since // notes moved into the diff flow as STML cards; their unit tests are the only consumers // left, so they are quarantined here until that call is made rather than deleted blind. - "^src/ui/components/panes/AgentCard\\.tsx$", - "^src/ui/lib/agentPopover\\.ts$", + "^packages/hunk/src/ui/components/panes/AgentCard\\.tsx$", + "^packages/hunk/src/ui/lib/agentPopover\\.ts$", ]; module.exports = { @@ -58,75 +58,78 @@ module.exports = { { name: "extension-api-is-import-free", comment: - "src/extension-api is the published contract; declaration emission publishes whatever it reaches (scripts/check-pack.ts gates the pack, this gates the graph).", + "packages/hunk/src/extension-api is the published contract; declaration emission publishes whatever it reaches (scripts/check-pack.ts gates the pack, this gates the graph).", severity: "error", - from: { path: "^src/extension-api/" }, - to: { path: "^(src|packages)/", pathNot: "^src/extension-api/" }, + from: { path: "^packages/hunk/src/extension-api/" }, + to: { path: "^packages/", pathNot: "^packages/hunk/src/extension-api/" }, }, { name: "lib-is-a-leaf", comment: - "src/lib holds dependency-free helpers usable from any tier; it may reach the import-free extension API contract and nothing else.", + "packages/hunk/src/lib holds dependency-free helpers usable from any tier; it may reach the import-free extension API contract and nothing else.", severity: "error", - from: { path: "^src/lib/" }, - to: { path: "^(src|packages)/", pathNot: "^src/(lib|extension-api)/" }, + from: { path: "^packages/hunk/src/lib/" }, + to: { path: "^packages/", pathNot: "^packages/hunk/src/(lib|extension-api)/" }, }, { name: "core-stays-domain", comment: - "src/core is the domain model. It may use src/lib and the extension-api contract, but never the UI, app composition, session brokering, extension host, or opentui facade above it.", + "packages/hunk/src/core is the domain model. It may use packages/hunk/src/lib and the extension-api contract, but never the UI, app composition, session brokering, extension host, or opentui facade above it.", severity: "error", - from: { path: "^src/core/" }, - to: { path: "^src/(ui|app|session|extensions|opentui)/" }, + from: { path: "^packages/hunk/src/core/" }, + to: { path: "^packages/hunk/src/(ui|app|session|extensions|opentui)/" }, }, { name: "extensions-host-stays-below-surfaces", comment: - "The extension host and bundled extensions sit below the surfaces that load them. The bundled UI tier (src/extensions/default/ui/) is exempt from the src/ui half by documented design: its dogfooding boundary is the published props contract — data, actions, theme — while rendering helpers are host code (see the sidebar module header).", + "The extension host and bundled extensions sit below the surfaces that load them. The bundled UI tier (packages/hunk/src/extensions/default/ui/) is exempt from the packages/hunk/src/ui half by documented design: its dogfooding boundary is the published props contract — data, actions, theme — while rendering helpers are host code (see the sidebar module header).", severity: "error", - from: { path: "^src/extensions/", pathNot: "^src/extensions/default/ui/" }, - to: { path: "^src/(ui|app|session|opentui)/" }, + from: { + path: "^packages/hunk/src/extensions/", + pathNot: "^packages/hunk/src/extensions/default/ui/", + }, + to: { path: "^packages/hunk/src/(ui|app|session|opentui)/" }, }, { name: "bundled-ui-extensions-render-only", comment: - "The bundled UI tier may consume src/ui rendering helpers as host code, but composition and session brokering stay out of reach — a pane gets its data and actions through the published props.", + "The bundled UI tier may consume packages/hunk/src/ui rendering helpers as host code, but composition and session brokering stay out of reach — a pane gets its data and actions through the published props.", severity: "error", - from: { path: "^src/extensions/default/ui/" }, - to: { path: "^src/(app|session|opentui)/" }, + from: { path: "^packages/hunk/src/extensions/default/ui/" }, + to: { path: "^packages/hunk/src/(app|session|opentui)/" }, }, { name: "session-stays-below-app-and-ui", comment: - "src/session brokers transport and protocol. It consumes core and packages; the app tier registers into it, not the other way round.", + "packages/hunk/src/session brokers transport and protocol. It consumes core and packages; the app tier registers into it, not the other way round.", severity: "error", - from: { path: "^src/session/" }, - to: { path: "^src/(ui|app|extensions|opentui)/" }, + from: { path: "^packages/hunk/src/session/" }, + to: { path: "^packages/hunk/src/(ui|app|extensions|opentui)/" }, }, { name: "app-composes-without-ui", comment: - "src/app wires core, extensions, and session together for startup; rendering stays in src/ui, which imports app — never the reverse.", + "packages/hunk/src/app wires core, extensions, and session together for startup; rendering stays in packages/hunk/src/ui, which imports app — never the reverse.", severity: "error", - from: { path: "^src/app/" }, - to: { path: "^src/(ui|opentui)/" }, + from: { path: "^packages/hunk/src/app/" }, + to: { path: "^packages/hunk/src/(ui|opentui)/" }, }, { name: "ui-couples-to-session-via-adapters", comment: - "Only the composition shell and the named session adapter hooks may import src/app or src/session; ordinary UI components and helpers stay presentation-only so the review surface can move to other hosts.", + "Only the composition shell and the named session adapter hooks may import packages/hunk/src/app or packages/hunk/src/session; ordinary UI components and helpers stay presentation-only so the review surface can move to other hosts.", severity: "error", - from: { path: "^src/ui/", pathNot: UI_SESSION_ADAPTERS }, - to: { path: "^src/(app|session)/" }, + from: { path: "^packages/hunk/src/ui/", pathNot: UI_SESSION_ADAPTERS }, + to: { path: "^packages/hunk/src/(app|session)/" }, }, { name: "no-dead-modules", comment: - "Every module under src/ earns its place by being reachable from an entry point. Dead files are worse than clutter: they still import, so they hold boundaries hostage and answer questions nobody asks. `orphan` only catches fully disconnected files, which misses dead code that still has dependencies — reachability catches both. A flagged module is either deleted or, if tests are its only real consumer, listed in TEST_ONLY_MODULES with a reason.", + "Every module under packages/hunk/src/ earns its place by being reachable from an entry point. Dead files are worse than clutter: they still import, so they hold boundaries hostage and answer questions nobody asks. `orphan` only catches fully disconnected files, which misses dead code that still has dependencies — reachability catches both. A flagged module is either deleted or, if tests are its only real consumer, listed in TEST_ONLY_MODULES with a reason.", severity: "error", from: { path: PRODUCTION_ENTRY_POINTS }, to: { - path: "^src/", + path: "^packages/hunk/src/", pathNot: [...PRODUCTION_ENTRY_POINTS, ...TEST_ONLY_MODULES], reachable: false, }, @@ -137,27 +140,27 @@ module.exports = { "core/bootstrap.ts composes the leaves: it names the changeset, the parsed input, the resolved preferences, and the detected theme mode to describe one launch. A module directory importing it back would invert that layering and rebuild the grab-bag cycle the 2026-08 phases dismantled. core/changeset/loaders.ts is the single exception — loadAppBootstrap assembles the value, so it names the shape it returns; its natural home is the app tier, and moving it there retires this exception.", severity: "error", from: { - path: "^src/core/(changeset|run|process|install|review|vcs|watch|patch|theme)/", - pathNot: "^src/core/changeset/loaders\\.ts$", + path: "^packages/hunk/src/core/(changeset|run|process|install|review|vcs|watch|patch|theme)/", + pathNot: "^packages/hunk/src/core/changeset/loaders\\.ts$", }, - to: { path: "^src/core/bootstrap\\.ts$" }, + to: { path: "^packages/hunk/src/core/bootstrap\\.ts$" }, }, { name: "review-reducer-is-module-internal", comment: - "The review reducer applies actions; callers state intent instead, so surfaces cannot reach past planReviewIntent into the transition table. First of the per-module interior rules — this establishes the mechanism later phases extend to the rest of src/core (identity.ts and the other named model modules stay public by design).", + "The review reducer applies actions; callers state intent instead, so surfaces cannot reach past planReviewIntent into the transition table. First of the per-module interior rules — this establishes the mechanism later phases extend to the rest of packages/hunk/src/core (identity.ts and the other named model modules stay public by design).", severity: "error", - from: { path: "^src/", pathNot: "^src/core/review/" }, - to: { path: "^src/core/review/reducer\\.ts$" }, + from: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/core/review/" }, + to: { path: "^packages/hunk/src/core/review/reducer\\.ts$" }, }, { name: "changeset-internals-stay-in-module", comment: "core/changeset owns the changeset model and the pipeline that acquires one. Outsiders name the model, the loaders, and the per-file helpers they build on (model, loaders, diffFile, fileSource, fileLanguage, binary, diffPaths, hunkHeader, hunkSummary); the patch-to-model parse, the Pierre extension-table lookup, and the sidecar reader are steps inside that pipeline, reached through the loaders instead.", severity: "error", - from: { path: "^src/", pathNot: "^src/core/changeset/" }, + from: { path: "^packages/hunk/src/", pathNot: "^packages/hunk/src/core/changeset/" }, to: { - path: "^src/core/changeset/(fromPatch|fileLanguageLookup|sidecar)\\.ts$", + path: "^packages/hunk/src/core/changeset/(fromPatch|fileLanguageLookup|sidecar)\\.ts$", }, }, { @@ -165,8 +168,8 @@ module.exports = { comment: "Workspace packages are standalone publishable units; they never import the app source tree.", severity: "error", - from: { path: "^packages/" }, - to: { path: "^src/" }, + from: { path: "^packages/(?!hunk/)" }, + to: { path: "^packages/hunk/src/" }, }, ], options: { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9b7a3a389..307686b96 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -150,7 +150,7 @@ jobs: pkg_dir="$(mktemp -d)" install_dir="$(mktemp -d)" node_dir="$(dirname "$(command -v node)")" - npm pack --pack-destination "$pkg_dir" >/dev/null + npm pack ./packages/hunk --ignore-scripts --pack-destination "$pkg_dir" >/dev/null pkg="$(find "$pkg_dir" -maxdepth 1 -name 'hunkdiff-*.tgz' | head -n1)" npm install -g --prefix "$install_dir" "$pkg" PATH="$install_dir/bin:$node_dir:/usr/bin:/bin" diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index d665bb595..429c17a18 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -212,7 +212,7 @@ jobs: pkg_dir="$(mktemp -d)" install_dir="$(mktemp -d)" node_dir="$(dirname "$(command -v node)")" - npm pack --pack-destination "$pkg_dir" >/dev/null + npm pack ./packages/hunk --ignore-scripts --pack-destination "$pkg_dir" >/dev/null pkg="$(find "$pkg_dir" -maxdepth 1 -name 'hunkdiff-*.tgz' | head -n1)" npm install -g --prefix "$install_dir" "$pkg" PATH="$install_dir/bin:$node_dir:/usr/bin:/bin" diff --git a/AGENTS.md b/AGENTS.md index 1ffb65328..17c4d6d61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ DiffFile[] -> projectReviewDocument -> ReviewDocumentV1 -> ReviewStore ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> surface projection ``` -- **Model:** `src/core/review/{types,document,identity}.ts` owns the ordered, JSON-safe document. +- **Model:** `packages/hunk/src/core/review/{types,document,identity}.ts` owns the ordered, JSON-safe document. File order is review/sidebar order; use `key` (referenced as `fileKey` elsewhere), `contentIdentity`, and `sourceIdentity` (cached source text additionally requires `sourceAttested`) — not runtime IDs or indexes — across reloads/surfaces. @@ -53,9 +53,9 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> repaid seam finding deletes copies, adds a file or banned-symbol tombstone and adversarial fixture, registers consumers, and updates `docs/browser-review-seam-audit.md`. -- Bundled VCS implementations live under `src/extensions/default/vcs//` and consume the - public extension contract; `src/app` composes their registrations into the provider-neutral - core VCS catalog. Do not add provider commands, spawning, or source readers under `src/core`. +- Bundled VCS implementations live under `packages/hunk/src/extensions/default/vcs//` and consume the + public extension contract; `packages/hunk/src/app` composes their registrations into the provider-neutral + core VCS catalog. Do not add provider commands, spawning, or source readers under `packages/hunk/src/core`. - `hunk daemon serve` is the one loopback daemon for all live sessions; sessions auto-start and register with it rather than opening per-TUI ports. Reuse `classifyReviewPublication` and `ReviewChunkAssembler` for publication ordering and bounded, digest-verified resources. Browser @@ -63,17 +63,17 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> its digest. Transport semantics come from the browser-safe review protocol modules and the existing intent path. See `docs/browser-review-rebuild.md` and the relevant module headers. - User and bundled extensions share one API and registry. Shipped VCS backends and the built-in - sidebar register through the public contract. Keep `src/extension-api/types.ts` import-free, + sidebar register through the public contract. Keep `packages/hunk/src/extension-api/types.ts` import-free, bundled VCS renderer-free, repo-local extensions trust-gated, and bundled extensions active under `--no-extensions`. See `docs/extension-architecture.md`, `docs/extensions.md`, and - `skills/hunk-extensions/SKILL.md`. + `packages/hunk/skills/hunk-extensions/SKILL.md`. - Sidecar file order is intentional sidebar and review-stream order. - Derive shared rendering, navigation, scrolling, and note behavior from one planning layer. Make shared geometry explicit, and remove obsolete paths instead of retaining parallel implementations. ## architectural rules -- Import boundaries between `src/` top-level trees are enforced by `bun run deps:check` +- Import boundaries between `packages/hunk/src/` top-level trees are enforced by `bun run deps:check` (dependency-cruiser; rules in `.dependency-cruiser.cjs`, target tiers in `docs/module-boundaries.md`). The known-violations baseline is shrink-only: fix an edge, rerun `bun run deps:baseline`, never add to it. @@ -95,16 +95,16 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> ## theme guidance -- Built-in theme ids and source metadata live in `src/core/theme/catalog.ts`; `src/ui/themes.ts` +- Built-in theme ids and source metadata live in `packages/hunk/src/core/theme/catalog.ts`; `packages/hunk/src/ui/themes.ts` derives Hunk's semantic `AppTheme` values. - When adding or renaming a built-in theme, update validation, public exports, docs/examples, the appropriate Changeset, and tests. Keep source palette tokens separate from semantic mappings and cover non-trivial derived colors. -- `BUNDLED_SHIKI_THEME_DIFF_COLORS` in `src/core/theme/catalog.ts` is generated. Edit the sourcing policy in `scripts/generate-theme-diff-colors.ts`, then run `bun run generate:theme-colors`. +- `BUNDLED_SHIKI_THEME_DIFF_COLORS` in `packages/hunk/src/core/theme/catalog.ts` is generated. Edit the sourcing policy in `scripts/generate-theme-diff-colors.ts`, then run `bun run generate:theme-colors`. ## testing -- Colocate unit tests with the code they cover (`src/core/foo.ts` + `src/core/foo.test.ts`, `src/ui/AppHost.*.test.tsx`, `src/ui/lib/*.test.ts`). +- Colocate unit tests with the code they cover (`packages/hunk/src/core/foo.ts` + `packages/hunk/src/core/foo.test.ts`, `packages/hunk/src/ui/AppHost.*.test.tsx`, `packages/hunk/src/ui/lib/*.test.ts`). - Put shared unit-test helpers in `test/helpers/`. - Name test helpers so they explicitly include `Test` and are clearly test-only (`createTestDiffFile`). - Use repo-level `test/` directories by intent: @@ -145,11 +145,11 @@ ReviewIntent + caller facts -> planReviewIntent -> ReviewAction[] -> reducer -> - Agent context belongs beside the code, not hidden in a separate mode or workflow. - Agent notes are hunk-specific: show notes for the selected hunk, render them in the diff flow near the annotated row, and keep a clear spatial relationship to the code they explain. - Keep note behavior explicit. If the UI intentionally prioritizes one note, one selection, or one active target, encode that as a named policy rather than scattering array-index assumptions through the codebase. -- STML markup notes (experimental) live in `src/ui/lib/stml/`. The layout engine is deliberately a deterministic line layout, not OpenTUI flexbox: the row-windowed review stream needs exact note heights before mount, so `(markup, width)` must always produce the same lines. Colors stay symbolic until render time so measurement never needs a theme. Do not "simplify" this into flexbox renderables, and keep note-card geometry in `agentNoteGeometry` as the single source for rendering, measurement, and agent-facing width reporting. +- STML markup notes (experimental) live in `packages/hunk/src/ui/lib/stml/`. The layout engine is deliberately a deterministic line layout, not OpenTUI flexbox: the row-windowed review stream needs exact note heights before mount, so `(markup, width)` must always produce the same lines. Colors stay symbolic until render time so measurement never needs a theme. Do not "simplify" this into flexbox renderables, and keep note-card geometry in `agentNoteGeometry` as the single source for rendering, measurement, and agent-facing width reporting. - Keep temporary sidecars concise and review-oriented. Their file order is intentional, while the visible note UI remains hunk-note driven rather than showing generic explainer cards. -- Agents review via `skills/hunk-review/SKILL.md` using `hunk session *` commands; do not run interactive TUI commands directly. -- `skills/hunk-review/SKILL.md` is generated. Edit `src/hunk-review/skillDocument.ts`, `src/session/agent/surface.ts`, or `src/session/agent/errors.ts`, then run `bun run generate:skill`; never hand-edit the skill file. +- Agents review via `packages/hunk/skills/hunk-review/SKILL.md` using `hunk session *` commands; do not run interactive TUI commands directly. +- `packages/hunk/skills/hunk-review/SKILL.md` is generated. Edit `packages/hunk/src/hunk-review/skillDocument.ts`, `packages/hunk/src/session/agent/surface.ts`, or `packages/hunk/src/session/agent/errors.ts`, then run `bun run generate:skill`; never hand-edit the skill file. ## binary notes diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 41094acba..3a042fbb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,7 +35,7 @@ Please discuss a change before implementation when it introduces or substantiall Before adding a built-in workflow, integration, or alternate presentation, check whether it can be implemented as an extension. Extensions are usually the better home for opt-in behavior such as VCS integrations, sidebars, file views, commands, keyboard modes, line highlighters, and repository-specific review workflows. -Start with [`docs/extensions.md`](docs/extensions.md) and the checked-in [`examples/extensions/`](examples/extensions/). If you use a coding agent, [`skills/hunk-extensions/SKILL.md`](skills/hunk-extensions/SKILL.md) maps the public API and its implementation. A small prototype is often the fastest way to learn whether the current API is enough. +Start with [`docs/extensions.md`](docs/extensions.md) and the checked-in [`examples/extensions/`](examples/extensions/). If you use a coding agent, [`packages/hunk/skills/hunk-extensions/SKILL.md`](packages/hunk/skills/hunk-extensions/SKILL.md) maps the public API and its implementation. A small prototype is often the fastest way to learn whether the current API is enough. If the extension API cannot express the idea, do not immediately bypass it with feature-specific core code. Explain: @@ -80,7 +80,7 @@ Install dependencies and run Hunk from source: ```bash bun install -bun run src/main.tsx -- diff +bun run packages/hunk/src/main.tsx -- diff ``` Nix users can run `nix develop` or use [direnv](https://direnv.net/) to enter the development shell. diff --git a/benchmarks/bootstrap-load.ts b/benchmarks/bootstrap-load.ts index 95c709318..6da0bad5d 100644 --- a/benchmarks/bootstrap-load.ts +++ b/benchmarks/bootstrap-load.ts @@ -5,8 +5,8 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { performance } from "perf_hooks"; import { parsePatchFiles } from "@pierre/diffs"; -import { getBundledVcsCatalog } from "../src/app/vcsCatalog"; -import { loadAppBootstrap } from "../src/core/changeset/loaders"; +import { getBundledVcsCatalog } from "../packages/hunk/src/app/vcsCatalog"; +import { loadAppBootstrap } from "../packages/hunk/src/core/changeset/loaders"; const FILE_COUNT = 64; const LINES_PER_FILE = 420; diff --git a/benchmarks/changeset-parse.ts b/benchmarks/changeset-parse.ts index a090f999b..fdd642521 100644 --- a/benchmarks/changeset-parse.ts +++ b/benchmarks/changeset-parse.ts @@ -1,9 +1,9 @@ // Benchmark raw patch parsing and sanitized DiffFile construction for several diff shapes. import { performance } from "perf_hooks"; import { parsePatchFiles } from "@pierre/diffs"; -import { buildDiffFile } from "../src/core/changeset/diffFile"; -import { findPatchChunk, splitPatchIntoFileChunks } from "../src/core/patch/chunks"; -import { sanitizePatchText } from "../src/core/patch/sanitize"; +import { buildDiffFile } from "../packages/hunk/src/core/changeset/diffFile"; +import { findPatchChunk, splitPatchIntoFileChunks } from "../packages/hunk/src/core/patch/chunks"; +import { sanitizePatchText } from "../packages/hunk/src/core/patch/sanitize"; import { createSyntheticPatch } from "./lib/fixtures"; interface Scenario { diff --git a/benchmarks/compact-highlight-payload.ts b/benchmarks/compact-highlight-payload.ts index f09189b59..674c611c1 100644 --- a/benchmarks/compact-highlight-payload.ts +++ b/benchmarks/compact-highlight-payload.ts @@ -3,20 +3,20 @@ // normalizes it, and the terminal paint seam remains a later milestone. import { performance } from "node:perf_hooks"; import { cleanLastNewline, parseDiffFromFile } from "@pierre/diffs"; -import type { DiffFile } from "../src/core/changeset/model"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; import { buildSplitRows, loadHighlightedDiff, type HighlightedDiffCode, -} from "../src/ui/diff/diffRows"; +} from "../packages/hunk/src/ui/diff/diffRows"; import { compactHighlightRunsForLine, compactHighlightTransferList, compactHighlightedDiffByteLength, encodeCompactHighlightedDiff, validateCompactHighlightedDiff, -} from "../src/ui/diff/worker"; -import { resolveTheme } from "../src/ui/themes"; +} from "../packages/hunk/src/ui/diff/worker"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; const LINE_COUNT = Number(process.env.HUNK_COMPACT_HIGHLIGHT_LINES ?? 8_000); const SAMPLES = Number(process.env.HUNK_COMPACT_HIGHLIGHT_SAMPLES ?? 7); diff --git a/benchmarks/geometry-memory.ts b/benchmarks/geometry-memory.ts index 04061d66a..53f402157 100644 --- a/benchmarks/geometry-memory.ts +++ b/benchmarks/geometry-memory.ts @@ -1,8 +1,8 @@ // Track retained memory for the all-files geometry cache used by review scrolling/navigation. import { heapStats } from "bun:jsc"; import { performance } from "node:perf_hooks"; -import { measureDiffSectionGeometry } from "../src/ui/diff/diffSectionGeometry"; -import { resolveTheme } from "../src/ui/themes"; +import { measureDiffSectionGeometry } from "../packages/hunk/src/ui/diff/diffSectionGeometry"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { createGiantSingleDiffFile, createLargeSplitStreamBootstrap, diff --git a/benchmarks/highlight-cache-layers.ts b/benchmarks/highlight-cache-layers.ts index f77ad098b..4b165e2ad 100644 --- a/benchmarks/highlight-cache-layers.ts +++ b/benchmarks/highlight-cache-layers.ts @@ -1,10 +1,10 @@ // Compare a resident main-process cache hit with a worker-LRU revisit after main-cache eviction. import { performance } from "node:perf_hooks"; import { parseDiffFromFile } from "@pierre/diffs"; -import type { DiffFile } from "../src/core/changeset/model"; -import { resolveTheme } from "../src/ui/themes"; -import { disposeHighlightWorker } from "../src/ui/diff/worker/highlightWorkerClient"; -import { prefetchHighlightedDiff } from "../src/ui/diff/useHighlightedDiff"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; +import { disposeHighlightWorker } from "../packages/hunk/src/ui/diff/worker/highlightWorkerClient"; +import { prefetchHighlightedDiff } from "../packages/hunk/src/ui/diff/useHighlightedDiff"; const lineCount = 8_000; const theme = resolveTheme("github-dark-default", null); diff --git a/benchmarks/highlight-prefetch.ts b/benchmarks/highlight-prefetch.ts index 1ccd7946c..340e14338 100644 --- a/benchmarks/highlight-prefetch.ts +++ b/benchmarks/highlight-prefetch.ts @@ -5,9 +5,9 @@ import React from "react"; import { testRender } from "@opentui/react/test-utils"; import { parseDiffFromFile } from "@pierre/diffs"; import { act } from "react"; -import { AppHost } from "../src/ui/AppHost"; -import type { AppBootstrap } from "../src/core/bootstrap"; -import type { DiffFile } from "../src/core/changeset/model"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; +import type { AppBootstrap } from "../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; function createDiffFile(index: number, marker: string): DiffFile { const path = `src/example${index}.ts`; diff --git a/benchmarks/huge-stream.ts b/benchmarks/huge-stream.ts index c4c006ac2..1d96bc196 100644 --- a/benchmarks/huge-stream.ts +++ b/benchmarks/huge-stream.ts @@ -7,7 +7,7 @@ import { performance } from "node:perf_hooks"; import { testRender } from "@opentui/react/test-utils"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createHugeStreamBootstrap, GIANT_SINGLE_FILE_LINES, diff --git a/benchmarks/interaction-latency.ts b/benchmarks/interaction-latency.ts index d1c3ee983..6fc0ed7e8 100644 --- a/benchmarks/interaction-latency.ts +++ b/benchmarks/interaction-latency.ts @@ -4,7 +4,7 @@ import { performance } from "node:perf_hooks"; import { testRender } from "@opentui/react/test-utils"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createLargeSplitStreamBootstrap, DEFAULT_FILE_COUNT, diff --git a/benchmarks/large-stream-fixture.ts b/benchmarks/large-stream-fixture.ts index c55366de3..c6dd56af9 100644 --- a/benchmarks/large-stream-fixture.ts +++ b/benchmarks/large-stream-fixture.ts @@ -1,6 +1,6 @@ import { parseDiffFromFile, parsePatchFiles } from "@pierre/diffs"; -import type { AppBootstrap } from "../src/core/bootstrap"; -import type { DiffFile } from "../src/core/changeset/model"; +import type { AppBootstrap } from "../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; export const DEFAULT_FILE_COUNT = 180; export const DEFAULT_LINES_PER_FILE = 120; diff --git a/benchmarks/large-stream-profile.ts b/benchmarks/large-stream-profile.ts index c5d1b8d97..eeaa64fc0 100644 --- a/benchmarks/large-stream-profile.ts +++ b/benchmarks/large-stream-profile.ts @@ -1,10 +1,10 @@ // Profile large split-mode review streams by timing the main pure planning stages // before the React tree and renderer get involved. import { performance } from "perf_hooks"; -import { buildSplitRows } from "../src/ui/diff/diffRows"; -import { buildReviewRenderPlan } from "../src/ui/diff/reviewRenderPlan"; -import { measureDiffSectionGeometry } from "../src/ui/diff/diffSectionGeometry"; -import { resolveTheme } from "../src/ui/themes"; +import { buildSplitRows } from "../packages/hunk/src/ui/diff/diffRows"; +import { buildReviewRenderPlan } from "../packages/hunk/src/ui/diff/reviewRenderPlan"; +import { measureDiffSectionGeometry } from "../packages/hunk/src/ui/diff/diffSectionGeometry"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { createLargeSplitStreamFiles, DEFAULT_FILE_COUNT, diff --git a/benchmarks/large-stream.ts b/benchmarks/large-stream.ts index 9a82c730b..1c460762d 100644 --- a/benchmarks/large-stream.ts +++ b/benchmarks/large-stream.ts @@ -2,8 +2,8 @@ import { performance } from "perf_hooks"; import React from "react"; import { testRender } from "@opentui/react/test-utils"; -import { AppHost } from "../src/ui/AppHost"; -import { VIEWPORT_READ_COALESCE_MS } from "../src/ui/lib/viewportTiming"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; +import { VIEWPORT_READ_COALESCE_MS } from "../packages/hunk/src/ui/lib/viewportTiming"; import { createLargeSplitStreamBootstrap, DEFAULT_FILE_COUNT, diff --git a/benchmarks/memory.ts b/benchmarks/memory.ts index cac06e3b3..34fb51d16 100644 --- a/benchmarks/memory.ts +++ b/benchmarks/memory.ts @@ -3,10 +3,10 @@ import { performance } from "perf_hooks"; import React from "react"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { buildSplitRows } from "../src/ui/diff/diffRows"; -import { buildReviewRenderPlan } from "../src/ui/diff/reviewRenderPlan"; -import { resolveTheme } from "../src/ui/themes"; -import { AppHost } from "../src/ui/AppHost"; +import { buildSplitRows } from "../packages/hunk/src/ui/diff/diffRows"; +import { buildReviewRenderPlan } from "../packages/hunk/src/ui/diff/reviewRenderPlan"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; const viewport = { width: 240, height: 28 } as const; diff --git a/benchmarks/navigation-memory.ts b/benchmarks/navigation-memory.ts index 18dcca633..a6193feec 100644 --- a/benchmarks/navigation-memory.ts +++ b/benchmarks/navigation-memory.ts @@ -3,7 +3,7 @@ import { testRender } from "@opentui/react/test-utils"; import { performance } from "node:perf_hooks"; import React from "react"; import { act } from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; type MemorySample = { diff --git a/benchmarks/non-ascii-stream.ts b/benchmarks/non-ascii-stream.ts index 9ce73db95..9c73ed441 100644 --- a/benchmarks/non-ascii-stream.ts +++ b/benchmarks/non-ascii-stream.ts @@ -5,7 +5,7 @@ import { performance } from "node:perf_hooks"; import { testRender } from "@opentui/react/test-utils"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; import { destroyRenderer, diff --git a/benchmarks/render-layout.ts b/benchmarks/render-layout.ts index d8c974151..6d5effc07 100644 --- a/benchmarks/render-layout.ts +++ b/benchmarks/render-layout.ts @@ -1,9 +1,9 @@ // Benchmark pure diff row/layout planning across split, stack, and size-shape cases. import { performance } from "perf_hooks"; -import { buildSplitRows, buildStackRows } from "../src/ui/diff/diffRows"; -import { buildReviewRenderPlan } from "../src/ui/diff/reviewRenderPlan"; -import { measureDiffSectionGeometry } from "../src/ui/diff/diffSectionGeometry"; -import { resolveTheme } from "../src/ui/themes"; +import { buildSplitRows, buildStackRows } from "../packages/hunk/src/ui/diff/diffRows"; +import { buildReviewRenderPlan } from "../packages/hunk/src/ui/diff/reviewRenderPlan"; +import { measureDiffSectionGeometry } from "../packages/hunk/src/ui/diff/diffSectionGeometry"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { createLargeSplitStreamFiles } from "./large-stream-fixture"; const theme = resolveTheme("midnight", null); diff --git a/benchmarks/resize-memory.ts b/benchmarks/resize-memory.ts index ca3b59e74..8e3840c19 100644 --- a/benchmarks/resize-memory.ts +++ b/benchmarks/resize-memory.ts @@ -4,7 +4,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { performance } from "node:perf_hooks"; import React from "react"; -import { AppHost } from "../src/ui/AppHost"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; import { createLargeSplitStreamBootstrap } from "./large-stream-fixture"; type MemorySample = { diff --git a/benchmarks/run.ts b/benchmarks/run.ts index d62688a25..9dd4e74cd 100644 --- a/benchmarks/run.ts +++ b/benchmarks/run.ts @@ -99,7 +99,9 @@ function gitSha() { async function packageVersion() { try { - const packageJson = JSON.parse(await Bun.file("package.json").text()) as { version?: string }; + const packageJson = JSON.parse(await Bun.file("packages/hunk/package.json").text()) as { + version?: string; + }; return packageJson.version; } catch { return undefined; diff --git a/benchmarks/terminal-width.ts b/benchmarks/terminal-width.ts index 012a323d5..53a32c565 100644 --- a/benchmarks/terminal-width.ts +++ b/benchmarks/terminal-width.ts @@ -1,7 +1,7 @@ // Benchmark Hunk's scalar fast path and cached complex-cluster path against string-width. import { performance } from "node:perf_hooks"; import stringWidth from "string-width"; -import { measureTextWidth } from "../src/ui/lib/text"; +import { measureTextWidth } from "../packages/hunk/src/ui/lib/text"; const ITERATIONS = 2_000; const WARMUP_ITERATIONS = 50; diff --git a/benchmarks/worker-highlight-cache.ts b/benchmarks/worker-highlight-cache.ts index 2e344423c..70a7d692b 100644 --- a/benchmarks/worker-highlight-cache.ts +++ b/benchmarks/worker-highlight-cache.ts @@ -4,8 +4,8 @@ import { parseDiffFromFile } from "@pierre/diffs"; import { disposeHighlightWorker, highlightDiffInWorker, -} from "../src/ui/diff/worker/highlightWorkerClient"; -import { compactHighlightedDiffByteLength } from "../src/ui/diff/worker/highlightCompact"; +} from "../packages/hunk/src/ui/diff/worker/highlightWorkerClient"; +import { compactHighlightedDiffByteLength } from "../packages/hunk/src/ui/diff/worker/highlightCompact"; const lineCount = 8_000; const additions = Array.from( diff --git a/benchmarks/working-tree-load.ts b/benchmarks/working-tree-load.ts index d8156cdef..285990174 100644 --- a/benchmarks/working-tree-load.ts +++ b/benchmarks/working-tree-load.ts @@ -1,7 +1,7 @@ // Benchmark git-backed working-tree loading, including untracked file handling. import { performance } from "perf_hooks"; -import { getBundledVcsCatalog } from "../src/app/vcsCatalog"; -import { loadAppBootstrap } from "../src/core/changeset/loaders"; +import { getBundledVcsCatalog } from "../packages/hunk/src/app/vcsCatalog"; +import { loadAppBootstrap } from "../packages/hunk/src/core/changeset/loaders"; import { addUntrackedFiles, createChangedRepo } from "./lib/fixtures"; interface Scenario { diff --git a/benchmarks/wrapped-cjk.ts b/benchmarks/wrapped-cjk.ts index 760e441f5..efe32beb7 100644 --- a/benchmarks/wrapped-cjk.ts +++ b/benchmarks/wrapped-cjk.ts @@ -4,12 +4,12 @@ import { performance } from "node:perf_hooks"; import { parsePatchFiles } from "@pierre/diffs"; import { testRender } from "@opentui/react/test-utils"; import React, { act } from "react"; -import type { AppBootstrap } from "../src/core/bootstrap"; -import type { DiffFile } from "../src/core/changeset/model"; -import { AppHost } from "../src/ui/AppHost"; -import { prefetchHighlightedDiff } from "../src/ui/diff/useHighlightedDiff"; -import { VIEWPORT_READ_COALESCE_MS } from "../src/ui/lib/viewportTiming"; -import { resolveTheme } from "../src/ui/themes"; +import type { AppBootstrap } from "../packages/hunk/src/core/bootstrap"; +import type { DiffFile } from "../packages/hunk/src/core/changeset/model"; +import { AppHost } from "../packages/hunk/src/ui/AppHost"; +import { prefetchHighlightedDiff } from "../packages/hunk/src/ui/diff/useHighlightedDiff"; +import { VIEWPORT_READ_COALESCE_MS } from "../packages/hunk/src/ui/lib/viewportTiming"; +import { resolveTheme } from "../packages/hunk/src/ui/themes"; import { destroyRenderer, renderPass, diff --git a/bun.lock b/bun.lock index 8293d3e78..a22e14aa2 100644 --- a/bun.lock +++ b/bun.lock @@ -4,16 +4,6 @@ "workspaces": { "": { "name": "hunk", - "dependencies": { - "bun": "^1.4.2", - "chokidar": "^4.0.3", - "commander": "^14.0.3", - "diff": "^8.0.3", - "get-east-asian-width": "^1.5.0", - "shell-quote": "1.9.0", - "string-width": "^8.2.1", - "zod": "~4.4.3", - }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", "@hunk/session-broker": "workspace:*", @@ -27,7 +17,9 @@ "@types/bun": "1.4.1", "@types/react": "^19.2.14", "@types/ws": "^8.18.1", + "bun": "^1.4.2", "dependency-cruiser": "18.2.0", + "diff": "^8.0.3", "knip": "^6.32.0", "lint-staged": "^16.4.0", "marked": "17.0.1", @@ -35,9 +27,38 @@ "oxlint": "^1.56.0", "react": "^19.2.4", "simple-git-hooks": "^2.14.0", + "string-width": "^8.2.1", "tuistory": "^0.11.0", "typescript": "^5.9.3", }, + }, + "packages/hunk": { + "name": "hunkdiff", + "version": "0.21.1", + "bin": { + "hunk": "./bin/hunk.cjs", + "hunkdiff": "./bin/hunk.cjs", + }, + "dependencies": { + "bun": "^1.4.2", + "chokidar": "^4.0.3", + "commander": "^14.0.3", + "diff": "^8.0.3", + "get-east-asian-width": "^1.5.0", + "shell-quote": "1.9.0", + "string-width": "^8.2.1", + "zod": "~4.4.3", + }, + "devDependencies": { + "@opentui/core": "^0.5.6", + "@opentui/react": "^0.5.6", + "@pierre/diffs": "1.3.5", + "@shikijs/themes": "3.23.0", + "@types/bun": "1.4.1", + "@types/react": "^19.2.14", + "react": "^19.2.4", + "typescript": "^5.9.3", + }, "peerDependencies": { "@opentui/core": "^0.5.6", "@opentui/react": "^0.5.6", @@ -487,6 +508,8 @@ "human-signals": ["human-signals@8.0.1", "", {}, "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="], + "hunkdiff": ["hunkdiff@workspace:packages/hunk"], + "ignore": ["ignore@7.0.6", "", {}, "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="], "ini": ["ini@4.1.1", "", {}, "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g=="], diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index 5020d45bc..318abc45b 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -8,7 +8,7 @@ Use Hunk with agents in two ways: ## Recommended workflow: steer a live Hunk window 1. Open Hunk in one terminal with a normal review command such as `hunk diff` or `hunk show`. -2. Load the Hunk review skill: [`skills/hunk-review/SKILL.md`](../skills/hunk-review/SKILL.md). +2. Load the Hunk review skill: [`packages/hunk/skills/hunk-review/SKILL.md`](../packages/hunk/skills/hunk-review/SKILL.md). 3. Ask the agent to use the skill and review the current session. A good generic prompt is: diff --git a/docs/browser-review-rebuild.md b/docs/browser-review-rebuild.md index 7d8d9ecce..d493893f6 100644 --- a/docs/browser-review-rebuild.md +++ b/docs/browser-review-rebuild.md @@ -15,20 +15,20 @@ Phase 6 carries the `minor` changeset announcing the feature. ## Phase 0 — seam contract and guardrails (this doc) -- Boundary gates for `src/core/review/` (the shared review model), `src/session/reviewProtocol.ts` - (the wire schema), and `src/web/` (the browser client). The gates tolerate absent trees, so +- Boundary gates for `packages/hunk/src/core/review/` (the shared review model), `packages/hunk/src/session/reviewProtocol.ts` + (the wire schema), and `packages/hunk/src/web/` (the browser client). The gates tolerate absent trees, so they land ahead of the code they constrain. - A shrink-only debt map for the Node-only primitives the prototype's model files still carry; each entry must be repaid with a platform-neutral implementation before a browser bundle may import that file. - The existing architecture boundaries stay at full strength. The prototype relocated bundled - VCS providers into `src/core/vcs/` and weakened this suite to compensate; that relocation must + VCS providers into `packages/hunk/src/core/vcs/` and weakened this suite to compensate; that relocation must not ride along with any rebuild phase — extraction PRs land against the restored gates. ## Phase 1 — review model + terminal adoption (three PRs) 1. **Review store**: `state / actions / reducer / store / intents / selectors` in - `src/core/review/`, with `useReviewController` / `App` / `AppHost` refactored onto it in the + `packages/hunk/src/core/review/`, with `useReviewController` / `App` / `AppHost` refactored onto it in the same PR. Behavior-neutral; existing PTY integration tests must pass untouched. 2. **Review document projection + diff geometry**: `document / identity / sourceIdentity / anchors / contentManifest / notes / expansion / reconcile / jsonStream` plus the geometry @@ -49,7 +49,7 @@ existing PTY suite passing untouched. ## Phase 2 — producer runtime -`src/app/reviewSessionRuntime.ts`: generations, snapshot serving, resource materialization, +`packages/hunk/src/app/reviewSessionRuntime.ts`: generations, snapshot serving, resource materialization, serving the existing `hunk session` surface only. Resource read failures map to distinct error codes (integrity failures are never collapsed into `unknown-resource`). @@ -176,7 +176,7 @@ browser mirrors the terminal theme. ## Commands and keyboard shortcuts in the browser -The terminal command system (`src/ui/lib/appCommands.ts`) fuses three separable things per +The terminal command system (`packages/hunk/src/ui/lib/appCommands.ts`) fuses three separable things per command: identity (id, title, chords), binding (terminal `KeyEvent` matching), and effect (closures over live App state). Making commands work in the browser means splitting them, not transporting them: diff --git a/docs/browser-review-seam-audit.md b/docs/browser-review-seam-audit.md index 485e4eb1d..555cd0188 100644 --- a/docs/browser-review-seam-audit.md +++ b/docs/browser-review-seam-audit.md @@ -50,8 +50,8 @@ whether a client needs more than that is Phase 5's first question. ## A. Diff geometry - **A1. Collapsed-gap line ranges — 3 implementations, terminal off by one.** Terminal - `src/ui/diff/pierre.ts` (`leadingCollapsedRanges`/`trailingCollapsedRanges`, ~:456-480) vs - core `src/core/review/expansion.ts` (`reviewGapAddress`); web already consumes core's gap + `packages/hunk/src/ui/diff/pierre.ts` (`leadingCollapsedRanges`/`trailingCollapsedRanges`, ~:456-480) vs + core `packages/hunk/src/core/review/expansion.ts` (`reviewGapAddress`); web already consumes core's gap objects. For a pure-insertion hunk the terminal's leading range is off by one against core's, and the trailing math diverges for zero-count sides; a note created on a terminal-expanded line can be rejected on reload because `intents.ts` validates against `reviewGapAddress`. @@ -63,7 +63,7 @@ whether a client needs more than that is Phase 5's first question. anchor side has zero rows, so `pure-deletion-hunk` pins lines 1–5 instead of preserving the former one-line undercount. A2's separate trailing-gap residual remains. - **A2. Trailing-context existence — 3 formulations.** `pierre.ts` `trailingCollapsedLines`, - producer `src/app/session/registration.ts` (~:131-139, boolean `hasTrailingContext`), core + producer `packages/hunk/src/app/session/registration.ts` (~:131-139, boolean `hasTrailingContext`), core `expansion.ts`. The browser can offer a "Trailing context" button whose expansion core then rejects (`gap-not-found`). Fix: one `reviewTrailingGap(file)` in core. _Repaid (Phase 1 PR 2)_: `reviewTrailingGap` in `core/review/expansion.ts`; @@ -137,7 +137,7 @@ whether a client needs more than that is Phase 5's first question. - **A11. Language registration side effect missing in browser.** `core/changeset/fileLanguage.ts` registers `.mts`/`.cts`; the web bundle never imports it, so Pierre's own inference runs unregistered for files without an explicit `language`. Fix: side-effect import in - `src/web/main.tsx` (or fold registration into the shared model). + `packages/hunk/src/web/main.tsx` (or fold registration into the shared model). Renderer-specific, do not unify: terminal row construction and measured-cell windowing (`rowWindowing.ts`, `diffSectionGeometry.ts`) vs browser IntersectionObserver windowing and @@ -251,7 +251,7 @@ duplication); hunk header text (browser delegates to Pierre separators); platfor decides whether the claim holds. The wire carries it as `expandedLineProof` on the two actions that can name a line (`notes/start-draft`'s target, and `notes/create-user`'s precondition on the draft it is saving), refusing evidence that accompanies no line, and - `src/app/session/reviewCommands.ts` checks it before planning. Where the resulting note hangs + `packages/hunk/src/app/session/reviewCommands.ts` checks it before planning. Where the resulting note hangs is deliberately _not_ decided there: it goes through `reviewLineAnchor`'s fallback owner exactly as a terminal note does, which `reviewCommands.test.ts` pins by asserting an empty intersection set and the declared owner. Fixtures `start-draft-on-an-expanded-line`, @@ -320,7 +320,7 @@ path suffixes, expansion retention, git-status badges). `test/review-conformance/orderingFixtures.ts` and cover both the classification and the transitions a real producer emits. Broker and browser sites close in Phases 3 and 5. _Repaid (Phase 3, broker site)_: the daemon's `ReviewMirror` - (`src/session/broker/reviewMirror.ts`) holds one publication per session and orders every + (`packages/hunk/src/session/broker/reviewMirror.ts`) holds one publication per session and orders every arriving one with a single `classifyReviewPublication` call — `accepted` advances the revision, `gap` replaces the generation and retires everything derived from the old one, `stale` is ignored. It has no comparison of its own, so the prototype's "accept equal @@ -328,7 +328,7 @@ path suffixes, expansion retention, git-status badges). new position is a replay, exactly as the contract says. The one non-ordering rule it does apply is stated as such — a later generation is adoptable only together with the catalog describing it, because a mirror holding a position whose resources it cannot name would - advertise reads nobody can serve. `src/app/session/reviewCommands.ts` makes the same one + advertise reads nobody can serve. `packages/hunk/src/app/session/reviewCommands.ts` makes the same one call for an action's `expectedStateRevision`, so "has the review moved past what this caller decided from" is the same question as "is this publication ahead". The mirror is registered against the Phase 2 fixtures as the `broker review mirror` ordering consumer, @@ -342,7 +342,7 @@ path suffixes, expansion retention, git-status badges). reservations, and concurrency tuning stay at the edges. _Repaid (Phase 2, producer site)_: `core/review/resources.ts` owns resource addressing, the chunk bound both ends validate against, and the failure vocabulary; - `src/app/review/resourceStore.ts` produces and serves the bytes. Single flight is + `packages/hunk/src/app/review/resourceStore.ts` produces and serves the bytes. Single flight is structural rather than a cache bolted on — a read reaches the underlying reader only through the in-flight map — and bulk loads run under an explicit concurrency limit instead of an unbounded `Promise.all`, which is the pair of defects the original review found. @@ -354,7 +354,7 @@ path suffixes, expansion retention, git-status badges). each chunk to start where the last ended, treats a chunk that neither advances nor ends as a failure, accepts a zero-length resource as one empty end-of-stream chunk, and verifies the assembled bytes against the digest with a `ReviewDigestFn` the caller injects. The - broker's load loop (`src/session/broker/state.ts`) does nothing but ask for the next window + broker's load loop (`packages/hunk/src/session/broker/state.ts`) does nothing but ask for the next window and decode it; there is one loop, where the prototype had two near-verbatim copies inside this file that already disagreed about progress and end-of-stream. Bounding is `reviewResourceCache.ts`: an LRU with a daemon-wide byte budget plus a reservation taken @@ -362,7 +362,7 @@ path suffixes, expansion retention, git-status badges). resource reserves one chunk and is resized to what the writer declares rather than reserving its kind's ceiling, which is what let a handful of ordinary patches serialize the parallel loads. Single flight is one map keyed by session, generation, and resource id; - concurrent callers await the same assembly. `src/session/broker/reviewResources.integration.test.ts` + concurrent callers await the same assembly. `packages/hunk/src/session/broker/reviewResources.integration.test.ts` drives the whole path with only the socket replaced. The browser's `apiClient` range loop closes in Phase 5. - **C3. Epoch/supersede/trailing-retry — 2 parallel machines.** Runtime reload queue @@ -373,9 +373,9 @@ path suffixes, expansion retention, git-status badges). begin/end envelopes, and the event-id grammar are built in `browserReviewServer.ts` and re-declared/regex-parsed in `mirror.ts`/`apiClient.ts`; client bounds (12 MiB / 1024 chunks) are unlinked from server bounds and only coincidentally compatible. Fix: - `src/session/reviewEventProtocol.ts` owning names, envelopes, id grammar, and bounds derived + `packages/hunk/src/session/reviewEventProtocol.ts` owning names, envelopes, id grammar, and bounds derived from `MAX_BROWSER_REVIEW_SNAPSHOT_BYTES`. - _Repaid (Phase 4, server side)_: `src/session/reviewEventProtocol.ts` owns the event + _Repaid (Phase 4, server side)_: `packages/hunk/src/session/reviewEventProtocol.ts` owns the event vocabulary, the frame names and their phases, the begin/chunk/end envelopes and their parsers, the event-id grammar, and every bound — `MAX_REVIEW_EVENT_PAYLOAD_BYTES` is the protocol's envelope bound, `REVIEW_EVENT_CHUNK_BYTES` is the shared resource chunk size, @@ -413,7 +413,7 @@ path suffixes, expansion retention, git-status badges). `test/review-conformance/noteSize.ts` pin the boundary the two prototype rules disagreed at, including a note whose summary, rationale, and markup each fit while the note itself is three times the bound. Wire and composer sites adopt it in Phases 3 and 5. - _Repaid (Phase 3, wire site)_: `isTransportableReviewNote` in `src/session/reviewProtocol.ts` + _Repaid (Phase 3, wire site)_: `isTransportableReviewNote` in `packages/hunk/src/session/reviewProtocol.ts` is `reviewNoteWithinSizeLimit` and nothing else — the wire has no per-field check any more, and declares no second bound. The protocol module is registered as a consumer of the note-size corpus, so `every-field-fits-but-the-note-does-not` — the note whose summary, rationale, and @@ -437,7 +437,7 @@ path suffixes, expansion retention, git-status badges). the prototype's failure impossible rather than merely fixed: its broker copy re-derived intersections, omitted the fallback branch, and rejected a legal expanded-gap note — and with it the whole registration. The case is pinned from the wire end in - `src/app/session/reviewCommands.test.ts`: a note created remotely on an expanded-gap line + `packages/hunk/src/app/session/reviewCommands.test.ts`: a note created remotely on an expanded-gap line ends up with an empty intersection set and the fallback owner the caller declared, which is exactly the shape the dropped branch produced. Web `pierreNoteAnchor` closes in Phase 5. - **D4. Canonical-file ↔ manifest consistency — 3 checks, 3 field lists.** Producer @@ -471,7 +471,7 @@ path suffixes, expansion retention, git-status badges). variant is what let a writer and a reader disagree — with `normalizeReviewDigest` for values arriving from outside and `reviewDigestsEqual` normalizing _both_ operands. Hashing itself is an injected `ReviewDigestFn` rather than inline `createHash` calls; the producer supplies - Node's at the edge (`src/core/reviewDigest.ts`), which is also what repaid the shared model's + Node's at the edge (`packages/hunk/src/core/reviewDigest.ts`), which is also what repaid the shared model's last node-debt entry. Resource bounds are constants in `core/review/resources.ts` that the producer imports rather than restates. Wire constants, the action-envelope parser, and the two note-filter namings are Phase 3. @@ -497,7 +497,7 @@ path suffixes, expansion retention, git-status badges). - **E1. File stat badges.** Terminal `ui/lib/files.ts` `formatSidebarStat` (zero-hiding, truncation marker) vs web inline `+${additions} −${deletions}` in `treeSource.ts`. One shared formatter. -- **E2. Theme.** Web hardcodes two standalone palettes disconnected from `src/ui/themes` and +- **E2. Theme.** Web hardcodes two standalone palettes disconnected from `packages/hunk/src/ui/themes` and the `AppTheme` mapping; whether the browser mirrors the terminal theme is an open product decision — decide before Phase 5, don't unify by default. @@ -508,14 +508,14 @@ like A–E; they are the copies the browser _would_ grow the moment shortcuts ar here so the extraction happens before the duplication exists. Design detail in `browser-review-rebuild.md` § "Commands and keyboard shortcuts in the browser". -- **F1. Command catalog fused with terminal binding and effects.** `src/ui/lib/appCommands.ts` +- **F1. Command catalog fused with terminal binding and effects.** `packages/hunk/src/ui/lib/appCommands.ts` couples identity (id, title, chords), binding (terminal `KeyEvent` matchers), and effect (closures over live App state) in one table; menus (`ui/lib/appMenus.ts`) and the help dialog render from it, so a browser palette or help screen would have to restate the list. Fix: extract a renderer-neutral catalog (id, title, category, default chords, resolution locus — semantic / client-local / host-only); terminal keeps matchers and handlers, browser adds its own, both render menus/help/palette from the catalog. - _Repaid (Phase 1 PR 3)_: `src/core/run/commandCatalog.ts` carries id, title, category, default + _Repaid (Phase 1 PR 3)_: `packages/hunk/src/core/run/commandCatalog.ts` carries id, title, category, default chords, resolution locus, extension visibility, and menu-closing behavior for all 44 built-ins. `ui/lib/appCommands.ts` builds its dispatch table from it — the handler map is keyed by `AppCommandId`, so a catalogued command with no terminal handler fails to typecheck — and @@ -624,13 +624,13 @@ implementation does. `no-dead-modules` rule now flags exactly that shape. The grammar above still stands as the design; write it again beside the first consumer that addresses a review across a boundary. - **G4. User-facing error catalog.** The repo already solves this once for agents: - `src/session/agent/errors.ts` single-sources every message the generated skill quotes, with + `packages/hunk/src/session/agent/errors.ts` single-sources every message the generated skill quotes, with contract tests. The browser has no equivalent — action rejections (`invalid-action`, `stale-generation`, resource integrity failures) would surface as ad-hoc strings invented in - `src/web`, drifting from what the terminal shows for the same failure. One error-code → + `packages/hunk/src/web`, drifting from what the terminal shows for the same failure. One error-code → user-message catalog beside the wire protocol, consumed by both clients (and reused by the agent surface where codes overlap). Phase 4 (codes stabilize) / Phase 5 (browser consumes). - _Repaid (Phase 4, catalog creation)_: `src/session/reviewErrorCatalog.ts` gives every code a + _Repaid (Phase 4, catalog creation)_: `packages/hunk/src/session/reviewErrorCatalog.ts` gives every code a statement and a remedy, in the agent surface's own pattern. Totality is mechanical rather than reviewed: the catalog is a `Record` over `HunkReviewClientErrorCodeV1`, itself _composed_ — resource plus request plus intent-planning plus the transport's own codes — diff --git a/docs/changelog-on-hunk-dev.md b/docs/changelog-on-hunk-dev.md index 73f663f92..4b186eeb4 100644 --- a/docs/changelog-on-hunk-dev.md +++ b/docs/changelog-on-hunk-dev.md @@ -134,6 +134,6 @@ none), and patch chips appear only when a series has more than one release. launch-video pipeline keeps generated media out of Git. - **Contributor lists.** The GitHub release bodies name first-time contributors, which is community goodwill and organic links. `CHANGELOG.md` does not carry authors, so this needs a second input. -- **The in-app update notice.** `src/core/process/updateNotice.ts` tells users a new version exists without +- **The in-app update notice.** `packages/hunk/src/core/process/updateNotice.ts` tells users a new version exists without linking what changed. Appending `hunk.dev/changelog/` is the highest-intent entry point available and is tracked separately. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index ccf422cd3..f4f633929 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -9,13 +9,13 @@ exists so you know which module owns what. ## Tiers and loading Extensions come in two tiers running through the same per-extension API -object and registry collection (`src/extensions/runExtension.ts`): +object and registry collection (`packages/hunk/src/extensions/runExtension.ts`): - **User extensions** load at interactive-app startup, before - `loadAppBootstrap` (`src/extensions/startup.ts`, `src/extensions/host.ts`). - Discovery groups and trust gating: `src/extensions/discovery.ts`, - `src/extensions/trust.ts`. -- **Bundled extensions** live in `src/extensions/default/` and are compiled + `loadAppBootstrap` (`packages/hunk/src/extensions/startup.ts`, `packages/hunk/src/extensions/host.ts`). + Discovery groups and trust gating: `packages/hunk/src/extensions/discovery.ts`, + `packages/hunk/src/extensions/trust.ts`. +- **Bundled extensions** live in `packages/hunk/src/extensions/default/` and are compiled into the binary. `default/vcs/{git,jujutsu,sapling}` is statically imported by the app composition root (`app/vcsCatalog.ts`) and loaded synchronously before config resolution, so backends exist without making core import the @@ -39,15 +39,15 @@ reserved ids (`hunk`, plus the base catalog's bundled backend ids), ids outside `/^[A-Za-z0-9][A-Za-z0-9_-]*$/` (a dot or colon would make the composed ids unsplittable), and the later of two sources claiming one id; each refusal is a load issue and costs only that extension. The rules themselves are stated in -`src/extensions/extensionIds.ts`. +`packages/hunk/src/extensions/extensionIds.ts`. ## One registry, one apply path Registrations (session behavior, themes, file languages, VCS adapters, changeset transforms, panes, interactive commands, top-level CLI commands, lifecycle/UI events, and inter-extension bus listeners) collect into one -`ExtensionRegistry` (`src/extensions/types.ts`) and are resolved/applied -through `src/extensions/apply.ts` on both startup and reload. File-language registrations stay as +`ExtensionRegistry` (`packages/hunk/src/extensions/types.ts`) and are resolved/applied +through `packages/hunk/src/extensions/apply.ts` on both startup and reload. File-language registrations stay as declarative extension, filename, or glob selectors until `fileLanguageLookup.ts` resolves them; Hunk then pins that answer into Pierre's metadata so rendering cannot re-derive a conflicting language. A live reload replaces the compiled selector generation while preparing its changeset @@ -80,7 +80,7 @@ the handler releases I/O. ## Host-served runtime modules Extension files import `react`, `@opentui/*`, and `hunkdiff/extension` as -host-served runtime modules (`src/extensions/hostRuntimeModules.ts`): a +host-served runtime modules (`packages/hunk/src/extensions/hostRuntimeModules.ts`): a per-extension-directory Bun loader hook transpiles extension source and rewrites those specifiers to prefixed virtual modules backed by the host's own instances. That identity is what lets `registerPane` components @@ -91,14 +91,14 @@ commands never pay OpenTUI's native-library extraction). ## Four-edge pane system -`src/ui/lib/extensionPanes.ts` owns open state, availability, and one rectangle +`packages/hunk/src/ui/lib/extensionPanes.ts` owns open state, availability, and one rectangle plan for panes, dividers, and review bounds. Left/right panes consume columns; top/bottom panes consume rows from the central review column, outside review stream coordinates. Pane registrations may opt into a body-axis `fraction`; the planner resolves it to an integer target before applying bounds and lets a session-local divider drag override that automatic size. -`src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions, +`packages/hunk/src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions, immutable delegated review metadata, and failure containment. The fixed three-row `hunk:review-info` top pane uses one border row above two metadata rows and is available only for delegated change requests, so ordinary reviews spend no geometry on it. `DiffPane` exposes optional current-line paint — the row @@ -109,22 +109,22 @@ normalize into this same registry and layout path. ## File-view system File-view registrations are selected per file but remain inside the one -host-owned review stream. `src/ui/fileViews/useFileViews.ts` bounds asynchronous +host-owned review stream. `packages/hunk/src/ui/fileViews/useFileViews.ts` bounds asynchronous extension work and retains only immutable layouts accepted by -`src/ui/fileViews/layout.ts`; width and registration identity are part of that +`packages/hunk/src/ui/fileViews/layout.ts`; width and registration identity are part of that accepted geometry. A stateful view has no such identity to change, so `ctx.fileViews.refresh` bumps an invalidation epoch owned by -`src/ui/fileViews/useFilePresentationController.ts` and modeled in -`src/ui/fileViews/state.ts`. That epoch participates in the same retention key, +`packages/hunk/src/ui/fileViews/useFilePresentationController.ts` and modeled in +`packages/hunk/src/ui/fileViews/state.ts`. That epoch participates in the same retention key, re-preparing the files presenting that view while their current rows stay visible. One map counts both view-wide and per-file invalidation, and `fileViewLayoutEpoch` is the single place that composes them into the epoch a -`(file, view)` preparation is retained under. `src/ui/fileViews/renderPlan.ts` is the shared insertion +`(file, view)` preparation is retained under. `packages/hunk/src/ui/fileViews/renderPlan.ts` is the shared insertion plan for validated extension rows and host-owned inline notes. It resolves only unambiguous exact-source bindings and returns an explicit unresolved set, so `DiffPane` falls the complete file back to Pierre rather than guessing or -silently dropping review data. `src/ui/fileViews/geometry.ts` measures that same -plan, and `src/ui/components/panes/FileView.tsx` windows and paints it. Extension +silently dropping review data. `packages/hunk/src/ui/fileViews/geometry.ts` measures that same +plan, and `packages/hunk/src/ui/components/panes/FileView.tsx` windows and paints it. Extension components can paint only their fixed validated rectangles; note cards, scrolling, hunk bounds, and navigation remain host-owned. @@ -132,46 +132,46 @@ scrolling, hunk bounds, and navigation remain host-owned. Line highlighters mark character ranges inside Hunk's own diff rendering, so the system is deliberately split between a pull-based preparation half and a -paint-only application half. `src/ui/highlights/useLineHighlights.ts` bounds +paint-only application half. `packages/hunk/src/ui/highlights/useLineHighlights.ts` bounds asynchronous extension work with the same timeout/concurrency discipline as file views and retains only marks accepted by -`src/ui/highlights/validate.ts`; results cache under `(file, highlighter, +`packages/hunk/src/ui/highlights/validate.ts`; results cache under `(file, highlighter, epoch)`, and each file's merged mark array keeps a stable identity while its inputs are unchanged so row memoization can hold. The epoch is owned by -`src/ui/highlights/useLineHighlightsController.ts` behind +`packages/hunk/src/ui/highlights/useLineHighlightsController.ts` behind `ctx.highlights.refresh`, using the shared scoped-epoch policy in -`src/ui/lib/scopedEpochs.ts` — the same module `src/ui/fileViews/state.ts` +`packages/hunk/src/ui/lib/scopedEpochs.ts` — the same module `packages/hunk/src/ui/fileViews/state.ts` delegates to — and the shared bounded `readDocument` capability lives in -`src/ui/lib/extensionDocumentReader.ts`. +`packages/hunk/src/ui/lib/extensionDocumentReader.ts`. -Application is paint-time by construction. `src/ui/diff/lineHighlightPaint.ts` +Application is paint-time by construction. `packages/hunk/src/ui/diff/lineHighlightPaint.ts` owns the one mapping from source coordinates (raw code-unit offsets) to terminal columns — sanitize-aware, tab-aware, snapped outward to grapheme clusters, with context and gap lines sharing one range list under both side keys — and the one span transform that repaints backgrounds without changing -text. `src/ui/diff/rowStyle.ts` resolves tones against the actual line +text. `packages/hunk/src/ui/diff/rowStyle.ts` resolves tones against the actual line background with the word-diff minimum-contrast guarantee. -`src/ui/diff/CodeRowView.tsx` applies the transform through the cell painter, +`packages/hunk/src/ui/diff/CodeRowView.tsx` applies the transform through the cell painter, which keeps highlights out of `buildDiffSectionRowPlan`, its caches, and every geometry measurement: a highlight change is a repaint, never a re-plan. -`src/ui/diff/DiffRowView.tsx` remains only the memoized dispatch facade; raw-row +`packages/hunk/src/ui/diff/DiffRowView.tsx` remains only the memoized dispatch facade; raw-row adaptation there supports the public OpenTUI and extension current-line surfaces, while -`src/ui/diff/cursorHighlight.ts` owns stable-key cursor matching. The static pager never +`packages/hunk/src/ui/diff/cursorHighlight.ts` owns stable-key cursor matching. The static pager never runs extension code, so highlights are interactive-only. Agent attention marks (`hunk session highlight add` / `clear`) join this same pipeline rather than growing a second one: `useTerminalReview.ts` validates each daemon-pushed mark with the same `validate.ts` contract and caps, holds -them per file, and `src/ui/highlights/merge.ts` appends them after extension +them per file, and `packages/hunk/src/ui/highlights/merge.ts` appends them after extension marks in the one map `DiffPane` paints from — so agent marks share paint, contrast, and geometry guarantees, and win where ranges overlap. Unlike extension marks, nothing re-derives agent marks after a reload, so -`src/ui/highlights/reconcile.ts` carries them across a document replacement only +`packages/hunk/src/ui/highlights/reconcile.ts` carries them across a document replacement only for files whose `contentIdentity` is unchanged — those still show the same characters — and drops the rest. Line-target `session navigate` reuses the same `revealLine` landing policy `ctx.navigation.revealLine` gets. -`src/ui/fileViews/mode.ts` owns file-view mode activation, validity, and callback +`packages/hunk/src/ui/fileViews/mode.ts` owns file-view mode activation, validity, and callback containment. The presentation controller stores the active mode and funnels all exit paths through one teardown, including re-entrant handoffs. @@ -181,17 +181,17 @@ keyboard modes and app commands. `"handled"` and `"exit"` consume the key; Session-wide modes registered through `registerKeyboardMode` are resolved with the same extension ownership and first-registration rules as other surfaces. -`src/ui/keyboardModes/useKeyboardModeController.ts` owns the one active session +`packages/hunk/src/ui/keyboardModes/useKeyboardModeController.ts` owns the one active session mode, with eager ref state for input chunks, registry-generation authority, contained synchronous lifecycle callbacks, and one teardown used by Escape, status, menu, reload, and unmount. Mode controls are activation-scoped; `onEnter` and `onExit` cannot change ownership, while `onKey` may deliberately replace its activation without letting the outgoing callback defeat recovery or manipulate the replacement. -`src/ui/lib/extensionKeyEvent.ts` freezes the method-free public key snapshot +`packages/hunk/src/ui/lib/extensionKeyEvent.ts` freezes the method-free public key snapshot used by both session and file-view mode delivery, so OpenTUI events and their consumption methods never cross the extension boundary. Their shared -`src/ui/lib/synchronousExtensionCallback.ts` path contains lifecycle failures, +`packages/hunk/src/ui/lib/synchronousExtensionCallback.ts` path contains lifecycle failures, rejects thenables without leaving unhandled rejections, and normalizes key results; each mode module supplies only its context and attributed warnings. A focused file-view mode may overlap and temporarily outrank a session mode; @@ -200,20 +200,20 @@ leaving it resumes the session mode rather than destroying unrelated state. ## Command system Every app-level keyboard shortcut is a named command in one dispatch table -(`src/ui/lib/appCommands.ts`), each id under Hunk's reserved vendor namespace +(`packages/hunk/src/ui/lib/appCommands.ts`), each id under Hunk's reserved vendor namespace (`hunk.app.quit`, `hunk.review.nextHunk`) — which is what keeps built-in ids and extension-owned ids in disjoint spaces however either grows; modal surfaces (dialogs, menus, focused inputs) own their keys first and are deliberately not commands. Extension `registerCommand` entries join the same table via -`src/ui/lib/extensionCommands.ts` — built-ins win key conflicts, refused one +`packages/hunk/src/ui/lib/extensionCommands.ts` — built-ins win key conflicts, refused one chord at a time and detected by probing matchers with a synthesized event -(`src/lib/commandKeys.ts`). Command handlers receive pane controls and a selection snapshot from -`src/ui/lib/extensionSelection.ts`, derived from the same frozen file views the +(`packages/hunk/src/lib/commandKeys.ts`). Command handlers receive pane controls and a selection snapshot from +`packages/hunk/src/ui/lib/extensionSelection.ts`, derived from the same frozen file views the panes render plus a copied source address for the active current-line cursor. App reads it through a ref so the dispatch table stays stable while line navigation moves. `ctx.review.snapshot()` takes the complementary whole-review -path: `src/extensions/reviewSnapshot.ts` copies the active shared ReviewStore's +path: `packages/hunk/src/extensions/reviewSnapshot.ts` copies the active shared ReviewStore's document identities and complete saved-note collections, preserving core-owned anchors and reconciliation verdicts. App pairs that state with the producer's current generation under the same review capability lease, so retained controls @@ -221,7 +221,7 @@ return `null` after reload instead of reading replacement content. The extension projection is registered in `test/review-conformance/` as a real semantic consumer rather than rebuilding note placement in the command host. -`src/ui/lib/extensionNavigation.ts` mints the guarded navigation behind both +`packages/hunk/src/ui/lib/extensionNavigation.ts` mints the guarded navigation behind both `ctx.navigation` and a pane's `actions`, so a jump from either surface is validated, attributed, and reported the same way. It owns argument policy only — visible-file validation, hunk clamping, `revealLine`'s side and line-number @@ -243,12 +243,12 @@ modal keys also remain outside the table and therefore outside the event. `ctx.dialogs` is the one place extension code can interrupt the user, so its ordering and settlement live outside React in -`src/ui/lib/extensionDialogs.ts` — one FIFO queue per App instance, minting a +`packages/hunk/src/ui/lib/extensionDialogs.ts` — one FIFO queue per App instance, minting a per-extension `dialogs` object, normalizing (and sanitizing) extension-authored text into a request the host draws, and answering by request id so a duplicated Enter cannot spill onto whatever was queued behind. App subscribes with `useSyncExternalStore`, renders the current request through -`src/ui/components/chrome/ExtensionDialog.tsx` (confirm reuses `ConfirmDialog`; +`packages/hunk/src/ui/components/chrome/ExtensionDialog.tsx` (confirm reuses `ConfirmDialog`; select and input are `ModalFrame` surfaces), and unmount calls `shutdown()` so every pending and queued dialog resolves its cancel value instead of leaving a handler awaiting forever. Key precedence in `useAppKeyboardShortcuts` places @@ -260,7 +260,7 @@ frame carries an `ext ` attribution row — the toast marker — for every user-installed extension, because its title is extension-authored and a prompt must not be able to impersonate Hunk. The host derives the extension's trusted bundled origin from registry metadata and omits the redundant marker only for -Hunk-owned bundled UI. `src/ui/lib/modalGeometry.ts` clamps the frame before +Hunk-owned bundled UI. `packages/hunk/src/ui/lib/modalGeometry.ts` clamps the frame before extension text is wrapped or windowed, so measurement and rendering use the same terminal width; body/options yield rows to a pinned mouse-clickable action footer on short terminals. @@ -274,7 +274,7 @@ per-extension event-context provider, while `AppHost` publishes mounted lifecycle order (`startup`, then `changeset_loaded`; reloads add `session_reload`) only after the matching child commit. Headless or pre-mount delivery resolves dialogs to their cancel values and refuses navigation with a warning. -`src/ui/lib/extensionCapabilityLease.ts` binds retained pane, navigation, +`packages/hunk/src/ui/lib/extensionCapabilityLease.ts` binds retained pane, navigation, dialog, review-reload, and workspace controls to one App, extension registry, and review generation. Soft reload or registry retirement therefore makes old host-mediated capabilities @@ -284,7 +284,7 @@ inert before shutdown begins. Session behavior requests are registry data too: presentation view changes ephemeral without teaching `App` about an extension id. -`src/ui/lib/extensionWorkspace.ts` owns the policy for `ctx.workspace`. Reads +`packages/hunk/src/ui/lib/extensionWorkspace.ts` owns the policy for `ctx.workspace`. Reads resolve reviewed file ids through the existing source fetcher, which retains ownership of caching and size limits. Missing or unreadable sources become `null`. @@ -295,27 +295,27 @@ through refs so soft reloads update the policy inputs. The host verifies the filesystem target before and after consent, writes it, then calls `refreshCurrentInput`. Consent uses the existing extension-dialog queue. -Commands declare chords, not matchers: `src/ui/lib/keymap.ts` folds every +Commands declare chords, not matchers: `packages/hunk/src/ui/lib/keymap.ts` folds every command's `defaultKeys` against the user's `[keybindings]` table (user config layer only) into one id-to-chords answer, from which matchers, key labels, and conflict probes are all derived — a user-bound chord is exclusive, so whatever held it by default gives it up. The chord grammar itself lives in -`src/extension-api/keys.ts` because it is published as `hunkdiff/extension` +`packages/hunk/src/extension-api/keys.ts` because it is published as `hunkdiff/extension` (`matchesKey`, `parseKeyChord`, `matchesKeyChord`) for extension components -that need internal keys; `src/lib/commandKeys.ts` re-exports it inward and +that need internal keys; `packages/hunk/src/lib/commandKeys.ts` re-exports it inward and keeps the host-only pieces. The table is also the only description of what each action is called and which key runs it, so the mouse surfaces read from it rather than restating it: the -dropdown menus (`src/ui/lib/appMenus.ts`) declare items as command ids plus +dropdown menus (`packages/hunk/src/ui/lib/appMenus.ts`) declare items as command ids plus menu-specific wording and checkbox state, and the controls help dialog -(`src/ui/lib/helpContent.ts`) declares curated rows the same way — both render +(`packages/hunk/src/ui/lib/helpContent.ts`) declares curated rows the same way — both render their key text from resolved `keyLabels` and run entries through `executeAppCommand`. A few commands ship with `defaultKeys: []` because they exist for a menu item; they never match a key but remain bindable by id. Command handlers receive guarded `ctx.commands` controls built by -`src/ui/lib/extensionCommandControls.ts`. They resolve the live App command table on every call, +`packages/hunk/src/ui/lib/extensionCommandControls.ts`. They resolve the live App command table on every call, then expose only built-ins carrying explicit public metadata. Counted movement reaches the same command callback once with a normalized delta; it is never implemented as repeated synchronous dispatch. Current-line alignment is also semantic: App raises an alignment request and `DiffPane` @@ -325,23 +325,23 @@ Extension commands remain private to prevent recursion and cross-extension execu The **Extensions** menu is generated from the registered extension commands, one item per command grouped by extension, and is absent entirely when there are none — which is why the visible menu list is derived from the menus record -(`buildMenuSpecs` in `src/ui/components/chrome/menu.ts`) rather than fixed. +(`buildMenuSpecs` in `packages/hunk/src/ui/components/chrome/menu.ts`) rather than fixed. ## VCS adapters -`src/core/vcs/index.ts` owns provider-neutral catalog ordering, lookup, -detection, and operation dispatch. `src/app/vcsCatalog.ts` composes bundled -registrations, while `src/app/sessionBootstrap.ts` extends that catalog with +`packages/hunk/src/core/vcs/index.ts` owns provider-neutral catalog ordering, lookup, +detection, and operation dispatch. `packages/hunk/src/app/vcsCatalog.ts` composes bundled +registrations, while `packages/hunk/src/app/sessionBootstrap.ts` extends that catalog with accepted user adapters and threads the same value through loading, reload, and watch. Detection is uniform across tiers: nearest checkout wins, priority breaks equal-distance ties, and an explicit `vcs` id owned by the catalog wins. Provider implementations — command construction, spawning, error translation, and exact-source reading — live entirely under -`src/extensions/default/vcs//`. `src/extensions/vcsPatchResult.ts` is +`packages/hunk/src/extensions/default/vcs//`. `packages/hunk/src/extensions/vcsPatchResult.ts` is the one conversion boundary where a published `ExtensionVcsPatchResult` becomes Hunk's internal diff model, including structural `too-large` source -results. `src/core/process/projectRoot.ts` treats `.hunk` as a provider-independent +results. `packages/hunk/src/core/process/projectRoot.ts` treats `.hunk` as a provider-independent bootstrap marker and also consults the available catalog; startup performs a second root/config pass when a global, config-path, or CLI adapter recognizes a repository unavailable to the bundled catalog. @@ -358,7 +358,7 @@ ordering before core or UI consumes it. ## Public contract rules The authoring surface is the `hunkdiff/extension` export — a façade over -internal types, declared in `src/extension-api/types.ts`. That module must +internal types, declared in `packages/hunk/src/extension-api/types.ts`. That module must stay import-free: declaration emission ships every module the entry reaches, so an import there publishes Hunk internals (`scripts/check-pack.ts` fails the pack when it does, and typechecks every `docs/extensions.md` example as diff --git a/docs/extension-system-exploration.md b/docs/extension-system-exploration.md index baa994f74..f5f7d9019 100644 --- a/docs/extension-system-exploration.md +++ b/docs/extension-system-exploration.md @@ -106,21 +106,21 @@ seams in very different states of readiness: **Nearly plugin-shaped already:** -- `vcsAdapters` (`src/core/vcs/index.ts`) — a real adapter pattern: detection, +- `vcsAdapters` (`packages/hunk/src/core/vcs/index.ts`) — a real adapter pattern: detection, per-operation handler maps, capability probing. Making the array appendable is almost the whole job for third-party VCS support. - The session broker (`packages/session-broker*`) is generic over session info/state/message types by design, and the app installs its command dispatcher at runtime via `createHunkSessionBridge(handlers)` + - `hostClient.setBridge(...)` (`src/app/session/bridge.ts`). That bridge is + `hostClient.setBridge(...)` (`packages/hunk/src/app/session/bridge.ts`). That bridge is the single clearest injection point for extension-provided session commands. - Dynamic theme registration exists (`ensureSyntaxHighlightThemeRegistered` → Pierre's `registerCustomTheme`), but the config layer caps custom themes at one `"custom"` slot. -- Pierre's `setCustomExtension` (`src/core/changeset/fileLanguage.ts`) is already used at +- Pierre's `setCustomExtension` (`packages/hunk/src/core/changeset/fileLanguage.ts`) is already used at import time for `.mts`/`.cts`; extending it to plugin-declared mappings is trivial. -- `StartupDeps` (`src/app/startup.ts`) fully injects the startup pipeline +- `StartupDeps` (`packages/hunk/src/app/startup.ts`) fully injects the startup pipeline (currently only tests use it), and unknown TOML keys are silently ignored, so an `[extensions]` config section is backward-compatible on day one. @@ -174,7 +174,7 @@ export default function (hunk: HunkExtensionAPI) { ```` - Discovery: `~/.config/hunk/extensions/*.ts` and `*/index.ts` (global, follows - our existing XDG path logic in `src/core/run/paths.ts`), `.hunk/extensions/` + our existing XDG path logic in `packages/hunk/src/core/run/paths.ts`), `.hunk/extensions/` (repo-local, **trust-gated**, same posture as pi's project trust), explicit `[extensions] paths = [...]` in `config.toml`, and a `--extension ` dev flag. `--no-extensions` for a clean run and for bug triage. diff --git a/docs/extensions.md b/docs/extensions.md index 60af74dc1..4bc4875f1 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -193,7 +193,7 @@ run without installing anything. Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension, and so is the **built-in file-navigation pane**. They live in -`src/extensions/default/`, are compiled into the binary, and register through +`packages/hunk/src/extensions/default/`, are compiled into the binary, and register through the same `hunk.registerVcsAdapter` and `hunk.registerPane` this guide documents. There is no private registration path. @@ -412,7 +412,7 @@ removes the patch on extension shutdown. Run it from this checkout with: ```bash -bun run src/main.tsx --extension ./examples/extensions/github-pr gh 123 +bun run packages/hunk/src/main.tsx --extension ./examples/extensions/github-pr gh 123 ``` ### `hunk.configureSession(options)` @@ -1053,7 +1053,7 @@ whatever version Hunk pins — a wider surface than `hunkdiff/extension` itself. The built-in files pane uses the same calls, so changes that break this contract break Hunk first. Keep scroll handling small and behind your own helpers. -Its implementation lives in `src/extensions/default/ui/sidebar/` and serves as +Its implementation lives in `packages/hunk/src/extensions/default/ui/sidebar/` and serves as the reference for third-party panes. #### Pane state from events diff --git a/docs/module-boundaries.md b/docs/module-boundaries.md index 68c4c9547..95df201bc 100644 --- a/docs/module-boundaries.md +++ b/docs/module-boundaries.md @@ -3,7 +3,7 @@ Defines the target import boundaries between Hunk's top-level source trees and records what the dependency graph actually looks like today. The boundaries are enforced by [dependency-cruiser](https://github.com/sverweij/dependency-cruiser) over the production import -graph (`src/` plus `packages/`, tests excluded): +graph (`packages/hunk/src/` plus `packages/`, tests excluded): - `bun run deps:check` — fails CI on any boundary violation not in the baseline. - `bun run deps:baseline` — regenerates `.dependency-cruiser-known-violations.json` after fixing @@ -20,28 +20,28 @@ tier-level complement, with real module resolution instead of regex import scann Tiers, bottom to top. A tier may import anything strictly below it and nothing above it: ```text -src/extension-api published contract; imports nothing -src/lib dependency-free helpers; may import extension-api only -src/core domain model (changesets, review, vcs catalog, config) +packages/hunk/src/extension-api published contract; imports nothing +packages/hunk/src/lib dependency-free helpers; may import extension-api only +packages/hunk/src/core domain model (changesets, review, vcs catalog, config) packages/* standalone publishable units (session broker, term-video); - never import src/; the per-app broker contract is in + never import packages/hunk/src/; the per-app broker contract is in docs/session-broker-sdk.md -src/extensions extension host + bundled extensions; consume core, never surfaces -src/session daemon/broker transport + protocol; consumes core and packages -src/app startup composition: CLI parsing plus the wiring of core, +packages/hunk/src/extensions extension host + bundled extensions; consume core, never surfaces +packages/hunk/src/session daemon/broker transport + protocol; consumes core and packages +packages/hunk/src/app startup composition: CLI parsing plus the wiring of core, extensions, and the session broker; no rendering -src/ui terminal surface; only the composition shell (App, AppHost, +packages/hunk/src/ui terminal surface; only the composition shell (App, AppHost, runInteractiveApp), the named session adapter hooks (useTerminalReview, useHunkSessionBridge), and their shared navigation helper (ui/lib/reviewState) may import app/session -src/opentui published facade re-exporting ui/core pieces for `hunkdiff/opentui` -src/main.tsx CLI entry +packages/hunk/src/opentui published facade re-exporting ui/core pieces for `hunkdiff/opentui` +packages/hunk/src/main.tsx CLI entry ``` Intentional exceptions, allowed by the rules: -- `src/opentui` imports `src/ui` internals: it is a packaging facade whose job is re-export. -- `src/hunk-review` imports `src/session/agent`: the skill document is generated from the agent +- `packages/hunk/src/opentui` imports `packages/hunk/src/ui` internals: it is a packaging facade whose job is re-export. +- `packages/hunk/src/hunk-review` imports `packages/hunk/src/session/agent`: the skill document is generated from the agent surface by design. - Tests are excluded: they are colocated and free to reach across boundaries. @@ -54,7 +54,7 @@ accidental reach-in fails `bun run deps:check` instead of quietly becoming API. Two supporting rules keep the interiors honest: -- **`no-dead-modules`** flags any module under `src/` that no entry point reaches +- **`no-dead-modules`** flags any module under `packages/hunk/src/` that no entry point reaches (`main.tsx`, `highlightWorkerEntry.ts`, the `opentui` and `extension-api` facades, and the skill generator). It uses `reachable: false` rather than `orphan`, which only catches fully disconnected files and so misses dead code that still imports. A hit is deleted, or — when @@ -69,7 +69,7 @@ Phase 0 (2026-08-17) established the mechanism: it deleted `core/review/address. speculative primitive with no consumers), added the two rules above, and froze the first interior — `core/review/reducer.ts` is importable only from within `core/review/`, because callers state intent and `planReviewIntent` owns the transition. Later phases extend the same -pattern across `src/core` as its subdirectories take shape; the review model's named modules +pattern across `packages/hunk/src/core` as its subdirectories take shape; the review model's named modules (`document`, `identity`, `geometry`, `state`, …) stay public by design. Phase 1 (2026-08-17) grouped the changeset model and its acquisition pipeline — twelve loose @@ -133,7 +133,7 @@ model: `terminal`, `jobControl`, `shutdown`, and `updateNotice` serve the intera `pager` serves the CLI entry and the startup plan; `projectRoot` and `appStateFile` serve the extension host and config resolution. -After this phase `src/core/` root holds only `types.ts`, `reviewDigest.ts`, and `liveComments.ts` +After this phase `packages/hunk/src/core/` root holds only `types.ts`, `reviewDigest.ts`, and `liveComments.ts` beside the seven subdirectories. Phase 4 melts what is left of `core/types.ts`. Phase 4 (2026-08-17) melted that shell. `core/types.ts` had stopped declaring most of what it @@ -156,7 +156,7 @@ module that owns their behaviour, one home each: is the line a user note hangs on, and every consumer reaches it through note code. Deleting the re-exports made one hidden dependency visible: `core/review/annotations.ts` names -`AgentAnnotation`, which is declared in `src/extension-api/types.ts` because it is +`AgentAnnotation`, which is declared in `packages/hunk/src/extension-api/types.ts` because it is simultaneously an internal model type and part of the published contract. Routing that through `core/types.ts` had disguised it as a core-local import, and `scripts/source-boundaries.test.ts` ("keeps the review model contained in core") caught it the moment the disguise came off. The @@ -171,9 +171,9 @@ changeset and command-input models they now name. `core/bootstrap.ts` imports do edge from every module directory. One exception is carved out and named in the rule: `core/changeset/loaders.ts` returns an `AppBootstrap` from `loadAppBootstrap`, so it names the shape it assembles; that function is composition living in the domain tier, and moving it to -`src/app` retires the exception. +`packages/hunk/src/app` retires the exception. -`src/core/` root now holds `bootstrap.ts`, `reviewDigest.ts`, and `liveComments.ts` beside the +`packages/hunk/src/core/` root now holds `bootstrap.ts`, `reviewDigest.ts`, and `liveComments.ts` beside the eight module directories. Phase 5 (2026-08-18) grouped **how this binary was installed and how it gets replaced** into @@ -206,29 +206,29 @@ rules: moved beside the client class it aliases; `CopySelectedRowRange` moved into `ui/lib/diffSpatial.ts`; `extensions/notifications.ts` now imports `ExtensionNotifyType` from its declaring module. -- **`src/core/cli.ts` → `src/app/cli.ts`.** CLI parsing that registers every tier's command +- **`packages/hunk/src/core/cli.ts` → `packages/hunk/src/app/cli.ts`.** CLI parsing that registers every tier's command surface (including `hunk session *` from `session/agent/surface.ts`) is composition, not domain — moving it made the core→session edges legal app→session edges. -- **`src/session/app/` → `src/app/session/`.** The mounted-review registration, bridge, and +- **`packages/hunk/src/session/app/` → `packages/hunk/src/app/session/`.** The mounted-review registration, bridge, and reload-authorization modules compose the app process with the session broker, and nothing - inside `src/session` imported them — they were app-tier code homed on the wrong side. + inside `packages/hunk/src/session` imported them — they were app-tier code homed on the wrong side. Moving the directory removed every session→app edge at once. -- **`src/lib/reviewDigest.ts` → `src/core/reviewDigest.ts`.** The Node digest implementation +- **`packages/hunk/src/lib/reviewDigest.ts` → `packages/hunk/src/core/reviewDigest.ts`.** The Node digest implementation is review-semantic and platform-bound; core root (Node-full, outside the platform-free `core/review/` seam) is its tier. - **`ui/lib/reviewState.ts`** resolves session-daemon navigation for the adapter hooks and is now a named entry in the adapter allowlist rather than an accidental reach-in. -- **The bundled sidebar's `src/ui` imports are documented design, not debt.** Its module +- **The bundled sidebar's `packages/hunk/src/ui` imports are documented design, not debt.** Its module header defines the dogfooding boundary as the published props contract (data, actions, theme); rendering helpers are host code. The rules now encode exactly that: - `src/extensions/default/ui/` may consume `src/ui`, and still may never touch - `src/app`/`src/session`. + `packages/hunk/src/extensions/default/ui/` may consume `packages/hunk/src/ui`, and still may never touch + `packages/hunk/src/app`/`packages/hunk/src/session`. ## After the baseline: next targets The tier rules now hold with no exceptions. Two follow-ups are worth doing next: -1. **Give `src/core` an interior.** _Done (phases 0–4, see Module interiors)._ Every group is a +1. **Give `packages/hunk/src/core` an interior.** _Done (phases 0–4, see Module interiors)._ Every group is a module directory — `review/`, `vcs/`, `theme/`, `watch/`, `patch/`, `changeset/`, `run/`, `process/` — and `core/*` root is down to `bootstrap.ts`, `reviewDigest.ts`, and `liveComments.ts`, with no grab-bag left to import. What remains is per-file public @@ -236,7 +236,7 @@ The tier rules now hold with no exceptions. Two follow-ups are worth doing next: `changeset-internals-stay-in-module` and `review` has `review-reducer-is-module-internal`, while `run`, `process`, `theme`, `vcs`, `watch`, and `patch` are still public in full because every file in them has an outside importer today. Two named follow-ups: move - `loadAppBootstrap` out of `core/changeset/loaders.ts` into `src/app` (it is composition, and + `loadAppBootstrap` out of `core/changeset/loaders.ts` into `packages/hunk/src/app` (it is composition, and it is the one exception `core-leaves-stay-below-bootstrap` has to carve out), and split `core/run/config.ts`, whose readers reach it for three unrelated reasons — the resolved `HunkConfigResolution`, the persisted view preferences, and the extension/keybinding diff --git a/docs/opentui-component.md b/docs/opentui-component.md index 9ea0c52f7..5cc67b5ae 100644 --- a/docs/opentui-component.md +++ b/docs/opentui-component.md @@ -231,4 +231,4 @@ If you need direct access to Pierre's parser, `parsePatchFiles(...)` is still re - Runnable demo overview: [`examples/README.md`](../examples/README.md) - Component demos: [`examples/7-opentui-component/README.md`](../examples/7-opentui-component/README.md) -The in-repo demos import from `../../src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui`. +The in-repo demos import from `../../packages/hunk/src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui`. diff --git a/docs/session-broker-sdk.md b/docs/session-broker-sdk.md index 14d273013..443a57423 100644 --- a/docs/session-broker-sdk.md +++ b/docs/session-broker-sdk.md @@ -40,7 +40,7 @@ all associated UI semantics. Publish one package, `@hunk/session-broker`, with protocol/state, daemon/connection, managed-host, and Node/Bun adapter boundaries kept as internal modules. Its only first-release entry point exports the shared primitives, broker APIs, supervision, managed producer API, and an automatically selected -`serveSessionBrokerDaemon`. Hunk composes the package; the package never imports `src/*`. +`serveSessionBrokerDaemon`. Hunk composes the package; the package never imports `packages/hunk/src/*`. Publication requires verified npm `@hunk` scope ownership and trusted publishing. An approved replacement name must not alter `appId`, wire identity, or runtime namespaces. diff --git a/docs/source-architecture.md b/docs/source-architecture.md index 20cbc37ea..2c85c9627 100644 --- a/docs/source-architecture.md +++ b/docs/source-architecture.md @@ -7,33 +7,33 @@ Use it when adding a new module or deciding where an existing responsibility bel ## Ownership ```text -src/app/ executable composition: CLI parsing, startup plans, and shared session bootstrap -src/app/session/ mounted-review registration, bridge, and reload authorization -src/core/ review model, patch handling, VCS contracts, configuration, and +packages/hunk/src/app/ executable composition: CLI parsing, startup plans, and shared session bootstrap +packages/hunk/src/app/session/ mounted-review registration, bridge, and reload authorization +packages/hunk/src/core/ review model, patch handling, VCS contracts, configuration, and runtime primitives -src/core/changeset/ the changeset model and the pipeline that acquires one: loaders, +packages/hunk/src/core/changeset/ the changeset model and the pipeline that acquires one: loaders, per-file construction, sidecar/source reads, and hunk formatting -src/core/run/ how a run is asked for: command inputs, layered configuration, the +packages/hunk/src/core/run/ how a run is asked for: command inputs, layered configuration, the command catalog, user-facing errors, paths, and version -src/core/process/ the process and terminal a run lives in: TTY capabilities, the pager, +packages/hunk/src/core/process/ the process and terminal a run lives in: TTY capabilities, the pager, job control, shutdown, project-root discovery, persisted app state, and startup/update notices -src/core/theme/ bundled theme metadata, custom-theme rules, and terminal theme detection -src/core/watch/ input signatures, observation plans/backends, and refresh coordination -src/core/vcs/ provider-neutral VCS catalog, contracts, operation dispatch, and host support -src/extensions/ extension host, registry, trust, lifecycle, and bundled extensions -src/session/ shared session protocol, schemas, types, agent surface, and broker transport -src/session/client/ shared session-daemon HTTP and compatibility client support -src/session/agent/ agent-facing session CLI, command manifest, errors, and formatting -src/session/broker/ local daemon transport, launcher, Hunk broker state, wire parsing, projections -src/ui/ interactive review application, rendering, interaction, and chrome -src/extension-api/ public `hunkdiff/extension` declaration and runtime boundary -src/opentui/ public `hunkdiff/opentui` component boundary -src/lib/ small product-wide utilities with no feature ownership +packages/hunk/src/core/theme/ bundled theme metadata, custom-theme rules, and terminal theme detection +packages/hunk/src/core/watch/ input signatures, observation plans/backends, and refresh coordination +packages/hunk/src/core/vcs/ provider-neutral VCS catalog, contracts, operation dispatch, and host support +packages/hunk/src/extensions/ extension host, registry, trust, lifecycle, and bundled extensions +packages/hunk/src/session/ shared session protocol, schemas, types, agent surface, and broker transport +packages/hunk/src/session/client/ shared session-daemon HTTP and compatibility client support +packages/hunk/src/session/agent/ agent-facing session CLI, command manifest, errors, and formatting +packages/hunk/src/session/broker/ local daemon transport, launcher, Hunk broker state, wire parsing, projections +packages/hunk/src/ui/ interactive review application, rendering, interaction, and chrome +packages/hunk/src/extension-api/ public `hunkdiff/extension` declaration and runtime boundary +packages/hunk/src/opentui/ public `hunkdiff/opentui` component boundary +packages/hunk/src/lib/ small product-wide utilities with no feature ownership ``` -`src/app/` is intentionally small: it composes subsystems but does not become a second -application framework. `src/core/` remains the shared product layer, not a synonym for +`packages/hunk/src/app/` is intentionally small: it composes subsystems but does not become a second +application framework. `packages/hunk/src/core/` remains the shared product layer, not a synonym for "anything outside React". Put a module in a more specific existing subdirectory whenever one owns its behaviour. @@ -42,10 +42,10 @@ one owns its behaviour. - `app` may compose `core`, `extensions`, `session`, and `ui`. - `ui` may consume core models and the extension/session contracts; it owns terminal rendering. - `extensions` may consume provider-neutral core models and contracts, but bundled VCS provider - implementations must depend only on `hunkdiff/extension`, local modules, and `src/lib` utilities. + implementations must depend only on `hunkdiff/extension`, local modules, and `packages/hunk/src/lib` utilities. Renderer access remains limited to `extensions/default/ui/`, the bundled-sidebar boundary. - `core` must not import `ui` or `extensions`. Shared data needed by both belongs in core-owned - structural contracts or `src/lib`, never in a reverse dependency. + structural contracts or `packages/hunk/src/lib`, never in a reverse dependency. - `extension-api/types.ts` stays import-free. It is a published declaration boundary, enforced by the package checks. - `opentui` and `extension-api` are public entrypoint directories, not general internal buckets. diff --git a/docs/watch-benchmark-final.md b/docs/watch-benchmark-final.md index 629a2a9b9..23ff86b12 100644 --- a/docs/watch-benchmark-final.md +++ b/docs/watch-benchmark-final.md @@ -259,7 +259,7 @@ Fourteen commits implement the production change: - `c152100`, `0659a30`, `d36c33f`, `fd00dd6` add resource fallback, Git ignored-root pruning, native recursive selection, and a bounded readiness deadline - `f6165bd`, `4f35a0e` stabilize cross-platform and sidecar watch coverage -Production implementation/seams are in `src/core/watch/signature.ts`, `plan.ts`, `controller.ts`, `observer.ts`, `src/core/vcs/*`, loader/type plumbing, `src/ui/hooks/useWatchedInput.ts`, and the App/AppHost integration. Matching unit, filesystem, AppHost, helper, and PTY coverage lives beside those files and in `test/helpers/watchTest.ts` and `test/pty/watch.test.ts`. CI and PR CI add a compiled-binary watch PTY check. README documents continuous observation with fallback. +Production implementation/seams are in `packages/hunk/src/core/watch/signature.ts`, `plan.ts`, `controller.ts`, `observer.ts`, `packages/hunk/src/core/vcs/*`, loader/type plumbing, `packages/hunk/src/ui/hooks/useWatchedInput.ts`, and the App/AppHost integration. Matching unit, filesystem, AppHost, helper, and PTY coverage lives beside those files and in `test/helpers/watchTest.ts` and `test/pty/watch.test.ts`. CI and PR CI add a compiled-binary watch PTY check. README documents continuous observation with fallback. The only new runtime dependency is `chokidar@^4.0.3` (and transitive `readdirp@4.1.2`), recorded in `package.json`, `bun.lock`, and `nix/bun.lock.nix`. The two user-visible patch changesets are `.changeset/calm-files-watch.md` and `.changeset/native-recursive-watch.md`. Existing benchmark fixture imports received small type/data updates, but no benchmark runner is a production runtime dependency. @@ -275,7 +275,7 @@ Complete harness file inventory: - **Terminal, ConPTY, sampling, and Git instrumentation:** `terminal.ts`, `sampler.ts`, `git-log.ts`, `observer-probe.ts`, `artifacts.ts`, with `terminal.test.ts`, `sampler.test.ts`, `git-log.test.ts`, and `observer-probe.test.ts`. - **Freeze, provenance, transfer, build, and preflight:** `freeze.ts`, `stage.ts`, `build-host.ts`, `host-identity.ts`, `host-preflight.ts`, `prepare-preflight.ts`, with `stage.test.ts`, `build-host.test.ts`, `host-identity.test.ts`, `host-preflight.test.ts`, and shared `test-helpers.ts`. - **Package/build integration:** seven `bench:watch*` scripts in `package.json`; dev-only `@xterm/headless@5.5.0` and `ghostty-opentui@1.4.10` plus `bun.lock`; `scripts/build-bin.ts` reuses `process.execPath` to prevent ambient-Bun builds. -- **Benchmark-only production seams:** `src/core/watch/observer.ts` and `.test.ts` accept an injected `auto|native|chokidar` backend for forced probes; production remains `auto`. +- **Benchmark-only production seams:** `packages/hunk/src/core/watch/observer.ts` and `.test.ts` accept an injected `auto|native|chokidar` backend for forced probes; production remains `auto`. - **Workflow:** `.github/workflows/benchmarks.yml` contains the Windows adapter. - **Maintenance changesets:** `.changeset/watch-benchmark-harness.md` and `.changeset/witty-schools-sit.md` are empty/non-release changesets. diff --git a/examples/7-opentui-component/README.md b/examples/7-opentui-component/README.md index 2ab36d2ec..48a87ba89 100644 --- a/examples/7-opentui-component/README.md +++ b/examples/7-opentui-component/README.md @@ -19,4 +19,4 @@ bun run examples/7-opentui-component/from-patch.tsx - switching between split and stacked layouts with example shell controls - a scrollable terminal diff component that other OpenTUI apps can reuse -The in-repo demos import from `../../src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui` instead. +The in-repo demos import from `../../packages/hunk/src/opentui` so they run from source. Published consumers should import from `hunkdiff/opentui` instead. diff --git a/examples/7-opentui-component/from-files.tsx b/examples/7-opentui-component/from-files.tsx index 98e2fb63b..d6321097f 100644 --- a/examples/7-opentui-component/from-files.tsx +++ b/examples/7-opentui-component/from-files.tsx @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { parseDiffFromFile } from "../../src/opentui"; +import { parseDiffFromFile } from "../../packages/hunk/src/opentui"; import { readExampleFile, runExample } from "./support"; const path = "src/reviewSummary.ts"; diff --git a/examples/7-opentui-component/from-patch.tsx b/examples/7-opentui-component/from-patch.tsx index 56445c9bf..9fab70ef7 100644 --- a/examples/7-opentui-component/from-patch.tsx +++ b/examples/7-opentui-component/from-patch.tsx @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import { parsePatchFiles } from "../../src/opentui"; +import { parsePatchFiles } from "../../packages/hunk/src/opentui"; import { readExampleFile, runExample } from "./support"; const patch = readExampleFile("change.patch"); diff --git a/examples/7-opentui-component/support.tsx b/examples/7-opentui-component/support.tsx index c01946dab..c1fe2c5e7 100644 --- a/examples/7-opentui-component/support.tsx +++ b/examples/7-opentui-component/support.tsx @@ -3,9 +3,9 @@ import path from "node:path"; import { createCliRenderer } from "@opentui/core"; import { createRoot, useTerminalDimensions } from "@opentui/react"; import { useState } from "react"; -import type { HunkDiffFile, HunkDiffLayout } from "../../src/opentui"; -import { HunkDiffView } from "../../src/opentui"; -import { fitText } from "../../src/ui/lib/text"; +import type { HunkDiffFile, HunkDiffLayout } from "../../packages/hunk/src/opentui"; +import { HunkDiffView } from "../../packages/hunk/src/opentui"; +import { fitText } from "../../packages/hunk/src/ui/lib/text"; interface ExampleProps { title: string; diff --git a/examples/8-opentui-primitives/README.md b/examples/8-opentui-primitives/README.md index 61374dae3..5a443929c 100644 --- a/examples/8-opentui-primitives/README.md +++ b/examples/8-opentui-primitives/README.md @@ -19,4 +19,4 @@ bun run examples/8-opentui-primitives/primitives-demo.tsx - Host-owned window borders/chrome around each primitive so you can inspect component boundaries - Host-owned state for selected file and split/stack layout -The in-repo demo imports from `../../src/opentui` so it runs from source. Published consumers should import from `hunkdiff/opentui` instead. +The in-repo demo imports from `../../packages/hunk/src/opentui` so it runs from source. Published consumers should import from `hunkdiff/opentui` instead. diff --git a/examples/8-opentui-primitives/primitives-demo.tsx b/examples/8-opentui-primitives/primitives-demo.tsx index 6098d4c32..2f193825d 100644 --- a/examples/8-opentui-primitives/primitives-demo.tsx +++ b/examples/8-opentui-primitives/primitives-demo.tsx @@ -10,8 +10,8 @@ import { HunkReviewStream, createHunkDiffFilesFromPatch, type HunkDiffLayout, -} from "../../src/opentui"; -import { fitText, padText } from "../../src/ui/lib/text"; +} from "../../packages/hunk/src/opentui"; +import { fitText, padText } from "../../packages/hunk/src/ui/lib/text"; const PATCH = `diff --git a/src/search.ts b/src/search.ts --- a/src/search.ts diff --git a/examples/extensions/cli-tools/README.md b/examples/extensions/cli-tools/README.md index af29dc753..8dab9cd7b 100644 --- a/examples/extensions/cli-tools/README.md +++ b/examples/extensions/cli-tools/README.md @@ -5,8 +5,8 @@ Demonstrates a generic top-level command tree. The handler owns every token belo Run it directly from this checkout: ```bash -bun run src/main.tsx --extension ./examples/extensions/cli-tools cli-tools status -bun run src/main.tsx --extension ./examples/extensions/cli-tools cli-tools review +bun run packages/hunk/src/main.tsx --extension ./examples/extensions/cli-tools cli-tools status +bun run packages/hunk/src/main.tsx --extension ./examples/extensions/cli-tools cli-tools review ``` `status` writes to stdout and exits. `review` performs signal-aware asynchronous preprocessing, writes progress to stderr, then delegates to `hunk diff`. A delegating handler must not write stdout or read stdin because the built-in command or TUI takes ownership of both. diff --git a/examples/extensions/github-pr/README.md b/examples/extensions/github-pr/README.md index 94d477fe1..642e585f1 100644 --- a/examples/extensions/github-pr/README.md +++ b/examples/extensions/github-pr/README.md @@ -13,7 +13,7 @@ The extension fetches the PR metadata and diff directly from GitHub's API, write Place `--extension` before the extension-owned command: ```bash -bun run src/main.tsx --extension ./examples/extensions/github-pr gh 123 +bun run packages/hunk/src/main.tsx --extension ./examples/extensions/github-pr gh 123 ``` A bare number infers `owner/repo` from the current checkout's GitHub `origin`. Explicit forms work outside a checkout and do not invoke Git: diff --git a/examples/extensions/inline-edit/README.md b/examples/extensions/inline-edit/README.md index 83bf95cdb..cf286fb7c 100644 --- a/examples/extensions/inline-edit/README.md +++ b/examples/extensions/inline-edit/README.md @@ -16,7 +16,7 @@ It exists to demonstrate that Hunk's interactive extension surfaces compose, so ## Try it from this checkout ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/inline-edit +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/inline-edit ``` ## Install it globally diff --git a/examples/extensions/jsx-file-view-gallery/README.md b/examples/extensions/jsx-file-view-gallery/README.md index 174298bae..eb097c91b 100644 --- a/examples/extensions/jsx-file-view-gallery/README.md +++ b/examples/extensions/jsx-file-view-gallery/README.md @@ -7,7 +7,7 @@ Three opt-in presentations exercise the constrained React/OpenTUI row contract a Nested boxes, responsive meters, semantic color, and selected-hunk styling summarize a multi-hunk TypeScript refactor. It uses no source parser and works from public hunk/change metadata alone. ```bash -bun run src/main.tsx -- diff \ +bun run packages/hunk/src/main.tsx -- diff \ --extension ./examples/extensions/jsx-file-view-gallery \ --mode stack \ examples/extensions/jsx-file-view-gallery/fixtures/change-atlas/before.ts \ @@ -19,7 +19,7 @@ bun run src/main.tsx -- diff \ The extension lazily reads both exact documents, associates changed opaque three- or six-digit hexadecimal custom properties with each real diff hunk, and paints old/new terminal color swatches inside deterministic two-row rectangles. ```bash -bun run src/main.tsx -- diff \ +bun run packages/hunk/src/main.tsx -- diff \ --extension ./examples/extensions/jsx-file-view-gallery \ --mode stack \ examples/extensions/jsx-file-view-gallery/fixtures/css-palette/before.css \ @@ -31,7 +31,7 @@ bun run src/main.tsx -- diff \ A conservative package-file parser highlights only the changed semantic-version segment: patch-only changes emphasize the patch number, minor upgrades emphasize the minor number, and major upgrades emphasize the full old/new strings. It retains positional bounds for every parsed hunk; invalid JSON or unavailable source falls back to raw diff. ```bash -bun run src/main.tsx -- diff \ +bun run packages/hunk/src/main.tsx -- diff \ --extension ./examples/extensions/jsx-file-view-gallery \ --mode stack \ examples/extensions/jsx-file-view-gallery/fixtures/package-dependencies/before/package.json \ diff --git a/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts b/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts index 86518dd1c..f8a048379 100644 --- a/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts +++ b/examples/extensions/jsx-file-view-gallery/mixed-review/run.ts @@ -73,7 +73,14 @@ try { const result = spawnSync( process.execPath, - [join(repoRoot, "src/main.tsx"), "diff", "--extension", galleryRoot, "--mode", "stack"], + [ + join(repoRoot, "packages/hunk/src/main.tsx"), + "diff", + "--extension", + galleryRoot, + "--mode", + "stack", + ], { cwd: demoRepo, stdio: "inherit", env: process.env }, ); if (result.error) throw result.error; diff --git a/examples/extensions/jsx-file-view/README.md b/examples/extensions/jsx-file-view/README.md index b90032124..82ebbdee4 100644 --- a/examples/extensions/jsx-file-view/README.md +++ b/examples/extensions/jsx-file-view/README.md @@ -5,7 +5,7 @@ An opt-in proof of concept for fixed-height React/OpenTUI rows in alternate file Run it from this checkout against a multi-hunk working-tree change: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/jsx-file-view +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/jsx-file-view ``` Choose **Extensions → Toggle JSX hunk cards (POC)**. The row component uses a React state hook and OpenTUI `box`/`text` elements. Its registered F8 command/menu item is the supported keyboard path. A cooperatively delivered, un-dragged left-button mouse-up toggles local detail and stops propagation; wheel, drag, and unhandled input remain host-owned. The row is a non-focusable paint surface, with no portal, renderer, focus, or input-delivery guarantee. Each component is a closure over the hunk summary; Hunk passes it only bounded paint props, including a live semantic theme palette that does not participate in layout. The `spans` on every row are the host-rendered fallback, clipped to the same declared fixed height if the component fails. Hook state survives selected-hunk updates while mounted, but is intentionally lost when windowing unmounts the row or a new layout generation replaces it. diff --git a/examples/extensions/pane-layout/README.md b/examples/extensions/pane-layout/README.md index f11d28be3..d559bd38b 100644 --- a/examples/extensions/pane-layout/README.md +++ b/examples/extensions/pane-layout/README.md @@ -5,7 +5,7 @@ Registers a resizable right pane and fixed two-row top and bottom panes. Run it from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/pane-layout +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/pane-layout ``` Press `ctrl+p` or use the **Extensions** menu. Drag the right divider to resize. diff --git a/examples/extensions/rendered-markdown/README.md b/examples/extensions/rendered-markdown/README.md index 8b6b9411d..53883bcb7 100644 --- a/examples/extensions/rendered-markdown/README.md +++ b/examples/extensions/rendered-markdown/README.md @@ -9,7 +9,7 @@ This example is **not bundled or loaded by Hunk**. Install it explicitly if you The repository's root install supplies the example's development dependency: ```bash -bun run src/main.tsx -- diff \ +bun run packages/hunk/src/main.tsx -- diff \ --extension ./examples/extensions/rendered-markdown \ before.md after.md ``` diff --git a/examples/extensions/review-note-navigator/README.md b/examples/extensions/review-note-navigator/README.md index 79e302cea..3e215145b 100644 --- a/examples/extensions/review-note-navigator/README.md +++ b/examples/extensions/review-note-navigator/README.md @@ -5,7 +5,7 @@ Lists every note currently saved in Hunk's shared ReviewStore, then navigates to Run it directly from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/review-note-navigator +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-note-navigator ``` Save one or more review notes, then run **Extensions → Navigate saved review note…** (`F8`). Each choice includes its reconciliation status, file, preferred line, side, and summary. diff --git a/examples/extensions/review-snapshot-export/README.md b/examples/extensions/review-snapshot-export/README.md index f831b6d75..3b792d431 100644 --- a/examples/extensions/review-snapshot-export/README.md +++ b/examples/extensions/review-snapshot-export/README.md @@ -5,7 +5,7 @@ Exports Hunk's authoritative saved review state as JSON. The example shows why ` Run it directly from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/review-snapshot-export +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-snapshot-export ``` Add one or more review notes, then run **Extensions → Export review snapshot…** (`F9`) and choose a new output path. Relative paths resolve from the review's working directory; the example refuses to overwrite an existing file. diff --git a/examples/extensions/review-snapshot-export/index.test.ts b/examples/extensions/review-snapshot-export/index.test.ts index e0e481d62..bf73c685d 100644 --- a/examples/extensions/review-snapshot-export/index.test.ts +++ b/examples/extensions/review-snapshot-export/index.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { resolve } from "node:path"; import { resolveSnapshotExportPath, snapshotPositionMatches } from "./index"; -import type { ExtensionReviewSnapshot } from "../../../src/extension-api/types"; +import type { ExtensionReviewSnapshot } from "../../../packages/hunk/src/extension-api/types"; /** Build the minimal immutable snapshot position these helper tests compare. */ function createTestSnapshot( diff --git a/examples/extensions/review-triage/README.md b/examples/extensions/review-triage/README.md index 31d4f606b..55f4b1173 100644 --- a/examples/extensions/review-triage/README.md +++ b/examples/extensions/review-triage/README.md @@ -5,7 +5,7 @@ A session-local hunk review board for Hunk. It records which hunks you have visi Run it directly from this checkout: ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/review-triage +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/review-triage ``` Or copy the directory to your Hunk extensions directory and keep its `package.json`; its manifest makes the folder a single `review-triage` extension. diff --git a/examples/extensions/vim-navigation/README.md b/examples/extensions/vim-navigation/README.md index 074c0bc4d..f67e46be3 100644 --- a/examples/extensions/vim-navigation/README.md +++ b/examples/extensions/vim-navigation/README.md @@ -7,7 +7,7 @@ This example is **not bundled or loaded by Hunk**. Install it explicitly if you ## Try it from this checkout ```bash -bun run src/main.tsx -- diff --extension ./examples/extensions/vim-navigation +bun run packages/hunk/src/main.tsx -- diff --extension ./examples/extensions/vim-navigation ``` Press `F6` or choose **Extensions → Toggle Vim navigation**. The persistent status badge shows when the mode owns review-level keys; click the badge, choose the host-owned exit menu item, or press `Esc` to leave. diff --git a/knip.json b/knip.json index ef1382aca..049c9ba80 100644 --- a/knip.json +++ b/knip.json @@ -3,29 +3,39 @@ "workspaces": { ".": { "entry": [ - "src/extension-api/index.ts", - "src/opentui/index.ts", - "src/**/*.test.{ts,tsx}", "scripts/**/*.test.ts", "test/**/*.test.{ts,tsx}", "test/cli/fixtures/compiled-opentui-positive-control.ts", + "test/cli/fixtures/compiled-highlight-worker-control.ts", + "test/cli/install-vm/validate-release-result.ts", + "test/session-broker-node/*.ts", + "test/session-broker-runtime/*.ts", "scripts/launch-video/*.{ts,mjs}", "scripts/probe-terminal-theme.ts", "scripts/test-large-untracked-render.tsx", "examples/extensions/**/index.{ts,tsx}", - "examples/extensions/**/run.ts" + "examples/extensions/**/run.ts", + "examples/**/*.test.ts" ], "project": [ - "src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx,mjs}", "test/**/*.{ts,tsx}", "benchmarks/**/*.ts", - "examples/extensions/**/*.{ts,tsx}", - "bin/**/*.cjs" + "examples/extensions/**/*.{ts,tsx}" ], "ignore": ["examples/extensions/**/fixtures/**"], "ignoreBinaries": ["nix"], - "ignoreDependencies": ["@changesets/changelog-github", "@shikijs/themes"], + "ignoreDependencies": ["@changesets/changelog-github", "@shikijs/themes", "playwright"], + "ignoreExportsUsedInFile": true + }, + "packages/hunk": { + "entry": [ + "src/highlightWorkerEntry.ts", + "src/extension-api/index.ts", + "src/opentui/index.ts", + "src/**/*.test.{ts,tsx}" + ], + "project": ["src/**/*.{ts,tsx}", "bin/**/*.cjs"], "ignoreExportsUsedInFile": true }, "packages/session-broker*": { @@ -39,7 +49,14 @@ } }, "ignoreIssues": { - "src/app/review/capability.ts": ["exports"], - "src/extension-api/types.ts": ["duplicates"] + "packages/hunk/src/app/review/capability.ts": ["exports"], + "packages/hunk/src/extension-api/types.ts": ["duplicates"], + "packages/hunk/src/core/install/latestRelease.ts": ["exports", "types"], + "packages/hunk/src/core/process/updateNotice.ts": ["exports", "types"], + "packages/hunk/src/extensions/types.ts": ["exports", "types"], + "packages/hunk/src/extensions/default/ui/sidebar/index.tsx": ["exports", "types"], + "packages/hunk/src/extensions/default/vcs/diffRange.ts": ["exports"], + "packages/hunk/src/session/broker/appContract.ts": ["exports", "types"], + "packages/hunk/src/ui/diff/worker/index.ts": ["exports", "types"] } } diff --git a/nix/bun.lock.nix b/nix/bun.lock.nix index 35fa53654..8db4df848 100644 --- a/nix/bun.lock.nix +++ b/nix/bun.lock.nix @@ -862,6 +862,7 @@ url = "https://registry.npmjs.org/human-signals/-/human-signals-8.0.1.tgz"; hash = "sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ=="; }; + "hunkdiff" = copyPathToStore ../packages/hunk; "ignore@7.0.6" = fetchurl { url = "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz"; hash = "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw=="; diff --git a/nix/package.nix b/nix/package.nix index 13925e510..7ccaa846b 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -7,7 +7,7 @@ stdenv, ... }: let - packageJson = lib.importJSON ../package.json; + packageJson = lib.importJSON ../packages/hunk/package.json; bunVersion = lib.removePrefix "bun@" packageJson.packageManager; bunCompilerArchives = { "aarch64-darwin" = fetchurl { @@ -73,7 +73,7 @@ in BUN_INSTALL=$PWD/.bun-install \ "$bun_compiler" build --compile \ --no-compile-autoload-bunfig \ - "./src/main.tsx" \ + "./packages/hunk/src/main.tsx" \ --outfile "hunk-bin" runHook postBuild ''; @@ -82,7 +82,7 @@ in runHook preInstall mkdir -p $out/bin cp -p ./hunk-bin $out/bin/hunk - cp -r ./skills $out/ + cp -r ./packages/hunk/skills $out/ wrapProgram $out/bin/hunk --set HUNK_INSTALL_SOURCE nix runHook postInstall ''; diff --git a/package.json b/package.json index 1b761ece8..310a6ab93 100644 --- a/package.json +++ b/package.json @@ -1,58 +1,13 @@ { - "name": "hunkdiff", - "version": "0.21.1", - "description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.", - "keywords": [ - "ai", - "code-review", - "diff", - "git", - "terminal", - "tui" - ], - "homepage": "https://hunk.dev", - "bugs": { - "url": "https://github.com/modem-dev/hunk/issues" - }, - "license": "MIT", - "repository": { - "type": "git", - "url": "git+https://github.com/modem-dev/hunk.git" - }, - "bin": { - "hunk": "./bin/hunk.cjs", - "hunkdiff": "./bin/hunk.cjs" - }, + "name": "@hunk/workspace", + "private": true, "workspaces": [ - ".", "packages/*" ], - "files": [ - "bin", - "dist/npm", - "skills/hunk-review", - "skills/hunk-extensions", - "README.md", - "LICENSE" - ], "type": "module", - "exports": { - "./extension": { - "types": "./dist/npm/extension/index.d.ts", - "import": "./dist/npm/extension/index.js" - }, - "./opentui": { - "types": "./dist/npm/opentui/index.d.ts", - "import": "./dist/npm/opentui/index.js" - }, - "./package.json": "./package.json" - }, - "publishConfig": { - "access": "public" - }, "scripts": { - "start": "bun run src/main.tsx", - "dev": "bun --watch src/main.tsx", + "start": "bun run packages/hunk/src/main.tsx", + "dev": "bun --watch packages/hunk/src/main.tsx", "build:npm": "bun run ./scripts/build-npm.ts", "build:bin": "bun run ./scripts/build-bin.ts", "build:prebuilt:npm": "bun run build:npm && bun run build:bin && bun run ./scripts/stage-prebuilt-npm.ts", @@ -77,14 +32,14 @@ "lint": "oxlint . --deny-warnings", "lint:fix": "oxlint . --fix", "knip": "knip", - "deps:check": "depcruise src packages --config .dependency-cruiser.cjs --ignore-known", - "deps:baseline": "depcruise src packages --config .dependency-cruiser.cjs --output-type baseline --output-to .dependency-cruiser-known-violations.json", + "deps:check": "depcruise packages --config .dependency-cruiser.cjs --ignore-known", + "deps:baseline": "depcruise packages --config .dependency-cruiser.cjs --output-type baseline --output-to .dependency-cruiser-known-violations.json", "changeset": "bunx @changesets/cli@2.31.0", "changeset:status": "bunx @changesets/cli@2.31.0 status", - "release:version": "bunx @changesets/cli@2.31.0 version", + "release:version": "bun run ./scripts/changeset-version.ts", "prepare": "simple-git-hooks", "test": "bun run ./scripts/run-test-suite.ts", - "test:theme-contrast": "bun test src/ui/themes.test.ts --test-name-pattern contrast", + "test:theme-contrast": "bun test packages/hunk/src/ui/themes.test.ts --test-name-pattern contrast", "test:integration": "\"${npm_execpath:-bun}\" test ./test/pty", "test:session-broker-node": "bun run ./scripts/test-session-broker-node.ts", "test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 \"${npm_execpath:-bun}\" test ./test/smoke", @@ -94,7 +49,6 @@ "check:prebuilt-pack": "bun run ./scripts/check-prebuilt-pack.ts", "smoke:prebuilt-install": "bun run ./scripts/smoke-prebuilt-install.ts", "publish:prebuilt:npm": "bun run ./scripts/publish-prebuilt-npm.ts", - "prepack": "bun run build:npm", "bench": "bun run benchmarks/run.ts", "bench:release": "bun run ./scripts/run-release-benchmark.ts", "bench:release:compare": "bun run ./scripts/compare-release-benchmarks.ts", @@ -121,16 +75,6 @@ "bench:competitors": "bun run benchmarks/competitors.ts", "nix:update-lock": "nix run .#update-bun-lock" }, - "dependencies": { - "bun": "^1.4.2", - "chokidar": "^4.0.3", - "commander": "^14.0.3", - "diff": "^8.0.3", - "get-east-asian-width": "^1.5.0", - "shell-quote": "1.9.0", - "string-width": "^8.2.1", - "zod": "~4.4.3" - }, "devDependencies": { "@changesets/changelog-github": "^0.7.0", "@hunk/session-broker": "workspace:*", @@ -144,7 +88,9 @@ "@types/bun": "1.4.1", "@types/react": "^19.2.14", "@types/ws": "^8.18.1", + "bun": "^1.4.2", "dependency-cruiser": "18.2.0", + "diff": "^8.0.3", "knip": "^6.32.0", "lint-staged": "^16.4.0", "marked": "17.0.1", @@ -152,20 +98,10 @@ "oxlint": "^1.56.0", "react": "^19.2.4", "simple-git-hooks": "^2.14.0", + "string-width": "^8.2.1", "tuistory": "^0.11.0", "typescript": "^5.9.3" }, - "peerDependencies": { - "@opentui/core": "^0.5.6", - "@opentui/react": "^0.5.6", - "@pierre/diffs": "1.3.5", - "react": "^19.2.4" - }, - "peerDependenciesMeta": { - "@pierre/diffs": { - "optional": true - } - }, "overrides": { "shell-quote": "1.9.0" }, @@ -178,7 +114,8 @@ "packageManager": "bun@1.4.2", "pi": { "skills": [ - "./skills" + "./skills", + "./packages/hunk/skills" ] } } diff --git a/packages/hunk/LICENSE b/packages/hunk/LICENSE new file mode 100644 index 000000000..21f202055 --- /dev/null +++ b/packages/hunk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Modem Labs Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/hunk/README.md b/packages/hunk/README.md new file mode 100644 index 000000000..c0275f47d --- /dev/null +++ b/packages/hunk/README.md @@ -0,0 +1,323 @@ +# hunk + +Hunk is a review-first terminal diff viewer for agent-authored changesets, built on [OpenTUI](https://github.com/anomalyco/opentui) and [Pierre diffs](https://www.npmjs.com/package/@pierre/diffs). + +**[hunk.dev](https://hunk.dev)** · [Documentation](https://hunk.dev/docs/) + +[![CI status](https://img.shields.io/github/actions/workflow/status/modem-dev/hunk/ci.yml?branch=main&style=for-the-badge&label=CI)](https://github.com/modem-dev/hunk/actions/workflows/ci.yml?branch=main) +[![Latest release](https://img.shields.io/github/v/release/modem-dev/hunk?style=for-the-badge)](https://github.com/modem-dev/hunk/releases) +[![MIT License](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE) +[![Join the Discord community](https://img.shields.io/badge/Discord-Join%20community-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/WZFjaP6Gt8) + +- multi-file review stream with sidebar navigation +- inline AI and agent annotations beside the code +- split, stack, and responsive auto layouts +- watch mode for auto-reloading file and Git-backed reviews +- keyboard, mouse, pager, and Git difftool support + + + + + + +
+ image +
+ Split view with sidebar and inline AI notes +
+ image +
+ Stacked view and mouse-selectable menus +
+ +## Install + +The default installation method on macOS and Linux downloads a standalone binary and installs it into `~/.hunk`. It checks the archive against the release checksum when both `SHA256SUMS` and a supported checksum tool are available, and warns otherwise: + +```bash +curl -fsSL https://hunk.dev/install.sh | sh +``` + +Windows users can install with npm or mise. Other installation methods are also available: + +```bash +npm i -g hunkdiff # macOS, Linux, or Windows; requires Node.js 22+ +brew install hunk # macOS or Linux +mise use -g hunk # macOS, Linux, or Windows +``` + +> [!NOTE] +> If you previously installed hunk via `modem-dev/tap`, be sure to uninstall it first with `brew uninstall modem-dev/tap/hunk`. + +Windows requires mise 2026.8.6 or newer. Nix users can use the `default` package exported in `flake.nix`; see [nix/README.md](./nix/README.md) for details. Hunk also ships as a default tool in [Omarchy](https://omarchy.org), installed through mise. + +Requirements: + +- macOS, Linux, or Windows +- On x86-64, a CPU with SSE4.2 (Intel Nehalem 2008+, AMD Bulldozer 2011+); arm64 has no CPU feature floor +- Node.js 22+ for the npm install; the install script, Homebrew, mise, and Nix ship a standalone binary that does not require Node.js +- Git recommended for most workflows + +### Update Hunk + +Starting with Hunk 0.20, npm, Homebrew, and default install-script installs use Hunk’s canonical update command: + +```bash +hunk update # install the newest release +hunk update --check # check without installing +hunk update 0.20.0 # select an exact npm or default install-script release +``` + +On an older release, update once with the installer or package manager that installed Hunk, then use `hunk update` going forward. Custom `HUNK_INSTALL_DIR` installs must re-run the installer with the same directory; mise, Nix, and source installs use their owning tools instead. + +## Quick start + +```bash +hunk # show help +hunk --version # print the installed version +``` + +### Working with Git + +Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI instead of plain text. + +```bash +hunk diff # review current repo changes, including untracked files +hunk --fast # experimentally offload eligible syntax highlighting +hunk diff --watch # auto-reload as the working tree changes +hunk show # review the latest commit +hunk show HEAD~1 # review an earlier commit +hunk log # browse history on a terminal; print when redirected +hunk log --static # force static output, paging when needed +``` + +`hunk log` is one auto-responsive, read-only history surface, not a repository manager. On a +terminal it opens the desktop history browser; pipes and redirects receive shell-native static +records automatically, and `--static` forces static output that pages only when needed. The selected VCS adapter +owns traversal, filtering, refs, and how a history item opens for review; the bundled Git and +Jujutsu adapters both implement that public capability. Static output keeps full commit, author, +date, message, branch/bookmark, remote, and tag details; `--oneline` provides compact records, and +`--theme` uses the same palette as Hunk review. Interactive rows adapt their information density to +the available width and keep commit ids right-aligned and clickable. After opening a commit, quit +its normal Hunk review to return to the same selection. + +### Working with Jujutsu and Sapling + +Hunk auto-detects Jujutsu and Sapling checkouts, so `hunk diff [revset]` and `hunk show [revset]` use native revsets inside jj or Sapling workspaces. `hunk log --vcs jj` also reads JJ history directly, including in a non-colocated workspace. To override VCS detection, set `vcs = "git"` or `vcs = "jj"` or `vcs = "sl"` in [config](#config). + +### Working with raw files and patches + +```bash +hunk diff --files before.ts after.ts # compare two files directly +hunk diff --files before.ts after.ts --watch # auto-reload when either file changes +git diff --no-color | hunk patch - # review a patch from stdin +``` + +Watch mode remains continuous. Direct-file and Git-backed reviews normally use filesystem observation to refresh promptly, with periodic polling retained as a fallback for missed events or unavailable watchers. Jujutsu and Sapling reviews currently use polling rather than filesystem observation. + +### Working with agents + +1. Open Hunk in another terminal with `hunk diff` or `hunk show`. +2. Tell your agent to add the skill file returned by `hunk skill path`. +3. Ask your agent to use the skill against the live Hunk session. + +A good generic prompt is: + +```text +Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path. +``` + +For the full live-session and `--agent-context` workflow guide, see [docs/agent-workflows.md](docs/agent-workflows.md). Experimental rich STML note bodies require starting the review with `--experimental`; plain agent notes remain the default. + +## 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/) | +| ---------------------------------- | ----------------------------------------- | ----------------------------------------- | --------------------------------------------------- | -------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------- | +| Review-first interactive UI | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Multi-file review stream + sidebar | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Inline agent / AI annotations | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Responsive auto split/stack layout | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ | +| Mouse support inside the viewer | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Runtime view toggles | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | +| Syntax highlighting | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | +| Structural diffing | ❌ | ❌ | ✅ | ❌ | ❌ | ❌ | +| Pager-compatible mode | ✅ | ❌ | ✅ | ✅ | ✅ | ✅ | + +Hunk is optimized for reviewing a full changeset interactively. + +## Advanced + +### Config + +You can persist preferences to a config file: + +- `~/.config/hunk/config.toml` +- `.hunk/config.toml` + +Example: + +```toml +theme = "github-dark-default" # any built-in theme id, auto, or custom +mode = "auto" # auto, split, stack +vcs = "git" # git, jj, sl +watch = false +exclude_untracked = false +line_numbers = true +tab_width = 4 # tab stops, 1-16 +file_gap = 1 # rows between files, including the ─ rule; 0 hides it +hunk_gap = 0 # blank rows before later hunks +wrap_lines = false +menu_bar = true +sidebar = "auto" # "auto", true, false +agent_notes = false +prompt_save_view_preferences = true +transparent_background = false +``` + +Choose a built-in theme, `auto`, or a custom theme with `theme`. See +[docs/themes.md](docs/themes.md) for automatic selection, custom theme tables, +syntax scopes, and legacy syntax-table migration. + +`exclude_untracked` affects Git/Sapling working-tree `hunk diff` sessions only. +`tab_width` controls source-code tab stops and can be overridden with `-x4` or `--tab-width 4`. +`file_gap` is separator height between files, including the `─` rule; `hunk_gap` is blank rows before later hunks. +`prompt_save_view_preferences = false` disables the quit prompt for saving changed view preferences. +`transparent_background` can also be written as `transparentBackground`. + +### Keybindings + +Every keyboard shortcut is a named command, and a `[keybindings]` table in your +user config remaps command ids to the keys you want them on — several keys per +command, exclusive claims over defaults, and `false` to unbind. See +[docs/keybindings.md](docs/keybindings.md) for the rules, the chord grammar, +and the full table of built-in commands and their default keys. + +### Git integration + +Set Hunk as your Git pager so `git diff` and `git show` open in Hunk automatically: + +> [!NOTE] +> Untracked files are auto-included only for Hunk's own `hunk diff` working-tree loader. If you open `git diff` through `hunk pager`, Git still decides the patch contents, so untracked files will not appear there. + +```bash +git config --global core.pager "hunk pager" +``` + +Or in your Git config: + +```ini +[core] + pager = hunk pager +``` + +If you want to keep Git's default pager and add opt-in aliases instead: + +```bash +git config --global alias.hdiff "-c core.pager=\"hunk pager\" diff" +git config --global alias.hshow "-c core.pager=\"hunk pager\" show" +``` + +### Jujutsu pager integration + +To use Hunk as jj's pager, run `jj config edit --user` and update: + +```toml +[ui] +pager = ["hunk", "pager"] +diff-formatter = ":git" +``` + +### Sapling pager integration + +To use Hunk as Sapling's pager, run `sl config -u` and update: + +```ini +[pager] +pager = hunk pager +``` + +### Extensions (experimental) + +The extension API is experimental and may change in breaking ways between +minor releases while it stabilizes; breaking changes are called out in +release notes. + +Hunk loads plain TypeScript extensions from `~/.config/hunk/extensions/`, from a +repository's `.hunk/extensions/` (after you explicitly trust that repository), +and from `--extension ` for development. `--no-extensions` turns those off +for one run; Hunk's own bundled backends (Git, Jujutsu, and Sapling) stay loaded. + +An extension can add generic top-level CLI workflows, contribute themes and +file-extension → language mappings, add a VCS backend, rewrite the changeset +before review (collapse lockfiles, reorder files by review priority), replace +the file-navigation sidebar with its own React component, react to lifecycle +events, and show transient messages: + +```ts +// ~/.config/hunk/extensions/collapse-lockfiles.ts +import type { HunkExtensionAPI } from "hunkdiff/extension"; + +export default function (hunk: HunkExtensionAPI) { + hunk.transformChangeset((changeset, ctx) => { + const files = changeset.files.filter((file) => !file.path.endsWith(".lock")); + ctx.notify(`Collapsed ${changeset.files.length - files.length} lockfiles`); + return { ...changeset, files }; + }); +} +``` + +Extensions shared as git repositories install straight from their host, and a +`hunk-extension` GitHub topic marks community ones: + +```bash +hunk extension install acme/hunk-word-diff@v1.2.0 # or git:host/path, a URL, a local path +hunk extension list # then update [name] / remove +``` + +Browse community extensions at +[github.com/topics/hunk-extension](https://github.com/topics/hunk-extension); +publish yours by pushing the extension to a repository root and adding that +topic. + +See [docs/extensions.md](docs/extensions.md) for the full API, the trust model, +publishing guidance, and the `[extensions]` / `[extension.]` config reference. +Installable examples include a dependency-free +[`hunk gh 123` GitHub PR workflow](examples/extensions/github-pr/), +[review triage](examples/extensions/review-triage/), +[authoritative review snapshot export](examples/extensions/review-snapshot-export/), an optional +[rendered Markdown file view](examples/extensions/rendered-markdown/), and a +[Vim navigation mode](examples/extensions/vim-navigation/) built from public semantic commands. + +### OpenTUI component + +Hunk also publishes `HunkDiffView` and lower-level primitives from `hunkdiff/opentui` for embedding the same diff renderer in your own OpenTUI app. + +See [docs/opentui-component.md](docs/opentui-component.md) for install, API, and runnable examples. + +## Examples + +Ready-to-run demo diffs live in [`examples/`](examples/README.md). + +Each example includes the exact command to run from the repository root. + +## Contributing + +💬 _Chat with users/contributors on the [Modem Discord server](https://discord.gg/WZFjaP6Gt8)_ + +For source setup, tests, packaging checks, and repo architecture, see [CONTRIBUTING.md](CONTRIBUTING.md). + +## Sponsor + +Sponsored by [Modem](https://modem.dev?utm_source=github&utm_medium=oss&utm_campaign=oss_hunk&utm_content=readme_footer). + + + + + + Modem + + + +## License + +[MIT](LICENSE) diff --git a/bin/hunk.cjs b/packages/hunk/bin/hunk.cjs similarity index 100% rename from bin/hunk.cjs rename to packages/hunk/bin/hunk.cjs diff --git a/packages/hunk/package.json b/packages/hunk/package.json new file mode 100644 index 000000000..7260fe707 --- /dev/null +++ b/packages/hunk/package.json @@ -0,0 +1,92 @@ +{ + "name": "hunkdiff", + "version": "0.21.1", + "description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.", + "keywords": [ + "ai", + "code-review", + "diff", + "git", + "terminal", + "tui" + ], + "homepage": "https://hunk.dev", + "bugs": { + "url": "https://github.com/modem-dev/hunk/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/modem-dev/hunk.git" + }, + "bin": { + "hunk": "./bin/hunk.cjs", + "hunkdiff": "./bin/hunk.cjs" + }, + "files": [ + "bin", + "dist/npm", + "skills/hunk-review", + "skills/hunk-extensions", + "README.md", + "LICENSE" + ], + "type": "module", + "exports": { + "./extension": { + "types": "./dist/npm/extension/index.d.ts", + "import": "./dist/npm/extension/index.js" + }, + "./opentui": { + "types": "./dist/npm/opentui/index.d.ts", + "import": "./dist/npm/opentui/index.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "prepack": "bun run ../../scripts/build-npm.ts" + }, + "dependencies": { + "bun": "^1.4.2", + "chokidar": "^4.0.3", + "commander": "^14.0.3", + "diff": "^8.0.3", + "get-east-asian-width": "^1.5.0", + "shell-quote": "1.9.0", + "string-width": "^8.2.1", + "zod": "~4.4.3" + }, + "devDependencies": { + "@opentui/core": "^0.5.6", + "@opentui/react": "^0.5.6", + "@pierre/diffs": "1.3.5", + "@shikijs/themes": "3.23.0", + "@types/bun": "1.4.1", + "@types/react": "^19.2.14", + "react": "^19.2.4", + "typescript": "^5.9.3" + }, + "peerDependencies": { + "@opentui/core": "^0.5.6", + "@opentui/react": "^0.5.6", + "@pierre/diffs": "1.3.5", + "react": "^19.2.4" + }, + "peerDependenciesMeta": { + "@pierre/diffs": { + "optional": true + } + }, + "engines": { + "node": ">=22" + }, + "packageManager": "bun@1.4.2", + "pi": { + "skills": [ + "./skills" + ] + } +} diff --git a/skills/hunk-extensions/SKILL.md b/packages/hunk/skills/hunk-extensions/SKILL.md similarity index 95% rename from skills/hunk-extensions/SKILL.md rename to packages/hunk/skills/hunk-extensions/SKILL.md index f4c8ae713..fd0f84784 100644 --- a/skills/hunk-extensions/SKILL.md +++ b/packages/hunk/skills/hunk-extensions/SKILL.md @@ -24,13 +24,13 @@ material before writing code. ## Sources of truth — read before writing -| Source | What it answers | -| --------------------------------------- | ------------------------------------------------------------ | -| `docs/extensions.md` | The authoring guide. Every call, every rule. Start here. | -| `src/extension-api/types.ts` | The contract — exact field names, optionality, doc comments. | -| `examples/extensions/*` | Working extensions. Copy these patterns rather than invent. | -| `docs/extension-architecture.md` | Hunk's internals. Needed only when changing the host. | -| `docs/keybindings.md`, `docs/themes.md` | Chord grammar and theme token rules that extensions inherit. | +| Source | What it answers | +| ------------------------------------------ | ------------------------------------------------------------ | +| `docs/extensions.md` | The authoring guide. Every call, every rule. Start here. | +| `packages/hunk/src/extension-api/types.ts` | The contract — exact field names, optionality, doc comments. | +| `examples/extensions/*` | Working extensions. Copy these patterns rather than invent. | +| `docs/extension-architecture.md` | Hunk's internals. Needed only when changing the host. | +| `docs/keybindings.md`, `docs/themes.md` | Chord grammar and theme token rules that extensions inherit. | Outside a Hunk checkout the guide is split across (discovery, trust, config) and its @@ -315,11 +315,11 @@ Practical checks, in order of cost: Only when the work is in the `hunk` repo rather than in a user extension: - Shipped VCS backends and the built-in files pane are **bundled extensions** in - `src/extensions/default/`, registering through the same public API. That + `packages/hunk/src/extensions/default/`, registering through the same public API. That dogfooding is deliberate — if the public contract cannot express something, that is a real gap, not a reason for a private path. `default/vcs/` loads from VCS adapter resolution and must stay renderer-free. -- `src/extension-api/types.ts` must stay **import-free**; declaration emission +- `packages/hunk/src/extension-api/types.ts` must stay **import-free**; declaration emission publishes whatever it reaches, and `scripts/check-pack.ts` fails the pack otherwise. Shapes shared with internal code are declared there and re-exported inward. diff --git a/skills/hunk-review/SKILL.md b/packages/hunk/skills/hunk-review/SKILL.md similarity index 100% rename from skills/hunk-review/SKILL.md rename to packages/hunk/skills/hunk-review/SKILL.md diff --git a/src/app/cli.test.ts b/packages/hunk/src/app/cli.test.ts similarity index 100% rename from src/app/cli.test.ts rename to packages/hunk/src/app/cli.test.ts diff --git a/src/app/cli.ts b/packages/hunk/src/app/cli.ts similarity index 100% rename from src/app/cli.ts rename to packages/hunk/src/app/cli.ts diff --git a/src/app/delegatedReview.test.ts b/packages/hunk/src/app/delegatedReview.test.ts similarity index 100% rename from src/app/delegatedReview.test.ts rename to packages/hunk/src/app/delegatedReview.test.ts diff --git a/src/app/delegatedReview.ts b/packages/hunk/src/app/delegatedReview.ts similarity index 100% rename from src/app/delegatedReview.ts rename to packages/hunk/src/app/delegatedReview.ts diff --git a/src/app/extensionBootstrap.test.ts b/packages/hunk/src/app/extensionBootstrap.test.ts similarity index 100% rename from src/app/extensionBootstrap.test.ts rename to packages/hunk/src/app/extensionBootstrap.test.ts diff --git a/src/app/extensionBootstrap.ts b/packages/hunk/src/app/extensionBootstrap.ts similarity index 100% rename from src/app/extensionBootstrap.ts rename to packages/hunk/src/app/extensionBootstrap.ts diff --git a/src/app/extensionCliBootstrap.test.ts b/packages/hunk/src/app/extensionCliBootstrap.test.ts similarity index 100% rename from src/app/extensionCliBootstrap.test.ts rename to packages/hunk/src/app/extensionCliBootstrap.test.ts diff --git a/src/app/extensionCliBootstrap.ts b/packages/hunk/src/app/extensionCliBootstrap.ts similarity index 100% rename from src/app/extensionCliBootstrap.ts rename to packages/hunk/src/app/extensionCliBootstrap.ts diff --git a/src/app/historyBootstrap.test.ts b/packages/hunk/src/app/historyBootstrap.test.ts similarity index 100% rename from src/app/historyBootstrap.test.ts rename to packages/hunk/src/app/historyBootstrap.test.ts diff --git a/src/app/historyBootstrap.ts b/packages/hunk/src/app/historyBootstrap.ts similarity index 100% rename from src/app/historyBootstrap.ts rename to packages/hunk/src/app/historyBootstrap.ts diff --git a/src/app/review/capability.ts b/packages/hunk/src/app/review/capability.ts similarity index 95% rename from src/app/review/capability.ts rename to packages/hunk/src/app/review/capability.ts index 901fcc7be..00047bb5a 100644 --- a/src/app/review/capability.ts +++ b/packages/hunk/src/app/review/capability.ts @@ -13,7 +13,7 @@ * that outlives its first minute needs. * * The grammar — token shape, fragment key, URL layout — is the shared browser-safe - * contract (`src/session/reviewHttpProtocol.ts`); this module only supplies the randomness + * contract (`packages/hunk/src/session/reviewHttpProtocol.ts`); this module only supplies the randomness * and the hashing that a platform has to provide. */ import { randomBytes } from "node:crypto"; diff --git a/src/app/review/producer.test.ts b/packages/hunk/src/app/review/producer.test.ts similarity index 99% rename from src/app/review/producer.test.ts rename to packages/hunk/src/app/review/producer.test.ts index aa05a8886..87c6329b2 100644 --- a/src/app/review/producer.test.ts +++ b/packages/hunk/src/app/review/producer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; import { SourceTextTooLargeError } from "../../core/changeset/fileSource"; import { parseReviewGeneration } from "../../core/review/generationOrder"; import { diff --git a/src/app/review/producer.ts b/packages/hunk/src/app/review/producer.ts similarity index 100% rename from src/app/review/producer.ts rename to packages/hunk/src/app/review/producer.ts diff --git a/src/app/review/publication.ts b/packages/hunk/src/app/review/publication.ts similarity index 96% rename from src/app/review/publication.ts rename to packages/hunk/src/app/review/publication.ts index b9af070b7..5a8588d8c 100644 --- a/src/app/review/publication.ts +++ b/packages/hunk/src/app/review/publication.ts @@ -7,10 +7,10 @@ * mutating this one — which is what makes "which generation is this?" answerable rather * than a matter of timing. * - * The document itself knows nothing about any of this (`src/core/review/document.ts`); + * The document itself knows nothing about any of this (`packages/hunk/src/core/review/document.ts`); * publication is layered on top so the shared model stays a description of a review rather * than of a transport. The vocabulary for addressing and ordering those generations is - * `src/core/review/generationOrder.ts`. + * `packages/hunk/src/core/review/generationOrder.ts`. */ import { buildReviewContentManifest, diff --git a/src/app/review/resourceStore.ts b/packages/hunk/src/app/review/resourceStore.ts similarity index 100% rename from src/app/review/resourceStore.ts rename to packages/hunk/src/app/review/resourceStore.ts diff --git a/src/app/session/bridge.test.ts b/packages/hunk/src/app/session/bridge.test.ts similarity index 100% rename from src/app/session/bridge.test.ts rename to packages/hunk/src/app/session/bridge.test.ts diff --git a/src/app/session/bridge.ts b/packages/hunk/src/app/session/bridge.ts similarity index 100% rename from src/app/session/bridge.ts rename to packages/hunk/src/app/session/bridge.ts diff --git a/src/app/session/registration.test.ts b/packages/hunk/src/app/session/registration.test.ts similarity index 99% rename from src/app/session/registration.test.ts rename to packages/hunk/src/app/session/registration.test.ts index 6f3d41b3f..823c1b3c5 100644 --- a/src/app/session/registration.test.ts +++ b/packages/hunk/src/app/session/registration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { reviewProcessCapability } from "../review/capability"; import { buildReviewPublication } from "../review/publication"; import type { AppBootstrap } from "../../core/bootstrap"; diff --git a/src/app/session/registration.ts b/packages/hunk/src/app/session/registration.ts similarity index 100% rename from src/app/session/registration.ts rename to packages/hunk/src/app/session/registration.ts diff --git a/src/app/session/reloadBounds.test.ts b/packages/hunk/src/app/session/reloadBounds.test.ts similarity index 100% rename from src/app/session/reloadBounds.test.ts rename to packages/hunk/src/app/session/reloadBounds.test.ts diff --git a/src/app/session/reloadBounds.ts b/packages/hunk/src/app/session/reloadBounds.ts similarity index 100% rename from src/app/session/reloadBounds.ts rename to packages/hunk/src/app/session/reloadBounds.ts diff --git a/src/app/session/reviewCommands.test.ts b/packages/hunk/src/app/session/reviewCommands.test.ts similarity index 99% rename from src/app/session/reviewCommands.test.ts rename to packages/hunk/src/app/session/reviewCommands.test.ts index e76d5afa9..4ec409263 100644 --- a/src/app/session/reviewCommands.test.ts +++ b/packages/hunk/src/app/session/reviewCommands.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { ReviewProducer } from "../review/producer"; import { reviewGapId } from "../../core/review/expansion"; import { reviewResourceId } from "../../core/review/resources"; diff --git a/src/app/session/reviewCommands.ts b/packages/hunk/src/app/session/reviewCommands.ts similarity index 97% rename from src/app/session/reviewCommands.ts rename to packages/hunk/src/app/session/reviewCommands.ts index a3e9107be..69c909f3c 100644 --- a/src/app/session/reviewCommands.ts +++ b/packages/hunk/src/app/session/reviewCommands.ts @@ -3,8 +3,8 @@ * * The daemon brokers two review commands to a live session: read one bounded slice of a * published resource, and apply one semantic action. Both are answered here, at the seam - * between the wire schema (`src/session/reviewProtocol.ts`) and the producer that owns - * the generation (`src/app/review/producer.ts`), so neither of those has to know about + * between the wire schema (`packages/hunk/src/session/reviewProtocol.ts`) and the producer that owns + * the generation (`packages/hunk/src/app/review/producer.ts`), so neither of those has to know about * the other's concerns. * * Three rules shape it: diff --git a/src/app/sessionBootstrap.test.ts b/packages/hunk/src/app/sessionBootstrap.test.ts similarity index 100% rename from src/app/sessionBootstrap.test.ts rename to packages/hunk/src/app/sessionBootstrap.test.ts diff --git a/src/app/sessionBootstrap.ts b/packages/hunk/src/app/sessionBootstrap.ts similarity index 100% rename from src/app/sessionBootstrap.ts rename to packages/hunk/src/app/sessionBootstrap.ts diff --git a/src/app/sessionSelector.test.ts b/packages/hunk/src/app/sessionSelector.test.ts similarity index 100% rename from src/app/sessionSelector.test.ts rename to packages/hunk/src/app/sessionSelector.test.ts diff --git a/src/app/sessionSelector.ts b/packages/hunk/src/app/sessionSelector.ts similarity index 100% rename from src/app/sessionSelector.ts rename to packages/hunk/src/app/sessionSelector.ts diff --git a/src/app/startup.test.ts b/packages/hunk/src/app/startup.test.ts similarity index 100% rename from src/app/startup.test.ts rename to packages/hunk/src/app/startup.test.ts diff --git a/src/app/startup.ts b/packages/hunk/src/app/startup.ts similarity index 100% rename from src/app/startup.ts rename to packages/hunk/src/app/startup.ts diff --git a/src/app/startup.vcsExtensions.test.ts b/packages/hunk/src/app/startup.vcsExtensions.test.ts similarity index 100% rename from src/app/startup.vcsExtensions.test.ts rename to packages/hunk/src/app/startup.vcsExtensions.test.ts diff --git a/src/app/types.ts b/packages/hunk/src/app/types.ts similarity index 100% rename from src/app/types.ts rename to packages/hunk/src/app/types.ts diff --git a/src/app/vcsCatalog.test.ts b/packages/hunk/src/app/vcsCatalog.test.ts similarity index 100% rename from src/app/vcsCatalog.test.ts rename to packages/hunk/src/app/vcsCatalog.test.ts diff --git a/src/app/vcsCatalog.ts b/packages/hunk/src/app/vcsCatalog.ts similarity index 100% rename from src/app/vcsCatalog.ts rename to packages/hunk/src/app/vcsCatalog.ts diff --git a/src/core/bootstrap.ts b/packages/hunk/src/core/bootstrap.ts similarity index 96% rename from src/core/bootstrap.ts rename to packages/hunk/src/core/bootstrap.ts index 4860fa1b8..80013de5d 100644 --- a/src/core/bootstrap.ts +++ b/packages/hunk/src/core/bootstrap.ts @@ -6,7 +6,7 @@ * This module names shapes the tiers below it already own — the changeset, the * parsed command input, the resolved view and keybinding preferences, the * detected theme mode — and composes them into the one value that crosses from - * startup into the UI. The module directories under `src/core` do not import it + * startup into the UI. The module directories under `packages/hunk/src/core` do not import it * back — composition sits above the leaves it composes — with one exception the * boundary rules name: `changeset/loaders.ts` assembles this value in * `loadAppBootstrap`, so it has to name the shape it returns. diff --git a/src/core/changeset/binary.test.ts b/packages/hunk/src/core/changeset/binary.test.ts similarity index 100% rename from src/core/changeset/binary.test.ts rename to packages/hunk/src/core/changeset/binary.test.ts diff --git a/src/core/changeset/binary.ts b/packages/hunk/src/core/changeset/binary.ts similarity index 100% rename from src/core/changeset/binary.ts rename to packages/hunk/src/core/changeset/binary.ts diff --git a/src/core/changeset/diffFile.test.ts b/packages/hunk/src/core/changeset/diffFile.test.ts similarity index 100% rename from src/core/changeset/diffFile.test.ts rename to packages/hunk/src/core/changeset/diffFile.test.ts diff --git a/src/core/changeset/diffFile.ts b/packages/hunk/src/core/changeset/diffFile.ts similarity index 100% rename from src/core/changeset/diffFile.ts rename to packages/hunk/src/core/changeset/diffFile.ts diff --git a/src/core/changeset/diffPaths.ts b/packages/hunk/src/core/changeset/diffPaths.ts similarity index 100% rename from src/core/changeset/diffPaths.ts rename to packages/hunk/src/core/changeset/diffPaths.ts diff --git a/src/core/changeset/fileLanguage.test.ts b/packages/hunk/src/core/changeset/fileLanguage.test.ts similarity index 100% rename from src/core/changeset/fileLanguage.test.ts rename to packages/hunk/src/core/changeset/fileLanguage.test.ts diff --git a/src/core/changeset/fileLanguage.ts b/packages/hunk/src/core/changeset/fileLanguage.ts similarity index 100% rename from src/core/changeset/fileLanguage.ts rename to packages/hunk/src/core/changeset/fileLanguage.ts diff --git a/src/core/changeset/fileLanguageLookup.ts b/packages/hunk/src/core/changeset/fileLanguageLookup.ts similarity index 100% rename from src/core/changeset/fileLanguageLookup.ts rename to packages/hunk/src/core/changeset/fileLanguageLookup.ts diff --git a/src/core/changeset/fileSource.test.ts b/packages/hunk/src/core/changeset/fileSource.test.ts similarity index 100% rename from src/core/changeset/fileSource.test.ts rename to packages/hunk/src/core/changeset/fileSource.test.ts diff --git a/src/core/changeset/fileSource.ts b/packages/hunk/src/core/changeset/fileSource.ts similarity index 100% rename from src/core/changeset/fileSource.ts rename to packages/hunk/src/core/changeset/fileSource.ts diff --git a/src/core/changeset/fromPatch.ts b/packages/hunk/src/core/changeset/fromPatch.ts similarity index 100% rename from src/core/changeset/fromPatch.ts rename to packages/hunk/src/core/changeset/fromPatch.ts diff --git a/src/core/changeset/hunkHeader.test.ts b/packages/hunk/src/core/changeset/hunkHeader.test.ts similarity index 100% rename from src/core/changeset/hunkHeader.test.ts rename to packages/hunk/src/core/changeset/hunkHeader.test.ts diff --git a/src/core/changeset/hunkHeader.ts b/packages/hunk/src/core/changeset/hunkHeader.ts similarity index 100% rename from src/core/changeset/hunkHeader.ts rename to packages/hunk/src/core/changeset/hunkHeader.ts diff --git a/src/core/changeset/hunkSummary.test.ts b/packages/hunk/src/core/changeset/hunkSummary.test.ts similarity index 95% rename from src/core/changeset/hunkSummary.test.ts rename to packages/hunk/src/core/changeset/hunkSummary.test.ts index 322985aea..7240d3743 100644 --- a/src/core/changeset/hunkSummary.test.ts +++ b/packages/hunk/src/core/changeset/hunkSummary.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { Hunk } from "@pierre/diffs"; -import { createJsxFileViewLayout } from "../../../examples/extensions/jsx-file-view"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createJsxFileViewLayout } from "../../../../../examples/extensions/jsx-file-view"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; import { createFileViewInput } from "../../ui/fileViews/host"; import { validateFileViewLayout } from "../../ui/fileViews/layout"; import { formatHunkHeader } from "./hunkHeader"; diff --git a/src/core/changeset/hunkSummary.ts b/packages/hunk/src/core/changeset/hunkSummary.ts similarity index 100% rename from src/core/changeset/hunkSummary.ts rename to packages/hunk/src/core/changeset/hunkSummary.ts diff --git a/src/core/changeset/loaders.ordering.test.ts b/packages/hunk/src/core/changeset/loaders.ordering.test.ts similarity index 96% rename from src/core/changeset/loaders.ordering.test.ts rename to packages/hunk/src/core/changeset/loaders.ordering.test.ts index 3143077b4..9651255d4 100644 --- a/src/core/changeset/loaders.ordering.test.ts +++ b/packages/hunk/src/core/changeset/loaders.ordering.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import type { SidecarContext } from "./model"; import { orderDiffFiles } from "./loaders"; -import { createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../../test/helpers/diff-helpers"; function sidecar(...paths: string[]): SidecarContext { return { diff --git a/src/core/changeset/loaders.test.ts b/packages/hunk/src/core/changeset/loaders.test.ts similarity index 100% rename from src/core/changeset/loaders.test.ts rename to packages/hunk/src/core/changeset/loaders.test.ts diff --git a/src/core/changeset/loaders.ts b/packages/hunk/src/core/changeset/loaders.ts similarity index 100% rename from src/core/changeset/loaders.ts rename to packages/hunk/src/core/changeset/loaders.ts diff --git a/src/core/changeset/model.ts b/packages/hunk/src/core/changeset/model.ts similarity index 100% rename from src/core/changeset/model.ts rename to packages/hunk/src/core/changeset/model.ts diff --git a/src/core/changeset/sidecar.test.ts b/packages/hunk/src/core/changeset/sidecar.test.ts similarity index 100% rename from src/core/changeset/sidecar.test.ts rename to packages/hunk/src/core/changeset/sidecar.test.ts diff --git a/src/core/changeset/sidecar.ts b/packages/hunk/src/core/changeset/sidecar.ts similarity index 98% rename from src/core/changeset/sidecar.ts rename to packages/hunk/src/core/changeset/sidecar.ts index 2540bd077..bb8c9ebb4 100644 --- a/src/core/changeset/sidecar.ts +++ b/packages/hunk/src/core/changeset/sidecar.ts @@ -4,7 +4,7 @@ * "Sidecar" names the file and its loader; "agent" names what the file carries. The notes * inside it stay `AgentAnnotation` / `AgentFileContext` because that is both the published * extension contract and the term the UI shows. Keeping the two apart leaves `agent` free to - * mean the coding-agent command surface in `src/session/agent/`. + * mean the coding-agent command surface in `packages/hunk/src/session/agent/`. */ import { resolve as resolvePath } from "node:path"; import type { AgentAnnotation, AgentFileContext } from "../../extension-api/types"; diff --git a/src/core/history/lanePlanner.test.ts b/packages/hunk/src/core/history/lanePlanner.test.ts similarity index 100% rename from src/core/history/lanePlanner.test.ts rename to packages/hunk/src/core/history/lanePlanner.test.ts diff --git a/src/core/history/lanePlanner.ts b/packages/hunk/src/core/history/lanePlanner.ts similarity index 100% rename from src/core/history/lanePlanner.ts rename to packages/hunk/src/core/history/lanePlanner.ts diff --git a/src/core/history/types.ts b/packages/hunk/src/core/history/types.ts similarity index 100% rename from src/core/history/types.ts rename to packages/hunk/src/core/history/types.ts diff --git a/src/core/install/installSource.test.ts b/packages/hunk/src/core/install/installSource.test.ts similarity index 100% rename from src/core/install/installSource.test.ts rename to packages/hunk/src/core/install/installSource.test.ts diff --git a/src/core/install/installSource.ts b/packages/hunk/src/core/install/installSource.ts similarity index 100% rename from src/core/install/installSource.ts rename to packages/hunk/src/core/install/installSource.ts diff --git a/src/core/install/latestRelease.test.ts b/packages/hunk/src/core/install/latestRelease.test.ts similarity index 100% rename from src/core/install/latestRelease.test.ts rename to packages/hunk/src/core/install/latestRelease.test.ts diff --git a/src/core/install/latestRelease.ts b/packages/hunk/src/core/install/latestRelease.ts similarity index 100% rename from src/core/install/latestRelease.ts rename to packages/hunk/src/core/install/latestRelease.ts diff --git a/src/core/install/selfUpdate.test.ts b/packages/hunk/src/core/install/selfUpdate.test.ts similarity index 100% rename from src/core/install/selfUpdate.test.ts rename to packages/hunk/src/core/install/selfUpdate.test.ts diff --git a/src/core/install/selfUpdate.ts b/packages/hunk/src/core/install/selfUpdate.ts similarity index 100% rename from src/core/install/selfUpdate.ts rename to packages/hunk/src/core/install/selfUpdate.ts diff --git a/src/core/liveComments.test.ts b/packages/hunk/src/core/liveComments.test.ts similarity index 98% rename from src/core/liveComments.test.ts rename to packages/hunk/src/core/liveComments.test.ts index c1a3a789b..3e1b1a129 100644 --- a/src/core/liveComments.test.ts +++ b/packages/hunk/src/core/liveComments.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../test/helpers/diff-helpers"; import { buildLiveComment, findDiffFileByPath, diff --git a/src/core/liveComments.ts b/packages/hunk/src/core/liveComments.ts similarity index 97% rename from src/core/liveComments.ts rename to packages/hunk/src/core/liveComments.ts index b4d431167..753994856 100644 --- a/src/core/liveComments.ts +++ b/packages/hunk/src/core/liveComments.ts @@ -21,7 +21,7 @@ export interface CommentTargetInput { line?: number; summary: string; rationale?: string; - /** Optional STML markup rendered as the note body (see src/ui/lib/stml). */ + /** Optional STML markup rendered as the note body (see packages/hunk/src/ui/lib/stml). */ markup?: string; author?: string; } diff --git a/src/core/patch/chunks.test.ts b/packages/hunk/src/core/patch/chunks.test.ts similarity index 100% rename from src/core/patch/chunks.test.ts rename to packages/hunk/src/core/patch/chunks.test.ts diff --git a/src/core/patch/chunks.ts b/packages/hunk/src/core/patch/chunks.ts similarity index 100% rename from src/core/patch/chunks.ts rename to packages/hunk/src/core/patch/chunks.ts diff --git a/src/core/patch/gitFormat.test.ts b/packages/hunk/src/core/patch/gitFormat.test.ts similarity index 100% rename from src/core/patch/gitFormat.test.ts rename to packages/hunk/src/core/patch/gitFormat.test.ts diff --git a/src/core/patch/gitFormat.ts b/packages/hunk/src/core/patch/gitFormat.ts similarity index 100% rename from src/core/patch/gitFormat.ts rename to packages/hunk/src/core/patch/gitFormat.ts diff --git a/src/core/patch/gitLog.test.ts b/packages/hunk/src/core/patch/gitLog.test.ts similarity index 100% rename from src/core/patch/gitLog.test.ts rename to packages/hunk/src/core/patch/gitLog.test.ts diff --git a/src/core/patch/gitLog.ts b/packages/hunk/src/core/patch/gitLog.ts similarity index 100% rename from src/core/patch/gitLog.ts rename to packages/hunk/src/core/patch/gitLog.ts diff --git a/src/core/patch/sanitize.test.ts b/packages/hunk/src/core/patch/sanitize.test.ts similarity index 100% rename from src/core/patch/sanitize.test.ts rename to packages/hunk/src/core/patch/sanitize.test.ts diff --git a/src/core/patch/sanitize.ts b/packages/hunk/src/core/patch/sanitize.ts similarity index 100% rename from src/core/patch/sanitize.ts rename to packages/hunk/src/core/patch/sanitize.ts diff --git a/src/core/patch/singleFile.ts b/packages/hunk/src/core/patch/singleFile.ts similarity index 100% rename from src/core/patch/singleFile.ts rename to packages/hunk/src/core/patch/singleFile.ts diff --git a/src/core/process/appStateFile.test.ts b/packages/hunk/src/core/process/appStateFile.test.ts similarity index 100% rename from src/core/process/appStateFile.test.ts rename to packages/hunk/src/core/process/appStateFile.test.ts diff --git a/src/core/process/appStateFile.ts b/packages/hunk/src/core/process/appStateFile.ts similarity index 100% rename from src/core/process/appStateFile.ts rename to packages/hunk/src/core/process/appStateFile.ts diff --git a/src/core/process/jobControl.test.ts b/packages/hunk/src/core/process/jobControl.test.ts similarity index 100% rename from src/core/process/jobControl.test.ts rename to packages/hunk/src/core/process/jobControl.test.ts diff --git a/src/core/process/jobControl.ts b/packages/hunk/src/core/process/jobControl.ts similarity index 100% rename from src/core/process/jobControl.ts rename to packages/hunk/src/core/process/jobControl.ts diff --git a/src/core/process/pager.test.ts b/packages/hunk/src/core/process/pager.test.ts similarity index 100% rename from src/core/process/pager.test.ts rename to packages/hunk/src/core/process/pager.test.ts diff --git a/src/core/process/pager.ts b/packages/hunk/src/core/process/pager.ts similarity index 100% rename from src/core/process/pager.ts rename to packages/hunk/src/core/process/pager.ts diff --git a/src/core/process/projectRoot.test.ts b/packages/hunk/src/core/process/projectRoot.test.ts similarity index 100% rename from src/core/process/projectRoot.test.ts rename to packages/hunk/src/core/process/projectRoot.test.ts diff --git a/src/core/process/projectRoot.ts b/packages/hunk/src/core/process/projectRoot.ts similarity index 100% rename from src/core/process/projectRoot.ts rename to packages/hunk/src/core/process/projectRoot.ts diff --git a/src/core/process/relaunch.test.ts b/packages/hunk/src/core/process/relaunch.test.ts similarity index 100% rename from src/core/process/relaunch.test.ts rename to packages/hunk/src/core/process/relaunch.test.ts diff --git a/src/core/process/relaunch.ts b/packages/hunk/src/core/process/relaunch.ts similarity index 100% rename from src/core/process/relaunch.ts rename to packages/hunk/src/core/process/relaunch.ts diff --git a/src/core/process/shutdown.test.ts b/packages/hunk/src/core/process/shutdown.test.ts similarity index 100% rename from src/core/process/shutdown.test.ts rename to packages/hunk/src/core/process/shutdown.test.ts diff --git a/src/core/process/shutdown.ts b/packages/hunk/src/core/process/shutdown.ts similarity index 100% rename from src/core/process/shutdown.ts rename to packages/hunk/src/core/process/shutdown.ts diff --git a/src/core/process/startupNotice.ts b/packages/hunk/src/core/process/startupNotice.ts similarity index 100% rename from src/core/process/startupNotice.ts rename to packages/hunk/src/core/process/startupNotice.ts diff --git a/src/core/process/stdout.test.ts b/packages/hunk/src/core/process/stdout.test.ts similarity index 100% rename from src/core/process/stdout.test.ts rename to packages/hunk/src/core/process/stdout.test.ts diff --git a/src/core/process/stdout.ts b/packages/hunk/src/core/process/stdout.ts similarity index 100% rename from src/core/process/stdout.ts rename to packages/hunk/src/core/process/stdout.ts diff --git a/src/core/process/terminal.test.ts b/packages/hunk/src/core/process/terminal.test.ts similarity index 100% rename from src/core/process/terminal.test.ts rename to packages/hunk/src/core/process/terminal.test.ts diff --git a/src/core/process/terminal.ts b/packages/hunk/src/core/process/terminal.ts similarity index 100% rename from src/core/process/terminal.ts rename to packages/hunk/src/core/process/terminal.ts diff --git a/src/core/process/updateNotice.test.ts b/packages/hunk/src/core/process/updateNotice.test.ts similarity index 100% rename from src/core/process/updateNotice.test.ts rename to packages/hunk/src/core/process/updateNotice.test.ts diff --git a/src/core/process/updateNotice.ts b/packages/hunk/src/core/process/updateNotice.ts similarity index 100% rename from src/core/process/updateNotice.ts rename to packages/hunk/src/core/process/updateNotice.ts diff --git a/src/core/review/actions.ts b/packages/hunk/src/core/review/actions.ts similarity index 100% rename from src/core/review/actions.ts rename to packages/hunk/src/core/review/actions.ts diff --git a/src/core/review/anchors.test.ts b/packages/hunk/src/core/review/anchors.test.ts similarity index 100% rename from src/core/review/anchors.test.ts rename to packages/hunk/src/core/review/anchors.test.ts diff --git a/src/core/review/anchors.ts b/packages/hunk/src/core/review/anchors.ts similarity index 100% rename from src/core/review/anchors.ts rename to packages/hunk/src/core/review/anchors.ts diff --git a/src/core/review/annotations.ts b/packages/hunk/src/core/review/annotations.ts similarity index 100% rename from src/core/review/annotations.ts rename to packages/hunk/src/core/review/annotations.ts diff --git a/src/core/review/canonicalFile.test.ts b/packages/hunk/src/core/review/canonicalFile.test.ts similarity index 97% rename from src/core/review/canonicalFile.test.ts rename to packages/hunk/src/core/review/canonicalFile.test.ts index d526b21d0..d40530db6 100644 --- a/src/core/review/canonicalFile.test.ts +++ b/packages/hunk/src/core/review/canonicalFile.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestReviewFile } from "../../../test/helpers/review-store-helpers"; +import { createTestReviewFile } from "../../../../../test/helpers/review-store-helpers"; import { buildReviewContentManifestFile } from "./contentManifest"; import { assertCanonicalFileMatchesManifest, diff --git a/src/core/review/canonicalFile.ts b/packages/hunk/src/core/review/canonicalFile.ts similarity index 100% rename from src/core/review/canonicalFile.ts rename to packages/hunk/src/core/review/canonicalFile.ts diff --git a/src/core/review/contentManifest.test.ts b/packages/hunk/src/core/review/contentManifest.test.ts similarity index 96% rename from src/core/review/contentManifest.test.ts rename to packages/hunk/src/core/review/contentManifest.test.ts index e6433367e..3a9291052 100644 --- a/src/core/review/contentManifest.test.ts +++ b/packages/hunk/src/core/review/contentManifest.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile, lines } from "../../../test/helpers/diff-helpers"; +import { createTestDiffFile, lines } from "../../../../../test/helpers/diff-helpers"; import { buildReviewContentManifest } from "./contentManifest"; import { projectReviewDocument } from "./document"; diff --git a/src/core/review/contentManifest.ts b/packages/hunk/src/core/review/contentManifest.ts similarity index 100% rename from src/core/review/contentManifest.ts rename to packages/hunk/src/core/review/contentManifest.ts diff --git a/src/core/review/document.test.ts b/packages/hunk/src/core/review/document.test.ts similarity index 99% rename from src/core/review/document.test.ts rename to packages/hunk/src/core/review/document.test.ts index 003afd4d9..a78cab72b 100644 --- a/src/core/review/document.test.ts +++ b/packages/hunk/src/core/review/document.test.ts @@ -3,7 +3,7 @@ import { createTestDiffFile, createTestSourceFetcher, lines, -} from "../../../test/helpers/diff-helpers"; +} from "../../../../../test/helpers/diff-helpers"; import { projectReviewDocument, reviewEmptyDiffReason } from "./document"; import type { DiffFile } from "../changeset/model"; diff --git a/src/core/review/document.ts b/packages/hunk/src/core/review/document.ts similarity index 100% rename from src/core/review/document.ts rename to packages/hunk/src/core/review/document.ts diff --git a/src/core/review/expansion.test.ts b/packages/hunk/src/core/review/expansion.test.ts similarity index 99% rename from src/core/review/expansion.test.ts rename to packages/hunk/src/core/review/expansion.test.ts index 4f262bd9d..c238d8a9d 100644 --- a/src/core/review/expansion.test.ts +++ b/packages/hunk/src/core/review/expansion.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestReviewFile } from "../../../test/helpers/review-store-helpers"; +import { createTestReviewFile } from "../../../../../test/helpers/review-store-helpers"; import { parseReviewGapId, resolveReviewExpandedLine, diff --git a/src/core/review/expansion.ts b/packages/hunk/src/core/review/expansion.ts similarity index 100% rename from src/core/review/expansion.ts rename to packages/hunk/src/core/review/expansion.ts diff --git a/src/core/review/generationOrder.test.ts b/packages/hunk/src/core/review/generationOrder.test.ts similarity index 100% rename from src/core/review/generationOrder.test.ts rename to packages/hunk/src/core/review/generationOrder.test.ts diff --git a/src/core/review/generationOrder.ts b/packages/hunk/src/core/review/generationOrder.ts similarity index 100% rename from src/core/review/generationOrder.ts rename to packages/hunk/src/core/review/generationOrder.ts diff --git a/src/core/review/geometry.test.ts b/packages/hunk/src/core/review/geometry.test.ts similarity index 100% rename from src/core/review/geometry.test.ts rename to packages/hunk/src/core/review/geometry.test.ts diff --git a/src/core/review/geometry.ts b/packages/hunk/src/core/review/geometry.ts similarity index 100% rename from src/core/review/geometry.ts rename to packages/hunk/src/core/review/geometry.ts diff --git a/src/core/review/identity.test.ts b/packages/hunk/src/core/review/identity.test.ts similarity index 100% rename from src/core/review/identity.test.ts rename to packages/hunk/src/core/review/identity.test.ts diff --git a/src/core/review/identity.ts b/packages/hunk/src/core/review/identity.ts similarity index 100% rename from src/core/review/identity.ts rename to packages/hunk/src/core/review/identity.ts diff --git a/src/core/review/intents.test.ts b/packages/hunk/src/core/review/intents.test.ts similarity index 99% rename from src/core/review/intents.test.ts rename to packages/hunk/src/core/review/intents.test.ts index 05737999c..3ff752b75 100644 --- a/src/core/review/intents.test.ts +++ b/packages/hunk/src/core/review/intents.test.ts @@ -3,7 +3,7 @@ import { createTestReviewDocument, createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { applyReviewIntent, isBlankReviewNoteBody, diff --git a/src/core/review/intents.ts b/packages/hunk/src/core/review/intents.ts similarity index 99% rename from src/core/review/intents.ts rename to packages/hunk/src/core/review/intents.ts index 1c9da0bb8..8203de7c5 100644 --- a/src/core/review/intents.ts +++ b/packages/hunk/src/core/review/intents.ts @@ -120,7 +120,7 @@ export type ReviewIntent = * The wire vocabulary is derived from this list instead of restated beside it * (`docs/browser-review-seam-audit.md`, B12). The assertion below makes the list total — * adding a member to `ReviewIntent` without naming it here fails to typecheck — and - * `src/session/reviewProtocol.ts` subtracts a named exclusion list from it rather than + * `packages/hunk/src/session/reviewProtocol.ts` subtracts a named exclusion list from it rather than * writing its own. */ export const REVIEW_INTENT_TYPES = [ diff --git a/src/core/review/navigation.test.ts b/packages/hunk/src/core/review/navigation.test.ts similarity index 100% rename from src/core/review/navigation.test.ts rename to packages/hunk/src/core/review/navigation.test.ts diff --git a/src/core/review/navigation.ts b/packages/hunk/src/core/review/navigation.ts similarity index 100% rename from src/core/review/navigation.ts rename to packages/hunk/src/core/review/navigation.ts diff --git a/src/core/review/noteSize.test.ts b/packages/hunk/src/core/review/noteSize.test.ts similarity index 100% rename from src/core/review/noteSize.test.ts rename to packages/hunk/src/core/review/noteSize.test.ts diff --git a/src/core/review/noteSize.ts b/packages/hunk/src/core/review/noteSize.ts similarity index 100% rename from src/core/review/noteSize.ts rename to packages/hunk/src/core/review/noteSize.ts diff --git a/src/core/review/reducer.test.ts b/packages/hunk/src/core/review/reducer.test.ts similarity index 99% rename from src/core/review/reducer.test.ts rename to packages/hunk/src/core/review/reducer.test.ts index 37439f151..15f2b9ce4 100644 --- a/src/core/review/reducer.test.ts +++ b/packages/hunk/src/core/review/reducer.test.ts @@ -3,7 +3,7 @@ import { createTestReviewDocument, createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { reduceReviewState } from "./reducer"; import type { ReviewState } from "./state"; diff --git a/src/core/review/reducer.ts b/packages/hunk/src/core/review/reducer.ts similarity index 100% rename from src/core/review/reducer.ts rename to packages/hunk/src/core/review/reducer.ts diff --git a/src/core/review/resourceAssembly.test.ts b/packages/hunk/src/core/review/resourceAssembly.test.ts similarity index 100% rename from src/core/review/resourceAssembly.test.ts rename to packages/hunk/src/core/review/resourceAssembly.test.ts diff --git a/src/core/review/resourceAssembly.ts b/packages/hunk/src/core/review/resourceAssembly.ts similarity index 100% rename from src/core/review/resourceAssembly.ts rename to packages/hunk/src/core/review/resourceAssembly.ts diff --git a/src/core/review/resources.test.ts b/packages/hunk/src/core/review/resources.test.ts similarity index 100% rename from src/core/review/resources.test.ts rename to packages/hunk/src/core/review/resources.test.ts diff --git a/src/core/review/resources.ts b/packages/hunk/src/core/review/resources.ts similarity index 100% rename from src/core/review/resources.ts rename to packages/hunk/src/core/review/resources.ts diff --git a/src/core/review/selectors.test.ts b/packages/hunk/src/core/review/selectors.test.ts similarity index 98% rename from src/core/review/selectors.test.ts rename to packages/hunk/src/core/review/selectors.test.ts index dd6abd164..cda5d45ef 100644 --- a/src/core/review/selectors.test.ts +++ b/packages/hunk/src/core/review/selectors.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; -import { createTestReviewFile } from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; +import { createTestReviewFile } from "../../../../../test/helpers/review-store-helpers"; import { reduceReviewState } from "./reducer"; import { isReviewGapExpanded, diff --git a/src/core/review/selectors.ts b/packages/hunk/src/core/review/selectors.ts similarity index 100% rename from src/core/review/selectors.ts rename to packages/hunk/src/core/review/selectors.ts diff --git a/src/core/review/state.test.ts b/packages/hunk/src/core/review/state.test.ts similarity index 96% rename from src/core/review/state.test.ts rename to packages/hunk/src/core/review/state.test.ts index c09f7247f..ed7f05112 100644 --- a/src/core/review/state.test.ts +++ b/packages/hunk/src/core/review/state.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestReviewDocument } from "../../../test/helpers/review-store-helpers"; +import { createTestReviewDocument } from "../../../../../test/helpers/review-store-helpers"; import { createInitialReviewState, isRenderableStoredReviewNote, diff --git a/src/core/review/state.ts b/packages/hunk/src/core/review/state.ts similarity index 100% rename from src/core/review/state.ts rename to packages/hunk/src/core/review/state.ts diff --git a/src/core/review/stml.test.ts b/packages/hunk/src/core/review/stml.test.ts similarity index 100% rename from src/core/review/stml.test.ts rename to packages/hunk/src/core/review/stml.test.ts diff --git a/src/core/review/stml.ts b/packages/hunk/src/core/review/stml.ts similarity index 100% rename from src/core/review/stml.ts rename to packages/hunk/src/core/review/stml.ts diff --git a/src/core/review/store.test.ts b/packages/hunk/src/core/review/store.test.ts similarity index 97% rename from src/core/review/store.test.ts rename to packages/hunk/src/core/review/store.test.ts index 19a70242d..0e518c708 100644 --- a/src/core/review/store.test.ts +++ b/packages/hunk/src/core/review/store.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewDocument, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { createReviewStore } from "./store"; describe("createReviewStore", () => { diff --git a/src/core/review/store.ts b/packages/hunk/src/core/review/store.ts similarity index 100% rename from src/core/review/store.ts rename to packages/hunk/src/core/review/store.ts diff --git a/src/core/review/types.ts b/packages/hunk/src/core/review/types.ts similarity index 100% rename from src/core/review/types.ts rename to packages/hunk/src/core/review/types.ts diff --git a/src/core/review/validation.test.ts b/packages/hunk/src/core/review/validation.test.ts similarity index 100% rename from src/core/review/validation.test.ts rename to packages/hunk/src/core/review/validation.test.ts diff --git a/src/core/review/validation.ts b/packages/hunk/src/core/review/validation.ts similarity index 100% rename from src/core/review/validation.ts rename to packages/hunk/src/core/review/validation.ts diff --git a/src/core/reviewDescriptor.test.ts b/packages/hunk/src/core/reviewDescriptor.test.ts similarity index 100% rename from src/core/reviewDescriptor.test.ts rename to packages/hunk/src/core/reviewDescriptor.test.ts diff --git a/src/core/reviewDescriptor.ts b/packages/hunk/src/core/reviewDescriptor.ts similarity index 100% rename from src/core/reviewDescriptor.ts rename to packages/hunk/src/core/reviewDescriptor.ts diff --git a/src/core/reviewDigest.ts b/packages/hunk/src/core/reviewDigest.ts similarity index 100% rename from src/core/reviewDigest.ts rename to packages/hunk/src/core/reviewDigest.ts diff --git a/src/core/run/cliCommandNames.ts b/packages/hunk/src/core/run/cliCommandNames.ts similarity index 100% rename from src/core/run/cliCommandNames.ts rename to packages/hunk/src/core/run/cliCommandNames.ts diff --git a/src/core/run/commandCatalog.test.ts b/packages/hunk/src/core/run/commandCatalog.test.ts similarity index 99% rename from src/core/run/commandCatalog.test.ts rename to packages/hunk/src/core/run/commandCatalog.test.ts index 2b04000d4..a0be55309 100644 --- a/src/core/run/commandCatalog.test.ts +++ b/packages/hunk/src/core/run/commandCatalog.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewState, createTestStoredNote, -} from "../../../test/helpers/review-store-helpers"; +} from "../../../../../test/helpers/review-store-helpers"; import { APP_COMMAND_CATALOG, appCommandCatalogEntry, diff --git a/src/core/run/commandCatalog.ts b/packages/hunk/src/core/run/commandCatalog.ts similarity index 99% rename from src/core/run/commandCatalog.ts rename to packages/hunk/src/core/run/commandCatalog.ts index 473151191..acfd9cf5d 100644 --- a/src/core/run/commandCatalog.ts +++ b/packages/hunk/src/core/run/commandCatalog.ts @@ -21,7 +21,7 @@ * (audit F4). * * This module is renderer-neutral and dependency-light: no OpenTUI, no React, no Node - * builtins, chords as plain strings. It is not part of `src/core/review` because it + * builtins, chords as plain strings. It is not part of `packages/hunk/src/core/review` because it * describes UI vocabulary rather than review semantics, and that module stays purely about * what a review *is*. */ diff --git a/src/core/run/commandInputs.ts b/packages/hunk/src/core/run/commandInputs.ts similarity index 100% rename from src/core/run/commandInputs.ts rename to packages/hunk/src/core/run/commandInputs.ts diff --git a/src/core/run/config.test.ts b/packages/hunk/src/core/run/config.test.ts similarity index 100% rename from src/core/run/config.test.ts rename to packages/hunk/src/core/run/config.test.ts diff --git a/src/core/run/config.ts b/packages/hunk/src/core/run/config.ts similarity index 99% rename from src/core/run/config.ts rename to packages/hunk/src/core/run/config.ts index 68ebc02b2..4ed7f695f 100644 --- a/src/core/run/config.ts +++ b/packages/hunk/src/core/run/config.ts @@ -67,7 +67,7 @@ export interface ExtensionsConfig { * Command ids are the ones the dispatch table declares — `"hunk.app.quit"`, * `"hunk.review.nextHunk"`, or `"."` for an extension * command. Resolution against each command's defaults lives in - * `src/ui/lib/keymap.ts`. + * `packages/hunk/src/ui/lib/keymap.ts`. */ export type UserKeyBinding = string | readonly string[] | false; diff --git a/src/core/run/errors.test.ts b/packages/hunk/src/core/run/errors.test.ts similarity index 100% rename from src/core/run/errors.test.ts rename to packages/hunk/src/core/run/errors.test.ts diff --git a/src/core/run/errors.ts b/packages/hunk/src/core/run/errors.ts similarity index 100% rename from src/core/run/errors.ts rename to packages/hunk/src/core/run/errors.ts diff --git a/src/core/run/experimental.test.ts b/packages/hunk/src/core/run/experimental.test.ts similarity index 92% rename from src/core/run/experimental.test.ts rename to packages/hunk/src/core/run/experimental.test.ts index d16c51ac3..59a12b5e5 100644 --- a/src/core/run/experimental.test.ts +++ b/packages/hunk/src/core/run/experimental.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from "bun:test"; -import { createTestAgentFileContext, createTestDiffFile } from "../../../test/helpers/diff-helpers"; +import { + createTestAgentFileContext, + createTestDiffFile, +} from "../../../../../test/helpers/diff-helpers"; import { resolveExperimentalDiffFiles, resolveExperimentalFeatures } from "./experimental"; describe("experimental review features", () => { diff --git a/src/core/run/experimental.ts b/packages/hunk/src/core/run/experimental.ts similarity index 100% rename from src/core/run/experimental.ts rename to packages/hunk/src/core/run/experimental.ts diff --git a/src/core/run/inputReload.ts b/packages/hunk/src/core/run/inputReload.ts similarity index 100% rename from src/core/run/inputReload.ts rename to packages/hunk/src/core/run/inputReload.ts diff --git a/src/core/run/paths.test.ts b/packages/hunk/src/core/run/paths.test.ts similarity index 100% rename from src/core/run/paths.test.ts rename to packages/hunk/src/core/run/paths.test.ts diff --git a/src/core/run/paths.ts b/packages/hunk/src/core/run/paths.ts similarity index 96% rename from src/core/run/paths.ts rename to packages/hunk/src/core/run/paths.ts index 7ded9f11c..33f61f850 100644 --- a/src/core/run/paths.ts +++ b/packages/hunk/src/core/run/paths.ts @@ -5,8 +5,8 @@ import { basename, dirname, join, resolve } from "node:path"; * Skills Hunk ships, in the order `hunk skill path` lists them. * * A skill is bundled only if it is in `package.json`'s `files` allowlist and the - * prebuilt artifact staging; `skills/` also holds maintainer-only documents that - * never ship, and naming them here would resolve paths users cannot have. + * prebuilt artifact staging. The repository-root `skills/` directory separately holds + * maintainer-only documents that never ship, and naming them here would resolve paths users cannot have. */ export const BUNDLED_SKILL_NAMES = ["hunk-review", "hunk-extensions"] as const; export type BundledSkillName = (typeof BUNDLED_SKILL_NAMES)[number]; diff --git a/src/core/run/reviewGap.test.ts b/packages/hunk/src/core/run/reviewGap.test.ts similarity index 100% rename from src/core/run/reviewGap.test.ts rename to packages/hunk/src/core/run/reviewGap.test.ts diff --git a/src/core/run/reviewGap.ts b/packages/hunk/src/core/run/reviewGap.ts similarity index 100% rename from src/core/run/reviewGap.ts rename to packages/hunk/src/core/run/reviewGap.ts diff --git a/src/core/run/tabWidth.ts b/packages/hunk/src/core/run/tabWidth.ts similarity index 100% rename from src/core/run/tabWidth.ts rename to packages/hunk/src/core/run/tabWidth.ts diff --git a/src/core/run/version.ts b/packages/hunk/src/core/run/version.ts similarity index 100% rename from src/core/run/version.ts rename to packages/hunk/src/core/run/version.ts diff --git a/src/core/theme/catalog.test.ts b/packages/hunk/src/core/theme/catalog.test.ts similarity index 100% rename from src/core/theme/catalog.test.ts rename to packages/hunk/src/core/theme/catalog.test.ts diff --git a/src/core/theme/catalog.ts b/packages/hunk/src/core/theme/catalog.ts similarity index 100% rename from src/core/theme/catalog.ts rename to packages/hunk/src/core/theme/catalog.ts diff --git a/src/core/theme/customThemes.test.ts b/packages/hunk/src/core/theme/customThemes.test.ts similarity index 100% rename from src/core/theme/customThemes.test.ts rename to packages/hunk/src/core/theme/customThemes.test.ts diff --git a/src/core/theme/customThemes.ts b/packages/hunk/src/core/theme/customThemes.ts similarity index 100% rename from src/core/theme/customThemes.ts rename to packages/hunk/src/core/theme/customThemes.ts diff --git a/src/core/theme/detection.test.ts b/packages/hunk/src/core/theme/detection.test.ts similarity index 100% rename from src/core/theme/detection.test.ts rename to packages/hunk/src/core/theme/detection.test.ts diff --git a/src/core/theme/detection.ts b/packages/hunk/src/core/theme/detection.ts similarity index 100% rename from src/core/theme/detection.ts rename to packages/hunk/src/core/theme/detection.ts diff --git a/src/core/theme/legacySyntaxScopes.test.ts b/packages/hunk/src/core/theme/legacySyntaxScopes.test.ts similarity index 100% rename from src/core/theme/legacySyntaxScopes.test.ts rename to packages/hunk/src/core/theme/legacySyntaxScopes.test.ts diff --git a/src/core/theme/legacySyntaxScopes.ts b/packages/hunk/src/core/theme/legacySyntaxScopes.ts similarity index 100% rename from src/core/theme/legacySyntaxScopes.ts rename to packages/hunk/src/core/theme/legacySyntaxScopes.ts diff --git a/src/core/vcs/index.test.ts b/packages/hunk/src/core/vcs/index.test.ts similarity index 100% rename from src/core/vcs/index.test.ts rename to packages/hunk/src/core/vcs/index.test.ts diff --git a/src/core/vcs/index.ts b/packages/hunk/src/core/vcs/index.ts similarity index 100% rename from src/core/vcs/index.ts rename to packages/hunk/src/core/vcs/index.ts diff --git a/src/core/vcs/types.ts b/packages/hunk/src/core/vcs/types.ts similarity index 100% rename from src/core/vcs/types.ts rename to packages/hunk/src/core/vcs/types.ts diff --git a/src/core/vcs/untracked.test.ts b/packages/hunk/src/core/vcs/untracked.test.ts similarity index 100% rename from src/core/vcs/untracked.test.ts rename to packages/hunk/src/core/vcs/untracked.test.ts diff --git a/src/core/vcs/untracked.ts b/packages/hunk/src/core/vcs/untracked.ts similarity index 100% rename from src/core/vcs/untracked.ts rename to packages/hunk/src/core/vcs/untracked.ts diff --git a/src/core/watch/controller.test.ts b/packages/hunk/src/core/watch/controller.test.ts similarity index 100% rename from src/core/watch/controller.test.ts rename to packages/hunk/src/core/watch/controller.test.ts diff --git a/src/core/watch/controller.ts b/packages/hunk/src/core/watch/controller.ts similarity index 100% rename from src/core/watch/controller.ts rename to packages/hunk/src/core/watch/controller.ts diff --git a/src/core/watch/observer.fs.test.ts b/packages/hunk/src/core/watch/observer.fs.test.ts similarity index 100% rename from src/core/watch/observer.fs.test.ts rename to packages/hunk/src/core/watch/observer.fs.test.ts diff --git a/src/core/watch/observer.test.ts b/packages/hunk/src/core/watch/observer.test.ts similarity index 98% rename from src/core/watch/observer.test.ts rename to packages/hunk/src/core/watch/observer.test.ts index 8372b040f..f909062b4 100644 --- a/src/core/watch/observer.test.ts +++ b/packages/hunk/src/core/watch/observer.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { join } from "node:path"; -import { createWatchTestClock } from "../../../test/helpers/watchTest"; +import { createWatchTestClock } from "../../../../../test/helpers/watchTest"; import { createWatchController, WATCH_EVENT_SOURCE_STARTUP_TIMEOUT_CODE } from "./controller"; import { createNativeTreeWatcher, diff --git a/src/core/watch/observer.ts b/packages/hunk/src/core/watch/observer.ts similarity index 100% rename from src/core/watch/observer.ts rename to packages/hunk/src/core/watch/observer.ts diff --git a/src/core/watch/plan.test.ts b/packages/hunk/src/core/watch/plan.test.ts similarity index 100% rename from src/core/watch/plan.test.ts rename to packages/hunk/src/core/watch/plan.test.ts diff --git a/src/core/watch/plan.ts b/packages/hunk/src/core/watch/plan.ts similarity index 100% rename from src/core/watch/plan.ts rename to packages/hunk/src/core/watch/plan.ts diff --git a/src/core/watch/runtime.test.ts b/packages/hunk/src/core/watch/runtime.test.ts similarity index 100% rename from src/core/watch/runtime.test.ts rename to packages/hunk/src/core/watch/runtime.test.ts diff --git a/src/core/watch/runtime.ts b/packages/hunk/src/core/watch/runtime.ts similarity index 100% rename from src/core/watch/runtime.ts rename to packages/hunk/src/core/watch/runtime.ts diff --git a/src/core/watch/signature.test.ts b/packages/hunk/src/core/watch/signature.test.ts similarity index 100% rename from src/core/watch/signature.test.ts rename to packages/hunk/src/core/watch/signature.test.ts diff --git a/src/core/watch/signature.ts b/packages/hunk/src/core/watch/signature.ts similarity index 100% rename from src/core/watch/signature.ts rename to packages/hunk/src/core/watch/signature.ts diff --git a/src/extension-api/index.ts b/packages/hunk/src/extension-api/index.ts similarity index 100% rename from src/extension-api/index.ts rename to packages/hunk/src/extension-api/index.ts diff --git a/src/extension-api/keys.test.ts b/packages/hunk/src/extension-api/keys.test.ts similarity index 98% rename from src/extension-api/keys.test.ts rename to packages/hunk/src/extension-api/keys.test.ts index 0d8812c6a..c75b9d22a 100644 --- a/src/extension-api/keys.test.ts +++ b/packages/hunk/src/extension-api/keys.test.ts @@ -10,7 +10,7 @@ import { /** * The published chord grammar. * - * These cover the grammar itself; `src/lib/commandKeys.test.ts` covers the + * These cover the grammar itself; `packages/hunk/src/lib/commandKeys.test.ts` covers the * internal-only pieces built on top of it. */ diff --git a/src/extension-api/keys.ts b/packages/hunk/src/extension-api/keys.ts similarity index 100% rename from src/extension-api/keys.ts rename to packages/hunk/src/extension-api/keys.ts diff --git a/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts similarity index 100% rename from src/extension-api/types.ts rename to packages/hunk/src/extension-api/types.ts diff --git a/src/extensions/apply.test.ts b/packages/hunk/src/extensions/apply.test.ts similarity index 99% rename from src/extensions/apply.test.ts rename to packages/hunk/src/extensions/apply.test.ts index bff56e08e..74178273a 100644 --- a/src/extensions/apply.test.ts +++ b/packages/hunk/src/extensions/apply.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { HUNK_CORE_VCS_DETECTION_PRIORITY, HUNK_DEFAULT_VCS_DETECTION_PRIORITY, diff --git a/src/extensions/apply.ts b/packages/hunk/src/extensions/apply.ts similarity index 100% rename from src/extensions/apply.ts rename to packages/hunk/src/extensions/apply.ts diff --git a/src/extensions/cliCommandRuntime.test.ts b/packages/hunk/src/extensions/cliCommandRuntime.test.ts similarity index 100% rename from src/extensions/cliCommandRuntime.test.ts rename to packages/hunk/src/extensions/cliCommandRuntime.test.ts diff --git a/src/extensions/cliCommandRuntime.ts b/packages/hunk/src/extensions/cliCommandRuntime.ts similarity index 100% rename from src/extensions/cliCommandRuntime.ts rename to packages/hunk/src/extensions/cliCommandRuntime.ts diff --git a/src/extensions/cliCommands.test.ts b/packages/hunk/src/extensions/cliCommands.test.ts similarity index 100% rename from src/extensions/cliCommands.test.ts rename to packages/hunk/src/extensions/cliCommands.test.ts diff --git a/src/extensions/cliCommands.ts b/packages/hunk/src/extensions/cliCommands.ts similarity index 100% rename from src/extensions/cliCommands.ts rename to packages/hunk/src/extensions/cliCommands.ts diff --git a/src/extensions/default/ui/index.test.ts b/packages/hunk/src/extensions/default/ui/index.test.ts similarity index 100% rename from src/extensions/default/ui/index.test.ts rename to packages/hunk/src/extensions/default/ui/index.test.ts diff --git a/src/extensions/default/ui/index.ts b/packages/hunk/src/extensions/default/ui/index.ts similarity index 100% rename from src/extensions/default/ui/index.ts rename to packages/hunk/src/extensions/default/ui/index.ts diff --git a/src/extensions/default/ui/reviewInfo/index.test.tsx b/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx similarity index 97% rename from src/extensions/default/ui/reviewInfo/index.test.tsx rename to packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx index 7728fb99f..228793e8a 100644 --- a/src/extensions/default/ui/reviewInfo/index.test.tsx +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; -import { capturedTestColorToHex } from "../../../../../test/helpers/test-color-helpers"; +import { capturedTestColorToHex } from "../../../../../../../test/helpers/test-color-helpers"; import type { ExtensionPaneProps } from "../../../../extension-api/types"; import { resolveTheme } from "../../../../ui/themes"; import { ReviewInfoPane } from "."; diff --git a/src/extensions/default/ui/reviewInfo/index.tsx b/packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx similarity index 100% rename from src/extensions/default/ui/reviewInfo/index.tsx rename to packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx diff --git a/src/extensions/default/ui/reviewInfo/presentation.test.ts b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts similarity index 100% rename from src/extensions/default/ui/reviewInfo/presentation.test.ts rename to packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts diff --git a/src/extensions/default/ui/reviewInfo/presentation.ts b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts similarity index 100% rename from src/extensions/default/ui/reviewInfo/presentation.ts rename to packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts diff --git a/src/extensions/default/ui/sidebar/FileSidebars.tsx b/packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx similarity index 100% rename from src/extensions/default/ui/sidebar/FileSidebars.tsx rename to packages/hunk/src/extensions/default/ui/sidebar/FileSidebars.tsx diff --git a/src/extensions/default/ui/sidebar/index.test.tsx b/packages/hunk/src/extensions/default/ui/sidebar/index.test.tsx similarity index 100% rename from src/extensions/default/ui/sidebar/index.test.tsx rename to packages/hunk/src/extensions/default/ui/sidebar/index.test.tsx diff --git a/src/extensions/default/ui/sidebar/index.tsx b/packages/hunk/src/extensions/default/ui/sidebar/index.tsx similarity index 100% rename from src/extensions/default/ui/sidebar/index.tsx rename to packages/hunk/src/extensions/default/ui/sidebar/index.tsx diff --git a/src/extensions/default/vcs/asyncProcess.test.ts b/packages/hunk/src/extensions/default/vcs/asyncProcess.test.ts similarity index 100% rename from src/extensions/default/vcs/asyncProcess.test.ts rename to packages/hunk/src/extensions/default/vcs/asyncProcess.test.ts diff --git a/src/extensions/default/vcs/asyncProcess.ts b/packages/hunk/src/extensions/default/vcs/asyncProcess.ts similarity index 100% rename from src/extensions/default/vcs/asyncProcess.ts rename to packages/hunk/src/extensions/default/vcs/asyncProcess.ts diff --git a/src/extensions/default/vcs/diffRange.ts b/packages/hunk/src/extensions/default/vcs/diffRange.ts similarity index 100% rename from src/extensions/default/vcs/diffRange.ts rename to packages/hunk/src/extensions/default/vcs/diffRange.ts diff --git a/src/extensions/default/vcs/git/commands.test.ts b/packages/hunk/src/extensions/default/vcs/git/commands.test.ts similarity index 100% rename from src/extensions/default/vcs/git/commands.test.ts rename to packages/hunk/src/extensions/default/vcs/git/commands.test.ts diff --git a/src/extensions/default/vcs/git/commands.ts b/packages/hunk/src/extensions/default/vcs/git/commands.ts similarity index 96% rename from src/extensions/default/vcs/git/commands.ts rename to packages/hunk/src/extensions/default/vcs/git/commands.ts index 82d8ee873..0b6080bfd 100644 --- a/src/extensions/default/vcs/git/commands.ts +++ b/packages/hunk/src/extensions/default/vcs/git/commands.ts @@ -15,7 +15,7 @@ import { runAbortableCommand } from "../asyncProcess"; * Every Git command Hunk runs, and the failures they translate into. * * This is the implementation layer behind the bundled Git backend - * (`src/extensions/default/vcs/git/`), so nothing here reaches into core, the + * (`packages/hunk/src/extensions/default/vcs/git/`), so nothing here reaches into core, the * diff engine, or the adapter registry — user-facing failures are raised as the * published `HunkExtensionUserError`, which is exactly what a third-party * backend would throw. @@ -553,26 +553,6 @@ export async function runGitTextAsync(options: RunGitTextOptions): Promise = {}, -) { - const result = runGitCommand({ - input, - args: ["config", "--get", key], - ...options, - acceptedExitCodes: [0, 1], - }); - - if (result.exitCode !== 0) { - return undefined; - } - - return result.stdout.trim() || undefined; -} - /** Normalize Git's diff.colorMoved config into the mode Hunk should request from Git. */ function normalizeGitColorMovedMode(value: string | undefined) { if (!value) { @@ -591,31 +571,6 @@ function normalizeGitColorMovedMode(value: string | undefined) { return value; } -/** Resolve whether Hunk should ask Git to color moved lines for this patch command. */ -export function resolveGitColorMovedOptions( - input: GitBackedInput, - options: Omit = {}, -): GitColorMovedOptions | null { - const gitMode = normalizeGitColorMovedMode( - readOptionalGitConfig(input, "diff.colorMoved", options), - ); - - if (gitMode === null) { - return null; - } - - const mode = gitMode ?? (input.options.colorMoved ? "zebra" : undefined); - if (!mode) { - return null; - } - - const whitespaceMode = readOptionalGitConfig(input, "diff.colorMovedWS", options); - return { - mode, - whitespaceMode, - }; -} - /** Resolve moved-line configuration without blocking an embedded renderer. */ export async function resolveGitColorMovedOptionsAsync( input: GitBackedInput, diff --git a/src/extensions/default/vcs/git/history.test.ts b/packages/hunk/src/extensions/default/vcs/git/history.test.ts similarity index 100% rename from src/extensions/default/vcs/git/history.test.ts rename to packages/hunk/src/extensions/default/vcs/git/history.test.ts diff --git a/src/extensions/default/vcs/git/history.ts b/packages/hunk/src/extensions/default/vcs/git/history.ts similarity index 100% rename from src/extensions/default/vcs/git/history.ts rename to packages/hunk/src/extensions/default/vcs/git/history.ts diff --git a/src/extensions/default/vcs/git/index.test.ts b/packages/hunk/src/extensions/default/vcs/git/index.test.ts similarity index 100% rename from src/extensions/default/vcs/git/index.test.ts rename to packages/hunk/src/extensions/default/vcs/git/index.test.ts diff --git a/src/extensions/default/vcs/git/index.ts b/packages/hunk/src/extensions/default/vcs/git/index.ts similarity index 99% rename from src/extensions/default/vcs/git/index.ts rename to packages/hunk/src/extensions/default/vcs/git/index.ts index a6489daa1..cf9e96e6e 100644 --- a/src/extensions/default/vcs/git/index.ts +++ b/packages/hunk/src/extensions/default/vcs/git/index.ts @@ -44,7 +44,7 @@ import { * file sources, skipped-too-large placeholders, untracked files, watch plans, * rich failures — so it is deliberately written the way a third-party backend * would be: it sees only the published `hunkdiff/extension` contract plus - * implementation helpers owned by this extension directory and generic `src/lib` + * implementation helpers owned by this extension directory and generic `packages/hunk/src/lib` * utilities. Nothing here reaches into core, the diff engine, or the * adapter registry. If something Git needs cannot be said in these types, the * published contract is missing it, and that is the point of shipping it this diff --git a/src/extensions/default/vcs/git/source.test.ts b/packages/hunk/src/extensions/default/vcs/git/source.test.ts similarity index 100% rename from src/extensions/default/vcs/git/source.test.ts rename to packages/hunk/src/extensions/default/vcs/git/source.test.ts diff --git a/src/extensions/default/vcs/git/source.ts b/packages/hunk/src/extensions/default/vcs/git/source.ts similarity index 100% rename from src/extensions/default/vcs/git/source.ts rename to packages/hunk/src/extensions/default/vcs/git/source.ts diff --git a/src/extensions/default/vcs/index.test.ts b/packages/hunk/src/extensions/default/vcs/index.test.ts similarity index 100% rename from src/extensions/default/vcs/index.test.ts rename to packages/hunk/src/extensions/default/vcs/index.test.ts diff --git a/src/extensions/default/vcs/index.ts b/packages/hunk/src/extensions/default/vcs/index.ts similarity index 100% rename from src/extensions/default/vcs/index.ts rename to packages/hunk/src/extensions/default/vcs/index.ts diff --git a/src/extensions/default/vcs/jujutsu/commands.test.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/commands.test.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/commands.test.ts rename to packages/hunk/src/extensions/default/vcs/jujutsu/commands.test.ts diff --git a/src/extensions/default/vcs/jujutsu/commands.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/commands.ts similarity index 98% rename from src/extensions/default/vcs/jujutsu/commands.ts rename to packages/hunk/src/extensions/default/vcs/jujutsu/commands.ts index daa3989c6..917d6c816 100644 --- a/src/extensions/default/vcs/jujutsu/commands.ts +++ b/packages/hunk/src/extensions/default/vcs/jujutsu/commands.ts @@ -414,18 +414,6 @@ export async function resolveJjRangeEndpointsAsync( return { newCommitId: toCommitIds[0]!, oldCommitIds: [fromCommitIds[0]!] }; } -export function resolveJjRepoRoot( - input: JjBackedInput, - options: Omit = {}, -) { - const repoRoot = runJjText({ - input, - args: ["root"], - ...options, - }).trim(); - return normalizePathForOS(repoRoot); -} - /** Resolve the JJ repository root without blocking renderer input. */ export async function resolveJjRepoRootAsync( input: JjBackedInput, diff --git a/src/extensions/default/vcs/jujutsu/history.test.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/history.test.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/history.test.ts rename to packages/hunk/src/extensions/default/vcs/jujutsu/history.test.ts diff --git a/src/extensions/default/vcs/jujutsu/history.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/history.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/history.ts rename to packages/hunk/src/extensions/default/vcs/jujutsu/history.ts diff --git a/src/extensions/default/vcs/jujutsu/index.test.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/index.test.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/index.test.ts rename to packages/hunk/src/extensions/default/vcs/jujutsu/index.test.ts diff --git a/src/extensions/default/vcs/jujutsu/index.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/index.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/index.ts rename to packages/hunk/src/extensions/default/vcs/jujutsu/index.ts diff --git a/src/extensions/default/vcs/jujutsu/source.test.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/source.test.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/source.test.ts rename to packages/hunk/src/extensions/default/vcs/jujutsu/source.test.ts diff --git a/src/extensions/default/vcs/jujutsu/source.ts b/packages/hunk/src/extensions/default/vcs/jujutsu/source.ts similarity index 100% rename from src/extensions/default/vcs/jujutsu/source.ts rename to packages/hunk/src/extensions/default/vcs/jujutsu/source.ts diff --git a/src/extensions/default/vcs/sapling/commands.test.ts b/packages/hunk/src/extensions/default/vcs/sapling/commands.test.ts similarity index 100% rename from src/extensions/default/vcs/sapling/commands.test.ts rename to packages/hunk/src/extensions/default/vcs/sapling/commands.test.ts diff --git a/src/extensions/default/vcs/sapling/commands.ts b/packages/hunk/src/extensions/default/vcs/sapling/commands.ts similarity index 100% rename from src/extensions/default/vcs/sapling/commands.ts rename to packages/hunk/src/extensions/default/vcs/sapling/commands.ts diff --git a/src/extensions/default/vcs/sapling/index.test.ts b/packages/hunk/src/extensions/default/vcs/sapling/index.test.ts similarity index 100% rename from src/extensions/default/vcs/sapling/index.test.ts rename to packages/hunk/src/extensions/default/vcs/sapling/index.test.ts diff --git a/src/extensions/default/vcs/sapling/index.ts b/packages/hunk/src/extensions/default/vcs/sapling/index.ts similarity index 100% rename from src/extensions/default/vcs/sapling/index.ts rename to packages/hunk/src/extensions/default/vcs/sapling/index.ts diff --git a/src/extensions/discovery.test.ts b/packages/hunk/src/extensions/discovery.test.ts similarity index 100% rename from src/extensions/discovery.test.ts rename to packages/hunk/src/extensions/discovery.test.ts diff --git a/src/extensions/discovery.ts b/packages/hunk/src/extensions/discovery.ts similarity index 100% rename from src/extensions/discovery.ts rename to packages/hunk/src/extensions/discovery.ts diff --git a/src/extensions/events.test.ts b/packages/hunk/src/extensions/events.test.ts similarity index 99% rename from src/extensions/events.test.ts rename to packages/hunk/src/extensions/events.test.ts index 8957295a9..b5086c644 100644 --- a/src/extensions/events.test.ts +++ b/packages/hunk/src/extensions/events.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { bindExtensionEventBus, emitExtensionCustomEvent, diff --git a/src/extensions/events.ts b/packages/hunk/src/extensions/events.ts similarity index 100% rename from src/extensions/events.ts rename to packages/hunk/src/extensions/events.ts diff --git a/src/extensions/extensionIds.ts b/packages/hunk/src/extensions/extensionIds.ts similarity index 95% rename from src/extensions/extensionIds.ts rename to packages/hunk/src/extensions/extensionIds.ts index cec899f29..65ba7898f 100644 --- a/src/extensions/extensionIds.ts +++ b/packages/hunk/src/extensions/extensionIds.ts @@ -15,7 +15,7 @@ * colon; no leading separator, so ids read as names. * * The rules are enforced once, where candidates become loadable extensions - * (`src/extensions/host.ts`); this module only states them, so keymap code can + * (`packages/hunk/src/extensions/host.ts`); this module only states them, so keymap code can * ask about the vendor namespace without pulling the loader in behind it. */ diff --git a/src/extensions/host.test.ts b/packages/hunk/src/extensions/host.test.ts similarity index 100% rename from src/extensions/host.test.ts rename to packages/hunk/src/extensions/host.test.ts diff --git a/src/extensions/host.ts b/packages/hunk/src/extensions/host.ts similarity index 100% rename from src/extensions/host.ts rename to packages/hunk/src/extensions/host.ts diff --git a/src/extensions/hostRuntimeModules.test.ts b/packages/hunk/src/extensions/hostRuntimeModules.test.ts similarity index 100% rename from src/extensions/hostRuntimeModules.test.ts rename to packages/hunk/src/extensions/hostRuntimeModules.test.ts diff --git a/src/extensions/hostRuntimeModules.ts b/packages/hunk/src/extensions/hostRuntimeModules.ts similarity index 100% rename from src/extensions/hostRuntimeModules.ts rename to packages/hunk/src/extensions/hostRuntimeModules.ts diff --git a/src/extensions/manage/cli.ts b/packages/hunk/src/extensions/manage/cli.ts similarity index 100% rename from src/extensions/manage/cli.ts rename to packages/hunk/src/extensions/manage/cli.ts diff --git a/src/extensions/manage/install.test.ts b/packages/hunk/src/extensions/manage/install.test.ts similarity index 100% rename from src/extensions/manage/install.test.ts rename to packages/hunk/src/extensions/manage/install.test.ts diff --git a/src/extensions/manage/install.ts b/packages/hunk/src/extensions/manage/install.ts similarity index 100% rename from src/extensions/manage/install.ts rename to packages/hunk/src/extensions/manage/install.ts diff --git a/src/extensions/manage/records.ts b/packages/hunk/src/extensions/manage/records.ts similarity index 100% rename from src/extensions/manage/records.ts rename to packages/hunk/src/extensions/manage/records.ts diff --git a/src/extensions/manage/source.test.ts b/packages/hunk/src/extensions/manage/source.test.ts similarity index 100% rename from src/extensions/manage/source.test.ts rename to packages/hunk/src/extensions/manage/source.test.ts diff --git a/src/extensions/manage/source.ts b/packages/hunk/src/extensions/manage/source.ts similarity index 100% rename from src/extensions/manage/source.ts rename to packages/hunk/src/extensions/manage/source.ts diff --git a/src/extensions/notifications.test.ts b/packages/hunk/src/extensions/notifications.test.ts similarity index 100% rename from src/extensions/notifications.test.ts rename to packages/hunk/src/extensions/notifications.test.ts diff --git a/src/extensions/notifications.ts b/packages/hunk/src/extensions/notifications.ts similarity index 100% rename from src/extensions/notifications.ts rename to packages/hunk/src/extensions/notifications.ts diff --git a/src/extensions/panes.ts b/packages/hunk/src/extensions/panes.ts similarity index 100% rename from src/extensions/panes.ts rename to packages/hunk/src/extensions/panes.ts diff --git a/src/extensions/publicApiRobustness.test.ts b/packages/hunk/src/extensions/publicApiRobustness.test.ts similarity index 99% rename from src/extensions/publicApiRobustness.test.ts rename to packages/hunk/src/extensions/publicApiRobustness.test.ts index 7ff7b09ce..961e51643 100644 --- a/src/extensions/publicApiRobustness.test.ts +++ b/packages/hunk/src/extensions/publicApiRobustness.test.ts @@ -3,7 +3,7 @@ import { collectSessionCustomThemes } from "../core/theme/customThemes"; import type { Changeset } from "../core/changeset/model"; import { detectVcs, extendVcsCatalog } from "../core/vcs"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; -import { createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { createTestDiffFile } from "../../../../test/helpers/diff-helpers"; import { applyExtensionChangesetTransforms, applyExtensionFileLanguages, diff --git a/src/extensions/reviewSnapshot.test.ts b/packages/hunk/src/extensions/reviewSnapshot.test.ts similarity index 99% rename from src/extensions/reviewSnapshot.test.ts rename to packages/hunk/src/extensions/reviewSnapshot.test.ts index a698069a0..512ea45e0 100644 --- a/src/extensions/reviewSnapshot.test.ts +++ b/packages/hunk/src/extensions/reviewSnapshot.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import { createTestReviewState, createTestStoredNote, -} from "../../test/helpers/review-store-helpers"; +} from "../../../../test/helpers/review-store-helpers"; import { buildExtensionReviewSnapshot, diffExtensionReviewNotes, diff --git a/src/extensions/reviewSnapshot.ts b/packages/hunk/src/extensions/reviewSnapshot.ts similarity index 100% rename from src/extensions/reviewSnapshot.ts rename to packages/hunk/src/extensions/reviewSnapshot.ts diff --git a/src/extensions/runExtension.test.ts b/packages/hunk/src/extensions/runExtension.test.ts similarity index 100% rename from src/extensions/runExtension.test.ts rename to packages/hunk/src/extensions/runExtension.test.ts diff --git a/src/extensions/runExtension.ts b/packages/hunk/src/extensions/runExtension.ts similarity index 99% rename from src/extensions/runExtension.ts rename to packages/hunk/src/extensions/runExtension.ts index 8f69b6f7e..8f2e05d8a 100644 --- a/src/extensions/runExtension.ts +++ b/packages/hunk/src/extensions/runExtension.ts @@ -45,7 +45,7 @@ import { copyExtensionCliCommand } from "./cliCommands"; /** * Running one extension factory into the shared registry. * - * This module deliberately imports nothing from `src/core/vcs` beyond its + * This module deliberately imports nothing from `packages/hunk/src/core/vcs` beyond its * types: it is what the bundled tier (`./bundled`) uses, and that tier is * loaded *from* VCS adapter resolution. Keeping the dependency one-way means * bundled loading cannot deadlock on a half-initialized module. diff --git a/src/extensions/startup.test.ts b/packages/hunk/src/extensions/startup.test.ts similarity index 100% rename from src/extensions/startup.test.ts rename to packages/hunk/src/extensions/startup.test.ts diff --git a/src/extensions/startup.ts b/packages/hunk/src/extensions/startup.ts similarity index 100% rename from src/extensions/startup.ts rename to packages/hunk/src/extensions/startup.ts diff --git a/src/extensions/trust.test.ts b/packages/hunk/src/extensions/trust.test.ts similarity index 100% rename from src/extensions/trust.test.ts rename to packages/hunk/src/extensions/trust.test.ts diff --git a/src/extensions/trust.ts b/packages/hunk/src/extensions/trust.ts similarity index 100% rename from src/extensions/trust.ts rename to packages/hunk/src/extensions/trust.ts diff --git a/src/extensions/types.ts b/packages/hunk/src/extensions/types.ts similarity index 98% rename from src/extensions/types.ts rename to packages/hunk/src/extensions/types.ts index 4bace99a9..dbd6be259 100644 --- a/src/extensions/types.ts +++ b/packages/hunk/src/extensions/types.ts @@ -22,7 +22,7 @@ import type { import { createExtensionNotificationHub, type ExtensionNotificationHub } from "./notifications"; /** - * The authoring contract lives in `src/extension-api/types.ts` and is re-exported + * The authoring contract lives in `packages/hunk/src/extension-api/types.ts` and is re-exported * here so host code keeps one import site for both halves of the system. That * module is self-contained because its declarations are published; this one is * free to reference Hunk internals. @@ -93,7 +93,7 @@ export type { /** * Where one extension came from, which decides its trust posture. * - * `bundled` is Hunk's own compiled-in tier (`src/extensions/default/`): no + * `bundled` is Hunk's own compiled-in tier (`packages/hunk/src/extensions/default/`): no * discovery, no trust prompt, and never disabled by `--no-extensions`. Every * other origin is a user extension loaded from disk. */ diff --git a/src/extensions/vcsPatchResult.test.ts b/packages/hunk/src/extensions/vcsPatchResult.test.ts similarity index 100% rename from src/extensions/vcsPatchResult.test.ts rename to packages/hunk/src/extensions/vcsPatchResult.test.ts diff --git a/src/extensions/vcsPatchResult.ts b/packages/hunk/src/extensions/vcsPatchResult.ts similarity index 100% rename from src/extensions/vcsPatchResult.ts rename to packages/hunk/src/extensions/vcsPatchResult.ts diff --git a/src/highlightWorkerClient.ts b/packages/hunk/src/highlightWorkerClient.ts similarity index 100% rename from src/highlightWorkerClient.ts rename to packages/hunk/src/highlightWorkerClient.ts diff --git a/src/highlightWorkerEntry.ts b/packages/hunk/src/highlightWorkerEntry.ts similarity index 100% rename from src/highlightWorkerEntry.ts rename to packages/hunk/src/highlightWorkerEntry.ts diff --git a/src/hunk-review/skillDocument.test.ts b/packages/hunk/src/hunk-review/skillDocument.test.ts similarity index 90% rename from src/hunk-review/skillDocument.test.ts rename to packages/hunk/src/hunk-review/skillDocument.test.ts index e45f4175e..ac5eded55 100644 --- a/src/hunk-review/skillDocument.test.ts +++ b/packages/hunk/src/hunk-review/skillDocument.test.ts @@ -9,7 +9,15 @@ import { import { renderHunkReviewSkill } from "./skillDocument"; const SKILL_PATH = join(import.meta.dir, "..", "..", "skills", "hunk-review", "SKILL.md"); -const AGENT_WORKFLOWS_PATH = join(import.meta.dir, "..", "..", "docs", "agent-workflows.md"); +const AGENT_WORKFLOWS_PATH = join( + import.meta.dir, + "..", + "..", + "..", + "..", + "docs", + "agent-workflows.md", +); /** Every flag the agent-facing docs may reference: the session surface plus auxiliary options. */ const DOCUMENTED_AGENT_FLAGS = new Set([ @@ -32,7 +40,7 @@ describe("hunk-review skill document", () => { if (checkedIn !== rendered) { throw new Error( - "skills/hunk-review/SKILL.md is out of date. Run `bun run generate:skill` and commit the result.", + "packages/hunk/skills/hunk-review/SKILL.md is out of date. Run `bun run generate:skill` and commit the result.", ); } diff --git a/src/hunk-review/skillDocument.ts b/packages/hunk/src/hunk-review/skillDocument.ts similarity index 99% rename from src/hunk-review/skillDocument.ts rename to packages/hunk/src/hunk-review/skillDocument.ts index 79694d612..8a3983f07 100644 --- a/src/hunk-review/skillDocument.ts +++ b/packages/hunk/src/hunk-review/skillDocument.ts @@ -2,7 +2,7 @@ import { AGENT_ERROR_DOCS } from "../session/agent/errors"; import { SESSION_AGENT_COMMANDS, type AgentCommandSpec } from "../session/agent/surface"; /** - * Deterministic renderer for `skills/hunk-review/SKILL.md`. + * Deterministic renderer for `packages/hunk/skills/hunk-review/SKILL.md`. * * The command reference blocks and the "Common errors" section are derived from * `agentSurface.ts` and `agentErrors.ts` so the skill can never disagree with the parser or the diff --git a/src/lib/commandKeys.test.ts b/packages/hunk/src/lib/commandKeys.test.ts similarity index 94% rename from src/lib/commandKeys.test.ts rename to packages/hunk/src/lib/commandKeys.test.ts index 54c451581..ee4f053c4 100644 --- a/src/lib/commandKeys.test.ts +++ b/packages/hunk/src/lib/commandKeys.test.ts @@ -7,7 +7,7 @@ import { synthesizeKeyEvent } from "../ui/lib/syntheticKeyEvent"; * The internal-only pieces of chord handling. * * The grammar itself is published as `hunkdiff/extension` and covered by - * `src/extension-api/keys.test.ts`; what lives here is what only Hunk needs. + * `packages/hunk/src/extension-api/keys.test.ts`; what lives here is what only Hunk needs. */ function parsed(chord: string) { diff --git a/src/lib/commandKeys.ts b/packages/hunk/src/lib/commandKeys.ts similarity index 96% rename from src/lib/commandKeys.ts rename to packages/hunk/src/lib/commandKeys.ts index f9de7322f..15ece1cf7 100644 --- a/src/lib/commandKeys.ts +++ b/packages/hunk/src/lib/commandKeys.ts @@ -4,7 +4,7 @@ import { matchesKeyChord, parseKeyChord, type ParsedKeyChord } from "../extensio /** * Key-chord parsing and matching for the command registry. * - * The grammar itself lives in `src/extension-api/keys.ts` because extensions + * The grammar itself lives in `packages/hunk/src/extension-api/keys.ts` because extensions * need it too — it is published as part of `hunkdiff/extension` — and is * re-exported here so internal code keeps importing key handling from one * place. What stays local is what extensions have no use for: synthesizing the diff --git a/src/lib/largeFile.ts b/packages/hunk/src/lib/largeFile.ts similarity index 100% rename from src/lib/largeFile.ts rename to packages/hunk/src/lib/largeFile.ts diff --git a/src/lib/osPath.test.ts b/packages/hunk/src/lib/osPath.test.ts similarity index 100% rename from src/lib/osPath.test.ts rename to packages/hunk/src/lib/osPath.test.ts diff --git a/src/lib/osPath.ts b/packages/hunk/src/lib/osPath.ts similarity index 100% rename from src/lib/osPath.ts rename to packages/hunk/src/lib/osPath.ts diff --git a/src/lib/patchPath.ts b/packages/hunk/src/lib/patchPath.ts similarity index 100% rename from src/lib/patchPath.ts rename to packages/hunk/src/lib/patchPath.ts diff --git a/src/lib/sourceText.test.ts b/packages/hunk/src/lib/sourceText.test.ts similarity index 100% rename from src/lib/sourceText.test.ts rename to packages/hunk/src/lib/sourceText.test.ts diff --git a/src/lib/sourceText.ts b/packages/hunk/src/lib/sourceText.ts similarity index 100% rename from src/lib/sourceText.ts rename to packages/hunk/src/lib/sourceText.ts diff --git a/src/lib/terminalText.test.ts b/packages/hunk/src/lib/terminalText.test.ts similarity index 100% rename from src/lib/terminalText.test.ts rename to packages/hunk/src/lib/terminalText.test.ts diff --git a/src/lib/terminalText.ts b/packages/hunk/src/lib/terminalText.ts similarity index 100% rename from src/lib/terminalText.ts rename to packages/hunk/src/lib/terminalText.ts diff --git a/src/main.tsx b/packages/hunk/src/main.tsx similarity index 100% rename from src/main.tsx rename to packages/hunk/src/main.tsx diff --git a/src/opentui/HunkDiffBody.tsx b/packages/hunk/src/opentui/HunkDiffBody.tsx similarity index 100% rename from src/opentui/HunkDiffBody.tsx rename to packages/hunk/src/opentui/HunkDiffBody.tsx diff --git a/src/opentui/HunkDiffFileHeader.tsx b/packages/hunk/src/opentui/HunkDiffFileHeader.tsx similarity index 100% rename from src/opentui/HunkDiffFileHeader.tsx rename to packages/hunk/src/opentui/HunkDiffFileHeader.tsx diff --git a/src/opentui/HunkDiffView.test.tsx b/packages/hunk/src/opentui/HunkDiffView.test.tsx similarity index 100% rename from src/opentui/HunkDiffView.test.tsx rename to packages/hunk/src/opentui/HunkDiffView.test.tsx diff --git a/src/opentui/HunkDiffView.tsx b/packages/hunk/src/opentui/HunkDiffView.tsx similarity index 100% rename from src/opentui/HunkDiffView.tsx rename to packages/hunk/src/opentui/HunkDiffView.tsx diff --git a/src/opentui/HunkFileNav.tsx b/packages/hunk/src/opentui/HunkFileNav.tsx similarity index 100% rename from src/opentui/HunkFileNav.tsx rename to packages/hunk/src/opentui/HunkFileNav.tsx diff --git a/src/opentui/HunkReviewStream.tsx b/packages/hunk/src/opentui/HunkReviewStream.tsx similarity index 100% rename from src/opentui/HunkReviewStream.tsx rename to packages/hunk/src/opentui/HunkReviewStream.tsx diff --git a/src/opentui/index.ts b/packages/hunk/src/opentui/index.ts similarity index 100% rename from src/opentui/index.ts rename to packages/hunk/src/opentui/index.ts diff --git a/src/opentui/model.ts b/packages/hunk/src/opentui/model.ts similarity index 88% rename from src/opentui/model.ts rename to packages/hunk/src/opentui/model.ts index 533c1ad34..0073ce809 100644 --- a/src/opentui/model.ts +++ b/packages/hunk/src/opentui/model.ts @@ -1,16 +1,18 @@ -import { parsePatchFiles } from "@pierre/diffs"; +import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"; import { patchLooksBinary } from "../core/changeset/binary"; import { normalizeDiffMetadataPaths, normalizeDiffPath } from "../core/changeset/diffPaths"; import { countDiffStats } from "../core/changeset/diffFile"; import { splitPatchIntoFileChunks, findPatchChunk } from "../core/patch/chunks"; import { sanitizePatch } from "../core/patch/sanitize"; import type { DiffFile } from "../core/changeset/model"; -import type { HunkDiffFile, HunkDiffFileInput } from "./types"; +import type { HunkDiffFile, HunkDiffFileInput, HunkDiffStats } from "./types"; const NORMALIZED_HUNK_DIFF_FILES = new WeakSet(); /** Count visible additions and deletions from Pierre metadata. */ -export const countHunkDiffStats = countDiffStats; +export function countHunkDiffStats(metadata: FileDiffMetadata): HunkDiffStats { + return countDiffStats(metadata); +} /** Build one public file while optionally preserving paths decoded exactly from Git quoting. */ function buildHunkDiffFile(input: HunkDiffFileInput, pathsAreExact: boolean): HunkDiffFile { @@ -48,7 +50,7 @@ function resolveHunkDiffFile(input: HunkDiffFileInput) { return createHunkDiffFile(input); } -/** Adapt the public OpenTUI file shape into Hunk's internal review file model. */ +/** @internal Adapt the public OpenTUI file shape into Hunk's internal review file model. */ export function toInternalDiffFile(diff: HunkDiffFileInput): DiffFile { const normalized = resolveHunkDiffFile(diff); const patch = normalized.patch ?? ""; @@ -93,7 +95,7 @@ export function createHunkDiffFilesFromPatch(patchText: string, sourceId = "patc }); } -/** Adapt a list of public OpenTUI files into Hunk's internal review file model. */ +/** @internal Adapt a list of public OpenTUI files into Hunk's internal review file model. */ export function toInternalDiffFiles(files: HunkDiffFileInput[]) { return files.map(toInternalDiffFile); } diff --git a/src/opentui/themes.ts b/packages/hunk/src/opentui/themes.ts similarity index 100% rename from src/opentui/themes.ts rename to packages/hunk/src/opentui/themes.ts diff --git a/src/opentui/types.ts b/packages/hunk/src/opentui/types.ts similarity index 100% rename from src/opentui/types.ts rename to packages/hunk/src/opentui/types.ts diff --git a/src/session/agent/cliClient.test.ts b/packages/hunk/src/session/agent/cliClient.test.ts similarity index 99% rename from src/session/agent/cliClient.test.ts rename to packages/hunk/src/session/agent/cliClient.test.ts index b58b0a9f9..7598c5a59 100644 --- a/src/session/agent/cliClient.test.ts +++ b/packages/hunk/src/session/agent/cliClient.test.ts @@ -8,7 +8,7 @@ import { createTestSessionReviewFile, createTestSessionReviewHunk, createTestSessionSnapshot, -} from "../../../test/helpers/session-daemon-fixtures"; +} from "../../../../../test/helpers/session-daemon-fixtures"; import type { SessionSelectorInput } from "../../core/run/commandInputs"; import { HUNK_SESSION_API_PATH, diff --git a/src/session/agent/cliClient.ts b/packages/hunk/src/session/agent/cliClient.ts similarity index 100% rename from src/session/agent/cliClient.ts rename to packages/hunk/src/session/agent/cliClient.ts diff --git a/src/session/agent/commands.daemon.test.ts b/packages/hunk/src/session/agent/commands.daemon.test.ts similarity index 98% rename from src/session/agent/commands.daemon.test.ts rename to packages/hunk/src/session/agent/commands.daemon.test.ts index dbb4c7f62..93f59b68d 100644 --- a/src/session/agent/commands.daemon.test.ts +++ b/packages/hunk/src/session/agent/commands.daemon.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { createServer } from "node:net"; import { platform } from "node:os"; import type { SessionCommandInput } from "../../core/run/commandInputs"; -import { createTestListedSession } from "../../../test/helpers/session-daemon-fixtures"; +import { createTestListedSession } from "../../../../../test/helpers/session-daemon-fixtures"; import { runSessionCommand, setSessionCommandTestHooks, diff --git a/src/session/agent/commands.test.ts b/packages/hunk/src/session/agent/commands.test.ts similarity index 99% rename from src/session/agent/commands.test.ts rename to packages/hunk/src/session/agent/commands.test.ts index 8b7d9e7b7..8470c8f5d 100644 --- a/src/session/agent/commands.test.ts +++ b/packages/hunk/src/session/agent/commands.test.ts @@ -6,7 +6,7 @@ import { createTestSessionFileSummary, createTestSessionReview as buildTestSessionReview, createTestSessionSnapshot, -} from "../../../test/helpers/session-daemon-fixtures"; +} from "../../../../../test/helpers/session-daemon-fixtures"; import type { SessionCommandInput, SessionSelectorInput } from "../../core/run/commandInputs"; import { runSessionCommand, diff --git a/src/session/agent/commands.ts b/packages/hunk/src/session/agent/commands.ts similarity index 100% rename from src/session/agent/commands.ts rename to packages/hunk/src/session/agent/commands.ts diff --git a/src/session/agent/errors.test.ts b/packages/hunk/src/session/agent/errors.test.ts similarity index 100% rename from src/session/agent/errors.test.ts rename to packages/hunk/src/session/agent/errors.test.ts diff --git a/src/session/agent/errors.ts b/packages/hunk/src/session/agent/errors.ts similarity index 97% rename from src/session/agent/errors.ts rename to packages/hunk/src/session/agent/errors.ts index dcba9dad4..b8db1f392 100644 --- a/src/session/agent/errors.ts +++ b/packages/hunk/src/session/agent/errors.ts @@ -1,7 +1,7 @@ /** * Agent-facing error messages for the `hunk session` surface. * - * Every message quoted by the generated `skills/hunk-review/SKILL.md` "Common errors" section is + * Every message quoted by the generated `packages/hunk/skills/hunk-review/SKILL.md` "Common errors" section is * defined (or contract-tested) here, so the skill can never quote wording the CLI no longer * throws. Throw sites import these builders instead of repeating string literals. */ diff --git a/src/session/agent/surface.test.ts b/packages/hunk/src/session/agent/surface.test.ts similarity index 100% rename from src/session/agent/surface.test.ts rename to packages/hunk/src/session/agent/surface.test.ts diff --git a/src/session/agent/surface.ts b/packages/hunk/src/session/agent/surface.ts similarity index 98% rename from src/session/agent/surface.ts rename to packages/hunk/src/session/agent/surface.ts index 80007bc85..eb2c056e8 100644 --- a/src/session/agent/surface.ts +++ b/packages/hunk/src/session/agent/surface.ts @@ -4,8 +4,8 @@ import type { SessionDaemonAction } from "../protocol"; * Declarative description of the agent-facing `hunk session` command surface. * * This module is the single source of truth for what agents can invoke: the Commander commands in - * `src/app/cli.ts`, the `hunk session --help` usage text, and the generated - * `skills/hunk-review/SKILL.md` reference sections are all derived from these specs, so the parser + * `packages/hunk/src/app/cli.ts`, the `hunk session --help` usage text, and the generated + * `packages/hunk/skills/hunk-review/SKILL.md` reference sections are all derived from these specs, so the parser * and the docs cannot drift apart. Keep it pure data with no runtime dependencies. */ @@ -67,7 +67,7 @@ type OptionValue