diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 41b1c7b2..1cffe420 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -5,10 +5,10 @@ on: branches: - main paths-ignore: - - '**/*.md' - - 'docs/**' - - 'assets/**' - - 'LICENSE' + - "**/*.md" + - "docs/**" + - "assets/**" + - "LICENSE" workflow_dispatch: concurrency: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56c920d7..fcbfbe2a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,10 +5,10 @@ on: branches: - main paths-ignore: - - '**/*.md' - - 'docs/**' - - 'assets/**' - - 'LICENSE' + - "**/*.md" + - "docs/**" + - "assets/**" + - "LICENSE" concurrency: group: main-ci-${{ github.workflow }}-${{ github.ref }} @@ -30,6 +30,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Format check + run: bun run format:check + - name: Typecheck run: bun run typecheck diff --git a/.github/workflows/pr-ci.yml b/.github/workflows/pr-ci.yml index 6753b30c..4f82d2f1 100644 --- a/.github/workflows/pr-ci.yml +++ b/.github/workflows/pr-ci.yml @@ -3,10 +3,10 @@ name: CI on: pull_request: paths-ignore: - - '**/*.md' - - 'docs/**' - - 'assets/**' - - 'LICENSE' + - "**/*.md" + - "docs/**" + - "assets/**" + - "LICENSE" concurrency: group: pr-ci-${{ github.workflow }}-${{ github.ref }} @@ -33,6 +33,9 @@ jobs: - name: Install dependencies run: bun install --frozen-lockfile + - name: Format check + run: bun run format:check + - name: Typecheck run: bun run typecheck diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 00000000..c8717300 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,3 @@ +{ + "ignorePatterns": [] +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9b239958..d99397c4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -34,6 +34,13 @@ bun test bun run test:tty-smoke ``` +Format the JS/TS/JSON codebase: + +```bash +bun run format +bun run format:check +``` + Build and verify the npm package: ```bash diff --git a/README.md b/README.md index d07206ad..e4d21c27 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ image - # hunk - TUI diff tool that's AI-friendly [![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) @@ -54,20 +53,20 @@ git diff --no-color | hunk patch - # review a patch from stdin ## Feature comparison -| Capability | hunk | difftastic | delta | diff | -| --- | --- | --- | --- | --- | -| Dedicated interactive review UI | ✅ | ❌ | ❌ | ❌ | -| Multi-file review stream with navigation sidebar | ✅ | ❌ | ❌ | ❌ | -| Agent / AI rationale sidecar | ✅ | ❌ | ❌ | ❌ | -| Split diffs | ✅ | ✅ | ✅ | ✅ | -| Stacked diffs | ✅ | ✅ | ✅ | ✅ | -| Auto responsive layouts | ✅ | ❌ | ❌ | ❌ | -| Themes | ✅ | ❌ | ✅ | ❌ | -| Syntax highlighting | ✅ | ✅ | ✅ | ❌ | -| Syntax-aware / structural diffing | ❌ | ✅ | ❌ | ❌ | -| Mouse support inside the diff viewer | ✅ | ❌ | ❌ | ❌ | -| Runtime toggles for wrapping / line numbers / hunk metadata | ✅ | ❌ | ❌ | ❌ | -| Pager-compatible mode | ✅ | ✅ | ✅ | ✅ | +| Capability | hunk | difftastic | delta | diff | +| ----------------------------------------------------------- | ---- | ---------- | ----- | ---- | +| Dedicated interactive review UI | ✅ | ❌ | ❌ | ❌ | +| Multi-file review stream with navigation sidebar | ✅ | ❌ | ❌ | ❌ | +| Agent / AI rationale sidecar | ✅ | ❌ | ❌ | ❌ | +| Split diffs | ✅ | ✅ | ✅ | ✅ | +| Stacked diffs | ✅ | ✅ | ✅ | ✅ | +| Auto responsive layouts | ✅ | ❌ | ❌ | ❌ | +| Themes | ✅ | ❌ | ✅ | ❌ | +| Syntax highlighting | ✅ | ✅ | ✅ | ❌ | +| Syntax-aware / structural diffing | ❌ | ✅ | ❌ | ❌ | +| Mouse support inside the diff viewer | ✅ | ❌ | ❌ | ❌ | +| Runtime toggles for wrapping / line numbers / hunk metadata | ✅ | ❌ | ❌ | ❌ | +| Pager-compatible mode | ✅ | ✅ | ✅ | ✅ | ## Git integration diff --git a/bin/hunk.cjs b/bin/hunk.cjs index 099e8568..5454b8c6 100755 --- a/bin/hunk.cjs +++ b/bin/hunk.cjs @@ -111,7 +111,9 @@ if (bunBinary) { run(bunBinary, [entrypoint, ...process.argv.slice(2)]); } -const printablePackages = hostCandidates().map((candidate) => `"${candidate.packageName}"`).join(" or "); +const printablePackages = hostCandidates() + .map((candidate) => `"${candidate.packageName}"`) + .join(" or "); console.error( printablePackages.length > 0 ? `Failed to locate a matching prebuilt Hunk binary. Try reinstalling hunkdiff or manually installing ${printablePackages}.` diff --git a/bun.lock b/bun.lock index 1543644c..c06325b4 100644 --- a/bun.lock +++ b/bun.lock @@ -17,6 +17,7 @@ "devDependencies": { "@types/bun": "latest", "@types/react": "^19.2.14", + "oxfmt": "^0.41.0", "typescript": "^5.9.3", }, }, @@ -120,6 +121,44 @@ "@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.11", "", { "os": "win32", "cpu": "x64" }, "sha512-cCsXK9AQ9Zf18QlVnbrFu2IKfr4sf2sfbErkF2jfCzyCO9Bnhl0KRx63zlN+Ni1xU7gcBLAssgcui5R400N2eA=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.41.0", "", { "os": "android", "cpu": "arm" }, "sha512-REfrqeMKGkfMP+m/ScX4f5jJBSmVNYcpoDF8vP8f8eYPDuPGZmzp56NIUsYmx3h7f6NzC6cE3gqh8GDWrJHCKw=="], + + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.41.0", "", { "os": "android", "cpu": "arm64" }, "sha512-s0b1dxNgb2KomspFV2LfogC2XtSJB42POXF4bMCLJyvQmAGos4ZtjGPfQreToQEaY0FQFjz3030ggI36rF1q5g=="], + + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.41.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EGXGualADbv/ZmamE7/2DbsrYmjoPlAmHEpTL4vapLF4EfVD6fr8/uQDFnPJkUBjiSWFJZtFNsGeN1B6V3owmA=="], + + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.41.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WxySJEvdQQYMmyvISH3qDpTvoS0ebnIP63IMxLLWowJyPp/AAH0hdWtlo+iGNK5y3eVfa5jZguwNaQkDKWpGSw=="], + + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.41.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Y2kzMkv3U3oyuYaR4wTfGjOTYTXiFC/hXmG0yVASKkbh02BJkvD98Ij8bIevr45hNZ0DmZEgqiXF+9buD4yMYQ=="], + + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.41.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ptazDjdUyhket01IjPTT6ULS1KFuBfTUU97osTP96X5y/0oso+AgAaJzuH81oP0+XXyrWIHbRzozSAuQm4p48g=="], + + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.41.0", "", { "os": "linux", "cpu": "arm" }, "sha512-UkoL2OKxFD+56bPEBcdGn+4juTW4HRv/T6w1dIDLnvKKWr6DbarB/mtHXlADKlFiJubJz8pRkttOR7qjYR6lTA=="], + + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.41.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-gofu0PuumSOHYczD8p62CPY4UF6ee+rSLZJdUXkpwxg6pILiwSDBIouPskjF/5nF3A7QZTz2O9KFNkNxxFN9tA=="], + + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.41.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-VfVZxL0+6RU86T8F8vKiDBa+iHsr8PAjQmKGBzSCAX70b6x+UOMFl+2dNihmKmUwqkCazCPfYjt6SuAPOeQJ3g=="], + + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.41.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-bwzokz2eGvdfJbc0i+zXMJ4BBjQPqg13jyWpEEZDOrBCQ91r8KeY2Mi2kUeuMTZNFXju+jcAbAbpyJxRGla0eg=="], + + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.41.0", "", { "os": "linux", "cpu": "none" }, "sha512-POLM//PCH9uqDeNDwWL3b3DkMmI3oI2cU6hwc2lnztD1o7dzrQs3R9nq555BZ6wI7t2lyhT9CS+CRaz5X0XqLA=="], + + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.41.0", "", { "os": "linux", "cpu": "none" }, "sha512-NNK7PzhFqLUwx/G12Xtm6scGv7UITvyGdAR5Y+TlqsG+essnuRWR4jRNODWRjzLZod0T3SayRbnkSIWMBov33w=="], + + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.41.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qVf/zDC5cN9eKe4qI/O/m445er1IRl6swsSl7jHkqmOSVfknwCe5JXitYjZca+V/cNJSU/xPlC5EFMabMMFDpw=="], + + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.41.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ojxYWu7vUb6ysYqVCPHuAPVZHAI40gfZ0PDtZAMwVmh2f0V8ExpPIKoAKr7/8sNbAXJBBpZhs2coypIo2jJX4w=="], + + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.41.0", "", { "os": "linux", "cpu": "x64" }, "sha512-O2exZLBxoCMIv2vlvcbkdedazJPTdG0VSup+0QUCfYQtx751zCZNboX2ZUOiQ/gDTdhtXvSiot0h6GEGkOyalA=="], + + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.41.0", "", { "os": "none", "cpu": "arm64" }, "sha512-N+31/VoL+z+NNBt8viy3I4NaIdPbiYeOnB884LKqvXldaE2dRztdPv3q5ipfZYv0RwFp7JfqS4I27K/DSHCakg=="], + + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.41.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z7NAtu/RN8kjCQ1y5oDD0nTAeRswh3GJ93qwcW51srmidP7XPBmZbLlwERu1W5veCevQJtPS9xmkpcDTYsGIwQ=="], + + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.41.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-uNxxP3l4bJ6VyzIeRqCmBU2Q0SkCFgIhvx9/9dJ9V8t/v+jP1IBsuaLwCXGR8JPHtkj4tFp+RHtUmU2ZYAUpMA=="], + + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.41.0", "", { "os": "win32", "cpu": "x64" }, "sha512-49ZSpbZ1noozyPapE8SUOSm3IN0Ze4b5nkO+4+7fq6oEYQQJFhE0saj5k/Gg4oewVPdjn0L3ZFeWk2Vehjcw7A=="], + "@pierre/diffs": ["@pierre/diffs@1.1.0", "", { "dependencies": { "@pierre/theme": "0.0.22", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-wbxrzcmanJuHZb81iir09j42uU9AnKxXDtAuEQJbAnti5f2UfYdCQYejawuHZStFrlsMacCZLh/dDHmqvAaQCw=="], "@pierre/theme": ["@pierre/theme@0.0.22", "", {}, "sha512-ePUIdQRNGjrveELTU7fY89Xa7YGHHEy5Po5jQy/18lm32eRn96+tnYJEtFooGdffrx55KBUtOXfvVy/7LDFFhA=="], @@ -252,6 +291,8 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], + "oxfmt": ["oxfmt@0.41.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.41.0", "@oxfmt/binding-android-arm64": "0.41.0", "@oxfmt/binding-darwin-arm64": "0.41.0", "@oxfmt/binding-darwin-x64": "0.41.0", "@oxfmt/binding-freebsd-x64": "0.41.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.41.0", "@oxfmt/binding-linux-arm-musleabihf": "0.41.0", "@oxfmt/binding-linux-arm64-gnu": "0.41.0", "@oxfmt/binding-linux-arm64-musl": "0.41.0", "@oxfmt/binding-linux-ppc64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-gnu": "0.41.0", "@oxfmt/binding-linux-riscv64-musl": "0.41.0", "@oxfmt/binding-linux-s390x-gnu": "0.41.0", "@oxfmt/binding-linux-x64-gnu": "0.41.0", "@oxfmt/binding-linux-x64-musl": "0.41.0", "@oxfmt/binding-openharmony-arm64": "0.41.0", "@oxfmt/binding-win32-arm64-msvc": "0.41.0", "@oxfmt/binding-win32-ia32-msvc": "0.41.0", "@oxfmt/binding-win32-x64-msvc": "0.41.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-sKLdJZdQ3bw6x9qKiT7+eID4MNEXlDHf5ZacfIircrq6Qwjk0L6t2/JQlZZrVHTXJawK3KaMuBoJnEJPcqCEdg=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], @@ -316,6 +357,8 @@ "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], diff --git a/docs/prebuilt-binaries-plan.md b/docs/prebuilt-binaries-plan.md index 910c427f..9501e919 100644 --- a/docs/prebuilt-binaries-plan.md +++ b/docs/prebuilt-binaries-plan.md @@ -180,7 +180,8 @@ dist/ hunkdiff-linux-x64/ hunkdiff-darwin-arm64/ ``` -``` + +```` The checked-in repo should contain templates and scripts, not prebuilt binaries. @@ -283,7 +284,7 @@ Top-level `package.json` should eventually look more like this: "hunkdiff-linux-x64": "0.3.0" } } -``` +```` Notably: diff --git a/examples/3-agent-review-demo/after/src/search.ts b/examples/3-agent-review-demo/after/src/search.ts index 2fd364d6..88957412 100644 --- a/examples/3-agent-review-demo/after/src/search.ts +++ b/examples/3-agent-review-demo/after/src/search.ts @@ -35,6 +35,9 @@ export function searchCommands(query: string, commands: Command[]) { return { command, score }; }) .filter((entry) => entry.score > 0) - .sort((left, right) => right.score - left.score || left.command.label.localeCompare(right.command.label)) + .sort( + (left, right) => + right.score - left.score || left.command.label.localeCompare(right.command.label), + ) .map((entry) => entry.command); } diff --git a/examples/5-pager-tour/after.ts b/examples/5-pager-tour/after.ts index 959d7f70..e679fef1 100644 --- a/examples/5-pager-tour/after.ts +++ b/examples/5-pager-tour/after.ts @@ -4,33 +4,51 @@ export const tourLine03 = "Keep the main review stream in view instead of bounci export const tourLine04 = "Use the same layout long enough to build a reading rhythm."; export const tourLine05 = "Stick with the default theme until the patch itself feels familiar."; export const tourLine06 = "Capture the main intent before you zoom into any implementation detail."; -export const tourLine07 = "Pause on surprising hunks before you decide whether the change makes sense."; -export const tourLine08 = "Large diffs get easier once you scan the visible anchors and move with intent."; -export const tourLine09 = "Review notes should stay next to the code they explain, not in a detached summary."; -export const tourLine10 = "A steady reading order helps people narrate the patch without losing context."; +export const tourLine07 = + "Pause on surprising hunks before you decide whether the change makes sense."; +export const tourLine08 = + "Large diffs get easier once you scan the visible anchors and move with intent."; +export const tourLine09 = + "Review notes should stay next to the code they explain, not in a detached summary."; +export const tourLine10 = + "A steady reading order helps people narrate the patch without losing context."; export const tourLine11 = "Give every changed block one clear reason to exist before you move on."; -export const tourLine12 = "Leave enough room for careful follow-up comments when the diff raises questions."; +export const tourLine12 = + "Leave enough room for careful follow-up comments when the diff raises questions."; export const tourLine13 = "Unchanged spacing between sections keeps separate hunks easy to spot."; export const tourLine14 = "Short stretches of context make larger edits feel trustworthy."; export const tourLine15 = "Preview the next stop before you jump ahead with a bigger movement."; -export const tourLine16 = "Use a calm pace when the patch mixes code, copy, and structural cleanup."; +export const tourLine16 = + "Use a calm pace when the patch mixes code, copy, and structural cleanup."; export const tourLine17 = "Complex refactors still benefit from a simple review rhythm."; -export const tourLine18 = "A single-file walkthrough can teach the whole keyboard model surprisingly fast."; +export const tourLine18 = + "A single-file walkthrough can teach the whole keyboard model surprisingly fast."; export const tourLine19 = "Consistency matters more than memorizing every shortcut in one pass."; export const tourLine20 = "Line-by-line movement should feel steady, local, and predictable."; export const tourLine21 = "Page jumps should land near the next cluster of interesting changes."; -export const tourLine22 = "Home and End are perfect when you want to reset your bearings instantly."; -export const tourLine23 = "The best demos let you try scrolling without any setup or repo plumbing."; -export const tourLine24 = "Readable filler text makes navigation practice feel intentional instead of synthetic."; +export const tourLine22 = + "Home and End are perfect when you want to reset your bearings instantly."; +export const tourLine23 = + "The best demos let you try scrolling without any setup or repo plumbing."; +export const tourLine24 = + "Readable filler text makes navigation practice feel intentional instead of synthetic."; export const tourLine25 = "A few distinct hunks are better than one endless wall of edits."; export const tourLine26 = "Split view is great when you want both sides visible at once."; export const tourLine27 = "Stack view can feel calmer when lines are long or heavily wrapped."; -export const tourLine28 = "Pager mode should stay familiar to people who already live in git diff and less."; -export const tourLine29 = "Clean copy changes are useful because they are easy to scan while you learn."; -export const tourLine30 = "Repeated line structure makes scrolling behavior obvious to first-time users."; -export const tourLine31 = "A review tool should reward curiosity instead of punishing it with awkward jumps."; -export const tourLine32 = "Watch how the current hunk stays visually anchored while you move around it."; -export const tourLine33 = "Try paging down first, then refine your position one line at a time with ↑ and ↓."; -export const tourLine34 = "When you overshoot, small upward steps should feel immediate and unsurprising."; +export const tourLine28 = + "Pager mode should stay familiar to people who already live in git diff and less."; +export const tourLine29 = + "Clean copy changes are useful because they are easy to scan while you learn."; +export const tourLine30 = + "Repeated line structure makes scrolling behavior obvious to first-time users."; +export const tourLine31 = + "A review tool should reward curiosity instead of punishing it with awkward jumps."; +export const tourLine32 = + "Watch how the current hunk stays visually anchored while you move around it."; +export const tourLine33 = + "Try paging down first, then refine your position one line at a time with ↑ and ↓."; +export const tourLine34 = + "When you overshoot, small upward steps should feel immediate and unsurprising."; export const tourLine35 = "The footer hints are there to help you build muscle memory on demand."; -export const tourLine36 = "Once the basics feel good, hunk jumps become the faster path between review beats."; +export const tourLine36 = + "Once the basics feel good, hunk jumps become the faster path between review beats."; diff --git a/examples/README.md b/examples/README.md index ce1a8086..eafc1bcf 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,14 +6,14 @@ Each folder tells a small review story and includes the exact command to run fro ## Quick menu -| Example | Best for | Command | -| --- | --- | --- | -| `1-hello-diff` | fastest first run | `hunk diff examples/1-hello-diff/before.ts examples/1-hello-diff/after.ts` | -| `2-mini-app-refactor` | realistic multi-file review | `hunk patch examples/2-mini-app-refactor/change.patch` | -| `3-agent-review-demo` | inline agent rationale | `hunk patch examples/3-agent-review-demo/change.patch --agent-context examples/3-agent-review-demo/agent-context.json` | -| `4-ui-polish` | screenshot-friendly TSX diff | `hunk diff examples/4-ui-polish/before.tsx examples/4-ui-polish/after.tsx` | -| `5-pager-tour` | line scrolling, paging, and hunk jumps | `hunk diff --pager examples/5-pager-tour/before.ts examples/5-pager-tour/after.ts` | -| `6-readme-screenshot` | README screenshot with agent notes | `hunk patch examples/6-readme-screenshot/change.patch --agent-context examples/6-readme-screenshot/agent-context.json --mode split --theme midnight` | +| Example | Best for | Command | +| --------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `1-hello-diff` | fastest first run | `hunk diff examples/1-hello-diff/before.ts examples/1-hello-diff/after.ts` | +| `2-mini-app-refactor` | realistic multi-file review | `hunk patch examples/2-mini-app-refactor/change.patch` | +| `3-agent-review-demo` | inline agent rationale | `hunk patch examples/3-agent-review-demo/change.patch --agent-context examples/3-agent-review-demo/agent-context.json` | +| `4-ui-polish` | screenshot-friendly TSX diff | `hunk diff examples/4-ui-polish/before.tsx examples/4-ui-polish/after.tsx` | +| `5-pager-tour` | line scrolling, paging, and hunk jumps | `hunk diff --pager examples/5-pager-tour/before.ts examples/5-pager-tour/after.ts` | +| `6-readme-screenshot` | README screenshot with agent notes | `hunk patch examples/6-readme-screenshot/change.patch --agent-context examples/6-readme-screenshot/agent-context.json --mode split --theme midnight` | ## Notes diff --git a/knip.json b/knip.json index 7048fac6..4478078c 100644 --- a/knip.json +++ b/knip.json @@ -1,10 +1,5 @@ { "$schema": "https://unpkg.com/knip@latest/schema.json", - "project": [ - "src/**/*.{ts,tsx}", - "scripts/**/*.ts", - "test/**/*.{ts,tsx}", - "bin/**/*.cjs" - ], + "project": ["src/**/*.{ts,tsx}", "scripts/**/*.ts", "test/**/*.{ts,tsx}", "bin/**/*.cjs"], "ignoreDependencies": ["bun"] } diff --git a/package.json b/package.json index e302d57f..e513f3c2 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,23 @@ "name": "hunkdiff", "version": "0.4.0", "description": "Desktop-inspired terminal diff viewer for understanding agent-authored changesets.", - "type": "module", - "packageManager": "bun@1.3.10", + "keywords": [ + "ai", + "code-review", + "diff", + "git", + "terminal", + "tui" + ], + "homepage": "https://github.com/modem-dev/hunk#readme", + "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" }, @@ -14,6 +29,10 @@ "README.md", "LICENSE" ], + "type": "module", + "publishConfig": { + "access": "public" + }, "scripts": { "start": "bun run src/main.tsx", "dev": "bun --watch src/main.tsx", @@ -24,6 +43,8 @@ "stage:prebuilt:release": "bun run ./scripts/stage-prebuilt-npm.ts --artifact-root ./dist/release/artifacts", "install:bin": "bash ./scripts/install-bin.sh", "typecheck": "tsc --noEmit", + "format": "oxfmt --write .", + "format:check": "oxfmt --check .", "test": "bun test", "test:tty-smoke": "HUNK_RUN_TTY_SMOKE=1 bun test test/tty-render-smoke.test.ts", "check:pack": "bun run ./scripts/check-pack.ts", @@ -35,33 +56,6 @@ "bench:highlight-prefetch": "bun run test/adjacent-highlight-prefetch-benchmark.ts", "bench:large-stream": "bun run test/large-stream-windowing-benchmark.ts" }, - "keywords": [ - "diff", - "git", - "tui", - "terminal", - "code-review", - "ai" - ], - "repository": { - "type": "git", - "url": "git+https://github.com/modem-dev/hunk.git" - }, - "homepage": "https://github.com/modem-dev/hunk#readme", - "bugs": { - "url": "https://github.com/modem-dev/hunk/issues" - }, - "engines": { - "node": ">=18" - }, - "publishConfig": { - "access": "public" - }, - "devDependencies": { - "@types/bun": "latest", - "@types/react": "^19.2.14", - "typescript": "^5.9.3" - }, "dependencies": { "@opentui/core": "^0.1.88", "@opentui/react": "^0.1.88", @@ -72,7 +66,16 @@ "react": "^19.2.4", "zod": "^4.3.6" }, - "license": "MIT", + "devDependencies": { + "@types/bun": "latest", + "@types/react": "^19.2.14", + "oxfmt": "^0.41.0", + "typescript": "^5.9.3" + }, + "engines": { + "node": ">=18" + }, + "packageManager": "bun@1.3.10", "pi": { "skills": [ "./skills" diff --git a/scripts/build-prebuilt-artifact.ts b/scripts/build-prebuilt-artifact.ts index e4bc062e..d64b07ba 100644 --- a/scripts/build-prebuilt-artifact.ts +++ b/scripts/build-prebuilt-artifact.ts @@ -2,7 +2,11 @@ import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import path from "node:path"; -import { binaryFilenameForSpec, getHostPlatformPackageSpec, releaseArtifactsDir } from "./prebuilt-package-helpers"; +import { + binaryFilenameForSpec, + getHostPlatformPackageSpec, + releaseArtifactsDir, +} from "./prebuilt-package-helpers"; function parseArgs(argv: string[]) { let outputRoot: string | undefined; @@ -37,7 +41,9 @@ const outputRoot = path.resolve(options.outputRoot ?? releaseArtifactsDir(repoRo const outputDir = path.join(outputRoot, spec.packageName); if (options.expectedPackage && options.expectedPackage !== spec.packageName) { - throw new Error(`Host build resolved to ${spec.packageName}, but the workflow expected ${options.expectedPackage}.`); + throw new Error( + `Host build resolved to ${spec.packageName}, but the workflow expected ${options.expectedPackage}.`, + ); } if (!existsSync(compiledBinary)) { diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index ca0ca03a..4818c815 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -55,7 +55,10 @@ const forbiddenPrefixes = [".github/", "src/", "test/", "scripts/", "tmp/"]; const forbiddenPaths = ["AGENTS.md", "bun.lock"]; for (const file of pack.files) { - if (forbiddenPrefixes.some((prefix) => file.path.startsWith(prefix)) || forbiddenPaths.includes(file.path)) { + if ( + forbiddenPrefixes.some((prefix) => file.path.startsWith(prefix)) || + forbiddenPaths.includes(file.path) + ) { throw new Error(`Unexpected file in npm package: ${file.path}`); } } @@ -64,4 +67,6 @@ if (pack.name !== "hunkdiff") { throw new Error(`Expected npm package name to be hunkdiff, got ${pack.name}.`); } -console.log(`Verified npm pack output for ${pack.name}@${pack.version} (${pack.entryCount} files).`); +console.log( + `Verified npm pack output for ${pack.name}@${pack.version} (${pack.entryCount} files).`, +); diff --git a/scripts/check-release-version.ts b/scripts/check-release-version.ts index 2c6efc5b..0b981ac9 100644 --- a/scripts/check-release-version.ts +++ b/scripts/check-release-version.ts @@ -3,7 +3,9 @@ import path from "node:path"; const repoRoot = path.resolve(import.meta.dir, ".."); -const packageJson = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()) as { version: string }; +const packageJson = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()) as { + version: string; +}; const refName = process.argv[2]; if (!refName) { @@ -12,7 +14,9 @@ if (!refName) { const expectedTag = `v${packageJson.version}`; if (refName !== expectedTag) { - throw new Error(`Tag ${refName} does not match package.json version ${packageJson.version} (${expectedTag}).`); + throw new Error( + `Tag ${refName} does not match package.json version ${packageJson.version} (${expectedTag}).`, + ); } console.log(`Verified release tag ${refName} matches package.json version ${packageJson.version}.`); diff --git a/scripts/prebuilt-package-helpers.ts b/scripts/prebuilt-package-helpers.ts index bff1a323..8c807330 100644 --- a/scripts/prebuilt-package-helpers.ts +++ b/scripts/prebuilt-package-helpers.ts @@ -27,9 +27,27 @@ const ARCH_NAME_MAP: Partial> = { /** Platforms we actually plan to publish in the first prebuilt-binary rollout. */ export const PLATFORM_PACKAGE_MATRIX: PlatformPackageSpec[] = [ - { packageName: "hunkdiff-darwin-arm64", os: "darwin", cpu: "arm64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, - { packageName: "hunkdiff-darwin-x64", os: "darwin", cpu: "x64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, - { packageName: "hunkdiff-linux-x64", os: "linux", cpu: "x64", binaryName: "hunk", binaryRelativePath: "bin/hunk" }, + { + packageName: "hunkdiff-darwin-arm64", + os: "darwin", + cpu: "arm64", + binaryName: "hunk", + binaryRelativePath: "bin/hunk", + }, + { + packageName: "hunkdiff-darwin-x64", + os: "darwin", + cpu: "x64", + binaryName: "hunk", + binaryRelativePath: "bin/hunk", + }, + { + packageName: "hunkdiff-linux-x64", + os: "linux", + cpu: "x64", + binaryName: "hunk", + binaryRelativePath: "bin/hunk", + }, ] as const; /** Normalize a Node platform string into Hunk's package naming vocabulary. */ @@ -48,7 +66,10 @@ export function getPlatformPackageSpecByName(packageName: string) { } /** Resolve the published package spec for a given Node platform/architecture pair. */ -export function getPlatformPackageSpecForHost(platform: NodeJS.Platform, arch: NodeJS.Architecture) { +export function getPlatformPackageSpecForHost( + platform: NodeJS.Platform, + arch: NodeJS.Architecture, +) { const normalizedPlatform = normalizeHostPlatform(platform); if (!normalizedPlatform) { throw new Error(`Unsupported host platform for prebuilt packaging: ${platform}`); @@ -59,9 +80,13 @@ export function getPlatformPackageSpecForHost(platform: NodeJS.Platform, arch: N throw new Error(`Unsupported host architecture for prebuilt packaging: ${arch}`); } - const spec = PLATFORM_PACKAGE_MATRIX.find((candidate) => candidate.os === normalizedPlatform && candidate.cpu === normalizedArch); + const spec = PLATFORM_PACKAGE_MATRIX.find( + (candidate) => candidate.os === normalizedPlatform && candidate.cpu === normalizedArch, + ); if (!spec) { - throw new Error(`No published prebuilt package spec matches ${normalizedPlatform}/${normalizedArch}`); + throw new Error( + `No published prebuilt package spec matches ${normalizedPlatform}/${normalizedArch}`, + ); } return spec; @@ -73,7 +98,10 @@ export function getHostPlatformPackageSpec() { } /** Build the optional dependency map for the top-level hunkdiff package. */ -export function buildOptionalDependencyMap(version: string, specs: readonly PlatformPackageSpec[] = PLATFORM_PACKAGE_MATRIX) { +export function buildOptionalDependencyMap( + version: string, + specs: readonly PlatformPackageSpec[] = PLATFORM_PACKAGE_MATRIX, +) { return Object.fromEntries(specs.map((spec) => [spec.packageName, version])); } diff --git a/scripts/publish-prebuilt-npm.ts b/scripts/publish-prebuilt-npm.ts index e166cb42..fd5a4bbb 100644 --- a/scripts/publish-prebuilt-npm.ts +++ b/scripts/publish-prebuilt-npm.ts @@ -52,7 +52,9 @@ function npmViewExists(name: string, version: string) { } function publishDirectory(directory: string, dryRun: boolean, npmTag: string) { - const packageJson = JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")) as PackageJson; + const packageJson = JSON.parse( + readFileSync(path.join(directory, "package.json"), "utf8"), + ) as PackageJson; if (npmViewExists(packageJson.name, packageJson.version)) { console.log( diff --git a/scripts/smoke-prebuilt-install.ts b/scripts/smoke-prebuilt-install.ts index d73aa35c..139c0f77 100644 --- a/scripts/smoke-prebuilt-install.ts +++ b/scripts/smoke-prebuilt-install.ts @@ -17,14 +17,17 @@ function run(command: string[], options?: { cwd?: string; env?: NodeJS.ProcessEn const stderr = Buffer.from(proc.stderr).toString("utf8"); if (proc.exitCode !== 0) { - throw new Error(`${command.join(" ")} failed with exit ${proc.exitCode}\n${stderr || stdout}`.trim()); + throw new Error( + `${command.join(" ")} failed with exit ${proc.exitCode}\n${stderr || stdout}`.trim(), + ); } return { stdout, stderr }; } const repoRoot = path.resolve(import.meta.dir, ".."); -const packageVersion = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()).version as string; +const packageVersion = JSON.parse(await Bun.file(path.join(repoRoot, "package.json")).text()) + .version as string; const releaseRoot = releaseNpmDir(repoRoot); const hostSpec = getHostPlatformPackageSpec(); const tempRoot = path.join(repoRoot, "tmp"); @@ -44,8 +47,12 @@ if (nodeBinary.exitCode !== 0 || resolvedNode.length === 0) { const nodeDir = path.dirname(resolvedNode); try { - run(["npm", "pack", "--pack-destination", packageDir], { cwd: path.join(releaseRoot, hostSpec.packageName) }); - run(["npm", "pack", "--pack-destination", packageDir], { cwd: path.join(releaseRoot, "hunkdiff") }); + run(["npm", "pack", "--pack-destination", packageDir], { + cwd: path.join(releaseRoot, hostSpec.packageName), + }); + run(["npm", "pack", "--pack-destination", packageDir], { + cwd: path.join(releaseRoot, "hunkdiff"), + }); const platformTarball = path.join(packageDir, `${hostSpec.packageName}-${packageVersion}.tgz`); const metaTarball = path.join(packageDir, `hunkdiff-${packageVersion}.tgz`); @@ -67,7 +74,11 @@ try { } const bunCheck = Bun.spawnSync( - [resolvedNode, "-e", "const {spawnSync}=require('node:child_process'); process.exit(spawnSync('bun',['--version'],{stdio:'ignore'}).status===0?1:0);"] , + [ + resolvedNode, + "-e", + "const {spawnSync}=require('node:child_process'); process.exit(spawnSync('bun',['--version'],{stdio:'ignore'}).status===0?1:0);", + ], { env: { ...process.env, diff --git a/scripts/stage-prebuilt-npm.ts b/scripts/stage-prebuilt-npm.ts index a8ea3d20..9336e8f2 100644 --- a/scripts/stage-prebuilt-npm.ts +++ b/scripts/stage-prebuilt-npm.ts @@ -1,6 +1,15 @@ #!/usr/bin/env bun -import { chmodSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import path from "node:path"; import { binaryFilenameForSpec, @@ -59,7 +68,12 @@ function writeJson(filePath: string, value: unknown) { writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); } -function stageMetaPackage(repoRoot: string, rootPackage: RootPackageJson, releaseRoot: string, specs: readonly PlatformPackageSpec[]) { +function stageMetaPackage( + repoRoot: string, + rootPackage: RootPackageJson, + releaseRoot: string, + specs: readonly PlatformPackageSpec[], +) { const metaDir = path.join(releaseRoot, rootPackage.name); ensureDirectory(path.join(metaDir, "bin")); cpSync(path.join(repoRoot, "bin", "hunk.cjs"), path.join(metaDir, "bin", "hunk.cjs")); @@ -131,7 +145,9 @@ function collectArtifactSpecs(artifactRoot: string) { } return directories.map((directory) => { - const metadata = JSON.parse(readFileSync(path.join(directory, "metadata.json"), "utf8")) as BinaryArtifactMetadata; + const metadata = JSON.parse( + readFileSync(path.join(directory, "metadata.json"), "utf8"), + ) as BinaryArtifactMetadata; const spec = getPlatformPackageSpecByName(metadata.packageName); if (!spec) { throw new Error(`Unknown platform package in artifact metadata: ${metadata.packageName}`); diff --git a/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index 0029b121..8410120b 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -15,6 +15,7 @@ When this skill activates, start by briefly explaining what Hunk is in plain lan Hunk is a review-first terminal diff viewer for agent-authored changesets. Keep these product rules in mind: + - the main pane is one top-to-bottom multi-file review stream - the sidebar is for navigation, not single-file mode switching - layouts are `auto`, `split`, and `stack` @@ -28,6 +29,7 @@ If a live Hunk session already exists, prefer `hunk session ...` over launching The local Hunk daemon is loopback-only by default and brokers commands to one or more live Hunk sessions. Important behavior: + - normal Hunk sessions auto-start and register with the daemon when MCP is enabled - `hunk mcp serve` exists for manual startup or debugging, but it is not the default review path - `HUNK_MCP_DISABLE=1` disables daemon registration for a session @@ -36,6 +38,7 @@ Important behavior: ## Recommended review loop Use this flow by default: + 1. `hunk session list` 2. `hunk session context` 3. `hunk session navigate` only if the current focus is wrong @@ -44,6 +47,7 @@ Use this flow by default: Use `hunk session get` only when you need broader session metadata. Guidelines: + - if multiple sessions are live, pass `sessionId` explicitly - prefer `hunk session context` before navigating blindly - use `hunk session navigate` for hunk-level movement; do not invent extra remote-control behavior @@ -77,6 +81,7 @@ For more CLI entrypoints, read [references/commands.md](references/commands.md). ## Repo-specific review notes When using Hunk for agent changes: + - prefer a real TTY or tmux session over redirected stdout captures - if a repo already has a fresh local sidecar, you can load it with `hunk diff --agent-context ` - treat `.hunk/latest.json` as an optional local convention, not required repo hygiene @@ -85,11 +90,13 @@ When using Hunk for agent changes: ## What this skill should steer Pi toward Prefer a skill over a prompt dump: + - keep always-loaded context small - load the full Hunk workflow only when the task is actually about review - use Hunk's session CLI rather than a separate agent-facing MCP tool surface Prefer review-oriented actions: + - inspect the current live diff session - move to the right hunk only when needed - attach concise inline review comments diff --git a/skills/hunk-review/references/commands.md b/skills/hunk-review/references/commands.md index 316fd3cd..cf6918d9 100644 --- a/skills/hunk-review/references/commands.md +++ b/skills/hunk-review/references/commands.md @@ -63,6 +63,7 @@ Use this when you already have a sidecar file for local review context. `.hunk/l ## TTY guidance For interactive verification: + - prefer a real terminal or tmux pane - do not rely on redirected stdout captures for behavior verification - if testing local Hunk source changes, use `bun run src/main.tsx -- ...` instead of an installed `hunk` binary diff --git a/skills/hunk-review/references/mcp-review.md b/skills/hunk-review/references/mcp-review.md index 11389994..0b638ab6 100644 --- a/skills/hunk-review/references/mcp-review.md +++ b/skills/hunk-review/references/mcp-review.md @@ -20,6 +20,7 @@ HUNK_MCP_DISABLE=1 hunk diff ## User and agent interface The review-oriented interface is `hunk session ...`: + - `hunk session list` - `hunk session get` - `hunk session context` @@ -42,6 +43,7 @@ Use explicit `sessionId` or `--repo ` whenever more than one live session ### 2. Inspect current focus Run `hunk session context` to see: + - current file - current hunk index - selected hunk old/new ranges @@ -53,6 +55,7 @@ This is the best way to respect what the human reviewer is already looking at. ### 3. Move only when needed If the current focus is wrong, run `hunk session navigate` with either: + - `--hunk `, or - `--old-line ` / `--new-line ` @@ -61,6 +64,7 @@ Prefer hunk-level movement over adding broader remote-control actions. ### 4. Leave inline review notes Run `hunk session comment add` with: + - `` or `--repo ` - `--file` - `--old-line` or `--new-line` diff --git a/src/core/agent.ts b/src/core/agent.ts index cd41f18a..19885fce 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -62,7 +62,9 @@ function normalizeAnnotationFile(file: unknown): AgentFileContext { newRange: normalizeRange(item.newRange), summary: item.summary, rationale: typeof item.rationale === "string" ? item.rationale : undefined, - tags: Array.isArray(item.tags) ? item.tags.filter((tag): tag is string => typeof tag === "string") : undefined, + tags: Array.isArray(item.tags) + ? item.tags.filter((tag): tag is string => typeof tag === "string") + : undefined, confidence: item.confidence === "low" || item.confidence === "medium" || item.confidence === "high" ? item.confidence @@ -112,7 +114,8 @@ export function findAgentFileContext( } return ( - agentContext.files.find((file) => file.path === currentPath || (previousPath ? file.path === previousPath : false)) ?? - null + agentContext.files.find( + (file) => file.path === currentPath || (previousPath ? file.path === previousPath : false), + ) ?? null ); } diff --git a/src/core/cli.ts b/src/core/cli.ts index 1865bf1b..3759e0cd 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -1,7 +1,13 @@ import { existsSync, readFileSync, statSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { Command } from "commander"; -import type { CommonOptions, HelpCommandInput, LayoutMode, PagerCommandInput, ParsedCliInput } from "./types"; +import type { + CommonOptions, + HelpCommandInput, + LayoutMode, + PagerCommandInput, + ParsedCliInput, +} from "./types"; /** Validate one requested layout mode from CLI input. */ function parseLayoutMode(value: string): LayoutMode { @@ -174,7 +180,12 @@ async function parseStandaloneCommand(command: Command, tokens: string[]) { try { await command.parseAsync(["bun", "hunk", ...tokens]); } catch (error) { - if (error && typeof error === "object" && "code" in error && error.code === "commander.helpDisplayed") { + if ( + error && + typeof error === "object" && + "code" in error && + error.code === "commander.helpDisplayed" + ) { return; } @@ -193,7 +204,10 @@ function resolveJsonOutput(options: { json?: boolean }) { } /** Normalize one explicit session selector from either session id or repo root. */ -function resolveExplicitSessionSelector(sessionId: string | undefined, repoRoot: string | undefined) { +function resolveExplicitSessionSelector( + sessionId: string | undefined, + repoRoot: string | undefined, +) { if (sessionId && repoRoot) { throw new Error("Specify either or --repo , not both."); } @@ -202,9 +216,7 @@ function resolveExplicitSessionSelector(sessionId: string | undefined, repoRoot: throw new Error("Specify one live Hunk session with or --repo ."); } - return sessionId - ? { sessionId } - : { repoRoot: resolve(repoRoot!) }; + return sessionId ? { sessionId } : { repoRoot: resolve(repoRoot!) }; } /** Parse the overloaded `hunk diff` command. */ @@ -252,7 +264,12 @@ async function parseDiffCommand(tokens: string[], argv: string[]): Promise { - const command = createCommand("patch", "review a patch file, or read a patch from stdin").argument("[file]"); + const command = createCommand( + "patch", + "review a patch file, or read a patch from stdin", + ).argument("[file]"); let parsedFile: string | undefined; let parsedOptions: Record = {}; @@ -319,7 +339,10 @@ async function parsePatchCommand(tokens: string[], argv: string[]): Promise { +async function parsePagerCommand( + tokens: string[], + argv: string[], +): Promise { const command = createCommand("pager", "general Git pager wrapper with diff detection"); let parsedOptions: Record = {}; @@ -351,12 +374,14 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise

= {}; - command.action((left: string, right: string, path: string | undefined, options: Record) => { - parsedLeft = left; - parsedRight = right; - parsedPath = path; - parsedOptions = options; - }); + command.action( + (left: string, right: string, path: string | undefined, options: Record) => { + parsedLeft = left; + parsedRight = right; + parsedPath = path; + parsedOptions = options; + }, + ); if (tokens.includes("--help") || tokens.includes("-h")) { return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; @@ -379,28 +404,31 @@ async function parseSessionCommand(tokens: string[]): Promise { if (!subcommand || subcommand === "--help" || subcommand === "-h") { return { kind: "help", - text: [ - "Usage: hunk session [options]", - "", - "Inspect and control live Hunk review sessions through the local daemon.", - "", - "Commands:", - " hunk session list", - " hunk session get ", - " hunk session get --repo ", - " hunk session context ", - " hunk session context --repo ", - " hunk session navigate --file (--hunk | --old-line | --new-line )", - " hunk session comment add --file (--old-line | --new-line ) --summary ", - " hunk session comment list ", - " hunk session comment rm ", - " hunk session comment clear --yes", - ].join("\n") + "\n", + text: + [ + "Usage: hunk session [options]", + "", + "Inspect and control live Hunk review sessions through the local daemon.", + "", + "Commands:", + " hunk session list", + " hunk session get ", + " hunk session get --repo ", + " hunk session context ", + " hunk session context --repo ", + " hunk session navigate --file (--hunk | --old-line | --new-line )", + " hunk session comment add --file (--old-line | --new-line ) --summary ", + " hunk session comment list ", + " hunk session comment rm ", + " hunk session comment clear --yes", + ].join("\n") + "\n", }; } if (subcommand === "list") { - const command = new Command("session list").description("list live Hunk sessions").option("--json", "emit structured JSON"); + const command = new Command("session list") + .description("list live Hunk sessions") + .option("--json", "emit structured JSON"); let parsedOptions: { json?: boolean } = {}; command.action((options: { json?: boolean }) => { @@ -421,7 +449,11 @@ async function parseSessionCommand(tokens: string[]): Promise { if (subcommand === "get" || subcommand === "context") { const command = new Command(`session ${subcommand}`) - .description(subcommand === "get" ? "show one live Hunk session" : "show the selected file and hunk for one live Hunk session") + .description( + subcommand === "get" + ? "show one live Hunk session" + : "show the selected file and hunk for one live Hunk session", + ) .argument("[sessionId]") .option("--repo ", "target the live session whose repo root matches this path") .option("--json", "emit structured JSON"); @@ -459,12 +491,31 @@ async function parseSessionCommand(tokens: string[]): Promise { .option("--json", "emit structured JSON"); let parsedSessionId: string | undefined; - let parsedOptions: { repo?: string; file: string; hunk?: number; oldLine?: number; newLine?: number; json?: boolean } = { file: "" }; - - command.action((sessionId: string | undefined, options: { repo?: string; file: string; hunk?: number; oldLine?: number; newLine?: number; json?: boolean }) => { - parsedSessionId = sessionId; - parsedOptions = options; - }); + let parsedOptions: { + repo?: string; + file: string; + hunk?: number; + oldLine?: number; + newLine?: number; + json?: boolean; + } = { file: "" }; + + command.action( + ( + sessionId: string | undefined, + options: { + repo?: string; + file: string; + hunk?: number; + oldLine?: number; + newLine?: number; + json?: boolean; + }, + ) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); if (rest.includes("--help") || rest.includes("-h")) { return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; @@ -472,9 +523,15 @@ async function parseSessionCommand(tokens: string[]): Promise { await parseStandaloneCommand(command, rest); - const selectors = [parsedOptions.hunk !== undefined, parsedOptions.oldLine !== undefined, parsedOptions.newLine !== undefined].filter(Boolean); + const selectors = [ + parsedOptions.hunk !== undefined, + parsedOptions.oldLine !== undefined, + parsedOptions.newLine !== undefined, + ].filter(Boolean); if (selectors.length !== 1) { - throw new Error("Specify exactly one navigation target: --hunk , --old-line , or --new-line ."); + throw new Error( + "Specify exactly one navigation target: --hunk , --old-line , or --new-line .", + ); } return { @@ -484,7 +541,12 @@ async function parseSessionCommand(tokens: string[]): Promise { selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), filePath: parsedOptions.file, hunkNumber: parsedOptions.hunk, - side: parsedOptions.oldLine !== undefined ? "old" : parsedOptions.newLine !== undefined ? "new" : undefined, + side: + parsedOptions.oldLine !== undefined + ? "old" + : parsedOptions.newLine !== undefined + ? "new" + : undefined, line: parsedOptions.oldLine ?? parsedOptions.newLine, }; } @@ -494,13 +556,14 @@ async function parseSessionCommand(tokens: string[]): Promise { if (!commentSubcommand || commentSubcommand === "--help" || commentSubcommand === "-h") { return { kind: "help", - text: [ - "Usage:", - " hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary ", - " hunk session comment list ( | --repo ) [--file ]", - " hunk session comment rm ( | --repo ) ", - " hunk session comment clear ( | --repo ) [--file ] --yes", - ].join("\n") + "\n", + text: + [ + "Usage:", + " hunk session comment add ( | --repo ) --file (--old-line | --new-line ) --summary ", + " hunk session comment list ( | --repo ) [--file ]", + " hunk session comment rm ( | --repo ) ", + " hunk session comment clear ( | --repo ) [--file ] --yes", + ].join("\n") + "\n", }; } @@ -535,20 +598,25 @@ async function parseSessionCommand(tokens: string[]): Promise { summary: "", }; - command.action((sessionId: string | undefined, options: { - repo?: string; - file: string; - summary: string; - oldLine?: number; - newLine?: number; - rationale?: string; - author?: string; - reveal?: boolean; - json?: boolean; - }) => { - parsedSessionId = sessionId; - parsedOptions = options; - }); + command.action( + ( + sessionId: string | undefined, + options: { + repo?: string; + file: string; + summary: string; + oldLine?: number; + newLine?: number; + rationale?: string; + author?: string; + reveal?: boolean; + json?: boolean; + }, + ) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); if (commentRest.includes("--help") || commentRest.includes("-h")) { return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; @@ -556,7 +624,10 @@ async function parseSessionCommand(tokens: string[]): Promise { await parseStandaloneCommand(command, commentRest); - const selectors = [parsedOptions.oldLine !== undefined, parsedOptions.newLine !== undefined].filter(Boolean); + const selectors = [ + parsedOptions.oldLine !== undefined, + parsedOptions.newLine !== undefined, + ].filter(Boolean); if (selectors.length !== 1) { throw new Error("Specify exactly one comment target: --old-line or --new-line ."); } @@ -587,10 +658,15 @@ async function parseSessionCommand(tokens: string[]): Promise { let parsedSessionId: string | undefined; let parsedOptions: { repo?: string; file?: string; json?: boolean } = {}; - command.action((sessionId: string | undefined, options: { repo?: string; file?: string; json?: boolean }) => { - parsedSessionId = sessionId; - parsedOptions = options; - }); + command.action( + ( + sessionId: string | undefined, + options: { repo?: string; file?: string; json?: boolean }, + ) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); if (commentRest.includes("--help") || commentRest.includes("-h")) { return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; @@ -619,11 +695,17 @@ async function parseSessionCommand(tokens: string[]): Promise { let parsedCommentId = ""; let parsedOptions: { repo?: string; json?: boolean } = {}; - command.action((sessionId: string | undefined, commentId: string, options: { repo?: string; json?: boolean }) => { - parsedSessionId = sessionId; - parsedCommentId = commentId; - parsedOptions = options; - }); + command.action( + ( + sessionId: string | undefined, + commentId: string, + options: { repo?: string; json?: boolean }, + ) => { + parsedSessionId = sessionId; + parsedCommentId = commentId; + parsedOptions = options; + }, + ); if (commentRest.includes("--help") || commentRest.includes("-h")) { return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; @@ -652,10 +734,15 @@ async function parseSessionCommand(tokens: string[]): Promise { let parsedSessionId: string | undefined; let parsedOptions: { repo?: string; file?: string; yes?: boolean; json?: boolean } = {}; - command.action((sessionId: string | undefined, options: { repo?: string; file?: string; yes?: boolean; json?: boolean }) => { - parsedSessionId = sessionId; - parsedOptions = options; - }); + command.action( + ( + sessionId: string | undefined, + options: { repo?: string; file?: string; yes?: boolean; json?: boolean }, + ) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); if (commentRest.includes("--help") || commentRest.includes("-h")) { return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; @@ -688,16 +775,17 @@ async function parseMcpCommand(tokens: string[]): Promise { if (!subcommand || subcommand === "--help" || subcommand === "-h") { return { kind: "help", - text: [ - "Usage: hunk mcp serve", - "", - "Run the local Hunk session daemon and websocket session broker.", - "", - "Environment:", - " HUNK_MCP_HOST bind host (default 127.0.0.1; loopback only unless explicitly overridden)", - " HUNK_MCP_PORT bind port (default 47657)", - " HUNK_MCP_UNSAFE_ALLOW_REMOTE set to 1 to allow non-loopback binding (unsafe)", - ].join("\n") + "\n", + text: + [ + "Usage: hunk mcp serve", + "", + "Run the local Hunk session daemon and websocket session broker.", + "", + "Environment:", + " HUNK_MCP_HOST bind host (default 127.0.0.1; loopback only unless explicitly overridden)", + " HUNK_MCP_PORT bind port (default 47657)", + " HUNK_MCP_UNSAFE_ALLOW_REMOTE set to 1 to allow non-loopback binding (unsafe)", + ].join("\n") + "\n", }; } @@ -708,11 +796,12 @@ async function parseMcpCommand(tokens: string[]): Promise { if (rest.includes("--help") || rest.includes("-h")) { return { kind: "help", - text: [ - "Usage: hunk mcp serve", - "", - "Run the local Hunk session daemon and websocket session broker.", - ].join("\n") + "\n", + text: + [ + "Usage: hunk mcp serve", + "", + "Run the local Hunk session daemon and websocket session broker.", + ].join("\n") + "\n", }; } @@ -727,15 +816,16 @@ async function parseStashCommand(tokens: string[], argv: string[]): Promise = {}; diff --git a/src/core/config.ts b/src/core/config.ts index 7167b2c6..ec4ce640 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -2,7 +2,15 @@ import fs from "node:fs"; import { dirname, join, resolve } from "node:path"; import type { CliInput, CommonOptions, LayoutMode, PersistedViewPreferences } from "./types"; -const CONFIG_SECTION_NAMES = ["pager", "git", "diff", "show", "stash-show", "patch", "difftool"] as const; +const CONFIG_SECTION_NAMES = [ + "pager", + "git", + "diff", + "show", + "stash-show", + "patch", + "difftool", +] as const; const DEFAULT_VIEW_PREFERENCES: PersistedViewPreferences = { mode: "auto", showLineNumbers: true, @@ -20,7 +28,6 @@ interface HunkConfigResolution { input: CliInput; globalConfigPath?: string; repoConfigPath?: string; - } function isRecord(value: unknown): value is Record { @@ -152,11 +159,17 @@ export function resolveConfiguredCliInput( }; if (userConfigPath) { - resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(readTomlRecord(userConfigPath), input)); + resolvedOptions = mergeOptions( + resolvedOptions, + resolveConfigLayer(readTomlRecord(userConfigPath), input), + ); } if (repoConfigPath) { - resolvedOptions = mergeOptions(resolvedOptions, resolveConfigLayer(readTomlRecord(repoConfigPath), input)); + resolvedOptions = mergeOptions( + resolvedOptions, + resolveConfigLayer(readTomlRecord(repoConfigPath), input), + ); } resolvedOptions = mergeOptions(resolvedOptions, input.options); @@ -180,4 +193,3 @@ export function resolveConfiguredCliInput( repoConfigPath, }; } - diff --git a/src/core/liveComments.ts b/src/core/liveComments.ts index 0c483974..f1712d22 100644 --- a/src/core/liveComments.ts +++ b/src/core/liveComments.ts @@ -4,8 +4,14 @@ import type { CommentToolInput, DiffSide, LiveComment } from "../mcp/types"; /** Compute the inclusive old/new line spans touched by one hunk. */ export function hunkLineRange(hunk: Hunk) { - const newEnd = Math.max(hunk.additionStart, hunk.additionStart + Math.max(hunk.additionLines, 1) - 1); - const oldEnd = Math.max(hunk.deletionStart, hunk.deletionStart + Math.max(hunk.deletionLines, 1) - 1); + const newEnd = Math.max( + hunk.additionStart, + hunk.additionStart + Math.max(hunk.additionLines, 1) - 1, + ); + const oldEnd = Math.max( + hunk.deletionStart, + hunk.deletionStart + Math.max(hunk.deletionLines, 1) - 1, + ); return { oldRange: [hunk.deletionStart, oldEnd] as [number, number], diff --git a/src/core/loaders.ts b/src/core/loaders.ts index 6da387cb..f11c6867 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -129,7 +129,10 @@ function findPatchChunk(metadata: FileDiffMetadata, chunks: string[], index: num [metadata.name, metadata.prevName] .filter((value): value is string => Boolean(value)) .map(stripPrefixes) - .some((path) => chunk.includes(`a/${path}`) || chunk.includes(`b/${path}`) || chunk.includes(path)), + .some( + (path) => + chunk.includes(`a/${path}`) || chunk.includes(`b/${path}`) || chunk.includes(path), + ), ) ?? "" ); } @@ -222,19 +225,33 @@ function normalizePatchChangeset( id: `changeset:${Date.now()}`, sourceLabel, title, - summary: parsedPatches.map((entry) => entry.patchMetadata).filter(Boolean).join("\n\n") || undefined, + summary: + parsedPatches + .map((entry) => entry.patchMetadata) + .filter(Boolean) + .join("\n\n") || undefined, agentSummary: agentContext?.summary, files: metadataFiles.map((metadata, index) => - buildDiffFile(metadata, findPatchChunk(metadata, chunks, index), index, sourceLabel, agentContext), + buildDiffFile( + metadata, + findPatchChunk(metadata, chunks, index), + index, + sourceLabel, + agentContext, + ), ), }; } /** Build a changeset by diffing two concrete files on disk. */ -async function loadFileDiffChangeset(input: FileCommandInput | DiffToolCommandInput, agentContext: AgentContext | null) { +async function loadFileDiffChangeset( + input: FileCommandInput | DiffToolCommandInput, + agentContext: AgentContext | null, +) { const leftText = await Bun.file(input.left).text(); const rightText = await Bun.file(input.right).text(); - const displayPath = input.kind === "difftool" ? input.path ?? basename(input.right) : basename(input.right); + const displayPath = + input.kind === "difftool" ? (input.path ?? basename(input.right)) : basename(input.right); const title = input.kind === "difftool" ? `git difftool: ${displayPath}` @@ -323,7 +340,10 @@ async function loadShowChangeset(input: ShowCommandInput, agentContext: AgentCon } /** Build a changeset from `git stash show -p`, which naturally maps to one reviewable patch. */ -async function loadStashShowChangeset(input: StashShowCommandInput, agentContext: AgentContext | null) { +async function loadStashShowChangeset( + input: StashShowCommandInput, + agentContext: AgentContext | null, +) { const repoRoot = spawnText(["git", "rev-parse", "--show-toplevel"]).trim(); const repoName = basename(repoRoot); const args = ["git", "stash", "show", "-p", "--find-renames", "--no-color"]; @@ -349,7 +369,12 @@ async function loadPatchChangeset(input: PatchCommandInput, agentContext: AgentC : await Bun.file(input.file).text()); const label = input.file && input.file !== "-" ? input.file : "stdin patch"; - return normalizePatchChangeset(patchText, `Patch review: ${basename(label)}`, label, agentContext); + return normalizePatchChangeset( + patchText, + `Patch review: ${basename(label)}`, + label, + agentContext, + ); } /** Resolve CLI input into the fully loaded app bootstrap state. */ diff --git a/src/core/startup.ts b/src/core/startup.ts index d6d36de8..72d0079a 100644 --- a/src/core/startup.ts +++ b/src/core/startup.ts @@ -1,7 +1,12 @@ import { resolveConfiguredCliInput } from "./config"; import { loadAppBootstrap } from "./loaders"; import { looksLikePatchInput } from "./pager"; -import { openControllingTerminal, resolveRuntimeCliInput, usesPipedPatchInput, type ControllingTerminal } from "./terminal"; +import { + openControllingTerminal, + resolveRuntimeCliInput, + usesPipedPatchInput, + type ControllingTerminal, +} from "./terminal"; import type { AppBootstrap, CliInput, ParsedCliInput, SessionCommandInput } from "./types"; import { parseCli } from "./cli"; @@ -48,7 +53,8 @@ export async function prepareStartupPlan( const readStdinText = deps.readStdinText ?? (() => new Response(Bun.stdin.stream()).text()); const looksLikePatchInputImpl = deps.looksLikePatchInputImpl ?? looksLikePatchInput; const resolveRuntimeCliInputImpl = deps.resolveRuntimeCliInputImpl ?? resolveRuntimeCliInput; - const resolveConfiguredCliInputImpl = deps.resolveConfiguredCliInputImpl ?? resolveConfiguredCliInput; + const resolveConfiguredCliInputImpl = + deps.resolveConfiguredCliInputImpl ?? resolveConfiguredCliInput; const loadAppBootstrapImpl = deps.loadAppBootstrapImpl ?? loadAppBootstrap; const usesPipedPatchInputImpl = deps.usesPipedPatchInputImpl ?? usesPipedPatchInput; const openControllingTerminalImpl = deps.openControllingTerminalImpl ?? openControllingTerminal; @@ -100,7 +106,9 @@ export async function prepareStartupPlan( const configured = resolveConfiguredCliInputImpl(runtimeCliInput); const cliInput = configured.input; const bootstrap = await loadAppBootstrapImpl(cliInput); - const controllingTerminal = usesPipedPatchInputImpl(cliInput) ? openControllingTerminalImpl() : null; + const controllingTerminal = usesPipedPatchInputImpl(cliInput) + ? openControllingTerminalImpl() + : null; return { kind: "app", diff --git a/src/core/terminal.ts b/src/core/terminal.ts index 5e99a143..8e4da0d8 100644 --- a/src/core/terminal.ts +++ b/src/core/terminal.ts @@ -13,7 +13,10 @@ export function shouldUsePagerMode(input: CliInput, stdinIsTTY = Boolean(process } /** Apply runtime CLI defaults that depend on whether stdin is an interactive terminal. */ -export function resolveRuntimeCliInput(input: CliInput, stdinIsTTY = Boolean(process.stdin.isTTY)): CliInput { +export function resolveRuntimeCliInput( + input: CliInput, + stdinIsTTY = Boolean(process.stdin.isTTY), +): CliInput { return { ...input, options: { diff --git a/src/core/types.ts b/src/core/types.ts index ff04beaf..53e08167 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -214,7 +214,12 @@ export type CliInput = | PatchCommandInput | DiffToolCommandInput; -export type ParsedCliInput = CliInput | HelpCommandInput | PagerCommandInput | McpServeCommandInput | SessionCommandInput; +export type ParsedCliInput = + | CliInput + | HelpCommandInput + | PagerCommandInput + | McpServeCommandInput + | SessionCommandInput; export interface AppBootstrap { input: CliInput; diff --git a/src/main.tsx b/src/main.tsx index be4bcf79..8427878b 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -38,7 +38,10 @@ if (startupPlan.kind !== "app") { } const { bootstrap, cliInput, controllingTerminal } = startupPlan; -const hostClient = new HunkHostClient(createSessionRegistration(bootstrap), createInitialSessionSnapshot(bootstrap)); +const hostClient = new HunkHostClient( + createSessionRegistration(bootstrap), + createInitialSessionSnapshot(bootstrap), +); hostClient.start(); const renderer = await createCliRenderer({ @@ -66,10 +69,4 @@ function shutdown() { } // The app owns the full alternate screen session from this point on. -root.render( - , -); +root.render(); diff --git a/src/mcp/client.ts b/src/mcp/client.ts index 779c6933..6879a477 100644 --- a/src/mcp/client.ts +++ b/src/mcp/client.ts @@ -9,8 +9,17 @@ import type { SessionCommandResult, SessionServerMessage, } from "./types"; -import { HUNK_SESSION_SOCKET_PATH, resolveHunkMcpConfig, type ResolvedHunkMcpConfig } from "./config"; -import { isHunkDaemonHealthy, isLoopbackPortReachable, launchHunkDaemon, waitForHunkDaemonHealth } from "./daemonLauncher"; +import { + HUNK_SESSION_SOCKET_PATH, + resolveHunkMcpConfig, + type ResolvedHunkMcpConfig, +} from "./config"; +import { + isHunkDaemonHealthy, + isLoopbackPortReachable, + launchHunkDaemon, + waitForHunkDaemonHealth, +} from "./daemonLauncher"; const DAEMON_LAUNCH_COOLDOWN_MS = 5_000; const DAEMON_STARTUP_TIMEOUT_MS = 3_000; @@ -18,10 +27,18 @@ const RECONNECT_DELAY_MS = 3_000; const HEARTBEAT_INTERVAL_MS = 10_000; interface HunkAppBridge { - applyComment: (message: Extract) => Promise; - navigateToHunk: (message: Extract) => Promise; - removeComment: (message: Extract) => Promise; - clearComments: (message: Extract) => Promise; + applyComment: ( + message: Extract, + ) => Promise; + navigateToHunk: ( + message: Extract, + ) => Promise; + removeComment: ( + message: Extract, + ) => Promise; + clearComments: ( + message: Extract, + ) => Promise; } /** Keep one running Hunk TUI session registered with the local MCP daemon. */ diff --git a/src/mcp/daemonLauncher.ts b/src/mcp/daemonLauncher.ts index 4ca604ee..adf9f6e8 100644 --- a/src/mcp/daemonLauncher.ts +++ b/src/mcp/daemonLauncher.ts @@ -11,7 +11,10 @@ export interface DaemonLaunchCommand { } /** Resolve how the current Hunk process should launch a sibling `hunk mcp serve` daemon. */ -export function resolveDaemonLaunchCommand(argv = process.argv, execPath = process.execPath): DaemonLaunchCommand { +export function resolveDaemonLaunchCommand( + argv = process.argv, + execPath = process.execPath, +): DaemonLaunchCommand { const entrypoint = argv[1]; if (entrypoint && !entrypoint.startsWith("-") && SCRIPT_ENTRYPOINT_PATTERN.test(entrypoint)) { @@ -28,7 +31,10 @@ export function resolveDaemonLaunchCommand(argv = process.argv, execPath = proce } /** Check whether the loopback Hunk daemon already answers health probes. */ -export async function isHunkDaemonHealthy(config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(), timeoutMs = 500) { +export async function isHunkDaemonHealthy( + config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(), + timeoutMs = 500, +) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); timeout.unref?.(); diff --git a/src/mcp/daemonState.ts b/src/mcp/daemonState.ts index 2dae9118..2b3fb629 100644 --- a/src/mcp/daemonState.ts +++ b/src/mcp/daemonState.ts @@ -48,11 +48,14 @@ function describeSessionChoices(sessions: ListedSession[]) { } function findSelectedFile(session: ListedSession) { - return session.files.find( - (file) => file.id === session.snapshot.selectedFileId - || file.path === session.snapshot.selectedFilePath - || file.previousPath === session.snapshot.selectedFilePath, - ) ?? null; + return ( + session.files.find( + (file) => + file.id === session.snapshot.selectedFileId || + file.path === session.snapshot.selectedFilePath || + file.previousPath === session.snapshot.selectedFilePath, + ) ?? null + ); } /** Resolve which live Hunk session one external command should target. */ @@ -87,7 +90,9 @@ export function resolveSessionTarget(sessions: ListedSession[], selector: Sessio } if (sessions.length === 0) { - throw new Error("No active Hunk sessions are registered with the daemon. Open Hunk and wait for it to connect."); + throw new Error( + "No active Hunk sessions are registered with the daemon. Open Hunk and wait for it to connect.", + ); } throw new Error( @@ -162,7 +167,11 @@ export class HunkDaemonState { return this.pendingCommands.size; } - registerSession(socket: DaemonSessionSocket, registration: HunkSessionRegistration, snapshot: HunkSessionSnapshot) { + registerSession( + socket: DaemonSessionSocket, + registration: HunkSessionRegistration, + snapshot: HunkSessionSnapshot, + ) { const previousSessionId = this.sessionIdsBySocket.get(socket); if (previousSessionId && previousSessionId !== registration.sessionId) { this.unregisterSocket(socket); @@ -171,7 +180,10 @@ export class HunkDaemonState { const existing = this.sessions.get(registration.sessionId); if (existing && existing.socket !== socket) { this.sessionIdsBySocket.delete(existing.socket); - this.rejectPendingCommandsForSession(registration.sessionId, new Error("Hunk session reconnected before the command completed.")); + this.rejectPendingCommandsForSession( + registration.sessionId, + new Error("Hunk session reconnected before the command completed."), + ); } const now = new Date().toISOString(); @@ -219,13 +231,7 @@ export class HunkDaemonState { this.removeSession(sessionId, "The targeted Hunk session disconnected."); } - pruneStaleSessions({ - ttlMs, - now = Date.now(), - }: { - ttlMs: number; - now?: number; - }) { + pruneStaleSessions({ ttlMs, now = Date.now() }: { ttlMs: number; now?: number }) { let removed = 0; const cutoff = now - ttlMs; @@ -235,7 +241,10 @@ export class HunkDaemonState { continue; } - this.removeSession(sessionId, "The targeted Hunk session became stale and was removed from the MCP daemon."); + this.removeSession( + sessionId, + "The targeted Hunk session became stale and was removed from the MCP daemon.", + ); removed += 1; } @@ -278,7 +287,12 @@ export class HunkDaemonState { ); } - handleCommandResult(message: { requestId: string; ok: boolean; result?: SessionCommandResult; error?: string }) { + handleCommandResult(message: { + requestId: string; + ok: boolean; + result?: SessionCommandResult; + error?: string; + }) { const pending = this.pendingCommands.get(message.requestId); if (!pending) { return; @@ -306,7 +320,10 @@ export class HunkDaemonState { this.sessions.clear(); } - private sendCommand( + private sendCommand< + ResultType extends SessionCommandResult, + CommandName extends SessionServerMessage["command"], + >( selector: SessionTargetInput, command: CommandName, input: Extract["input"], @@ -348,7 +365,11 @@ export class HunkDaemonState { } catch (error) { clearTimeout(timeout); this.pendingCommands.delete(requestId); - reject(error instanceof Error ? error : new Error("The targeted Hunk session could not receive the command.")); + reject( + error instanceof Error + ? error + : new Error("The targeted Hunk session could not receive the command."), + ); } }); } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 8673860f..4cd69d64 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -29,9 +29,9 @@ function formatDaemonServeError(error: unknown, host: string, port: number) { const message = error instanceof Error ? error.message : String(error); const normalized = message.toLowerCase(); if ( - normalized.includes("eaddrinuse") - || normalized.includes("address already in use") - || normalized.includes(`is port ${port} in use?`) + normalized.includes("eaddrinuse") || + normalized.includes("address already in use") || + normalized.includes(`is port ${port} in use?`) ) { return new Error( `Hunk MCP daemon could not bind ${host}:${port} because the port is already in use. ` + @@ -81,7 +81,10 @@ async function handleSessionApiRequest(state: HunkDaemonState, request: Request) response = { context: state.getSelectedContext(input.selector) }; break; case "navigate": { - if (input.hunkNumber === undefined && (input.side === undefined || input.line === undefined)) { + if ( + input.hunkNumber === undefined && + (input.side === undefined || input.line === undefined) + ) { throw new Error("navigate requires either hunkNumber or both side and line."); } @@ -186,7 +189,10 @@ export function serveHunkMcpServer() { } if (url.pathname === "/mcp") { - return jsonError("Hunk no longer exposes agent-facing MCP tools. Use `hunk session ...` instead.", 410); + return jsonError( + "Hunk no longer exposes agent-facing MCP tools. Use `hunk session ...` instead.", + 410, + ); } if (url.pathname === HUNK_SESSION_SOCKET_PATH) { diff --git a/src/mcp/sessionRegistration.ts b/src/mcp/sessionRegistration.ts index 4868c827..573ff248 100644 --- a/src/mcp/sessionRegistration.ts +++ b/src/mcp/sessionRegistration.ts @@ -4,7 +4,9 @@ import { hunkLineRange } from "../core/liveComments"; import type { HunkSessionRegistration, HunkSessionSnapshot } from "./types"; function inferRepoRoot(bootstrap: AppBootstrap) { - return bootstrap.input.kind === "git" || bootstrap.input.kind === "show" || bootstrap.input.kind === "stash-show" + return bootstrap.input.kind === "git" || + bootstrap.input.kind === "show" || + bootstrap.input.kind === "stash-show" ? bootstrap.changeset.sourceLabel : undefined; } diff --git a/src/mcp/types.ts b/src/mcp/types.ts index 4ad247dc..50b7bb27 100644 --- a/src/mcp/types.ts +++ b/src/mcp/types.ts @@ -135,7 +135,11 @@ export interface SelectedSessionContext { liveCommentCount: number; } -export type SessionCommandResult = AppliedCommentResult | NavigatedSelectionResult | RemovedCommentResult | ClearedCommentsResult; +export type SessionCommandResult = + | AppliedCommentResult + | NavigatedSelectionResult + | RemovedCommentResult + | ClearedCommentsResult; export type SessionClientMessage = | { diff --git a/src/session/commands.ts b/src/session/commands.ts index 31485ec5..e51ba6fd 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -9,7 +9,12 @@ import type { SessionNavigateCommandInput, SessionSelectorInput, } from "../core/types"; -import { isHunkDaemonHealthy, isLoopbackPortReachable, launchHunkDaemon, waitForHunkDaemonHealth } from "../mcp/daemonLauncher"; +import { + isHunkDaemonHealthy, + isLoopbackPortReachable, + launchHunkDaemon, + waitForHunkDaemonHealth, +} from "../mcp/daemonLauncher"; import { resolveHunkMcpConfig } from "../mcp/config"; import type { AppliedCommentResult, @@ -55,7 +60,10 @@ const REQUIRED_ACTION_BY_COMMAND: Record HunkDaemonCliClient; resolveDaemonAvailability?: (action: SessionCommandInput["action"]) => Promise; - restartDaemonForMissingAction?: (action: SessionDaemonAction, selector?: SessionSelectorInput) => Promise; + restartDaemonForMissingAction?: ( + action: SessionDaemonAction, + selector?: SessionSelectorInput, + ) => Promise; } let sessionCommandTestHooks: SessionCommandTestHooks | null = null; @@ -127,7 +135,9 @@ class HttpHunkDaemonCliClient implements HunkDaemonCliClient { } async getSelectedContext(selector: SessionSelectorInput) { - return (await this.request<{ context: SelectedSessionContext }>({ action: "context", selector })).context; + return ( + await this.request<{ context: SelectedSessionContext }>({ action: "context", selector }) + ).context; } async navigateToHunk(input: SessionNavigateCommandInput) { @@ -261,7 +271,10 @@ async function waitForSessionRegistration(selector?: SessionSelectorInput, timeo return false; } -async function restartDaemonForMissingAction(action: SessionDaemonAction, selector?: SessionSelectorInput) { +async function restartDaemonForMissingAction( + action: SessionDaemonAction, + selector?: SessionSelectorInput, +) { const health = await readDaemonHealth(); const pid = health?.pid; const hadSessions = (health?.sessions ?? 0) > 0; @@ -276,7 +289,9 @@ async function restartDaemonForMissingAction(action: SessionDaemonAction, select const shutDown = await waitForDaemonShutdown(); if (!shutDown) { - throw new Error(`Stopped waiting for the old Hunk session daemon to exit after it was found missing ${action}.`); + throw new Error( + `Stopped waiting for the old Hunk session daemon to exit after it was found missing ${action}.`, + ); } launchHunkDaemon(); @@ -290,7 +305,9 @@ async function restartDaemonForMissingAction(action: SessionDaemonAction, select if (selector || hadSessions) { const registered = await waitForSessionRegistration(selector); if (!registered) { - throw new Error("Timed out waiting for the live Hunk session to reconnect after refreshing the session daemon."); + throw new Error( + "Timed out waiting for the live Hunk session to reconnect after refreshing the session daemon.", + ); } } } @@ -302,10 +319,8 @@ async function ensureRequiredAction(action: SessionDaemonAction, selector?: Sess return; } - await ( - sessionCommandTestHooks?.restartDaemonForMissingAction?.(action, selector) - ?? restartDaemonForMissingAction(action, selector) - ); + await (sessionCommandTestHooks?.restartDaemonForMissingAction?.(action, selector) ?? + restartDaemonForMissingAction(action, selector)); } function stringifyJson(value: unknown) { @@ -336,13 +351,15 @@ function formatListOutput(sessions: ListedSession[]) { } return `${sessions - .map((session) => [ - `${session.sessionId} ${session.title}`, - ` repo: ${session.repoRoot ?? session.cwd}`, - ` focus: ${formatSelectedSummary(session)}`, - ` files: ${session.fileCount}`, - ` comments: ${session.snapshot.liveCommentCount}`, - ].join("\n")) + .map((session) => + [ + `${session.sessionId} ${session.title}`, + ` repo: ${session.repoRoot ?? session.cwd}`, + ` focus: ${formatSelectedSummary(session)}`, + ` files: ${session.fileCount}`, + ` comments: ${session.snapshot.liveCommentCount}`, + ].join("\n"), + ) .join("\n\n")}\n`; } @@ -358,7 +375,10 @@ function formatSessionOutput(session: ListedSession) { `Agent notes visible: ${session.snapshot.showAgentNotes ? "yes" : "no"}`, `Live comments: ${session.snapshot.liveCommentCount}`, "Files:", - ...session.files.map((file) => ` - ${file.path} (+${file.additions} -${file.deletions}, hunks: ${file.hunkCount})`), + ...session.files.map( + (file) => + ` - ${file.path} (+${file.additions} -${file.deletions}, hunks: ${file.hunkCount})`, + ), "", ].join("\n"); } @@ -366,8 +386,12 @@ function formatSessionOutput(session: ListedSession) { function formatContextOutput(context: SelectedSessionContext) { const selectedFile = context.selectedFile?.path ?? "(none)"; const hunkNumber = context.selectedHunk ? context.selectedHunk.index + 1 : 0; - const oldRange = context.selectedHunk?.oldRange ? `${context.selectedHunk.oldRange[0]}..${context.selectedHunk.oldRange[1]}` : "-"; - const newRange = context.selectedHunk?.newRange ? `${context.selectedHunk.newRange[0]}..${context.selectedHunk.newRange[1]}` : "-"; + const oldRange = context.selectedHunk?.oldRange + ? `${context.selectedHunk.oldRange[0]}..${context.selectedHunk.oldRange[1]}` + : "-"; + const newRange = context.selectedHunk?.newRange + ? `${context.selectedHunk.newRange[0]}..${context.selectedHunk.newRange[1]}` + : "-"; return [ `Session: ${context.sessionId}`, @@ -391,18 +415,23 @@ function formatCommentOutput(selector: SessionSelectorInput, result: AppliedComm return `Added live comment ${result.commentId} on ${result.filePath}:${result.line} (${result.side}) in hunk ${result.hunkIndex + 1} for ${formatSelector(selector)}.\n`; } -function formatCommentListOutput(selector: SessionSelectorInput, comments: SessionLiveCommentSummary[]) { +function formatCommentListOutput( + selector: SessionSelectorInput, + comments: SessionLiveCommentSummary[], +) { if (comments.length === 0) { return `No live comments for ${formatSelector(selector)}.\n`; } return `${comments - .map((comment) => [ - `${comment.commentId} ${comment.filePath}:${comment.line} (${comment.side})`, - ` hunk: ${comment.hunkIndex + 1}`, - ` summary: ${comment.summary}`, - ...(comment.author ? [` author: ${comment.author}`] : []), - ].join("\n")) + .map((comment) => + [ + `${comment.commentId} ${comment.filePath}:${comment.line} (${comment.side})`, + ` hunk: ${comment.hunkIndex + 1}`, + ` summary: ${comment.summary}`, + ...(comment.author ? [` author: ${comment.author}`] : []), + ].join("\n"), + ) .join("\n\n")}\n`; } @@ -411,7 +440,9 @@ function formatRemoveCommentOutput(selector: SessionSelectorInput, result: Remov } function formatClearCommentsOutput(selector: SessionSelectorInput, result: ClearedCommentsResult) { - const scope = result.filePath ? `${result.filePath} in ${formatSelector(selector)}` : formatSelector(selector); + const scope = result.filePath + ? `${result.filePath} in ${formatSelector(selector)}` + : formatSelector(selector); return `Cleared ${result.removedCount} live comments from ${scope}. Remaining comments: ${result.remainingCommentCount}.\n`; } @@ -445,7 +476,9 @@ async function resolveDaemonAvailability(action: SessionCommandInput["action"]) return false; } - throw new Error("No active Hunk sessions are registered with the daemon. Open Hunk and wait for it to connect."); + throw new Error( + "No active Hunk sessions are registered with the daemon. Open Hunk and wait for it to connect.", + ); } function renderOutput(output: SessionCommandOutput, value: unknown, formatText: () => string) { @@ -453,13 +486,18 @@ function renderOutput(output: SessionCommandOutput, value: unknown, formatText: } export async function runSessionCommand(input: SessionCommandInput) { - const daemonAvailable = await (sessionCommandTestHooks?.resolveDaemonAvailability?.(input.action) ?? resolveDaemonAvailability(input.action)); + const daemonAvailable = await (sessionCommandTestHooks?.resolveDaemonAvailability?.( + input.action, + ) ?? resolveDaemonAvailability(input.action)); if (!daemonAvailable && input.action === "list") { return renderOutput(input.output, { sessions: [] }, () => formatListOutput([])); } const normalizedSelector = "selector" in input ? normalizeRepoRoot(input.selector) : null; - await ensureRequiredAction(REQUIRED_ACTION_BY_COMMAND[input.action], normalizedSelector ?? undefined); + await ensureRequiredAction( + REQUIRED_ACTION_BY_COMMAND[input.action], + normalizedSelector ?? undefined, + ); const client = createDaemonCliClient(); @@ -481,35 +519,45 @@ export async function runSessionCommand(input: SessionCommandInput) { ...input, selector: normalizedSelector!, }); - return renderOutput(input.output, { result }, () => formatNavigationOutput(input.selector, result)); + return renderOutput(input.output, { result }, () => + formatNavigationOutput(input.selector, result), + ); } case "comment-add": { const result = await client.addComment({ ...input, selector: normalizedSelector!, }); - return renderOutput(input.output, { result }, () => formatCommentOutput(input.selector, result)); + return renderOutput(input.output, { result }, () => + formatCommentOutput(input.selector, result), + ); } case "comment-list": { const comments = await client.listComments({ ...input, selector: normalizedSelector!, }); - return renderOutput(input.output, { comments }, () => formatCommentListOutput(input.selector, comments)); + return renderOutput(input.output, { comments }, () => + formatCommentListOutput(input.selector, comments), + ); } case "comment-rm": { const result = await client.removeComment({ ...input, selector: normalizedSelector!, }); - return renderOutput(input.output, { result }, () => formatRemoveCommentOutput(input.selector, result)); + return renderOutput(input.output, { result }, () => + formatRemoveCommentOutput(input.selector, result), + ); } case "comment-clear": { const result = await client.clearComments({ ...input, selector: normalizedSelector!, }); - return renderOutput(input.output, { result }, () => formatClearCommentsOutput(input.selector, result)); + return renderOutput(input.output, { result }, () => + formatClearCommentsOutput(input.selector, result), + ); } } } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 00df7255..2dfbfe5f 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,6 +1,21 @@ -import { MouseButton, type KeyEvent, type MouseEvent as TuiMouseEvent, type ScrollBoxRenderable } from "@opentui/core"; +import { + MouseButton, + type KeyEvent, + type MouseEvent as TuiMouseEvent, + type ScrollBoxRenderable, +} from "@opentui/core"; import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/react"; -import { Suspense, lazy, startTransition, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import { + Suspense, + lazy, + startTransition, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from "react"; import type { AppBootstrap, LayoutMode } from "../core/types"; import { HunkHostClient } from "../mcp/client"; import { MenuBar } from "./components/chrome/MenuBar"; @@ -21,8 +36,12 @@ import { resolveTheme, THEMES } from "./themes"; type FocusArea = "files" | "filter"; -const LazyHelpDialog = lazy(async () => ({ default: (await import("./components/chrome/HelpDialog")).HelpDialog })); -const LazyMenuDropdown = lazy(async () => ({ default: (await import("./components/chrome/MenuDropdown")).MenuDropdown })); +const LazyHelpDialog = lazy(async () => ({ + default: (await import("./components/chrome/HelpDialog")).HelpDialog, +})); +const LazyMenuDropdown = lazy(async () => ({ + default: (await import("./components/chrome/MenuDropdown")).MenuDropdown, +})); /** Clamp a value into an inclusive range. */ function clamp(value: number, min: number, max: number) { @@ -50,7 +69,9 @@ export function App({ const filesScrollRef = useRef(null); const diffScrollRef = useRef(null); const [layoutMode, setLayoutMode] = useState(bootstrap.initialMode); - const [themeId, setThemeId] = useState(() => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id); + const [themeId, setThemeId] = useState( + () => resolveTheme(bootstrap.initialTheme, renderer.themeMode).id, + ); const [showAgentNotes, setShowAgentNotes] = useState(bootstrap.initialShowAgentNotes ?? false); const [showLineNumbers, setShowLineNumbers] = useState(bootstrap.initialShowLineNumbers ?? true); const [wrapLines, setWrapLines] = useState(bootstrap.initialWrapLines ?? false); @@ -82,7 +103,9 @@ export function App({ setShowAgentNotes(true); }, []); - const baseSelectedFile = bootstrap.changeset.files.find((file) => file.id === selectedFileId) ?? bootstrap.changeset.files[0]; + const baseSelectedFile = + bootstrap.changeset.files.find((file) => file.id === selectedFileId) ?? + bootstrap.changeset.files[0]; const { liveCommentsByFileId } = useHunkSessionBridge({ currentHunk: baseSelectedFile?.metadata.hunks[selectedHunkIndex], files: bootstrap.changeset.files, @@ -119,11 +142,17 @@ export function App({ return true; } - const haystack = [file.path, file.previousPath, file.agent?.summary].filter(Boolean).join(" ").toLowerCase(); + const haystack = [file.path, file.previousPath, file.agent?.summary] + .filter(Boolean) + .join(" ") + .toLowerCase(); return haystack.includes(deferredFilter.trim().toLowerCase()); }); - const selectedFile = filteredFiles.find((file) => file.id === selectedFileId) ?? allFiles.find((file) => file.id === selectedFileId) ?? filteredFiles[0]; + const selectedFile = + filteredFiles.find((file) => file.id === selectedFileId) ?? + allFiles.find((file) => file.id === selectedFileId) ?? + filteredFiles[0]; const hunkCursors = buildHunkCursors(filteredFiles); const bodyPadding = pagerMode ? 0 : BODY_PADDING; @@ -132,14 +161,21 @@ export function App({ const canForceShowFilesPane = bodyWidth >= FILES_MIN_WIDTH + DIVIDER_WIDTH + DIFF_MIN_WIDTH; const showFilesPane = pagerMode ? false - : sidebarVisible && (responsiveLayout.showFilesPane || (forceSidebarOpen && canForceShowFilesPane)); + : sidebarVisible && + (responsiveLayout.showFilesPane || (forceSidebarOpen && canForceShowFilesPane)); const centerWidth = bodyWidth; const resolvedLayout = responsiveLayout.layout; const currentHunk = selectedFile?.metadata.hunks[selectedHunkIndex]; const activeAnnotations = getSelectedAnnotations(selectedFile, currentHunk); - const availableCenterWidth = showFilesPane ? Math.max(0, centerWidth - DIVIDER_WIDTH) : Math.max(0, centerWidth); - const maxFilesPaneWidth = showFilesPane ? Math.max(FILES_MIN_WIDTH, availableCenterWidth - DIFF_MIN_WIDTH) : FILES_MIN_WIDTH; - const clampedFilesPaneWidth = showFilesPane ? clamp(filesPaneWidth, FILES_MIN_WIDTH, maxFilesPaneWidth) : 0; + const availableCenterWidth = showFilesPane + ? Math.max(0, centerWidth - DIVIDER_WIDTH) + : Math.max(0, centerWidth); + const maxFilesPaneWidth = showFilesPane + ? Math.max(FILES_MIN_WIDTH, availableCenterWidth - DIFF_MIN_WIDTH) + : FILES_MIN_WIDTH; + const clampedFilesPaneWidth = showFilesPane + ? clamp(filesPaneWidth, FILES_MIN_WIDTH, maxFilesPaneWidth) + : 0; const diffPaneWidth = showFilesPane ? Math.max(DIFF_MIN_WIDTH, availableCenterWidth - clampedFilesPaneWidth) : Math.max(0, availableCenterWidth); @@ -171,7 +207,11 @@ export function App({ return; } - if (selectedFile && !filteredFiles.some((file) => file.id === selectedFile.id) && filteredFiles[0]) { + if ( + selectedFile && + !filteredFiles.some((file) => file.id === selectedFile.id) && + filteredFiles[0] + ) { startTransition(() => { setSelectedFileId(filteredFiles[0]!.id); setSelectedHunkIndex(0); @@ -378,7 +418,13 @@ export function App({ } setFilesPaneWidth( - resizeSidebarWidth(resizeStartWidth, resizeDragOriginX, event.x, FILES_MIN_WIDTH, maxFilesPaneWidth), + resizeSidebarWidth( + resizeStartWidth, + resizeDragOriginX, + event.x, + FILES_MIN_WIDTH, + maxFilesPaneWidth, + ), ); event.preventDefault(); event.stopPropagation(); @@ -397,8 +443,14 @@ export function App({ }; const fileEntries = filteredFiles.map(buildFileListEntry); - const totalAdditions = bootstrap.changeset.files.reduce((sum, file) => sum + file.stats.additions, 0); - const totalDeletions = bootstrap.changeset.files.reduce((sum, file) => sum + file.stats.deletions, 0); + const totalAdditions = bootstrap.changeset.files.reduce( + (sum, file) => sum + file.stats.additions, + 0, + ); + const totalDeletions = bootstrap.changeset.files.reduce( + (sum, file) => sum + file.stats.deletions, + 0, + ); const topTitle = `${bootstrap.changeset.title} +${totalAdditions} -${totalDeletions}`; const helpWidth = Math.min(68, Math.max(44, terminal.width - 8)); const helpLeft = Math.max(1, Math.floor((terminal.width - helpWidth) / 2)); @@ -409,7 +461,8 @@ export function App({ const diffSeparatorWidth = Math.max(4, diffContentWidth - 2); useKeyboard((key: KeyEvent) => { - const pageDownKey = key.name === "pagedown" || key.name === "space" || key.name === " " || key.sequence === " "; + const pageDownKey = + key.name === "pagedown" || key.name === "space" || key.name === " " || key.sequence === " "; const pageUpKey = key.name === "pageup" || key.name === "b" || key.sequence === "b"; const stepDownKey = key.name === "down" || key.name === "j" || key.sequence === "j"; const stepUpKey = key.name === "up" || key.name === "k" || key.sequence === "k"; @@ -777,7 +830,12 @@ export function App({ {!pagerMode && showHelp ? ( - setShowHelp(false)} /> + setShowHelp(false)} + /> ) : null} diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index 6b74de75..d5ee0363 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -31,11 +31,13 @@ export function HelpDialog({ onMouseUp={onClose} > Keyboard - F10 menus arrows navigate menus Enter select Esc close menu - 1 split 2 stack 0 auto t theme a notes l lines w wrap m meta - ↑/↓ line scroll space next page b previous page Home/End jump [ previous hunk ] next hunk + F10 menus arrows navigate menus Enter select Esc close menu + 1 split 2 stack 0 auto t theme a notes l lines w wrap m meta + + ↑/↓ line scroll space next page b previous page Home/End jump [ previous hunk ] next hunk + drag the Files/Diff divider with the mouse to resize the columns - / focus filter Tab swap files/filter q quit + / focus filter Tab swap files/filter q quit click anywhere on this panel to close ); diff --git a/src/ui/components/chrome/MenuDropdown.tsx b/src/ui/components/chrome/MenuDropdown.tsx index 293343db..a885b660 100644 --- a/src/ui/components/chrome/MenuDropdown.tsx +++ b/src/ui/components/chrome/MenuDropdown.tsx @@ -9,12 +9,17 @@ function renderMenuLine( theme: AppTheme, selected: boolean, ) { - const text = entry.checked === undefined ? ` ${entry.label}` : `${entry.checked ? "[x]" : "[ ]"} ${entry.label}`; + const text = + entry.checked === undefined + ? ` ${entry.label}` + : `${entry.checked ? "[x]" : "[ ]"} ${entry.label}`; const hint = entry.hint ? entry.hint : ""; const leftWidth = Math.max(0, width - hint.length - (hint.length > 0 ? 1 : 0)); return ( - + {padText(text, leftWidth)} @@ -64,8 +69,13 @@ export function MenuDropdown({ > {activeMenuEntries.map((entry, index) => entry.kind === "separator" ? ( - - {padText("-".repeat(activeMenuWidth - 4), activeMenuWidth - 2)} + + + {padText("-".repeat(activeMenuWidth - 4), activeMenuWidth - 2)} + ) : ( ) : ( - {fitText(`${hintParts.join(" ")}${filter ? ` filter=${filter}` : ""}`, terminalWidth - 2)} + {fitText( + `${hintParts.join(" ")}${filter ? ` filter=${filter}` : ""}`, + terminalWidth - 2, + )} )} diff --git a/src/ui/components/panes/AgentCard.tsx b/src/ui/components/panes/AgentCard.tsx index 5c4151b4..b996059d 100644 --- a/src/ui/components/panes/AgentCard.tsx +++ b/src/ui/components/panes/AgentCard.tsx @@ -65,7 +65,10 @@ export function AgentCard({ {popover.summaryLines.map((line, index) => ( - + {padText(line, popover.innerWidth)} ))} @@ -76,7 +79,10 @@ export function AgentCard({ {" ".repeat(popover.innerWidth)} {popover.rationaleLines.map((line, index) => ( - + {padText(line, popover.innerWidth)} ))} diff --git a/src/ui/components/panes/DiffPane.tsx b/src/ui/components/panes/DiffPane.tsx index fe1d1b8e..19740594 100644 --- a/src/ui/components/panes/DiffPane.tsx +++ b/src/ui/components/panes/DiffPane.tsx @@ -24,7 +24,9 @@ function noteAnchorColumn( note: VisibleAgentNote, ) { if (layout === "split") { - return note.annotation.oldRange && !note.annotation.newRange ? 1 : Math.max(2, Math.floor(width * 0.58)); + return note.annotation.oldRange && !note.annotation.newRange + ? 1 + : Math.max(2, Math.floor(width * 0.58)); } return showLineNumbers ? Math.max(2, String(maxLineNumber(file)).length + 4) : 2; @@ -151,7 +153,9 @@ export function DiffPane({ const nextHeight = scrollRef.current?.viewport.height ?? 0; setScrollViewport((current) => - current.top === nextTop && current.height === nextHeight ? current : { top: nextTop, height: nextHeight }, + current.top === nextTop && current.height === nextHeight + ? current + : { top: nextTop, height: nextHeight }, ); }; @@ -187,9 +191,19 @@ export function DiffPane({ } sectionTop += (selectedFileIndex > 0 ? 1 : 0) + 1; - const anchorRowTop = sectionTop + estimateHunkAnchorRow(selectedFile, layout, showHunkHeaders, selectedHunkIndex); - const anchorColumn = noteAnchorColumn(selectedFile, layout, diffContentWidth, showLineNumbers, note); - const noteWidth = Math.min(Math.max(34, Math.floor(diffContentWidth * 0.42)), Math.max(12, diffContentWidth - 2)); + const anchorRowTop = + sectionTop + estimateHunkAnchorRow(selectedFile, layout, showHunkHeaders, selectedHunkIndex); + const anchorColumn = noteAnchorColumn( + selectedFile, + layout, + diffContentWidth, + showLineNumbers, + note, + ); + const noteWidth = Math.min( + Math.max(34, Math.floor(diffContentWidth * 0.42)), + Math.max(12, diffContentWidth - 2), + ); const locationLabel = annotationLocationLabel(selectedFile, note.annotation); const popover = buildAgentPopoverContent({ summary: note.annotation.summary, @@ -222,7 +236,17 @@ export function DiffPane({ top: placement.top, locationLabel, }; - }, [diffContentWidth, estimatedBodyHeights, files, layout, selectedFileId, selectedHunkIndex, showHunkHeaders, showLineNumbers, visibleAgentNotesByFile]); + }, [ + diffContentWidth, + estimatedBodyHeights, + files, + layout, + selectedFileId, + selectedHunkIndex, + showHunkHeaders, + showLineNumbers, + visibleAgentNotesByFile, + ]); const visibleViewportFileIds = useMemo(() => { const overscanRows = 8; @@ -264,10 +288,14 @@ export function DiffPane({ return next; }, [adjacentPrefetchFileIds, selectedFileId, visibleViewportFileIds, windowingEnabled]); - const selectedFileIndex = selectedFileId ? files.findIndex((file) => file.id === selectedFileId) : -1; + const selectedFileIndex = selectedFileId + ? files.findIndex((file) => file.id === selectedFileId) + : -1; const selectedFile = selectedFileIndex >= 0 ? files[selectedFileIndex] : undefined; const selectedAnchorId = selectedFile - ? (selectedFile.metadata.hunks[selectedHunkIndex] ? diffHunkId(selectedFile.id, selectedHunkIndex) : diffSectionId(selectedFile.id)) + ? selectedFile.metadata.hunks[selectedHunkIndex] + ? diffHunkId(selectedFile.id, selectedHunkIndex) + : diffSectionId(selectedFile.id) : null; const selectedEstimatedScrollTop = useMemo(() => { if (!selectedFile || selectedFileIndex < 0) { @@ -286,7 +314,15 @@ export function DiffPane({ top += 1; top += estimateHunkAnchorRow(selectedFile, layout, showHunkHeaders, selectedHunkIndex); return top; - }, [estimatedBodyHeights, files, layout, selectedFile, selectedFileIndex, selectedHunkIndex, showHunkHeaders]); + }, [ + estimatedBodyHeights, + files, + layout, + selectedFile, + selectedFileIndex, + selectedHunkIndex, + showHunkHeaders, + ]); useLayoutEffect(() => { if (!selectedAnchorId) { @@ -317,7 +353,14 @@ export function DiffPane({ return () => { timeouts.forEach((timeout) => clearTimeout(timeout)); }; - }, [scrollRef, scrollViewport.height, selectedAnchorId, selectedEstimatedScrollTop, visibleAgentNotesByFile.size, wrapLines]); + }, [ + scrollRef, + scrollViewport.height, + selectedAnchorId, + selectedEstimatedScrollTop, + visibleAgentNotesByFile.size, + wrapLines, + ]); return ( - + {files.map((file, index) => { const shouldRenderSection = visibleWindowedFileIds?.has(file.id) ?? true; const shouldPrefetchVisibleHighlight = @@ -364,9 +414,13 @@ export function DiffPane({ selected={file.id === selectedFileId} selectedHunkIndex={file.id === selectedFileId ? selectedHunkIndex : -1} shouldLoadHighlight={ - file.id === selectedFileId || adjacentPrefetchFileIds.has(file.id) || shouldPrefetchVisibleHighlight + file.id === selectedFileId || + adjacentPrefetchFileIds.has(file.id) || + shouldPrefetchVisibleHighlight + } + onHighlightReady={ + file.id === selectedFileId ? handleSelectedHighlightReady : undefined } - onHighlightReady={file.id === selectedFileId ? handleSelectedHighlightReady : undefined} separatorWidth={separatorWidth} showSeparator={index > 0} showLineNumbers={showLineNumbers} @@ -394,7 +448,14 @@ export function DiffPane({ ); })} {selectedFileId && selectedOverlayNote ? ( - + {/* Clicking the file header jumps the main stream selection without collapsing to a single-file view. */} {fitText(fileLabel(file), headerLabelWidth)} - + {additionsText} {deletionsText} diff --git a/src/ui/components/panes/DiffSectionPlaceholder.tsx b/src/ui/components/panes/DiffSectionPlaceholder.tsx index 9c66afb5..52660e67 100644 --- a/src/ui/components/panes/DiffSectionPlaceholder.tsx +++ b/src/ui/components/panes/DiffSectionPlaceholder.tsx @@ -65,7 +65,14 @@ export function DiffSectionPlaceholder({ onMouseUp={onSelect} > {fitText(fileLabel(file), headerLabelWidth)} - + {additionsText} {deletionsText} diff --git a/src/ui/diff/PierreDiffView.tsx b/src/ui/diff/PierreDiffView.tsx index 34097631..c7176b57 100644 --- a/src/ui/diff/PierreDiffView.tsx +++ b/src/ui/diff/PierreDiffView.tsx @@ -5,7 +5,13 @@ import { diffHunkId } from "../lib/ids"; import type { AppTheme } from "../themes"; import { buildSelectedOverlayNote, renderAgentPopover } from "./agentNoteOverlay"; import { buildSplitRows, buildStackRows } from "./pierre"; -import { diffMessage, DiffRowView, findMaxLineNumber, fitText, measureRenderedRowHeight } from "./renderRows"; +import { + diffMessage, + DiffRowView, + findMaxLineNumber, + fitText, + measureRenderedRowHeight, +} from "./renderRows"; import { useHighlightedDiff } from "./useHighlightedDiff"; const EMPTY_ANNOTATED_HUNK_INDICES = new Set(); @@ -53,7 +59,12 @@ export function PierreDiffView({ }); const rows = useMemo( - () => (file ? (layout === "split" ? buildSplitRows(file, resolvedHighlighted, theme) : buildStackRows(file, resolvedHighlighted, theme)) : []), + () => + file + ? layout === "split" + ? buildSplitRows(file, resolvedHighlighted, theme) + : buildStackRows(file, resolvedHighlighted, theme) + : [], [file, layout, resolvedHighlighted, theme], ); const hunkAnchorIds = useMemo(() => { @@ -85,7 +96,15 @@ export function PierreDiffView({ let offset = 0; for (const row of rows) { - const height = measureRenderedRowHeight(row, width, lineNumberDigits, showLineNumbers, showHunkHeaders, wrapLines, theme); + const height = measureRenderedRowHeight( + row, + width, + lineNumberDigits, + showLineNumbers, + showHunkHeaders, + wrapLines, + theme, + ); metrics.set(row.key, { top: offset, height }); offset += height; } @@ -96,8 +115,25 @@ export function PierreDiffView({ }; }, [lineNumberDigits, rows, showHunkHeaders, showLineNumbers, theme, width, wrapLines]); const selectedOverlayNote = useMemo( - () => buildSelectedOverlayNote(rows, visibleAgentNotes, selectedHunkIndex, showHunkHeaders, width, lineNumberDigits, showLineNumbers), - [lineNumberDigits, rows, selectedHunkIndex, showHunkHeaders, showLineNumbers, visibleAgentNotes, width], + () => + buildSelectedOverlayNote( + rows, + visibleAgentNotes, + selectedHunkIndex, + showHunkHeaders, + width, + lineNumberDigits, + showLineNumbers, + ), + [ + lineNumberDigits, + rows, + selectedHunkIndex, + showHunkHeaders, + showLineNumbers, + visibleAgentNotes, + width, + ], ); if (!file) { @@ -139,7 +175,15 @@ export function PierreDiffView({ const contentWithOverlay = ( {content} - {renderAgentPopover(selectedOverlayNote, file, width, rowMetrics.contentHeight, rowMetrics.metrics, theme, onDismissAgentNote)} + {renderAgentPopover( + selectedOverlayNote, + file, + width, + rowMetrics.contentHeight, + rowMetrics.metrics, + theme, + onDismissAgentNote, + )} ); diff --git a/src/ui/diff/agentNoteOverlay.tsx b/src/ui/diff/agentNoteOverlay.tsx index f7e7ce70..aed731cb 100644 --- a/src/ui/diff/agentNoteOverlay.tsx +++ b/src/ui/diff/agentNoteOverlay.tsx @@ -33,27 +33,40 @@ function noteAnchor(annotation: AgentAnnotation) { } /** Check whether a rendered row is the visual anchor for a note. */ -function rowMatchesNote(row: Extract, note: VisibleAgentNote) { +function rowMatchesNote( + row: Extract, + note: VisibleAgentNote, +) { const anchor = noteAnchor(note.annotation); if (!anchor) { return false; } if (row.type === "split-line") { - return anchor.side === "new" ? row.right.lineNumber === anchor.lineNumber : row.left.lineNumber === anchor.lineNumber; + return anchor.side === "new" + ? row.right.lineNumber === anchor.lineNumber + : row.left.lineNumber === anchor.lineNumber; } - return anchor.side === "new" ? row.cell.newLineNumber === anchor.lineNumber : row.cell.oldLineNumber === anchor.lineNumber; + return anchor.side === "new" + ? row.cell.newLineNumber === anchor.lineNumber + : row.cell.oldLineNumber === anchor.lineNumber; } /** Resolve the rendered row for the currently visible popover note. */ -function findNoteAnchorRow(rows: DiffRow[], note: VisibleAgentNote, selectedHunkIndex: number, showHunkHeaders: boolean) { +function findNoteAnchorRow( + rows: DiffRow[], + note: VisibleAgentNote, + selectedHunkIndex: number, + showHunkHeaders: boolean, +) { const selectedHunkRows = rows.filter((row) => row.hunkIndex === selectedHunkIndex); const lineRows = selectedHunkRows.filter( - (row): row is Extract => row.type === "split-line" || row.type === "stack-line", + (row): row is Extract => + row.type === "split-line" || row.type === "stack-line", ); const headerRow = selectedHunkRows.find((row) => row.type === "hunk-header"); - const firstVisibleRow = showHunkHeaders ? headerRow ?? lineRows[0] : lineRows[0] ?? headerRow; + const firstVisibleRow = showHunkHeaders ? (headerRow ?? lineRows[0]) : (lineRows[0] ?? headerRow); return lineRows.find((row) => rowMatchesNote(row, note)) ?? firstVisibleRow; } @@ -160,7 +173,9 @@ export function renderAgentPopover( summary={selectedOverlayNote.note.annotation.summary} theme={theme} width={noteWidth} - onClose={onDismissAgentNote ? () => onDismissAgentNote(selectedOverlayNote.note.id) : undefined} + onClose={ + onDismissAgentNote ? () => onDismissAgentNote(selectedOverlayNote.note.id) : undefined + } /> ); diff --git a/src/ui/diff/pierre.ts b/src/ui/diff/pierre.ts index 8a5828d0..1fa7b4df 100644 --- a/src/ui/diff/pierre.ts +++ b/src/ui/diff/pierre.ts @@ -111,7 +111,6 @@ function tabify(text: string) { return text.replaceAll("\t", " "); } - /** Parse an inline CSS style string from Pierre's highlighted HAST output. */ function parseStyleValue(styleValue: unknown) { const styles = new Map(); @@ -153,7 +152,10 @@ function normalizeHighlightedColor(color: string | undefined, theme: AppTheme) { } const normalized = color.trim().toLowerCase(); - const reserved = RESERVED_PIERRE_TOKEN_COLORS[theme.appearance][normalized as keyof (typeof RESERVED_PIERRE_TOKEN_COLORS)[typeof theme.appearance]]; + const reserved = + RESERVED_PIERRE_TOKEN_COLORS[theme.appearance][ + normalized as keyof (typeof RESERVED_PIERRE_TOKEN_COLORS)[typeof theme.appearance] + ]; if (!reserved) { return color; } @@ -204,7 +206,10 @@ function flattenHighlightedLine( const styles = parseStyleValue(properties.style); const nextStyle: Pick = { // Newer Pierre output can emit direct `color:#...` styles instead of theme CSS variables. - fg: normalizeHighlightedColor(styles.get(colorVariable) ?? styles.get("color") ?? inherited.fg, theme), + fg: normalizeHighlightedColor( + styles.get(colorVariable) ?? styles.get("color") ?? inherited.fg, + theme, + ), // Pierre marks inline word-diff emphasis spans with a data attribute rather than a separate row kind. bg: Object.hasOwn(properties, "data-diff-span") ? emphasisBg : inherited.bg, }; @@ -249,11 +254,17 @@ function makeSplitCell( // Startup renders often build rows before highlighted HAST exists, so keep that plain-text path cheap. const spans = highlightedLine === undefined - ? (fallbackText.length > 0 ? [{ text: fallbackText }] : []) + ? fallbackText.length > 0 + ? [{ text: fallbackText }] + : [] : flattenHighlightedLine( highlightedLine, theme, - kind === "addition" ? theme.addedContentBg : kind === "deletion" ? theme.removedContentBg : theme.contextContentBg, + kind === "addition" + ? theme.addedContentBg + : kind === "deletion" + ? theme.removedContentBg + : theme.contextContentBg, fallbackText, ); @@ -279,11 +290,17 @@ function makeStackCell( // Startup renders often build rows before highlighted HAST exists, so keep that plain-text path cheap. const spans = highlightedLine === undefined - ? (fallbackText.length > 0 ? [{ text: fallbackText }] : []) + ? fallbackText.length > 0 + ? [{ text: fallbackText }] + : [] : flattenHighlightedLine( highlightedLine, theme, - kind === "addition" ? theme.addedContentBg : kind === "deletion" ? theme.removedContentBg : theme.contextContentBg, + kind === "addition" + ? theme.addedContentBg + : kind === "deletion" + ? theme.removedContentBg + : theme.contextContentBg, fallbackText, ); @@ -299,7 +316,8 @@ function makeStackCell( /** Format a hunk header exactly as the review stream should display it. */ function hunkHeader(hunk: Hunk) { const specs = - hunk.hunkSpecs ?? `@@ -${hunk.deletionStart},${hunk.deletionLines} +${hunk.additionStart},${hunk.additionLines} @@`; + hunk.hunkSpecs ?? + `@@ -${hunk.deletionStart},${hunk.deletionLines} +${hunk.additionStart},${hunk.additionLines} @@`; return hunk.hunkContext ? `${specs} ${hunk.hunkContext}` : specs; } @@ -315,8 +333,10 @@ function trailingCollapsedLines(metadata: FileDiffMetadata) { return 0; } - const additionRemaining = metadata.additionLines.length - (lastHunk.additionLineIndex + lastHunk.additionCount); - const deletionRemaining = metadata.deletionLines.length - (lastHunk.deletionLineIndex + lastHunk.deletionCount); + const additionRemaining = + metadata.additionLines.length - (lastHunk.additionLineIndex + lastHunk.additionCount); + const deletionRemaining = + metadata.deletionLines.length - (lastHunk.deletionLineIndex + lastHunk.deletionCount); if (additionRemaining !== deletionRemaining) { return 0; @@ -326,7 +346,10 @@ function trailingCollapsedLines(metadata: FileDiffMetadata) { } /** Prepare syntax highlighting for one language/appearance pair using Pierre's shared highlighter. */ -async function prepareHighlighter(language: string | undefined, appearance: AppTheme["appearance"]) { +async function prepareHighlighter( + language: string | undefined, + appearance: AppTheme["appearance"], +) { const resolvedLanguage = language ?? "text"; const cacheKey = `${appearance}:${resolvedLanguage}`; const options = @@ -376,7 +399,11 @@ export async function loadHighlightedDiff( try { const highlighter = await prepareHighlighter(file.language, appearance); return queueHighlightedDiff(() => { - const highlighted = renderDiffWithHighlighter(file.metadata, highlighter, pierreRenderOptions(appearance)); + const highlighted = renderDiffWithHighlighter( + file.metadata, + highlighter, + pierreRenderOptions(appearance), + ); return { deletionLines: highlighted.code.deletionLines as Array, additionLines: highlighted.code.additionLines as Array, @@ -399,7 +426,11 @@ export async function loadHighlightedDiff( } /** Expand Pierre metadata into the flat split-view row stream consumed by the renderer. */ -export function buildSplitRows(file: DiffFile, highlighted: HighlightedDiffCode | null, theme: AppTheme): DiffRow[] { +export function buildSplitRows( + file: DiffFile, + highlighted: HighlightedDiffCode | null, + theme: AppTheme, +): DiffRow[] { const rows: DiffRow[] = []; const deletionLines = highlighted?.deletionLines ?? []; const additionLines = highlighted?.additionLines ?? []; @@ -514,7 +545,11 @@ export function buildSplitRows(file: DiffFile, highlighted: HighlightedDiffCode } /** Expand Pierre metadata into the flat stack-view row stream consumed by the renderer. */ -export function buildStackRows(file: DiffFile, highlighted: HighlightedDiffCode | null, theme: AppTheme): DiffRow[] { +export function buildStackRows( + file: DiffFile, + highlighted: HighlightedDiffCode | null, + theme: AppTheme, +): DiffRow[] { const rows: DiffRow[] = []; const deletionLines = highlighted?.deletionLines ?? []; const additionLines = highlighted?.additionLines ?? []; diff --git a/src/ui/diff/renderRows.tsx b/src/ui/diff/renderRows.tsx index c980e36f..40401f1b 100644 --- a/src/ui/diff/renderRows.tsx +++ b/src/ui/diff/renderRows.tsx @@ -207,7 +207,11 @@ function renderInlineSpans( // Fold trailing padding into the last span when the colors already match. // That keeps the output identical while avoiding one extra rendered span. - if (lastSpan && (lastSpan.fg ?? fallbackColor) === fallbackColor && (lastSpan.bg ?? fallbackBg) === fallbackBg) { + if ( + lastSpan && + (lastSpan.fg ?? fallbackColor) === fallbackColor && + (lastSpan.bg ?? fallbackBg) === fallbackBg + ) { lastSpan.text += " ".repeat(padding); padding = 0; } @@ -216,11 +220,21 @@ function renderInlineSpans( return ( <> {trimmed.map((span, index) => ( - + {span.text} ))} - {padding > 0 ? {`${" ".repeat(padding)}`} : null} + {padding > 0 ? ( + {`${" ".repeat(padding)}`} + ) : null} ); } @@ -294,7 +308,9 @@ function buildWrappedSplitCell( const gutterWidth = Math.min(availableWidth, showLineNumbers ? lineNumberDigits + 3 : 2); const contentWidth = Math.max(0, availableWidth - gutterWidth); const firstGutterText = showLineNumbers - ? `${cell.lineNumber ? String(cell.lineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits)} ${cell.sign}`.padEnd(gutterWidth) + ? `${cell.lineNumber ? String(cell.lineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits)} ${cell.sign}`.padEnd( + gutterWidth, + ) : `${cell.sign} `.padEnd(gutterWidth); const wrappedSpans = wrapSpans(cell.spans, contentWidth); @@ -321,9 +337,15 @@ function buildWrappedStackCell( const availableWidth = Math.max(0, width - prefixWidth); const gutterWidth = Math.min(availableWidth, showLineNumbers ? lineNumberDigits * 2 + 5 : 2); const contentWidth = Math.max(0, availableWidth - gutterWidth); - const oldNumber = cell.oldLineNumber ? String(cell.oldLineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits); - const newNumber = cell.newLineNumber ? String(cell.newLineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits); - const firstGutterText = (showLineNumbers ? `${oldNumber} ${newNumber} ${cell.sign}` : `${cell.sign} `).padEnd(gutterWidth); + const oldNumber = cell.oldLineNumber + ? String(cell.oldLineNumber).padStart(lineNumberDigits, " ") + : " ".repeat(lineNumberDigits); + const newNumber = cell.newLineNumber + ? String(cell.newLineNumber).padStart(lineNumberDigits, " ") + : " ".repeat(lineNumberDigits); + const firstGutterText = ( + showLineNumbers ? `${oldNumber} ${newNumber} ${cell.sign}` : `${cell.sign} ` + ).padEnd(gutterWidth); const wrappedSpans = wrapSpans(cell.spans, contentWidth); return { @@ -356,7 +378,9 @@ function renderSplitCell( const gutterWidth = Math.min(availableWidth, showLineNumbers ? lineNumberDigits + 3 : 2); const contentWidth = Math.max(0, availableWidth - gutterWidth); const gutterText = showLineNumbers - ? `${cell.lineNumber ? String(cell.lineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits)} ${cell.sign}`.padEnd(gutterWidth) + ? `${cell.lineNumber ? String(cell.lineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits)} ${cell.sign}`.padEnd( + gutterWidth, + ) : `${cell.sign} `.padEnd(gutterWidth); return ( @@ -369,7 +393,13 @@ function renderSplitCell( {gutterText} - {renderInlineSpans(cell.spans, contentWidth, theme.text, palette.contentBg, `${keyPrefix}:content`)} + {renderInlineSpans( + cell.spans, + contentWidth, + theme.text, + palette.contentBg, + `${keyPrefix}:content`, + )} ); } @@ -394,8 +424,12 @@ function renderStackCell( const gutterWidth = Math.min(availableWidth, showLineNumbers ? lineNumberDigits * 2 + 5 : 2); const contentWidth = Math.max(0, availableWidth - gutterWidth); - const oldNumber = cell.oldLineNumber ? String(cell.oldLineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits); - const newNumber = cell.newLineNumber ? String(cell.newLineNumber).padStart(lineNumberDigits, " ") : " ".repeat(lineNumberDigits); + const oldNumber = cell.oldLineNumber + ? String(cell.oldLineNumber).padStart(lineNumberDigits, " ") + : " ".repeat(lineNumberDigits); + const newNumber = cell.newLineNumber + ? String(cell.newLineNumber).padStart(lineNumberDigits, " ") + : " ".repeat(lineNumberDigits); return ( <> @@ -405,9 +439,17 @@ function renderStackCell( ) : null} - {(showLineNumbers ? `${oldNumber} ${newNumber} ${cell.sign}` : `${cell.sign} `).padEnd(gutterWidth)} + {(showLineNumbers ? `${oldNumber} ${newNumber} ${cell.sign}` : `${cell.sign} `).padEnd( + gutterWidth, + )} - {renderInlineSpans(cell.spans, contentWidth, theme.text, palette.contentBg, `${keyPrefix}:content`)} + {renderInlineSpans( + cell.spans, + contentWidth, + theme.text, + palette.contentBg, + `${keyPrefix}:content`, + )} ); } @@ -433,7 +475,13 @@ function renderWrappedSplitCellLine( {line.gutterText} - {renderInlineSpans(line.spans, contentWidth, theme.text, palette.contentBg, `${keyPrefix}:content`)} + {renderInlineSpans( + line.spans, + contentWidth, + theme.text, + palette.contentBg, + `${keyPrefix}:content`, + )} ); } @@ -459,7 +507,13 @@ function renderWrappedStackCellLine( {line.gutterText} - {renderInlineSpans(line.spans, contentWidth, theme.text, palette.contentBg, `${keyPrefix}:content`)} + {renderInlineSpans( + line.spans, + contentWidth, + theme.text, + palette.contentBg, + `${keyPrefix}:content`, + )} ); } @@ -486,7 +540,11 @@ export function findMaxLineNumber(file: DiffFile) { let highest = 0; for (const hunk of file.metadata.hunks) { - highest = Math.max(highest, hunk.deletionStart + hunk.deletionCount, hunk.additionStart + hunk.additionCount); + highest = Math.max( + highest, + hunk.deletionStart + hunk.deletionCount, + hunk.additionStart + hunk.additionCount, + ); } return Math.max(highest, 1); @@ -521,10 +579,16 @@ function renderHeaderRow( }} > - + {marker()} - + {label} @@ -545,15 +609,24 @@ function renderHeaderRow( > - + {marker()} - + {label} - onOpenAgentNotesAtHunk?.(row.hunkIndex)}> + onOpenAgentNotesAtHunk?.(row.hunkIndex)} + > {badgeText} @@ -588,8 +661,22 @@ export function measureRenderedRowHeight( const usableWidth = Math.max(0, width - markerWidth - separatorWidth); const leftWidth = Math.max(0, markerWidth + Math.floor(usableWidth / 2)); const rightWidth = Math.max(0, separatorWidth + usableWidth - Math.floor(usableWidth / 2)); - const leftLayout = buildWrappedSplitCell(row.left, leftWidth, lineNumberDigits, showLineNumbers, markerWidth, theme); - const rightLayout = buildWrappedSplitCell(row.right, rightWidth, lineNumberDigits, showLineNumbers, markerWidth, theme); + const leftLayout = buildWrappedSplitCell( + row.left, + leftWidth, + lineNumberDigits, + showLineNumbers, + markerWidth, + theme, + ); + const rightLayout = buildWrappedSplitCell( + row.right, + rightWidth, + lineNumberDigits, + showLineNumbers, + markerWidth, + theme, + ); return Math.max(leftLayout.lines.length, rightLayout.lines.length); } @@ -602,7 +689,14 @@ export function measureRenderedRowHeight( return 1; } - const layout = buildWrappedStackCell(row.cell, width, lineNumberDigits, showLineNumbers, marker().length, theme); + const layout = buildWrappedStackCell( + row.cell, + width, + lineNumberDigits, + showLineNumbers, + marker().length, + theme, + ); return layout.lines.length; } @@ -623,9 +717,19 @@ function renderRow( let baseRow: ReactNode; if (row.type === "collapsed") { - baseRow = renderHeaderRow(row, width, theme, selected, annotated, anchorId, onOpenAgentNotesAtHunk); + baseRow = renderHeaderRow( + row, + width, + theme, + selected, + annotated, + anchorId, + onOpenAgentNotesAtHunk, + ); } else if (row.type === "hunk-header") { - baseRow = showHunkHeaders ? renderHeaderRow(row, width, theme, selected, annotated, anchorId, onOpenAgentNotesAtHunk) : null; + baseRow = showHunkHeaders + ? renderHeaderRow(row, width, theme, selected, annotated, anchorId, onOpenAgentNotesAtHunk) + : null; } else if (row.type === "split-line") { const markerWidth = 1; const separatorWidth = 1; @@ -650,23 +754,65 @@ function renderRow( baseRow = ( - {renderSplitCell(row.left, leftWidth, lineNumberDigits, showLineNumbers, theme, `${row.key}:left`, leftPrefix)} - {renderSplitCell(row.right, rightWidth, lineNumberDigits, showLineNumbers, theme, `${row.key}:right`, rightPrefix)} + {renderSplitCell( + row.left, + leftWidth, + lineNumberDigits, + showLineNumbers, + theme, + `${row.key}:left`, + leftPrefix, + )} + {renderSplitCell( + row.right, + rightWidth, + lineNumberDigits, + showLineNumbers, + theme, + `${row.key}:right`, + rightPrefix, + )} ); } else { - const leftLayout = buildWrappedSplitCell(row.left, leftWidth, lineNumberDigits, showLineNumbers, leftPrefix.text.length, theme); - const rightLayout = buildWrappedSplitCell(row.right, rightWidth, lineNumberDigits, showLineNumbers, rightPrefix.text.length, theme); - const leftContentWidth = Math.max(0, leftWidth - leftPrefix.text.length - leftLayout.gutterWidth); - const rightContentWidth = Math.max(0, rightWidth - rightPrefix.text.length - rightLayout.gutterWidth); + const leftLayout = buildWrappedSplitCell( + row.left, + leftWidth, + lineNumberDigits, + showLineNumbers, + leftPrefix.text.length, + theme, + ); + const rightLayout = buildWrappedSplitCell( + row.right, + rightWidth, + lineNumberDigits, + showLineNumbers, + rightPrefix.text.length, + theme, + ); + const leftContentWidth = Math.max( + 0, + leftWidth - leftPrefix.text.length - leftLayout.gutterWidth, + ); + const rightContentWidth = Math.max( + 0, + rightWidth - rightPrefix.text.length - rightLayout.gutterWidth, + ); const visualLineCount = Math.max(leftLayout.lines.length, rightLayout.lines.length); baseRow = ( {Array.from({ length: visualLineCount }, (_, index) => { - const leftLine = leftLayout.lines[index] ?? { gutterText: " ".repeat(leftLayout.gutterWidth), spans: [] }; - const rightLine = rightLayout.lines[index] ?? { gutterText: " ".repeat(rightLayout.gutterWidth), spans: [] }; + const leftLine = leftLayout.lines[index] ?? { + gutterText: " ".repeat(leftLayout.gutterWidth), + spans: [], + }; + const rightLine = rightLayout.lines[index] ?? { + gutterText: " ".repeat(rightLayout.gutterWidth), + spans: [], + }; return ( @@ -704,18 +850,44 @@ function renderRow( if (!wrapLines) { baseRow = ( - {renderStackCell(row.cell, width, lineNumberDigits, showLineNumbers, theme, `${row.key}:stack`, prefix)} + + {renderStackCell( + row.cell, + width, + lineNumberDigits, + showLineNumbers, + theme, + `${row.key}:stack`, + prefix, + )} + ); } else { - const layout = buildWrappedStackCell(row.cell, width, lineNumberDigits, showLineNumbers, prefix.text.length, theme); + const layout = buildWrappedStackCell( + row.cell, + width, + lineNumberDigits, + showLineNumbers, + prefix.text.length, + theme, + ); const contentWidth = Math.max(0, width - prefix.text.length - layout.gutterWidth); baseRow = ( {layout.lines.map((line, index) => ( - {renderWrappedStackCellLine(line, layout.palette, contentWidth, theme, `${row.key}:stack:${index}`, prefix)} + + {renderWrappedStackCellLine( + line, + layout.palette, + contentWidth, + theme, + `${row.key}:stack:${index}`, + prefix, + )} + ))} diff --git a/src/ui/diff/useHighlightedDiff.ts b/src/ui/diff/useHighlightedDiff.ts index e123bc53..c8c5667a 100644 --- a/src/ui/diff/useHighlightedDiff.ts +++ b/src/ui/diff/useHighlightedDiff.ts @@ -23,7 +23,12 @@ export function useHighlightedDiff({ // Selected files load immediately; background prefetch can opt neighboring files in later. const pendingHighlight = useMemo(() => { - if (!shouldLoadHighlight || !file || !appearanceCacheKey || SHARED_HIGHLIGHTED_DIFF_CACHE.has(appearanceCacheKey)) { + if ( + !shouldLoadHighlight || + !file || + !appearanceCacheKey || + SHARED_HIGHLIGHTED_DIFF_CACHE.has(appearanceCacheKey) + ) { return null; } diff --git a/src/ui/hooks/useHunkSessionBridge.ts b/src/ui/hooks/useHunkSessionBridge.ts index 595c487e..0759d4b0 100644 --- a/src/ui/hooks/useHunkSessionBridge.ts +++ b/src/ui/hooks/useHunkSessionBridge.ts @@ -1,6 +1,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { DiffFile } from "../../core/types"; -import { buildLiveComment, findDiffFileByPath, findHunkIndexForLine, hunkLineRange } from "../../core/liveComments"; +import { + buildLiveComment, + findDiffFileByPath, + findHunkIndexForLine, + hunkLineRange, +} from "../../core/liveComments"; import { HunkHostClient } from "../../mcp/client"; import type { LiveComment, SessionLiveCommentSummary, SessionServerMessage } from "../../mcp/types"; @@ -24,7 +29,9 @@ export function useHunkSessionBridge({ selectedHunkIndex: number; showAgentNotes: boolean; }) { - const [liveCommentsByFileId, setLiveCommentsByFileId] = useState>({}); + const [liveCommentsByFileId, setLiveCommentsByFileId] = useState>( + {}, + ); const liveCommentsByFileIdRef = useRef>({}); const buildSelectedHunkSummary = useCallback((file: DiffFile, hunkIndex: number) => { @@ -85,7 +92,12 @@ export function useHunkSessionBridge({ } const commentId = `mcp:${message.requestId}`; - const liveComment = buildLiveComment(message.input, commentId, new Date().toISOString(), hunkIndex); + const liveComment = buildLiveComment( + message.input, + commentId, + new Date().toISOString(), + hunkIndex, + ); setLiveCommentsByFileId((current) => ({ ...current, @@ -203,7 +215,13 @@ export function useHunkSessionBridge({ return () => { hostClient.setBridge(null); }; - }, [applyIncomingComment, clearIncomingComments, hostClient, navigateToHunkSelection, removeIncomingComment]); + }, [ + applyIncomingComment, + clearIncomingComments, + hostClient, + navigateToHunkSelection, + removeIncomingComment, + ]); const liveCommentSummaries = useMemo( () => @@ -242,7 +260,16 @@ export function useHunkSessionBridge({ liveComments: liveCommentSummaries, updatedAt: new Date().toISOString(), }); - }, [currentHunk, hostClient, liveCommentCount, liveCommentSummaries, selectedFile?.id, selectedFile?.path, selectedHunkIndex, showAgentNotes]); + }, [ + currentHunk, + hostClient, + liveCommentCount, + liveCommentSummaries, + selectedFile?.id, + selectedFile?.path, + selectedHunkIndex, + showAgentNotes, + ]); return { liveCommentsByFileId, diff --git a/src/ui/hooks/useMenuController.ts b/src/ui/hooks/useMenuController.ts index e599e7d9..f65d8288 100644 --- a/src/ui/hooks/useMenuController.ts +++ b/src/ui/hooks/useMenuController.ts @@ -1,5 +1,12 @@ import { useMemo, useState } from "react"; -import { MENU_ORDER, buildMenuSpecs, menuWidth, nextMenuItemIndex, type MenuEntry, type MenuId } from "../components/chrome/menu"; +import { + MENU_ORDER, + buildMenuSpecs, + menuWidth, + nextMenuItemIndex, + type MenuEntry, + type MenuId, +} from "../components/chrome/menu"; /** Drive menu selection/open state for the desktop-style top menu bar. */ export function useMenuController(menus: Record) { diff --git a/src/ui/lib/agentAnnotations.ts b/src/ui/lib/agentAnnotations.ts index d0a2eee6..daf3a4da 100644 --- a/src/ui/lib/agentAnnotations.ts +++ b/src/ui/lib/agentAnnotations.ts @@ -14,8 +14,14 @@ function overlap(rangeA: [number, number], rangeB: [number, number]) { /** Compute the old/new line ranges covered by a hunk, including single-line edge cases. */ function hunkLineRange(hunk: Hunk) { - const newEnd = Math.max(hunk.additionStart, hunk.additionStart + Math.max(hunk.additionLines, 1) - 1); - const oldEnd = Math.max(hunk.deletionStart, hunk.deletionStart + Math.max(hunk.deletionLines, 1) - 1); + const newEnd = Math.max( + hunk.additionStart, + hunk.additionStart + Math.max(hunk.additionLines, 1) - 1, + ); + const oldEnd = Math.max( + hunk.deletionStart, + hunk.deletionStart + Math.max(hunk.deletionLines, 1) - 1, + ); return { oldRange: [hunk.deletionStart, oldEnd] as [number, number], diff --git a/src/ui/lib/agentPopover.ts b/src/ui/lib/agentPopover.ts index 4add9300..9b7ea8c1 100644 --- a/src/ui/lib/agentPopover.ts +++ b/src/ui/lib/agentPopover.ts @@ -74,7 +74,8 @@ export function buildAgentPopoverContent({ const summaryLines = wrapText(summary, innerWidth); const rationaleLines = rationale ? wrapText(rationale, innerWidth) : []; const footer = fitText(locationLabel, innerWidth); - const contentLineCount = 1 + summaryLines.length + (rationaleLines.length > 0 ? 1 + rationaleLines.length : 0) + 1 + 1; + const contentLineCount = + 1 + summaryLines.length + (rationaleLines.length > 0 ? 1 + rationaleLines.length : 0) + 1 + 1; return { title: agentPopoverTitle(noteIndex, noteCount), diff --git a/src/ui/lib/files.ts b/src/ui/lib/files.ts index 1804b2cd..470ac873 100644 --- a/src/ui/lib/files.ts +++ b/src/ui/lib/files.ts @@ -17,7 +17,10 @@ export function buildFileListEntry(file: DiffFile): FileListEntry { ? "R" : "M"; - const pathLabel = file.previousPath && file.previousPath !== file.path ? `${file.previousPath} -> ${file.path}` : file.path; + const pathLabel = + file.previousPath && file.previousPath !== file.path + ? `${file.previousPath} -> ${file.path}` + : file.path; return { id: file.id, @@ -32,5 +35,7 @@ export function fileLabel(file: DiffFile | undefined) { return "No file selected"; } - return file.previousPath && file.previousPath !== file.path ? `${file.previousPath} -> ${file.path}` : file.path; + return file.previousPath && file.previousPath !== file.path + ? `${file.previousPath} -> ${file.path}` + : file.path; } diff --git a/src/ui/lib/hunks.ts b/src/ui/lib/hunks.ts index 39c6609f..bf45b1d8 100644 --- a/src/ui/lib/hunks.ts +++ b/src/ui/lib/hunks.ts @@ -7,7 +7,9 @@ export interface HunkCursor { /** Flatten the visible files into one review-stream hunk cursor list. */ export function buildHunkCursors(files: DiffFile[]): HunkCursor[] { - return files.flatMap((file) => file.metadata.hunks.map((_, hunkIndex) => ({ fileId: file.id, hunkIndex }))); + return files.flatMap((file) => + file.metadata.hunks.map((_, hunkIndex) => ({ fileId: file.id, hunkIndex })), + ); } /** Move forward or backward through the review-stream hunk cursor list. */ @@ -21,7 +23,9 @@ export function findNextHunkCursor( return null; } - const currentIndex = cursors.findIndex((cursor) => cursor.fileId === currentFileId && cursor.hunkIndex === currentHunkIndex); + const currentIndex = cursors.findIndex( + (cursor) => cursor.fileId === currentFileId && cursor.hunkIndex === currentHunkIndex, + ); const nextIndex = currentIndex >= 0 ? Math.min(Math.max(currentIndex + delta, 0), cursors.length - 1) diff --git a/src/ui/lib/responsive.ts b/src/ui/lib/responsive.ts index 3568cdf1..949c3ea3 100644 --- a/src/ui/lib/responsive.ts +++ b/src/ui/lib/responsive.ts @@ -25,7 +25,10 @@ function resolveResponsiveViewport(viewportWidth: number): ResponsiveViewport { } /** Resolve the effective layout after combining the explicit mode with viewport size. */ -export function resolveResponsiveLayout(requestedLayout: LayoutMode, viewportWidth: number): ResponsiveLayout { +export function resolveResponsiveLayout( + requestedLayout: LayoutMode, + viewportWidth: number, +): ResponsiveLayout { const viewport = resolveResponsiveViewport(viewportWidth); if (requestedLayout === "split") { diff --git a/src/ui/lib/sectionHeights.ts b/src/ui/lib/sectionHeights.ts index cb18ba01..aee0369b 100644 --- a/src/ui/lib/sectionHeights.ts +++ b/src/ui/lib/sectionHeights.ts @@ -8,8 +8,10 @@ function trailingCollapsedLines(metadata: FileDiffMetadata) { return 0; } - const additionRemaining = metadata.additionLines.length - (lastHunk.additionLineIndex + lastHunk.additionCount); - const deletionRemaining = metadata.deletionLines.length - (lastHunk.deletionLineIndex + lastHunk.deletionCount); + const additionRemaining = + metadata.additionLines.length - (lastHunk.additionLineIndex + lastHunk.additionCount); + const deletionRemaining = + metadata.deletionLines.length - (lastHunk.deletionLineIndex + lastHunk.deletionCount); if (additionRemaining !== deletionRemaining) { return 0; @@ -46,7 +48,10 @@ function estimateHunkRows( continue; } - rows += layout === "split" ? Math.max(content.deletions, content.additions) : content.deletions + content.additions; + rows += + layout === "split" + ? Math.max(content.deletions, content.additions) + : content.deletions + content.additions; } return rows; diff --git a/src/ui/themes.ts b/src/ui/themes.ts index c85e9eee..160ffcad 100644 --- a/src/ui/themes.ts +++ b/src/ui/themes.ts @@ -62,7 +62,10 @@ function createSyntaxStyle(colors: SyntaxColors) { } /** Lazily attach syntax colors so startup only pays for the active theme's token style. */ -function withLazySyntaxStyle(theme: Omit, syntaxColors: SyntaxColors): AppTheme { +function withLazySyntaxStyle( + theme: Omit, + syntaxColors: SyntaxColors, +): AppTheme { let syntaxStyle: SyntaxStyle | null = null; return { @@ -76,33 +79,35 @@ function withLazySyntaxStyle(theme: Omit span.text.includes(marker) && span.text.trim().length < text.trim().length); + return line.spans.some( + (span) => span.text.includes(marker) && span.text.trim().length < text.trim().length, + ); }); } -const setup = await testRender(React.createElement(App, { bootstrap: createBootstrap() }), { width: 240, height: 24 }); +const setup = await testRender(React.createElement(App, { bootstrap: createBootstrap() }), { + width: 240, + height: 24, +}); const start = performance.now(); let iterations = 0; let selectedStartupMs = 0; diff --git a/test/agent.test.ts b/test/agent.test.ts index c4ef4948..36d97f35 100644 --- a/test/agent.test.ts +++ b/test/agent.test.ts @@ -34,7 +34,14 @@ describe("agent context", () => { { path: "src/example.ts", summary: "Explains the file change", - annotations: [{ newRange: [4, 8], summary: "Added a helper", confidence: "high", tags: ["review", 7] }], + annotations: [ + { + newRange: [4, 8], + summary: "Added a helper", + confidence: "high", + tags: ["review", 7], + }, + ], }, ], }), @@ -44,7 +51,9 @@ describe("agent context", () => { expect(context?.summary).toBe("Agent summary"); expect(findAgentFileContext(context, "src/example.ts")?.annotations).toHaveLength(1); - expect(findAgentFileContext(context, "src/example.ts")?.annotations[0]?.tags).toEqual(["review"]); + expect(findAgentFileContext(context, "src/example.ts")?.annotations[0]?.tags).toEqual([ + "review", + ]); expect(findAgentFileContext(context, "src/renamed.ts", "src/example.ts")?.summary).toBe( "Explains the file change", ); @@ -63,7 +72,9 @@ describe("agent context", () => { }), ); - await expect(loadAgentContext(invalidFilePath)).rejects.toThrow("Agent context file entries require a non-empty path."); + await expect(loadAgentContext(invalidFilePath)).rejects.toThrow( + "Agent context file entries require a non-empty path.", + ); const invalidRangePath = join(dir, "invalid-range.json"); writeFileSync( @@ -79,7 +90,9 @@ describe("agent context", () => { }), ); - await expect(loadAgentContext(invalidRangePath)).rejects.toThrow("Annotation ranges must be integer tuples."); + await expect(loadAgentContext(invalidRangePath)).rejects.toThrow( + "Annotation ranges must be integer tuples.", + ); const negativeRangePath = join(dir, "negative-range.json"); writeFileSync( @@ -113,6 +126,8 @@ describe("agent context", () => { }), ); - await expect(loadAgentContext(reversedRangePath)).rejects.toThrow("Annotation ranges must be ordered start..end tuples."); + await expect(loadAgentContext(reversedRangePath)).rejects.toThrow( + "Annotation ranges must be ordered start..end tuples.", + ); }); }); diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx index 51017b74..cf85ab12 100644 --- a/test/app-interactions.test.tsx +++ b/test/app-interactions.test.tsx @@ -6,7 +6,13 @@ import type { AppBootstrap, DiffFile, LayoutMode } from "../src/core/types"; const { App } = await import("../src/ui/App"); -function createDiffFile(id: string, path: string, before: string, after: string, withAgent = false): DiffFile { +function createDiffFile( + id: string, + path: string, + before: string, + after: string, + withAgent = false, +): DiffFile { const metadata = parseDiffFromFile( { name: path, @@ -44,7 +50,13 @@ function createDiffFile(id: string, path: string, before: string, after: string, ? { path, summary: `${path} note`, - annotations: [{ newRange: [2, 2], summary: `Annotation for ${path}`, rationale: `Why ${path} changed` }], + annotations: [ + { + newRange: [2, 2], + summary: `Annotation for ${path}`, + rationale: `Why ${path} changed`, + }, + ], } : null, }; @@ -65,8 +77,20 @@ function createBootstrap(initialMode: LayoutMode = "split", pager = false): AppB sourceLabel: "repo", title: "repo working tree", files: [ - createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\nexport const add = true;\n", true), - createDiffFile("beta", "beta.ts", "export const beta = 1;\n", "export const betaValue = 1;\n", false), + createDiffFile( + "alpha", + "alpha.ts", + "export const alpha = 1;\n", + "export const alpha = 2;\nexport const add = true;\n", + true, + ), + createDiffFile( + "beta", + "beta.ts", + "export const beta = 1;\n", + "export const betaValue = 1;\n", + false, + ), ], }, initialMode, @@ -87,7 +111,15 @@ function createSingleFileBootstrap(): AppBootstrap { id: "changeset:app-single-file", sourceLabel: "repo", title: "repo working tree", - files: [createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\nexport const add = true;\n", true)], + files: [ + createDiffFile( + "alpha", + "alpha.ts", + "export const alpha = 1;\n", + "export const alpha = 2;\nexport const add = true;\n", + true, + ), + ], }, initialMode: "split", initialTheme: "midnight", @@ -123,8 +155,16 @@ function createWrapBootstrap(): AppBootstrap { } function createLineScrollBootstrap(pager = false): AppBootstrap { - const before = Array.from({ length: 18 }, (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1};`).join("\n") + "\n"; - const after = Array.from({ length: 18 }, (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 101};`).join("\n") + "\n"; + const before = + Array.from( + { length: 18 }, + (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 1};`, + ).join("\n") + "\n"; + const after = + Array.from( + { length: 18 }, + (_, index) => `export const line${String(index + 1).padStart(2, "0")} = ${index + 101};`, + ).join("\n") + "\n"; return { input: { @@ -156,7 +196,10 @@ async function flush(setup: Awaited>) { describe("App interactions", () => { test("keyboard shortcuts toggle notes, line numbers, and hunk metadata", async () => { - const setup = await testRender(, { width: 240, height: 24 }); + const setup = await testRender(, { + width: 240, + height: 24, + }); try { await flush(setup); @@ -195,7 +238,10 @@ describe("App interactions", () => { }); test("keyboard shortcut can wrap long lines in the app shell", async () => { - const setup = await testRender(, { width: 140, height: 20 }); + const setup = await testRender(, { + width: 140, + height: 20, + }); try { await flush(setup); @@ -273,7 +319,10 @@ describe("App interactions", () => { }); test("menu navigation can switch layouts and activate view actions", async () => { - const setup = await testRender(, { width: 220, height: 24 }); + const setup = await testRender(, { + width: 220, + height: 24, + }); try { await flush(setup); @@ -311,7 +360,10 @@ describe("App interactions", () => { }); test("arrow keys keep the current file selected for agent notes", async () => { - const setup = await testRender(, { width: 240, height: 24 }); + const setup = await testRender(, { + width: 240, + height: 24, + }); try { await flush(setup); @@ -336,7 +388,10 @@ describe("App interactions", () => { }); test("arrow keys scroll the review pane line by line", async () => { - const setup = await testRender(, { width: 220, height: 12 }); + const setup = await testRender(, { + width: 220, + height: 12, + }); try { await flush(setup); @@ -380,7 +435,10 @@ describe("App interactions", () => { }); test("pager mode arrow keys also scroll line by line", async () => { - const setup = await testRender(, { width: 220, height: 8 }); + const setup = await testRender(, { + width: 220, + height: 8, + }); try { await flush(setup); @@ -424,7 +482,10 @@ describe("App interactions", () => { }); test("filter focus accepts typed input and narrows the visible file set", async () => { - const setup = await testRender(, { width: 240, height: 24 }); + const setup = await testRender(, { + width: 240, + height: 24, + }); try { await flush(setup); @@ -450,7 +511,10 @@ describe("App interactions", () => { }); test("filtering away the selected file reselects the first visible match", async () => { - const setup = await testRender(, { width: 240, height: 24 }); + const setup = await testRender(, { + width: 240, + height: 24, + }); try { await flush(setup); @@ -488,7 +552,10 @@ describe("App interactions", () => { }); test("menu navigation wraps across the first and last top-level menus", async () => { - const setup = await testRender(, { width: 220, height: 24 }); + const setup = await testRender(, { + width: 220, + height: 24, + }); try { await flush(setup); @@ -527,7 +594,10 @@ describe("App interactions", () => { }); test("sidebar visibility can toggle off and back on", async () => { - const setup = await testRender(, { width: 240, height: 24 }); + const setup = await testRender(, { + width: 240, + height: 24, + }); try { await flush(setup); @@ -561,7 +631,10 @@ describe("App interactions", () => { }); test("sidebar shortcut can force the files pane open when responsive layout hides it", async () => { - const setup = await testRender(, { width: 160, height: 24 }); + const setup = await testRender(, { + width: 160, + height: 24, + }); try { await flush(setup); @@ -596,7 +669,10 @@ describe("App interactions", () => { test("quit shortcuts route through the provided onQuit handler in regular and pager modes", async () => { const regularQuit = mock(() => undefined); - const regularSetup = await testRender(, { width: 220, height: 24 }); + const regularSetup = await testRender( + , + { width: 220, height: 24 }, + ); try { await flush(regularSetup); @@ -613,7 +689,10 @@ describe("App interactions", () => { } const pagerQuit = mock(() => undefined); - const pagerSetup = await testRender(, { width: 180, height: 20 }); + const pagerSetup = await testRender( + , + { width: 180, height: 20 }, + ); try { await flush(pagerSetup); @@ -629,5 +708,4 @@ describe("App interactions", () => { }); } }); - }); diff --git a/test/app-responsive.test.tsx b/test/app-responsive.test.tsx index ba020722..74f85df0 100644 --- a/test/app-responsive.test.tsx +++ b/test/app-responsive.test.tsx @@ -6,7 +6,13 @@ import type { AppBootstrap, DiffFile, LayoutMode } from "../src/core/types"; const { App } = await import("../src/ui/App"); -function createDiffFile(id: string, path: string, before: string, after: string, withAgent = false): DiffFile { +function createDiffFile( + id: string, + path: string, + before: string, + after: string, + withAgent = false, +): DiffFile { const metadata = parseDiffFromFile( { name: path, @@ -70,8 +76,20 @@ function createBootstrap(initialMode: LayoutMode = "auto", pager = false): AppBo summary: "Patch summary", agentSummary: "Changeset summary", files: [ - createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\nexport const add = true;\n", true), - createDiffFile("beta", "beta.ts", "export const beta = 1;\n", "export const betaValue = 1;\n", false), + createDiffFile( + "alpha", + "alpha.ts", + "export const alpha = 1;\n", + "export const alpha = 2;\nexport const add = true;\n", + true, + ), + createDiffFile( + "beta", + "beta.ts", + "export const beta = 1;\n", + "export const betaValue = 1;\n", + false, + ), ], }, initialMode, @@ -188,7 +206,10 @@ describe("responsive shell", () => { const exitMock = mock(() => undefined as never); (process as typeof process & { exit: typeof exitMock }).exit = exitMock; - const setup = await testRender(, { width: 240, height: 24 }); + const setup = await testRender(, { + width: 240, + height: 24, + }); try { await act(async () => { @@ -213,5 +234,4 @@ describe("responsive shell", () => { }); } }); - }); diff --git a/test/cli.test.ts b/test/cli.test.ts index 567b82e3..495e97f5 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -114,7 +114,15 @@ describe("parseCli", () => { }); test("parses pathspec-limited git diffs", async () => { - const parsed = await parseCli(["bun", "hunk", "diff", "main", "--", "src/app.ts", "test/app.test.ts"]); + const parsed = await parseCli([ + "bun", + "hunk", + "diff", + "main", + "--", + "src/app.ts", + "test/app.test.ts", + ]); expect(parsed).toMatchObject({ kind: "git", @@ -258,7 +266,15 @@ describe("parseCli", () => { }); test("parses session comment rm", async () => { - const parsed = await parseCli(["bun", "hunk", "session", "comment", "rm", "session-1", "comment-1"]); + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "comment", + "rm", + "session-1", + "comment-1", + ]); expect(parsed).toEqual({ kind: "session", @@ -353,7 +369,16 @@ describe("parseCli", () => { }); test("parses difftool mode with display path", async () => { - const parsed = await parseCli(["bun", "hunk", "difftool", "left.ts", "right.ts", "src/example.ts", "--mode", "stack"]); + const parsed = await parseCli([ + "bun", + "hunk", + "difftool", + "left.ts", + "right.ts", + "src/example.ts", + "--mode", + "stack", + ]); expect(parsed).toMatchObject({ kind: "difftool", diff --git a/test/config.test.ts b/test/config.test.ts index 7ef2617e..3621a375 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -66,13 +66,7 @@ describe("config resolution", () => { mkdirSync(join(repo, ".hunk"), { recursive: true }); writeFileSync( join(repo, ".hunk", "config.toml"), - [ - 'theme = "paper"', - "wrap_lines = true", - "", - "[pager]", - "hunk_headers = false", - ].join("\n"), + ['theme = "paper"', "wrap_lines = true", "", "[pager]", "hunk_headers = false"].join("\n"), ); const resolved = resolveConfiguredCliInput(createPatchPagerInput({ agentNotes: true }), { @@ -109,11 +103,7 @@ describe("config resolution", () => { mkdirSync(join(home, ".config", "hunk"), { recursive: true }); writeFileSync( join(home, ".config", "hunk", "config.toml"), - [ - '[show]', - 'mode = "stack"', - 'line_numbers = false', - ].join('\n'), + ["[show]", 'mode = "stack"', "line_numbers = false"].join("\n"), ); const resolved = resolveConfiguredCliInput( @@ -139,11 +129,11 @@ describe("config resolution", () => { join(home, ".config", "hunk", "config.toml"), [ 'theme = "paper"', - 'line_numbers = false', - 'wrap_lines = true', - 'hunk_headers = false', - 'agent_notes = true', - ].join('\n'), + "line_numbers = false", + "wrap_lines = true", + "hunk_headers = false", + "agent_notes = true", + ].join("\n"), ); const before = join(repo, "before.ts"); diff --git a/test/daemon-launcher.test.ts b/test/daemon-launcher.test.ts index cedc9f59..54f754e4 100644 --- a/test/daemon-launcher.test.ts +++ b/test/daemon-launcher.test.ts @@ -9,14 +9,18 @@ describe("MCP daemon launcher", () => { args: ["src/main.tsx", "mcp", "serve"], }); - expect(resolveDaemonLaunchCommand(["node", "/app/bin/hunk.cjs", "diff"], "/usr/bin/node")).toEqual({ + expect( + resolveDaemonLaunchCommand(["node", "/app/bin/hunk.cjs", "diff"], "/usr/bin/node"), + ).toEqual({ command: "/usr/bin/node", args: ["/app/bin/hunk.cjs", "mcp", "serve"], }); }); test("falls back to relaunching the current executable when no script entrypoint is present", () => { - expect(resolveDaemonLaunchCommand(["/usr/local/bin/hunk", "diff"], "/usr/local/bin/hunk")).toEqual({ + expect( + resolveDaemonLaunchCommand(["/usr/local/bin/hunk", "diff"], "/usr/local/bin/hunk"), + ).toEqual({ command: "/usr/local/bin/hunk", args: ["mcp", "serve"], }); diff --git a/test/help-output.test.ts b/test/help-output.test.ts index 4407827d..9cb33435 100644 --- a/test/help-output.test.ts +++ b/test/help-output.test.ts @@ -43,13 +43,20 @@ describe("CLI help output", () => { }); test("general pager mode falls back to plain text for non-diff stdin", () => { - const proc = Bun.spawnSync(["bash", "-lc", "printf '* main\\n feature/demo\\n' | HUNK_TEXT_PAGER=cat bun run src/main.tsx pager"], { - cwd: process.cwd(), - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - env: process.env, - }); + const proc = Bun.spawnSync( + [ + "bash", + "-lc", + "printf '* main\\n feature/demo\\n' | HUNK_TEXT_PAGER=cat bun run src/main.tsx pager", + ], + { + cwd: process.cwd(), + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + env: process.env, + }, + ); const stdout = Buffer.from(proc.stdout).toString("utf8"); const stderr = Buffer.from(proc.stderr).toString("utf8"); diff --git a/test/large-stream-windowing-benchmark.ts b/test/large-stream-windowing-benchmark.ts index 9ecb7ae3..ec0f8db2 100644 --- a/test/large-stream-windowing-benchmark.ts +++ b/test/large-stream-windowing-benchmark.ts @@ -73,7 +73,10 @@ function createBootstrap(): AppBootstrap { } const start = performance.now(); -const setup = await testRender(React.createElement(App, { bootstrap: createBootstrap() }), { width: 240, height: 28 }); +const setup = await testRender(React.createElement(App, { bootstrap: createBootstrap() }), { + width: 240, + height: 28, +}); try { await act(async () => { diff --git a/test/live-comments.test.ts b/test/live-comments.test.ts index f55ec90b..09530dc2 100644 --- a/test/live-comments.test.ts +++ b/test/live-comments.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import { parseDiffFromFile } from "@pierre/diffs"; -import { buildLiveComment, findDiffFileByPath, findHunkIndexForLine, hunkLineRange } from "../src/core/liveComments"; +import { + buildLiveComment, + findDiffFileByPath, + findHunkIndexForLine, + hunkLineRange, +} from "../src/core/liveComments"; import type { DiffFile } from "../src/core/types"; function createDiffFile(): DiffFile { diff --git a/test/loaders.test.ts b/test/loaders.test.ts index 03ee7a93..1cc00cb8 100644 --- a/test/loaders.test.ts +++ b/test/loaders.test.ts @@ -305,12 +305,15 @@ describe("loadAppBootstrap", () => { writeFileSync(before, "export const answer = 41;\n"); writeFileSync(after, "export const answer = 42;\nexport const added = true;\n"); - const diffProc = Bun.spawnSync(["git", "diff", "--no-index", "--color=always", "--", before, after], { - cwd: dir, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const diffProc = Bun.spawnSync( + ["git", "diff", "--no-index", "--color=always", "--", before, after], + { + cwd: dir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ); if (diffProc.exitCode !== 0 && diffProc.exitCode !== 1) { const stderr = Buffer.from(diffProc.stderr).toString("utf8"); diff --git a/test/mcp-client.test.ts b/test/mcp-client.test.ts index 58705145..2f2ce721 100644 --- a/test/mcp-client.test.ts +++ b/test/mcp-client.test.ts @@ -103,7 +103,9 @@ describe("Hunk MCP client", () => { client.start(); await waitUntil("non-loopback MCP warning", () => messages.length === 1); - expect(messages[0]).toContain("[hunk:mcp] Hunk MCP refuses to bind 0.0.0.0:47657 because the daemon is local-only by default."); + expect(messages[0]).toContain( + "[hunk:mcp] Hunk MCP refuses to bind 0.0.0.0:47657 because the daemon is local-only by default.", + ); expect(messages[0]).toContain("HUNK_MCP_UNSAFE_ALLOW_REMOTE=1"); } finally { client.stop(); @@ -141,8 +143,12 @@ describe("Hunk MCP client", () => { await Bun.sleep(2_000); expect(messages).toHaveLength(1); - expect(messages[0]).toContain(`[hunk:mcp] Hunk MCP port 127.0.0.1:${port} is already in use by another process.`); - expect(messages[0]).toContain("Stop the conflicting process or set HUNK_MCP_PORT to a different loopback port."); + expect(messages[0]).toContain( + `[hunk:mcp] Hunk MCP port 127.0.0.1:${port} is already in use by another process.`, + ); + expect(messages[0]).toContain( + "Stop the conflicting process or set HUNK_MCP_PORT to a different loopback port.", + ); } finally { client.stop(); await new Promise((resolve) => conflictingListener.close(() => resolve())); diff --git a/test/mcp-daemon.test.ts b/test/mcp-daemon.test.ts index a6bd4760..c884685d 100644 --- a/test/mcp-daemon.test.ts +++ b/test/mcp-daemon.test.ts @@ -44,7 +44,9 @@ function createListedSession(overrides: Partial = {}): ListedSess }; } -function createRegistration(overrides: Partial = {}): HunkSessionRegistration { +function createRegistration( + overrides: Partial = {}, +): HunkSessionRegistration { return { sessionId: "session-1", pid: 123, @@ -80,7 +82,9 @@ function createSnapshot(overrides: Partial = {}): HunkSessi }; } -function createLiveComment(overrides: Partial = {}): SessionLiveCommentSummary { +function createLiveComment( + overrides: Partial = {}, +): SessionLiveCommentSummary { return { commentId: "comment-1", filePath: "src/example.ts", @@ -96,13 +100,21 @@ function createLiveComment(overrides: Partial = {}): describe("Hunk MCP daemon state", () => { test("resolves one target session by session id, repo root, or sole-session fallback", () => { const one = [createListedSession()]; - const two = [createListedSession(), createListedSession({ sessionId: "session-2", snapshot: { ...createSnapshot(), updatedAt: "2026-03-22T00:00:01.000Z" } })]; + const two = [ + createListedSession(), + createListedSession({ + sessionId: "session-2", + snapshot: { ...createSnapshot(), updatedAt: "2026-03-22T00:00:01.000Z" }, + }), + ]; expect(resolveSessionTarget(one, {}).sessionId).toBe("session-1"); expect(resolveSessionTarget(one, { repoRoot: "/repo" }).sessionId).toBe("session-1"); expect(resolveSessionTarget(two, { sessionId: "session-2" }).sessionId).toBe("session-2"); expect(() => resolveSessionTarget(two, {})).toThrow("specify sessionId or repoRoot"); - expect(() => resolveSessionTarget(two, { repoRoot: "/repo" })).toThrow("specify sessionId instead"); + expect(() => resolveSessionTarget(two, { repoRoot: "/repo" })).toThrow( + "specify sessionId instead", + ); }); test("exposes the selected session context from snapshot state", () => { @@ -147,7 +159,12 @@ describe("Hunk MCP daemon state", () => { liveCommentCount: 2, liveComments: [ createLiveComment(), - createLiveComment({ commentId: "comment-2", filePath: "src/other.ts", line: 9, summary: "Other" }), + createLiveComment({ + commentId: "comment-2", + filePath: "src/other.ts", + line: 9, + summary: "Other", + }), ], }), ); @@ -359,7 +376,11 @@ describe("Hunk MCP daemon state", () => { summary: "Review note", }); - state.registerSession(replacementSocket, createRegistration(), createSnapshot({ updatedAt: "2026-03-22T00:00:01.000Z" })); + state.registerSession( + replacementSocket, + createRegistration(), + createSnapshot({ updatedAt: "2026-03-22T00:00:01.000Z" }), + ); await expect(pending).rejects.toThrow("reconnected before the command completed"); expect(state.listSessions()).toHaveLength(1); diff --git a/test/mcp-e2e.test.ts b/test/mcp-e2e.test.ts index 1461013b..0d02802b 100644 --- a/test/mcp-e2e.test.ts +++ b/test/mcp-e2e.test.ts @@ -7,11 +7,12 @@ import { join } from "node:path"; const repoRoot = process.cwd(); const sourceEntrypoint = join(repoRoot, "src/main.tsx"); const tempDirs: string[] = []; -const ttyToolsAvailable = Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", -}).exitCode === 0; +const ttyToolsAvailable = + Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).exitCode === 0; interface HealthResponse { ok: boolean; @@ -59,7 +60,11 @@ function stripTerminalControl(text: string) { .replace(/\x1b[@-_]/g, ""); } -function createFixtureFiles(name: string, beforeLines: string[], afterLines: string[]): FixtureFiles { +function createFixtureFiles( + name: string, + beforeLines: string[], + afterLines: string[], +): FixtureFiles { const dir = mkdtempSync(join(tmpdir(), `hunk-session-e2e-${name}-`)); tempDirs.push(dir); @@ -126,7 +131,12 @@ function runSessionCli(args: string[], port: number) { return { proc, stdout, stderr }; } -async function waitUntil(label: string, fn: () => Promise | T | null, timeoutMs = 10_000, intervalMs = 150) { +async function waitUntil( + label: string, + fn: () => Promise | T | null, + timeoutMs = 10_000, + intervalMs = 150, +) { const deadline = Date.now() + timeoutMs; for (;;) { @@ -193,7 +203,9 @@ describe("live session end-to-end", () => { return parsed.sessions.length > 0 ? parsed.sessions : null; }); - const targetSession = listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ?? listed[0]!; + const targetSession = + listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ?? + listed[0]!; const comment = runSessionCli( [ "comment", @@ -293,7 +305,9 @@ describe("live session end-to-end", () => { const parsed = JSON.parse(stdout) as SessionListJson; return parsed.sessions.length > 0 ? parsed.sessions : null; }); - const targetSession = listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ?? listed[0]!; + const targetSession = + listed.find((session) => session.files.some((file) => file.path === fixture.afterName)) ?? + listed[0]!; const initialContext = runSessionCli(["context", targetSession.sessionId, "--json"], port); expect(initialContext.proc.exitCode).toBe(0); @@ -326,7 +340,9 @@ describe("live session end-to-end", () => { return null; } - const parsed = JSON.parse(context.stdout) as { context?: { selectedHunk?: { index: number } } }; + const parsed = JSON.parse(context.stdout) as { + context?: { selectedHunk?: { index: number } }; + }; return parsed.context?.selectedHunk?.index === 1 ? parsed : null; }); @@ -362,8 +378,16 @@ describe("live session end-to-end", () => { ["export const beta = 2;", "export const shared = true;", "export const onlyBeta = true;"], ); const port = 49000 + Math.floor(Math.random() * 1000); - const hunkProcA = spawnHunkSession(fixtureA, { port, quitAfterSeconds: 10, timeoutSeconds: 12 }); - const hunkProcB = spawnHunkSession(fixtureB, { port, quitAfterSeconds: 10, timeoutSeconds: 12 }); + const hunkProcA = spawnHunkSession(fixtureA, { + port, + quitAfterSeconds: 10, + timeoutSeconds: 12, + }); + const hunkProcB = spawnHunkSession(fixtureB, { + port, + quitAfterSeconds: 10, + timeoutSeconds: 12, + }); let daemonPid: number | null = null; @@ -382,8 +406,12 @@ describe("live session end-to-end", () => { return parsed.sessions.length === 2 ? parsed.sessions : null; }); - const sessionA = sessions.find((session) => session.files.some((file) => file.path === fixtureA.afterName)); - const sessionB = sessions.find((session) => session.files.some((file) => file.path === fixtureB.afterName)); + const sessionA = sessions.find((session) => + session.files.some((file) => file.path === fixtureA.afterName), + ); + const sessionB = sessions.find((session) => + session.files.some((file) => file.path === fixtureB.afterName), + ); expect(sessionA).toBeDefined(); expect(sessionB).toBeDefined(); diff --git a/test/mcp-server.test.ts b/test/mcp-server.test.ts index 79b27beb..b1a68b36 100644 --- a/test/mcp-server.test.ts +++ b/test/mcp-server.test.ts @@ -79,7 +79,16 @@ describe("Hunk session daemon server", () => { expect(capabilities.status).toBe(200); await expect(capabilities.json()).resolves.toMatchObject({ version: 1, - actions: ["list", "get", "context", "navigate", "comment-add", "comment-list", "comment-rm", "comment-clear"], + actions: [ + "list", + "get", + "context", + "navigate", + "comment-add", + "comment-list", + "comment-rm", + "comment-clear", + ], }); const legacyMcp = await fetch(`http://127.0.0.1:${port}/mcp`, { diff --git a/test/pager.test.ts b/test/pager.test.ts index 132f4cb3..42978d10 100644 --- a/test/pager.test.ts +++ b/test/pager.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test"; import { EventEmitter } from "node:events"; import { PassThrough } from "node:stream"; -import { looksLikePatchInput, pagePlainText, resolveTextPagerCommand, type PlainTextPagerDeps } from "../src/core/pager"; +import { + looksLikePatchInput, + pagePlainText, + resolveTextPagerCommand, + type PlainTextPagerDeps, +} from "../src/core/pager"; function createPagerDeps(overrides: Partial = {}): PlainTextPagerDeps { return { @@ -69,7 +74,9 @@ describe("general pager detection", () => { for (const newline of ["\n", "\r\n"]) { const patch = lines.join(newline); expect(looksLikePatchInput(patch)).toBe(true); - expect(looksLikePatchInput(`\u001b]0;title\u0007${patch}\u001bPignored\u001b\\`)).toBe(true); + expect(looksLikePatchInput(`\u001b]0;title\u0007${patch}\u001bPignored\u001b\\`)).toBe( + true, + ); } } }); @@ -95,7 +102,9 @@ describe("plain text pager fallback", () => { }); 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: "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"); }); diff --git a/test/pierre.test.ts b/test/pierre.test.ts index fd6dc331..ac5e7c9d 100644 --- a/test/pierre.test.ts +++ b/test/pierre.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test"; import { parseDiffFromFile } from "@pierre/diffs"; import type { DiffFile } from "../src/core/types"; -import { buildSplitRows, buildStackRows, loadHighlightedDiff, type DiffRow } from "../src/ui/diff/pierre"; +import { + buildSplitRows, + buildStackRows, + loadHighlightedDiff, + type DiffRow, +} from "../src/ui/diff/pierre"; import { resolveTheme } from "../src/ui/themes"; function createDiffFile(): DiffFile { @@ -13,7 +18,8 @@ function createDiffFile(): DiffFile { }, { name: "example.ts", - contents: "export const answer = 42;\nexport const stable = true;\nexport const added = true;\n", + contents: + "export const answer = 42;\nexport const stable = true;\nexport const added = true;\n", cacheKey: "after", }, { context: 3 }, @@ -74,7 +80,8 @@ describe("Pierre diff rows", () => { expect(rows.some((row) => row.type === "hunk-header")).toBe(true); const changedRow = rows.find( - (row) => row.type === "split-line" && row.left.kind === "deletion" && row.right.kind === "addition", + (row) => + row.type === "split-line" && row.left.kind === "deletion" && row.right.kind === "addition", ); expect(changedRow).toBeDefined(); @@ -87,7 +94,11 @@ describe("Pierre diff rows", () => { expect(changedRow.right.spans.some((span) => span.text.includes("42"))).toBe(true); expect(changedRow.left.spans.some((span) => span.bg === theme.removedContentBg)).toBe(true); expect(changedRow.right.spans.some((span) => span.bg === theme.addedContentBg)).toBe(true); - expect(changedRow.right.spans.some((span) => span.text.includes("export") && typeof span.fg === "string")).toBe(true); + expect( + changedRow.right.spans.some( + (span) => span.text.includes("export") && typeof span.fg === "string", + ), + ).toBe(true); }); test("builds stacked rows with separate deletion and addition lines", () => { @@ -95,8 +106,12 @@ describe("Pierre diff rows", () => { const theme = resolveTheme("paper", null); const rows = buildStackRows(file, null, theme); - const deletionRow = rows.find((row) => row.type === "stack-line" && row.cell.kind === "deletion"); - const additionRow = rows.find((row) => row.type === "stack-line" && row.cell.kind === "addition"); + const deletionRow = rows.find( + (row) => row.type === "stack-line" && row.cell.kind === "deletion", + ); + const additionRow = rows.find( + (row) => row.type === "stack-line" && row.cell.kind === "addition", + ); expect(deletionRow).toBeDefined(); expect(additionRow).toBeDefined(); @@ -122,11 +137,16 @@ describe("Pierre diff rows", () => { const theme = resolveTheme(themeId, null); const highlighted = await loadHighlightedDiff(file, theme.appearance); const rows = buildStackRows(file, highlighted, theme).filter( - (row): row is Extract => row.type === "stack-line" && row.cell.kind === "addition", + (row): row is Extract => + row.type === "stack-line" && row.cell.kind === "addition", ); - const headingRow = rows.find((row) => row.cell.spans.some((span) => span.text.includes("Heading"))); - const inlineCodeRow = rows.find((row) => row.cell.spans.some((span) => span.text.includes("inline code"))); + const headingRow = rows.find((row) => + row.cell.spans.some((span) => span.text.includes("Heading")), + ); + const inlineCodeRow = rows.find((row) => + row.cell.spans.some((span) => span.text.includes("inline code")), + ); expect(headingRow).toBeDefined(); expect(inlineCodeRow).toBeDefined(); @@ -135,10 +155,22 @@ describe("Pierre diff rows", () => { throw new Error("Expected highlighted markdown rows"); } - expect(headingRow.cell.spans.some((span) => span.text.includes("Heading") && span.fg === theme.syntaxColors.keyword)).toBe(true); - expect(inlineCodeRow.cell.spans.some((span) => span.text.includes("inline code") && span.fg === theme.syntaxColors.string)).toBe(true); - expect(headingRow.cell.spans.some((span) => span.fg === "#ff6762" || span.fg === "#d52c36")).toBe(false); - expect(inlineCodeRow.cell.spans.some((span) => span.fg === "#5ecc71" || span.fg === "#199f43")).toBe(false); + expect( + headingRow.cell.spans.some( + (span) => span.text.includes("Heading") && span.fg === theme.syntaxColors.keyword, + ), + ).toBe(true); + expect( + inlineCodeRow.cell.spans.some( + (span) => span.text.includes("inline code") && span.fg === theme.syntaxColors.string, + ), + ).toBe(true); + expect( + headingRow.cell.spans.some((span) => span.fg === "#ff6762" || span.fg === "#d52c36"), + ).toBe(false); + expect( + inlineCodeRow.cell.spans.some((span) => span.fg === "#5ecc71" || span.fg === "#199f43"), + ).toBe(false); } }); }); diff --git a/test/prebuilt-package-helpers.test.ts b/test/prebuilt-package-helpers.test.ts index 1a75e324..6fdfe3cd 100644 --- a/test/prebuilt-package-helpers.test.ts +++ b/test/prebuilt-package-helpers.test.ts @@ -17,7 +17,9 @@ describe("prebuilt package helpers", () => { const version = "9.9.9"; const dependencies = buildOptionalDependencyMap(version); - expect(Object.keys(dependencies).sort()).toEqual(PLATFORM_PACKAGE_MATRIX.map((spec) => spec.packageName).sort()); + expect(Object.keys(dependencies).sort()).toEqual( + PLATFORM_PACKAGE_MATRIX.map((spec) => spec.packageName).sort(), + ); expect(new Set(Object.values(dependencies))).toEqual(new Set([version])); }); @@ -57,18 +59,24 @@ describe("prebuilt package helpers", () => { test("getPlatformPackageSpecForHost resolves supported combinations and rejects unsupported ones", () => { expect(getPlatformPackageSpecForHost("linux", "x64").packageName).toBe("hunkdiff-linux-x64"); - expect(getPlatformPackageSpecForHost("darwin", "arm64").packageName).toBe("hunkdiff-darwin-arm64"); + expect(getPlatformPackageSpecForHost("darwin", "arm64").packageName).toBe( + "hunkdiff-darwin-arm64", + ); expect(() => getPlatformPackageSpecForHost("freebsd" as NodeJS.Platform, "x64")).toThrow( "Unsupported host platform for prebuilt packaging: freebsd", ); expect(() => getPlatformPackageSpecForHost("linux", "ia32" as NodeJS.Architecture)).toThrow( "Unsupported host architecture for prebuilt packaging: ia32", ); - expect(() => getPlatformPackageSpecForHost("linux", "arm64")).toThrow("No published prebuilt package spec matches linux/arm64"); + expect(() => getPlatformPackageSpecForHost("linux", "arm64")).toThrow( + "No published prebuilt package spec matches linux/arm64", + ); }); test("getHostPlatformPackageSpec resolves the current machine", () => { - expect(getHostPlatformPackageSpec()).toEqual(getPlatformPackageSpecForHost(process.platform, process.arch)); + expect(getHostPlatformPackageSpec()).toEqual( + getPlatformPackageSpecForHost(process.platform, process.arch), + ); }); test("sortPlatformPackageSpecs keeps package publish order stable", () => { diff --git a/test/session-cli.test.ts b/test/session-cli.test.ts index b60d452d..4e514263 100644 --- a/test/session-cli.test.ts +++ b/test/session-cli.test.ts @@ -6,11 +6,12 @@ import { join } from "node:path"; const repoRoot = process.cwd(); const sourceEntrypoint = join(repoRoot, "src/main.tsx"); const tempDirs: string[] = []; -const ttyToolsAvailable = Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", -}).exitCode === 0; +const ttyToolsAvailable = + Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).exitCode === 0; interface SessionListJson { sessions: Array<{ @@ -34,7 +35,12 @@ function shellQuote(value: string) { return `'${value.replaceAll("'", "'\\''")}'`; } -function waitUntil(label: string, poll: () => T | null | Promise, timeoutMs = 10_000, intervalMs = 100) { +function waitUntil( + label: string, + poll: () => T | null | Promise, + timeoutMs = 10_000, + intervalMs = 100, +) { const deadline = Date.now() + timeoutMs; return new Promise(async (resolve, reject) => { @@ -253,7 +259,9 @@ describe("session CLI", () => { return null; } - const parsed = JSON.parse(context.stdout) as { context?: { selectedHunk?: { index: number } } }; + const parsed = JSON.parse(context.stdout) as { + context?: { selectedHunk?: { index: number } }; + }; return parsed.context?.selectedHunk?.index === 1 ? parsed : null; }); @@ -278,7 +286,15 @@ describe("session CLI", () => { ); expect(comment.proc.exitCode).toBe(0); expect(comment.stderr).toBe(""); - const addedComment = JSON.parse(comment.stdout) as { result?: { commentId?: string; filePath?: string; hunkIndex?: number; side?: string; line?: number } }; + const addedComment = JSON.parse(comment.stdout) as { + result?: { + commentId?: string; + filePath?: string; + hunkIndex?: number; + side?: string; + line?: number; + }; + }; expect(addedComment).toMatchObject({ result: { filePath: fixture.afterName, @@ -289,7 +305,6 @@ describe("session CLI", () => { }); expect(typeof addedComment.result?.commentId).toBe("string"); - } finally { session.kill(); await session.exited; diff --git a/test/session-commands.test.ts b/test/session-commands.test.ts index a1419b61..72cd4598 100644 --- a/test/session-commands.test.ts +++ b/test/session-commands.test.ts @@ -1,6 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; import type { SessionCommandInput, SessionSelectorInput } from "../src/core/types"; -import { runSessionCommand, setSessionCommandTestHooks, type HunkDaemonCliClient } from "../src/session/commands"; +import { + runSessionCommand, + setSessionCommandTestHooks, + type HunkDaemonCliClient, +} from "../src/session/commands"; function createListedSession(sessionId: string) { return { @@ -40,7 +44,16 @@ function createClient(overrides: Partial): HunkDaemonCliCli return { getCapabilities: async () => ({ version: 1, - actions: ["list", "get", "context", "navigate", "comment-add", "comment-list", "comment-rm", "comment-clear"], + actions: [ + "list", + "get", + "context", + "navigate", + "comment-add", + "comment-list", + "comment-rm", + "comment-clear", + ], }), listSessions: async () => [], getSession: async () => createListedSession("session-1"), @@ -188,7 +201,16 @@ describe("session command compatibility checks", () => { createClient({ getCapabilities: async () => ({ version: 1, - actions: ["list", "get", "context", "navigate", "comment-add", "comment-list", "comment-rm", "comment-clear"], + actions: [ + "list", + "get", + "context", + "navigate", + "comment-add", + "comment-list", + "comment-rm", + "comment-clear", + ], }), }), resolveDaemonAvailability: async () => true, diff --git a/test/terminal.test.ts b/test/terminal.test.ts index eceb3e91..56f8c603 100644 --- a/test/terminal.test.ts +++ b/test/terminal.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test"; import type { CliInput } from "../src/core/types"; -import { openControllingTerminal, resolveRuntimeCliInput, shouldUsePagerMode, usesPipedPatchInput } from "../src/core/terminal"; +import { + openControllingTerminal, + resolveRuntimeCliInput, + shouldUsePagerMode, + usesPipedPatchInput, +} from "../src/core/terminal"; function createPatchInput(file?: string, pager = false): CliInput { return { diff --git a/test/tty-render-smoke.test.ts b/test/tty-render-smoke.test.ts index 7e0e9d0d..f2198696 100644 --- a/test/tty-render-smoke.test.ts +++ b/test/tty-render-smoke.test.ts @@ -11,11 +11,12 @@ if (enableTtySmokeTests) { setDefaultTimeout(15000); } -const ttyToolsAvailable = Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], { - stdin: "ignore", - stdout: "ignore", - stderr: "ignore", -}).exitCode === 0; +const ttyToolsAvailable = + Bun.spawnSync(["bash", "-lc", "command -v script >/dev/null && command -v timeout >/dev/null"], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }).exitCode === 0; function cleanupTempDirs() { while (tempDirs.length > 0) { @@ -56,13 +57,17 @@ function createFixtureFiles(lines = 1) { } else { writeFileSync( before, - Array.from({ length: lines }, (_, index) => `export const before_${String(index + 1).padStart(2, "0")} = ${index + 1};`).join("\n") + - "\n", + Array.from( + { length: lines }, + (_, index) => `export const before_${String(index + 1).padStart(2, "0")} = ${index + 1};`, + ).join("\n") + "\n", ); writeFileSync( after, - Array.from({ length: lines }, (_, index) => `export const after_${String(index + 1).padStart(2, "0")} = ${index + 101};`).join("\n") + - "\n", + Array.from( + { length: lines }, + (_, index) => `export const after_${String(index + 1).padStart(2, "0")} = ${index + 101};`, + ).join("\n") + "\n", ); } writeFileSync( @@ -78,18 +83,24 @@ function createFixtureFiles(lines = 1) { }), ); - const patchProc = Bun.spawnSync(["git", "diff", "--no-index", "--no-color", "--", before, after], { - cwd: dir, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); - const coloredPatchProc = Bun.spawnSync(["git", "diff", "--no-index", "--color=always", "--", before, after], { - cwd: dir, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - }); + const patchProc = Bun.spawnSync( + ["git", "diff", "--no-index", "--no-color", "--", before, after], + { + cwd: dir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ); + const coloredPatchProc = Bun.spawnSync( + ["git", "diff", "--no-index", "--color=always", "--", before, after], + { + cwd: dir, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }, + ); if (patchProc.exitCode !== 0 && patchProc.exitCode !== 1) { const stderr = Buffer.from(patchProc.stderr).toString("utf8"); @@ -98,7 +109,9 @@ function createFixtureFiles(lines = 1) { if (coloredPatchProc.exitCode !== 0 && coloredPatchProc.exitCode !== 1) { const stderr = Buffer.from(coloredPatchProc.stderr).toString("utf8"); - throw new Error(stderr.trim() || `failed to build colored fixture patch: ${coloredPatchProc.exitCode}`); + throw new Error( + stderr.trim() || `failed to build colored fixture patch: ${coloredPatchProc.exitCode}`, + ); } writeFileSync(patch, Buffer.from(patchProc.stdout).toString("utf8")); @@ -107,7 +120,11 @@ function createFixtureFiles(lines = 1) { return { dir, before, after, agent, patch, coloredPatch }; } -async function runTtySmoke(options: { mode?: "split" | "stack"; pager?: boolean; agentContext?: boolean }) { +async function runTtySmoke(options: { + mode?: "split" | "stack"; + pager?: boolean; + agentContext?: boolean; +}) { const fixture = createFixtureFiles(); const transcript = join(fixture.dir, "transcript.txt"); const args = ["diff", fixture.before, fixture.after]; @@ -147,13 +164,19 @@ async function runTtySmoke(options: { mode?: "split" | "stack"; pager?: boolean; return stripTerminalControl(await Bun.file(transcript).text()); } -async function runStdinPagerSmoke(options?: { input?: string; inputCommand?: string; lines?: number; command?: "patch" | "pager" }) { +async function runStdinPagerSmoke(options?: { + input?: string; + inputCommand?: string; + lines?: number; + command?: "patch" | "pager"; +}) { const fixture = createFixtureFiles(options?.lines ?? 1); const transcript = join(fixture.dir, "stdin-pager-transcript.txt"); const subcommand = options?.command === "pager" ? "pager" : "patch -"; const patchCommand = `cat ${shellQuote(fixture.coloredPatch)} | bun run ${shellQuote(sourceEntrypoint)} ${subcommand}`; const scriptCommand = `timeout 7 script -q -f -e -c ${shellQuote(patchCommand)} ${shellQuote(transcript)}`; - const inputCommand = options?.inputCommand ?? `(sleep 2; printf ${shellQuote(options?.input ?? "q")})`; + const inputCommand = + options?.inputCommand ?? `(sleep 2; printf ${shellQuote(options?.input ?? "q")})`; const proc = Bun.spawnSync(["bash", "-lc", `${inputCommand} | ${scriptCommand}`], { cwd: fixture.dir, stdin: "ignore", @@ -196,18 +219,21 @@ describe("TTY render smoke", () => { expect(output).toContain("▌1 + export const answer = 42;"); }); - ttyTest("stack mode keeps the terminal-native stacked rows without split separators", async () => { - if (!ttyToolsAvailable) { - return; - } + ttyTest( + "stack mode keeps the terminal-native stacked rows without split separators", + async () => { + if (!ttyToolsAvailable) { + return; + } - const output = await runTtySmoke({ mode: "stack" }); + const output = await runTtySmoke({ mode: "stack" }); - expect(output).toContain("View Navigate Theme Agent Help"); - expect(output).toContain("▌1 - export const answer = 41;"); - expect(output).toContain("▌ 1 + export const answer = 42;"); - expect(output).not.toContain("│1 + export const answer = 42;"); - }); + expect(output).toContain("View Navigate Theme Agent Help"); + expect(output).toContain("▌1 - export const answer = 41;"); + expect(output).toContain("▌ 1 + export const answer = 42;"); + expect(output).not.toContain("│1 + export const answer = 42;"); + }, + ); ttyTest("pager mode hides chrome while still rendering the diff transcript", async () => { if (!ttyToolsAvailable) { diff --git a/test/ui-components.test.tsx b/test/ui-components.test.tsx index cd09486f..1a2904a5 100644 --- a/test/ui-components.test.tsx +++ b/test/ui-components.test.tsx @@ -12,10 +12,17 @@ const { AgentCard } = await import("../src/ui/components/panes/AgentCard"); const { DiffPane } = await import("../src/ui/components/panes/DiffPane"); const { MenuDropdown } = await import("../src/ui/components/chrome/MenuDropdown"); const { StatusBar } = await import("../src/ui/components/chrome/StatusBar"); -const { DiffSectionPlaceholder } = await import("../src/ui/components/panes/DiffSectionPlaceholder"); +const { DiffSectionPlaceholder } = + await import("../src/ui/components/panes/DiffSectionPlaceholder"); const { PierreDiffView } = await import("../src/ui/diff/PierreDiffView"); -function createDiffFile(id: string, path: string, before: string, after: string, withAgent = false): DiffFile { +function createDiffFile( + id: string, + path: string, + before: string, + after: string, + withAgent = false, +): DiffFile { const metadata = parseDiffFromFile( { name: path, @@ -86,8 +93,20 @@ function createBootstrap(): AppBootstrap { summary: "Patch summary", agentSummary: "Changeset summary", files: [ - createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\nexport const add = true;\n", true), - createDiffFile("beta", "beta.ts", "export const beta = 1;\n", "export const betaValue = 1;\n", false), + createDiffFile( + "alpha", + "alpha.ts", + "export const alpha = 1;\n", + "export const alpha = 2;\nexport const add = true;\n", + true, + ), + createDiffFile( + "beta", + "beta.ts", + "export const beta = 1;\n", + "export const betaValue = 1;\n", + false, + ), ], }, initialMode: "split", @@ -167,7 +186,9 @@ function frameHasHighlightedMarker( return false; } - return line.spans.some((span) => span.text.includes(marker) && span.text.trim().length < text.trim().length); + return line.spans.some( + (span) => span.text.includes(marker) && span.text.trim().length < text.trim().length, + ); }); } @@ -252,7 +273,10 @@ describe("UI components", () => { 12, ); - const lines = frame.split("\n").slice(0, 8).map((line) => line.trimEnd()); + const lines = frame + .split("\n") + .slice(0, 8) + .map((line) => line.trimEnd()); expect(lines[0]).toBe("┌────────────────────────────────┐"); expect(lines[1]).toContain("AI note"); expect(lines[2]).toContain("Annotation for alpha.ts"); @@ -412,7 +436,11 @@ describe("UI components", () => { test("HelpDialog renders the keyboard help copy", async () => { const theme = resolveTheme("midnight", null); - const frame = await captureFrame( {}} />, 76, 14); + const frame = await captureFrame( + {}} />, + 76, + 14, + ); expect(frame).toContain("Keyboard"); expect(frame).toContain("F10 menus"); @@ -602,7 +630,10 @@ describe("UI components", () => { const addedLines = frame .split("\n") - .filter((line) => line.includes("export const message = 'this is a very") || /^▌\s{6,}\S/.test(line)); + .filter( + (line) => + line.includes("export const message = 'this is a very") || /^▌\s{6,}\S/.test(line), + ); expect(frame).toContain("1 - export const message = 'short';"); expect(addedLines[0]).toContain("1 + export const message = 'this is a very l"); @@ -655,7 +686,14 @@ describe("UI components", () => { const theme = resolveTheme("midnight", null); const noFileFrame = await captureFrame( - , + , 76, 6, ); @@ -676,14 +714,28 @@ describe("UI components", () => { expect(renameOnlyFrame).toContain("This change only renames the file."); const newFileFrame = await captureFrame( - , + , 76, 6, ); expect(newFileFrame).toContain("The file is marked as new."); const deletedFileFrame = await captureFrame( - , + , 76, 6, ); @@ -700,7 +752,14 @@ describe("UI components", () => { const theme = resolveTheme("midnight", null); const firstSetup = await testRender( - , + , { width: 184, height: 10 }, ); diff --git a/test/ui-lib.test.ts b/test/ui-lib.test.ts index 3c23bc7b..1c5218ec 100644 --- a/test/ui-lib.test.ts +++ b/test/ui-lib.test.ts @@ -1,8 +1,18 @@ import { describe, expect, test } from "bun:test"; import { parseDiffFromFile } from "@pierre/diffs"; import type { DiffFile } from "../src/core/types"; -import { buildMenuSpecs, menuBoxHeight, menuWidth, nextMenuItemIndex, type MenuEntry } from "../src/ui/components/chrome/menu"; -import { buildAgentPopoverContent, resolveAgentPopoverPlacement, wrapText } from "../src/ui/lib/agentPopover"; +import { + buildMenuSpecs, + menuBoxHeight, + menuWidth, + nextMenuItemIndex, + type MenuEntry, +} from "../src/ui/components/chrome/menu"; +import { + buildAgentPopoverContent, + resolveAgentPopoverPlacement, + wrapText, +} from "../src/ui/lib/agentPopover"; import { buildAppMenus } from "../src/ui/lib/appMenus"; import { fitText, padText } from "../src/ui/lib/text"; import { estimateDiffBodyRows } from "../src/ui/lib/sectionHeights"; @@ -40,7 +50,14 @@ describe("ui helpers", () => { test("buildMenuSpecs lays out the fixed top-level order", () => { const specs = buildMenuSpecs(); - expect(specs.map((spec) => spec.id)).toEqual(["file", "view", "navigate", "theme", "agent", "help"]); + expect(specs.map((spec) => spec.id)).toEqual([ + "file", + "view", + "navigate", + "theme", + "agent", + "help", + ]); expect(specs[0]).toMatchObject({ id: "file", left: 1, width: 6, label: "File" }); expect(specs[1]?.left).toBe(specs[0]!.left + specs[0]!.width + 1); }); @@ -97,20 +114,22 @@ describe("ui helpers", () => { expect( menus.view - .filter((entry): entry is Extract => entry.kind === "item" && Boolean(entry.checked)) + .filter( + (entry): entry is Extract => + entry.kind === "item" && Boolean(entry.checked), + ) .map((entry) => entry.label), - ).toEqual([ - "Stacked view", - "Agent notes", - "Line numbers", - "Line wrapping", - ]); + ).toEqual(["Stacked view", "Agent notes", "Line numbers", "Line wrapping"]); expect( menus.theme .filter((entry): entry is Extract => entry.kind === "item") .map((entry) => entry.label), ).toEqual(["Graphite", "Midnight", "Paper", "Ember"]); - expect(menus.theme.some((entry) => entry.kind === "item" && entry.label === "Graphite" && entry.checked)).toBe(true); + expect( + menus.theme.some( + (entry) => entry.kind === "item" && entry.label === "Graphite" && entry.checked, + ), + ).toBe(true); }); test("fitText and padText clamp using the terminal fallback marker", () => { @@ -174,8 +193,12 @@ describe("ui helpers", () => { const file = createDiffFile(); expect(estimateDiffBodyRows(file, "split", true)).toBeGreaterThan(0); - expect(estimateDiffBodyRows(file, "stack", true)).toBeGreaterThan(estimateDiffBodyRows(file, "split", true)); - expect(estimateDiffBodyRows(file, "split", false)).toBe(estimateDiffBodyRows(file, "split", true) - file.metadata.hunks.length); + expect(estimateDiffBodyRows(file, "stack", true)).toBeGreaterThan( + estimateDiffBodyRows(file, "split", true), + ); + expect(estimateDiffBodyRows(file, "split", false)).toBe( + estimateDiffBodyRows(file, "split", true) - file.metadata.hunks.length, + ); }); test("resolveTheme falls back by requested id and renderer mode while lazily exposing syntax styles", () => { diff --git a/test/ui-scroll-regression.test.tsx b/test/ui-scroll-regression.test.tsx index b2c09e49..5f012f12 100644 --- a/test/ui-scroll-regression.test.tsx +++ b/test/ui-scroll-regression.test.tsx @@ -9,7 +9,10 @@ mock.restore(); const { App } = await import("../src/ui/App"); function createScrollBootstrap(): AppBootstrap { - const before = Array.from({ length: 80 }, (_, index) => `line ${String(index + 1).padStart(2, "0")} old value\n`).join(""); + const before = Array.from( + { length: 80 }, + (_, index) => `line ${String(index + 1).padStart(2, "0")} old value\n`, + ).join(""); const after = Array.from({ length: 80 }, (_, index) => index === 35 ? `line ${String(index + 1).padStart(2, "0")} new value with long long text abcdefghijklmnopqrstuvwxyz\n` @@ -65,7 +68,10 @@ function createScrollBootstrap(): AppBootstrap { describe("UI scroll regression", () => { test("keeps split diff lines intact after a wheel scroll repaint", async () => { - const setup = await testRender(, { width: 160, height: 20 }); + const setup = await testRender(, { + width: 160, + height: 20, + }); try { await act(async () => {