Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions .claude/skills/demo-gifs/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
---
name: demo-gifs
description: Record and maintain the README demo GIFs for hunk-commit — a real Hunk TUI session driven by VHS against a throwaway fixture repository. Use when the user wants to add, regenerate, or fix a demo GIF, or when a keybinding, prompt, or confirmation wording changes and the committed GIFs may no longer match.
---

# hunk-commit demo GIFs

The README's GIFs are recordings of the **real extension** running in a real
Hunk session. They are evidence, not illustration — so they are produced by a
checked-in pipeline anyone can run, not by an agent improvising a recording.

Your job is to **write and maintain that pipeline**, and to run it when asked.
`./demo/record.sh` must keep working with no agent involved.

## Ground rules

- **Never fake a frame.** Every GIF comes from a real session against the
fixture repository. If a demo cannot be recorded, say so — do not simulate it.
- **Never commit.** Stop at the working copy and hand off (see *Handoff*).
- **Never `reload` the user's Hunk session or open the TUI interactively**
outside a VHS recording.

## Inventory

Four GIFs. This list is deliberate — commands not listed here are explained
well enough in prose, and a GIF per command rots faster than it teaches.

| GIF | Shows | Backend | README home |
| --- | --- | --- | --- |
| `hero.gif` | Mark two hunks with `x`, `C`, type a summary, review reloads without them | git | under the opening paragraph, before `## Requirements` |
| `commit.gif` | `C` end to end, including the refusal when something is already staged | git | end of `### Committing` |
| `into.gif` | `F`, the picker, the squash landing immediately | **jj** | end of `### Putting hunks into an existing commit` |
| `discard.gif` | `D`, the confirmation naming what is lost, the hunks reverting | git | end of `### Discarding` |

`into.gif` is jj because jj's squash completes on screen; git's path ends on
"now go run this rebase command", which is a dead final frame. **The backend
must be visible inside the frame** — the fixture sets a shell prompt naming it
(`git-demo ~/cart>` / `jj-demo ~/cart>`) so no viewer mistakes jj's immediate
squash for git's deferred one.

No GIFs for `S` (staging is the familiar `git add -p` shape) or `B` (it is `C`
plus one question).

## Pipeline layout

```
demo/
fixture.ts builds the throwaway repo — git and jj variants
record.sh preflight, then every tape, then verification
*.tape one VHS tape per GIF
*.gif committed output
```

`demo/fixture.ts` follows the shape of `test/support/repo.ts` and
`test/support/gitRepo.ts` — pinned author (`Test <test@example.com>`), isolated
`JJ_CONFIG` so nothing reads the machine's real jj configuration — but does
**not import them**. Tests and demos want to diverge; coupling them means a
test refactor silently breaks the README.

### Fixture content

A tiny fake project, not this repo's own source. TypeScript at 16px is
unreadable and `bun.lock` shows up in a real working copy.

Two files, 2–3 hunks, all visible without scrolling, and the *intent* legible
in three seconds without narration: one hunk that is clearly **the fix**, one
that is clearly **an unrelated typo**. That is what makes "mark only some
hunks" self-explanatory.

## Capture settings

Fixed in every tape, so the GIFs match each other and do not change when the
user rethemes:

- **90×28 cells.** Hunk is a split-pane viewer and wants width, but a 120-col
capture scaled into GitHub's ~900px column is unreadable. If the split diff
wraps at 90, **stop and ask** rather than silently widening.
- **Font size 16–18**, `Set Theme` pinned in the tape.
- **~12 fps, 15–20s maximum.**
- **1.5 MB hard cap per GIF.** Over budget: fewer frames or a shorter take —
not more columns.

## Recording loop

1. **Preflight.** Require `vhs`, `ttyd`, `ffmpeg`, `git`, `jj`, `hunk`. `ttyd`
is the one that is usually missing and VHS shells out to it
(`nix profile install nixpkgs#ttyd`). Missing binary → stop, name it.
2. **Build the fixture** for that tape's backend.
3. **Gate on readiness.** Poll `hunk session list --json` until the session
exists, *before* the tape's first keystroke. **If it never appears, fail
hard** — a tape that types into a TUI that has not painted yet records a
blank terminal and looks like a successful run.
4. **Record**, with real keystrokes for everything the README documents. Keys
are the whole story of this tool; a cursor that moves by RPC is not
evidence that `x` works. `hunk session navigate --file X --hunk N` is a
fallback for a tape that proves flaky, not the default.
5. **Verify** (see below).
6. **Hand off.**

Sleeps are generous — 800ms+, timed to read well to a human, not to just
barely win the race. A GIF too fast to follow fails at the only thing it was
for.

## Verification

Every recording asserts, and `record.sh` exits non-zero on any failure:

- the session appeared before the first keystroke;
- the **last frame differs from the first** — proof something visibly
happened, and the one check that catches a frozen or blank recording;
- the file is **under 1.5 MB**.

## README integration

GIFs **supplement** the prose; no paragraph is removed. The `F` and `D`
sections explain consequences (recoverable in jj, not in git) that no GIF can
show.

Each GIF goes at the **end** of its section, after the explanation — a reader
who meets an autoplaying animation first loses the explanation.

**Every GIF gets real alt text** describing what happens — "Two hunks marked
amber, then C, then a summary prompt, then the review reloads without them" —
never `demo`. GIFs are invisible to screen readers, to slow connections, and
to the agents that will read this README when someone asks how the extension
works.

## Handoff

Report the paths written, the verification results, and the size of each GIF.

Then say explicitly: **open the GIFs and watch them before committing.**
GIFs are binary, so a Hunk review cannot show the change — `X` marks a binary
file wholesale and there is no hunk to read. Watching is the only real review
a demo GIF can get.
53 changes: 25 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ leaving the review — into the git index, or into a commit: a git commit, or a
Whichever you choose, **your files on disk never change** — the `git add -p`
shape, extended to committing, and to Jujutsu, which has no index.

![Two hunks of a fix marked amber in a Hunk review, then C, then a one-line
summary prompt, then the review reloads with those hunks gone and an unrelated
typo left behind.](demo/hero.gif)

## Requirements

- Hunk 0.20 or newer (extension API v8)
Expand All @@ -30,15 +34,15 @@ hunk diff --extension /path/to/hunk-commit

Open a working-copy review (`hunk diff`), then:

| Key | Does |
| --- | --- |
| `x` | Mark or unmark the hunk under the cursor — only needed to batch several |
| `X` | Mark or unmark the whole file — the only way to mark a binary or oversized file |
| `N` | Clear all marks |
| `S` | Stage the marked hunks, or the one under the cursor — git only |
| `C` | Commit the marked hunks, asking only for a summary |
| `B` | Commit them with a description as well — one more question |
| `F` | Add the marked hunks to a specific commit, selected from a list |
| Key | Does |
| --- | ----------------------------------------------------------------------------------------- |
| `x` | Mark or unmark the hunk under the cursor — only needed to batch several |
| `X` | Mark or unmark the whole file — the only way to mark a binary or oversized file |
| `N` | Clear all marks |
| `S` | Stage the marked hunks, or the one under the cursor — git only |
| `C` | Commit the marked hunks, asking only for a summary |
| `B` | Commit them with a description as well — one more question |
| `F` | Add the marked hunks to a specific commit, selected from a list |
| `D` | Discard the marked hunks, or the one under the cursor — reverts them in your working copy |

Marked lines are painted amber in the diff — a hue the diff's own green, red,
Expand Down Expand Up @@ -77,7 +81,7 @@ branch, or a new Jujutsu revision — instead of leaving them staged. It asks on
question, the summary, and commits. `B` is the same command with a description:
Hunk's input dialog holds a single line, so a body costs a second question, and
only the key that promises one asks it. Cancelling any question abandons the
commit, and typing the message *is* the confirmation, so nothing asks again
commit, and typing the message _is_ the confirmation, so nothing asks again
afterwards.

In git it refuses, before asking anything, when something is already staged
Expand All @@ -97,7 +101,7 @@ What happens next differs, and the confirmation says which:
- **In Jujutsu it happens now.** `jj squash` moves the hunks into the revision,
rebases its descendants, and records one operation that `jj undo` reverses.
- **In git it is deferred.** A `fixup!` commit is added on top, naming the
target by its full hash — git matches a title *or* a hash, and titles repeat.
target by its full hash — git matches a title _or_ a hash, and titles repeat.
Nothing is rewritten until you run the `git rebase --autosquash --autostash`
command the message gives you, at a moment you choose. `--autostash` is part
of it because the hunks you did not mark are still in your working tree.
Expand Down Expand Up @@ -128,7 +132,7 @@ record of what it held before — revert those with `jj restore` or
## How it works

The marking half is shared. The applying half is not, because the two systems
disagree about what staging *is*.
disagree about what staging _is_.

**Git** has an index, so staging is a patch applied to it. `hunk diff` in a git
repository is a bare `git diff` — the working tree against the index — so a
Expand Down Expand Up @@ -194,13 +198,6 @@ Staging is refused, with nothing written, when:
- **Windows works for git, not jj.** The jj helper needs a POSIX shell; the
seam for a PowerShell version is `src/jj/script.ts`.

### Compared with `hunk-git-lite`

[`hunk-git-lite`](https://github.com/joshedler/hunk-git-lite) also stages from
inside Hunk, at **file** granularity, with a status pane showing staged and
unstaged sections. This extension works at **hunk** granularity and covers
Jujutsu as well, but has no status pane. They are complementary.

## Development

```bash
Expand All @@ -215,15 +212,15 @@ is missing.

Layout:

| Path | Holds |
| --- | --- |
| `index.ts` | composition root: Hunk commands, events, highlights |
| `src/patch/` | parsing patches, rebuilding files, writing a patch back out |
| `src/staging/` | the backend port, and what each file contributes |
| `src/git/` | the index backend |
| `src/jj/` | the revision backend, and jj's diff-editor protocol |
| `src/ui/` | marks, painted highlights, wording, settings |
| `src/workspace.ts` | which system a review sits in — jj wins a colocated tie |
| Path | Holds |
| ------------------ | ----------------------------------------------------------- |
| `index.ts` | composition root: Hunk commands, events, highlights |
| `src/patch/` | parsing patches, rebuilding files, writing a patch back out |
| `src/staging/` | the backend port, and what each file contributes |
| `src/git/` | the index backend |
| `src/jj/` | the revision backend, and jj's diff-editor protocol |
| `src/ui/` | marks, painted highlights, wording, settings |
| `src/workspace.ts` | which system a review sits in — jj wins a colocated tie |

`src/` never imports from `index.ts`, nothing under `src/patch/` touches the
filesystem, and neither backend knows the other exists.
Expand Down
128 changes: 128 additions & 0 deletions demo/fixture.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Builds the throwaway repository the README demos are recorded against.
*
* Deliberately not importing `test/support/` — tests and demos want to
* diverge, and coupling them means a test refactor silently breaks the
* README. The shape is copied (pinned author, isolated `JJ_CONFIG`), not the
* code.
*
* bun demo/fixture.ts --backend git --out /tmp/hunk-commit-demo/cart
*/
import { execFile } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";

const run = promisify(execFile);

/**
* Pinned identity and dates: a demo repository that commits "now" produces a
* different log on every take, and `F`'s commit picker puts that log on screen.
*/
const IDENTITY = {
GIT_AUTHOR_NAME: "Test",
GIT_AUTHOR_EMAIL: "test@example.com",
GIT_COMMITTER_NAME: "Test",
GIT_COMMITTER_EMAIL: "test@example.com",
GIT_AUTHOR_DATE: "2026-01-01T09:00:00+00:00",
GIT_COMMITTER_DATE: "2026-01-01T09:00:00+00:00",
};

/**
* Lines stay under ~40 columns: the demos record at 90 cells, and a split
* diff halves that before anything is drawn.
*/
const BASE = {
"cart.ts": `export interface Item {
name: string;
price: number;
qty: number;
}

export function subtotal(items: Item[]) {
let total = 0;
for (const item of items) {
total += item.price * item.qty;
}
return total;
}

export function count(items: Item[]) {
return items.length;
}

export function withTax(items: Item[]) {
return subtotal(items) * 1.2;
}
`,
"README.md": `# cart

A tiny shopping cart.

Orders recieve a 20% tax.
`,
};

/**
* Three hunks, two stories. Both `cart.ts` hunks are the money fix and belong
* in one commit; the `README.md` typo is unrelated and is what stays behind —
* that contrast is what makes "mark only some hunks" legible without
* narration. `count()` sits between the two cart hunks so three lines of
* context on each side cannot merge them into one.
*/
const EDITED = {
"cart.ts": BASE["cart.ts"]
.replace(
" total += item.price * item.qty;",
" if (item.qty < 1) continue;\n total += item.price * item.qty;",
)
.replace(
" return subtotal(items) * 1.2;",
" const gross = subtotal(items) * 1.2;\n return Math.round(gross * 100) / 100;",
),
"README.md": BASE["README.md"].replace("recieve", "receive"),
};

function arg(name: string, fallback: string): string {
const at = process.argv.indexOf(`--${name}`);
return at === -1 ? fallback : (process.argv[at + 1] ?? fallback);
}

async function writeAll(root: string, files: Record<string, string>) {
for (const [path, content] of Object.entries(files)) {
const absolute = join(root, path);
await mkdir(dirname(absolute), { recursive: true });
await writeFile(absolute, content, "utf8");
}
}

const backend = arg("backend", "git");
const root = arg("out", join(process.cwd(), ".demo-work", "cart"));

await rm(root, { recursive: true, force: true });
await mkdir(root, { recursive: true });

const env = { ...process.env, ...IDENTITY };
const git = (...args: string[]) => run("git", args, { cwd: root, env });

await git("init", "-q", "-b", "main", ".");
await git("config", "user.name", IDENTITY.GIT_AUTHOR_NAME);
await git("config", "user.email", IDENTITY.GIT_AUTHOR_EMAIL);

await writeAll(root, BASE);
await git("add", ".");
await git("commit", "-q", "-m", "feat: add the cart");

if (backend === "jj") {
// Colocated, so the recording shows jj driving a repository git also
// understands — and `JJ_CONFIG` keeps the machine's real config out of it.
await run("jj", ["git", "init", "--colocate"], {
cwd: root,
env: { ...env, JJ_CONFIG: join(root, ".jjconfig.toml") },
});
}

// The working-copy state the demo opens on.
await writeAll(root, EDITED);

console.log(root);
Binary file added demo/hero.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading