diff --git a/AGENTS.md b/AGENTS.md index d42fa166..270723a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,10 +46,10 @@ CLI input - `[` and `]` navigate hunks across the full review stream. Do not reintroduce `j`/`k` hunk navigation unless the user asks. - Agent context belongs beside the code, not hidden in a separate mode or workflow. - Agent notes are hunk-specific: show notes for the selected hunk, render them in the diff flow near the annotated row, and keep a clear spatial relationship to the code they explain. -- When making code changes in this repo, also refresh `.hunk/latest.json` so the next review can load agent rationale with `hunk git --agent-context .hunk/latest.json`. +- When making code changes in this repo, also refresh `.hunk/latest.json` so the next review can load agent rationale with `hunk diff --agent-context .hunk/latest.json`. - Keep `.hunk/latest.json` concise and review-oriented: one changeset summary, file summaries in narrative order, and a few hunk-level annotations with real rationale. - File order in `.hunk/latest.json` is intentional, but the visible note UI should stay hunk-note driven rather than showing generic file or changeset explainer cards. -- If newly created files should appear in `hunk git` before commit, use `git add -N ` so they show up in the review stream without staging content. +- If newly created files should appear in `hunk diff` before commit, use `git add -N ` so they show up in the review stream without staging content. ## commands diff --git a/README.md b/README.md index bd032d02..541870d5 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,100 @@ # hunk -Hunk is a desktop-inspired terminal diff viewer for understanding AI-authored changesets in Bun + TypeScript with OpenTUI. +Hunk is a terminal diff viewer for reviewing agent-authored changesets with a desktop-style UI. -## Requirements - -- Bun -- Zig +- full-screen multi-file review stream +- split, stacked, and responsive auto layouts +- keyboard and mouse navigation +- optional agent rationale beside annotated hunks +- Git pager and difftool integration ## Install ```bash -bun install +npm i -g hunkdiff +``` + +For now, the published `hunk` executable still expects [Bun](https://bun.sh) 1.3.10+ to be available on your `PATH` at runtime. + +## Quick start + +Review your current working tree: + +```bash +hunk diff ``` -## Run +Review staged changes: ```bash -bun run src/main.tsx -- diff +hunk diff --staged ``` -## Standalone binary +Review a commit: + +```bash +hunk show HEAD~1 +``` -Build a local executable: +Compare two files directly: ```bash -bun run build:bin -./dist/hunk diff +hunk diff before.ts after.ts ``` -Install it into `~/.local/bin`: +Open a patch from stdin: ```bash -bun run install:bin -hunk -hunk diff +git diff --no-color | hunk patch - ``` -If you want a different install location, set `HUNK_INSTALL_DIR` before running the install script. +## 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 | ✅ | ✅ | ✅ | ✅ | + +## Benchmarks + +Quick local timing snapshot from one Linux machine on the same 120-line TypeScript file pair. Metric: time until a changed marker first became visible. + +| Tool | Avg first-visible changed output | +| --- | ---: | +| `diff` | ~37 ms | +| `delta --paging=never` | ~35 ms | +| `hunk diff` | ~219 ms | +| `difft --display side-by-side` | ~266 ms | + +Takeaway: -## Workflows +- `diff` and `delta` are fastest here because they print plain diff text and exit. +- `hunk` spends more startup time on an interactive UI, syntax highlighting, navigation state, and optional agent context. +- `difftastic` spends more startup time on structural diffing. -- `hunk` — print standard CLI help with the most common commands -- `hunk diff` — review local working tree changes in the full Hunk UI -- `hunk diff --staged` / `hunk diff --cached` — review staged changes in the full Hunk UI +## Common workflows + +- `hunk` — print CLI help +- `hunk diff` — review working tree changes +- `hunk diff --staged` / `hunk diff --cached` — review staged changes - `hunk diff ` — review changes versus a branch, tag, or commit-ish -- `hunk diff ..` / `hunk diff ...` — review common Git ranges -- `hunk diff -- ` — review only selected paths -- `hunk show [ref]` — review the last commit or a given ref in the full Hunk UI -- `hunk stash show [ref]` — review a stash entry in the full Hunk UI -- `hunk diff ` — compare two concrete files directly -- `hunk patch [file|-]` — review a patch file or stdin, including pager mode -- `hunk pager` — act as a general Git pager wrapper, opening Hunk for diff-like stdin and falling back to normal text paging otherwise +- `hunk diff ..` / `hunk diff ...` — review Git ranges +- `hunk diff -- ` — limit review to selected paths +- `hunk show [ref]` — review the last commit or a specific ref +- `hunk stash show [ref]` — review a stash entry +- `hunk patch [file|-]` — review a patch file or stdin +- `hunk pager` — act as a Git pager wrapper, opening Hunk for diff-like stdin and falling back to plain text paging otherwise - `hunk difftool [path]` — integrate with Git difftool -- `hunk git [range]` — legacy alias for the original Git-style diff entrypoint ## Interaction @@ -70,6 +112,38 @@ If you want a different install location, set `HUNK_INSTALL_DIR` before running - `tab` cycle focus regions - `q` or `Esc` quit +## Git integration + +Use Hunk directly for full-screen review: + +```bash +hunk diff +hunk diff --staged +hunk diff main...feature +hunk show +hunk stash show +``` + +Use Hunk as a pager for `git diff` and `git show`: + +```bash +git config --global core.pager 'hunk patch -' +``` + +Or scope it just to diff/show: + +```bash +git config --global pager.diff 'hunk patch -' +git config --global pager.show 'hunk patch -' +``` + +Use Hunk as a Git difftool: + +```bash +git config --global diff.tool hunk +git config --global difftool.hunk.cmd 'hunk difftool "$LOCAL" "$REMOTE" "$MERGED"' +``` + ## Configuration Hunk reads layered TOML config with this precedence: @@ -77,12 +151,10 @@ Hunk reads layered TOML config with this precedence: 1. built-in defaults 2. global config: `$XDG_CONFIG_HOME/hunk/config.toml` or `~/.config/hunk/config.toml` 3. repo-local config: `.hunk/config.toml` -4. command-specific sections like `[git]`, `[diff]`, `[show]`, `[stash-show]`, `[patch]`, `[difftool]` +4. command-specific sections like `[diff]`, `[show]`, `[stash-show]`, `[patch]`, `[difftool]` 5. `[pager]` when Hunk is running in pager mode 6. explicit CLI flags -When you change persistent view settings inside Hunk, it writes them back to `.hunk/config.toml` in the current repo when possible, or to the global config file outside a repo. - Example: ```toml @@ -101,16 +173,7 @@ line_numbers = false mode = "split" ``` -CLI overrides are available when you want one-off or pager-specific behavior: - -```bash -hunk diff --mode split --line-numbers -hunk show HEAD~1 --theme paper -hunk patch - --mode stack --no-line-numbers -hunk diff before.ts after.ts --theme paper --wrap -``` - -Supported persistent CLI overrides: +Supported one-off CLI overrides: - `--mode ` - `--theme ` @@ -119,11 +182,11 @@ Supported persistent CLI overrides: - `--hunk-headers` / `--no-hunk-headers` - `--agent-notes` / `--no-agent-notes` -## Agent sidecar format +## Agent context sidecar Use `--agent-context ` to load a JSON sidecar and show agent rationale next to the diff. -The order of `files` in the sidecar is significant. Hunk uses that order for the sidebar and main review stream so an agent can tell a story instead of relying on raw patch order. +The order of `files` in the sidecar is significant. Hunk uses that order for the sidebar and the main review stream so an agent can present a review narrative instead of raw patch order. ```json { @@ -142,130 +205,40 @@ The order of `files` in the sidecar is significant. Hunk uses that order for the "confidence": "high" } ] - }, - { - "path": "src/ui/App.tsx", - "summary": "Presents the new workflow after the loader changes.", - "annotations": [ - { - "newRange": [90, 136], - "summary": "Uses the normalized model in the review shell.", - "rationale": "The reader should inspect this after understanding the loader changes.", - "tags": ["ui"], - "confidence": "medium" - } - ] } ] } ``` -Files omitted from the sidecar keep their original diff order and appear after the explicitly ordered files. - -## Codex workflow - -For Codex-driven changes, keep a transient sidecar at `.hunk/latest.json` and load it during review: +For local agent-driven review, keep a transient sidecar at `.hunk/latest.json` and load it with: ```bash hunk diff --agent-context .hunk/latest.json ``` -Suggested pattern: - -- Codex makes code changes. -- Codex refreshes `.hunk/latest.json` with a concise changeset summary, file summaries, and hunk-level rationale. -- You open `hunk diff`, `hunk diff --staged`, or `hunk show ` with that sidecar. - -Keep the sidecar concise. It should explain why a hunk exists, what risk to review, and how the files fit together. It should not narrate obvious syntax edits line by line. - -## Comparison +## Development -### 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 | ✅ | ✅ | ✅ | ✅ | - -### Local timing snapshot - -These numbers are **not a universal benchmark**. They are a quick local comparison from one Linux machine using tmux panes, measuring **time until a changed marker first became visible** on the same 120-line TypeScript file pair. - -Commands used: - -- `hunk diff before.ts after.ts` -- `difft --display side-by-side before.ts after.ts` -- `delta --paging=never before.ts after.ts` -- `diff -u before.ts after.ts` - -| Tool | Avg first-visible changed output | -| --- | ---: | -| `diff` | ~37 ms | -| `delta --paging=never` | ~35 ms | -| `hunk diff` | ~219 ms | -| `difft --display side-by-side` | ~266 ms | - -Interpretation: - -- `diff` and `delta` are fastest here because they emit plain diff text and exit. -- `hunk` pays extra startup cost for an interactive terminal UI, syntax highlighting, navigation state, and optional agent context. -- `difftastic` pays extra cost for syntax-aware / structural diffing. -- For larger review sessions, Hunk is optimized for **navigating and understanding** a changeset, not just dumping the quickest possible patch text. - -## Git integration - -For full-screen review, you can invoke Hunk directly with Git-shaped commands: - -```bash -hunk diff -hunk diff --staged -hunk diff main...feature -hunk show -hunk show HEAD~1 -hunk stash show -``` - -Use Hunk as the default Git pager when you want it to behave like a normal pager under `git diff` / `git show`: +Install dependencies: ```bash -git config --global core.pager 'hunk patch -' +bun install ``` -Or scope it just to `git diff` and `git show`: +Validate a change: ```bash -git config --global pager.diff 'hunk patch -' -git config --global pager.show 'hunk patch -' +bun run typecheck +bun test +bun run test:tty-smoke ``` -When Hunk reads a patch from stdin, it automatically switches to pager-style chrome, strips Git's color escape sequences before parsing, and binds keyboard input to the controlling terminal so it works correctly as a Git pager. - -Then: +Build the npm runtime bundle used for publishing: ```bash -git diff -git show HEAD +bun run build:npm +bun run check:pack ``` -If you want Git to launch Hunk as a difftool for file-to-file comparisons: +## License -```bash -git config --global diff.tool hunk -git config --global difftool.hunk.cmd 'hunk difftool "$LOCAL" "$REMOTE" "$MERGED"' -``` -e comparisons: - -```bash -git config --global diff.tool hunk -git config --global difftool.hunk.cmd 'hunk difftool "$LOCAL" "$REMOTE" "$MERGED"' -``` +[MIT](LICENSE) diff --git a/package.json b/package.json index 4131a53e..f108430b 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,7 @@ "files": [ "dist/npm", "README.md", - "LICENSE", - "CONTRIBUTING.md", - "SECURITY.md" + "LICENSE" ], "scripts": { "start": "bun run src/main.tsx", diff --git a/scripts/check-pack.ts b/scripts/check-pack.ts index 4e0ea292..8317091d 100644 --- a/scripts/check-pack.ts +++ b/scripts/check-pack.ts @@ -1,7 +1,5 @@ #!/usr/bin/env bun -import { existsSync } from "node:fs"; - interface PackedFile { path: string; size: number; @@ -46,7 +44,6 @@ if (!pack) { const publishedPaths = new Set(pack.files.map((file) => file.path)); const requiredPaths = ["dist/npm/main.js", "README.md", "LICENSE", "package.json"]; -const optionalPaths = ["CONTRIBUTING.md", "SECURITY.md"]; for (const path of requiredPaths) { if (!publishedPaths.has(path)) { @@ -54,12 +51,6 @@ for (const path of requiredPaths) { } } -for (const path of optionalPaths) { - if (existsSync(path) && !publishedPaths.has(path)) { - throw new Error(`Expected npm package to include ${path} when it exists in the repo.`); - } -} - const forbiddenPrefixes = [".github/", "src/", "test/", "scripts/", "tmp/"]; const forbiddenPaths = ["AGENTS.md", "autoresearch.checks.sh", "autoresearch.sh", "bun.lock"]; diff --git a/src/core/cli.ts b/src/core/cli.ts index 9aefa419..8c445de5 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -84,7 +84,6 @@ function renderCliHelp() { " hunk patch [file] review a patch file or stdin", " hunk pager general Git pager wrapper with diff detection", " hunk difftool [path] review Git difftool file pairs", - " hunk git [range] legacy alias for git diff-style review", "", "Options:", " -h, --help show help", @@ -227,37 +226,6 @@ async function parseShowCommand(tokens: string[], argv: string[]): Promise { - const { commandTokens, pathspecs } = splitPathspecArgs(tokens); - const command = createCommand("git", "legacy alias for Git diff-style review") - .option("--staged", "show staged changes instead of the working tree") - .option("--cached", "alias for --staged") - .argument("[range]"); - - let parsedRange: string | undefined; - let parsedOptions: Record = {}; - - command.action((range: string | undefined, options: Record) => { - parsedRange = range; - parsedOptions = options; - }); - - if (commandTokens.includes("--help") || commandTokens.includes("-h")) { - return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; - } - - await parseStandaloneCommand(command, commandTokens); - - return { - kind: "git", - range: parsedRange, - staged: Boolean(parsedOptions.staged) || Boolean(parsedOptions.cached), - pathspecs: pathspecs.length > 0 ? pathspecs : undefined, - options: buildCommonOptions(parsedOptions, argv), - }; -} - /** Parse the patch-file / stdin patch entrypoint. */ async function parsePatchCommand(tokens: string[], argv: string[]): Promise { const command = createCommand("patch", "review a patch file, or read a patch from stdin").argument("[file]"); @@ -397,8 +365,6 @@ export async function parseCli(argv: string[]): Promise { return parseDiffCommand(rest, argv); case "show": return parseShowCommand(rest, argv); - case "git": - return parseGitCommand(rest, argv); case "patch": return parsePatchCommand(rest, argv); case "pager": diff --git a/test/cli.test.ts b/test/cli.test.ts index ed4f4227..36ad1fcb 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -144,23 +144,8 @@ describe("parseCli", () => { }); }); - test("parses legacy git mode with range and staged flag", async () => { - const parsed = await parseCli(["bun", "hunk", "git", "HEAD~1..HEAD", "--staged", "--theme", "ember"]); - - expect(parsed).toMatchObject({ - kind: "git", - range: "HEAD~1..HEAD", - staged: true, - options: { - theme: "ember", - }, - }); - if (parsed.kind !== "git") { - throw new Error("Expected legacy git command input."); - } - - expect(parsed.options.mode).toBeUndefined(); - expect(parsed.options.pager).toBeUndefined(); + test("rejects removed legacy git alias", async () => { + await expect(parseCli(["bun", "hunk", "git"])).rejects.toThrow("Unknown command: git"); }); test("parses patch mode from a file", async () => { diff --git a/test/help-output.test.ts b/test/help-output.test.ts index 3260743c..17d46822 100644 --- a/test/help-output.test.ts +++ b/test/help-output.test.ts @@ -18,6 +18,7 @@ describe("CLI help output", () => { expect(stdout).toContain("hunk diff"); expect(stdout).toContain("hunk show"); expect(stdout).toContain("hunk pager"); + expect(stdout).not.toContain("hunk git"); expect(stdout).not.toContain("\u001b[?1049h"); });