Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/tidy-eels-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": minor
---

Add an interactive Hunk Tutor whose instructional diff teaches the review workflow and live keybindings.
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@

Hunk is a review-first terminal diff viewer for agent-authored changesets, built on [OpenTUI](https://github.com/anomalyco/opentui) and [Pierre diffs](https://www.npmjs.com/package/@pierre/diffs).

**[hunk.dev](https://hunk.dev)** · [Documentation](https://hunk.dev/docs/)

[![CI status](https://img.shields.io/github/actions/workflow/status/modem-dev/hunk/ci.yml?branch=main&style=for-the-badge&label=CI)](https://github.com/modem-dev/hunk/actions/workflows/ci.yml?branch=main)
[![Latest release](https://img.shields.io/github/v/release/modem-dev/hunk?style=for-the-badge)](https://github.com/modem-dev/hunk/releases)
[![MIT License](https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge)](LICENSE)
Expand Down Expand Up @@ -65,8 +63,14 @@ Requirements:
```bash
hunk # show help
hunk --version # print the installed version
hunk tutor # learn the interface inside a guided review
```

New to Hunk? `hunk tutor` opens a self-contained, vimtutor-inspired tutorial whose diff is the
guide. Its bundled extension tracks the commands you actually use, follows custom keybindings, and teaches the
multi-file review stream, layouts, filtering, inline notes, agent context, menus, mouse support,
themes, and extension commands without touching your repository.

### Working with Git

Hunk mirrors Git's diff-style commands, but opens the changeset in a review UI instead of plain text.
Expand Down
7 changes: 5 additions & 2 deletions docs/extension-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ object and registry collection (`src/extensions/runExtension.ts`):
extension host. `default/ui/index.ts` is deliberately not part of that list:
it synchronously loads the bundled files pane through `runExtensionFactory`
only where the app resolves UI panes.
`default/ui/tutor/` is another public-API consumer; it loads only for
`hunk tutor`, after parsing establishes that the process is taking an
interactive path.

Git and the built-in file navigation use the public `registerVcsAdapter` and
`registerPane` paths. The current-line lens remains an installable example.
Git, built-in file navigation, and Tutor use the public `registerVcsAdapter`
and `registerPane` paths. The current-line lens remains an installable example.

Bundled extensions are implicitly trusted and stay loaded under
`--no-extensions`, which governs user extensions only.
Expand Down
9 changes: 6 additions & 3 deletions docs/extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,8 @@ run without installing anything.
## Bundled extensions

Every VCS backend Hunk ships — **Git, Jujutsu, and Sapling** — is an extension,
and so is the **built-in file-navigation pane**. They live in
as are the **built-in file-navigation pane** and the interactive guide opened
by `hunk tutor`. They live in
`src/extensions/default/`, are compiled into the binary, and register through
the same `hunk.registerVcsAdapter` and `hunk.registerPane` this guide
documents. There is no private registration path.
Expand All @@ -204,8 +205,10 @@ can do, because Git does it the same way you would.
Bundled extensions differ from yours in three ways, all of them consequences of
being Hunk's own code:

- They are **statically imported**, so they load synchronously, before config
resolution picks the session's VCS.
- The VCS adapters and default sidebar are **statically imported**, so they load
synchronously before config resolution picks the session's VCS. The UI-backed tutor
extension is imported only after the `tutor` command is selected, keeping headless
commands free of OpenTUI's native runtime.
- They are **implicitly trusted**: no discovery, no trust prompt, and no
`[extension.<id>]` config table.
- They stay loaded under `--no-extensions` and `[extensions] enabled = false`.
Expand Down
59 changes: 59 additions & 0 deletions src/app/startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,65 @@ function createBootstrap(input: CliInput): AppBootstrap {
}

describe("startup planning", () => {
test("installs the bundled tutor extension for an enabled extension session", async () => {
const cliInput: CliInput = { kind: "tutor", options: {} };
const extensionResult = createEmptyExtensionLoadResult();

const plan = await prepareStartupPlan(["bun", "hunk", "tutor"], {
parseCliImpl: async () => cliInput,
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) =>
createTestConfigResolution(input, {
extensions: { enabled: true, paths: [], repoPaths: [], extensionConfigs: {} },
}),
loadStartupExtensionsImpl: async () => extensionResult,
usesPipedPatchInputImpl: () => false,
stdinIsTTY: true,
stdoutIsTTY: false,
});

expect(plan.kind).toBe("app");
if (plan.kind !== "app") {
throw new Error("Expected app startup plan.");
}

expect(plan.bootstrap.extensions?.loaded.map((extension) => extension.id)).toContain(
"hunk-tutor",
);
expect(plan.bootstrap.extensions?.registry.panes).toMatchObject([
{ extensionId: "hunk-tutor", pane: { id: "guide", replaces: "hunk:files" } },
]);
expect(plan.bootstrap.customThemes?.map((theme) => theme.id)).toContain("hunk-tutor");
expect(
plan.bootstrap.changeset.files.find((file) => file.path.includes("context-and-notes"))?.agent
?.annotations,
).toHaveLength(2);
});

test("does not install the bundled tutor extension when extensions are disabled", async () => {
const cliInput: CliInput = { kind: "tutor", options: { extensions: false } };
const extensionResult = createEmptyExtensionLoadResult();

const plan = await prepareStartupPlan(["bun", "hunk", "tutor", "--no-extensions"], {
parseCliImpl: async () => cliInput,
resolveRuntimeCliInputImpl: (input) => input,
resolveConfiguredCliInputImpl: (input) => createTestConfigResolution(input),
loadStartupExtensionsImpl: async () => extensionResult,
usesPipedPatchInputImpl: () => false,
stdinIsTTY: true,
stdoutIsTTY: false,
});

expect(plan.kind).toBe("app");
if (plan.kind !== "app") {
throw new Error("Expected app startup plan.");
}

expect(plan.bootstrap.extensions?.loaded).toEqual([]);
expect(plan.bootstrap.extensions?.registry.panes).toEqual([]);
expect(plan.bootstrap.customThemes).toEqual([]);
});

test("returns help output without entering app startup", async () => {
let loaded = false;

Expand Down
8 changes: 8 additions & 0 deletions src/app/startup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,14 @@ export async function prepareStartupPlan(
configured = resolvedExtensions.configured;
cliInput = configured.input;
const extensionResult = resolvedExtensions.extensions;
if (cliInput.kind === "tutor" && configured.extensions.enabled) {
// UI-backed bundled extensions stay behind the interactive command path so
// headless commands never materialize OpenTUI's embedded native library.
// Unlike the core bundled extensions, Tutor also respects the session's
// extension switch so `--no-extensions` produces a plain synthetic review.
const { installBundledTutorExtension } = await import("../extensions/default/ui/tutor");
installBundledTutorExtension(extensionResult);
}

let preparedSession: SessionBootstrapResult;
try {
Expand Down
24 changes: 24 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe("parseCli", () => {
expect(parsed.text).toContain("Usage:");
expect(parsed.text).toContain("hunk diff");
expect(parsed.text).toContain("hunk show");
expect(parsed.text).toContain("hunk tutor");
expect(parsed.text).toContain("hunk skill path");
expect(parsed.text).toContain("Global options:");
expect(parsed.text).toContain("Common review options:");
Expand Down Expand Up @@ -151,6 +152,28 @@ describe("parseCli", () => {
});
});

test("parses the interactive tutor with normal review preferences", async () => {
const parsed = await parseCli([
"bun",
"hunk",
"tutor",
"--mode",
"stack",
"--theme",
"github-light-default",
"--no-extensions",
]);

expect(parsed).toMatchObject({
kind: "tutor",
options: {
mode: "stack",
theme: "github-light-default",
extensions: false,
},
});
});

test("parses the current-line style and rejects an unknown one", async () => {
const parsed = await parseCli(["bun", "hunk", "diff", "--cursor-line", "number"]);

Expand Down Expand Up @@ -1114,6 +1137,7 @@ describe("parseCli command help text", () => {
expect(await expectHelp(["patch", "--help"])).toContain("review a patch file");
expect(await expectHelp(["pager", "--help"])).toContain("general Git pager wrapper");
expect(await expectHelp(["difftool", "--help"])).toContain("review Git difftool file pairs");
expect(await expectHelp(["tutor", "--help"])).toContain("interactive guided changeset");
});

test("renders the stash command overview and the stash show command help", async () => {
Expand Down
28 changes: 27 additions & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,12 @@ export const CLI_REFERENCE_COMMANDS = {
commonReviewOptions: true,
watch: true,
},
tutor: {
path: "tutor",
summary: "learn Hunk inside an interactive guided changeset",
synopsis: ["hunk tutor"],
commonReviewOptions: true,
},
"markup-render": {
path: "markup render",
summary: "preview experimental STML markup as terminal text",
Expand Down Expand Up @@ -417,6 +423,7 @@ function renderCliHelp() {
" hunk patch [file] review a patch file or stdin",
" hunk pager general Git pager wrapper with diff detection",
" hunk difftool <left> <right> [path] review Git difftool file pairs",
" hunk tutor learn Hunk in an interactive guided changeset",
" hunk session <subcommand> inspect or control a live Hunk session",
" hunk markup render (<file> | -) preview experimental STML note markup",
" hunk markup guide print the experimental STML authoring guide",
Expand Down Expand Up @@ -837,6 +844,23 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise<P
};
}

/** Parse the self-contained interactive tutorial entrypoint. */
async function parseTutorCommand(tokens: string[], argv: string[]): Promise<ParsedCliInput> {
const command = createCliReferenceCommand("tutor");
let parsedOptions: Record<string, unknown> = {};

command.action((options: Record<string, unknown>) => {
parsedOptions = options;
});

if (tokens.includes("--help") || tokens.includes("-h")) {
return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` };
}

await parseStandaloneCommand(command, tokens);
return { kind: "tutor", options: buildCommonOptions(parsedOptions, argv) };
}

function requireReloadableCliInput(input: ParsedCliInput): CliInput {
if (
input.kind === "help" ||
Expand Down Expand Up @@ -1624,7 +1648,7 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {

if (
prefixedExperimental &&
!["diff", "show", "patch", "pager", "difftool", "stash"].includes(commandName)
!["diff", "show", "patch", "pager", "difftool", "stash", "tutor"].includes(commandName)
) {
throw new Error("`--experimental` must be used with a Hunk review command.");
}
Expand All @@ -1640,6 +1664,8 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
return parsePagerCommand(rest, argv);
case "difftool":
return parseDifftoolCommand(rest, argv);
case "tutor":
return parseTutorCommand(rest, argv);
case "stash":
return parseStashCommand(rest, argv);
case "session":
Expand Down
16 changes: 16 additions & 0 deletions src/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,22 @@ describe("config resolution", () => {
expect(resolved.input.options.theme).toBe("github-dark-default");
});

test("gives tutor its dedicated theme while preserving explicit theme preferences", () => {
const home = createTempDir("hunk-config-home-");
const cwd = createTempDir("hunk-config-cwd-");
const input: CliInput = { kind: "tutor", options: {} };

expect(resolveConfiguredCliInput(input, { cwd, env: { HOME: home } }).input.options.theme).toBe(
"hunk-tutor",
);

mkdirSync(join(home, ".config", "hunk"), { recursive: true });
writeFileSync(join(home, ".config", "hunk", "config.toml"), '[tutor]\ntheme = "dracula"\n');
expect(resolveConfiguredCliInput(input, { cwd, env: { HOME: home } }).input.options.theme).toBe(
"dracula",
);
});

test("command-specific config sections also apply to show mode", () => {
const home = createTempDir("hunk-config-home-");
mkdirSync(join(home, ".config", "hunk"), { recursive: true });
Expand Down
7 changes: 7 additions & 0 deletions src/core/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,8 +361,14 @@ export const CONFIG_COMMAND_SECTIONS = {
diff: "two-file comparisons (`hunk diff <left> <right>`)",
patch: "patch-file reviews (`hunk patch`)",
difftool: "Git difftool pair reviews (`hunk difftool`)",
tutor: "interactive tutorial reviews (`hunk tutor`)",
} as const satisfies Record<CliInput["kind"], string>;

/** Command-specific defaults applied before user, repo, and CLI preference layers. */
const CONFIG_COMMAND_DEFAULTS: Partial<Record<CliInput["kind"], CommonOptions>> = {
tutor: { theme: "hunk-tutor" },
};

/** Reference metadata for the root-only custom-theme tables. */
export const CONFIG_REFERENCE_CUSTOM_THEME = {
table: "custom_theme",
Expand Down Expand Up @@ -1059,6 +1065,7 @@ export function resolveConfiguredCliInput(

let resolvedOptions: CommonOptions = {
...buildDefaultConfigPreferences(cwd, vcsCatalog),
...CONFIG_COMMAND_DEFAULTS[input.kind],
agentContext: input.options.agentContext,
pager: input.options.pager ?? false,
experimental: false,
Expand Down
31 changes: 31 additions & 0 deletions src/core/loaders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,37 @@ afterEach(() => {
});

describe("loadAppBootstrap", () => {
test("loads the bundled tutor as an ordered, multi-file synthetic changeset", async () => {
const bootstrap = await loadAppBootstrap({ kind: "tutor", options: { mode: "auto" } });

expect(bootstrap.changeset.title).toBe("Hunk Tutor");
expect(bootstrap.changeset.sourceLabel).toBe("hunk tutor");
expect(bootstrap.changeset.files.map((file) => file.path)).toEqual([
"00-start-here.md",
"01-moving-through-a-review.md",
"02-scrolling-and-panning.md",
"03-shaping-the-view.md",
"04-find-a-file/haystack-a.md",
"04-find-a-file/needle.md",
"04-find-a-file/haystack-b.md",
"05-context-and-notes.md",
"06-how-the-tutor-works.md",
"07-finish-and-next-steps.md",
]);
expect(
bootstrap.changeset.files.find((file) => file.path.includes("context-and-notes"))?.metadata
.hunks,
).toHaveLength(2);
const scrollingLesson = bootstrap.changeset.files.find((file) =>
file.path.includes("scrolling-and-panning"),
);
expect(scrollingLesson?.patch).toContain("YOU FOUND IT");
expect(scrollingLesson?.metadata.hunks).toHaveLength(2);
expect(await scrollingLesson?.sourceFetcher?.getFullText("new")).toContain(
"YOU REVEALED THE FOLDED GUIDE",
);
});

test("synthesizes untracked file diffs an adapter reported by path", async () => {
const dir = createTempDir("hunk-adapter-untracked-");
writeFileSync(join(dir, "note.txt"), "hello\n");
Expand Down
16 changes: 16 additions & 0 deletions src/core/loaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
import type { VcsCatalog } from "./vcs/types";
import { buildFilesystemUntrackedDiffFile } from "./vcs/untracked";
import { computeWatchSignature } from "./watch";
import { getTutorDocumentText, TUTOR_PATCH } from "../tutor/content";
import type {
AppBootstrap,
AgentContext,
Expand Down Expand Up @@ -451,6 +452,18 @@ async function loadPatchChangeset(
);
}

/** Build the bundled tutorial as an ordinary normalized patch changeset. */
function loadTutorChangeset(agentContext: AgentContext | null) {
return normalizePatchChangeset(TUTOR_PATCH, "Hunk Tutor", "hunk tutor", agentContext, {
sourceFetcherBuilder: ({ path }) => ({
cacheKey: `hunk-tutor:${path}`,
async getFullText(side) {
return getTutorDocumentText(path, side);
},
}),
});
}

/** Resolve CLI input into the fully loaded app bootstrap state. */
export async function loadAppBootstrap(
input: CliInput,
Expand Down Expand Up @@ -495,6 +508,9 @@ export async function loadAppBootstrap(
case "difftool":
changeset = await loadFileDiffChangeset(input, agentContext, cwd);
break;
case "tutor":
changeset = loadTutorChangeset(agentContext);
break;
}

changeset = {
Expand Down
9 changes: 8 additions & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,13 +330,20 @@ export interface DiffToolCommandInput {
options: CommonOptions;
}

/** Launch the bundled, synthetic review used by Hunk's interactive tutorial. */
export interface TutorCommandInput {
kind: "tutor";
options: CommonOptions;
}

export type CliInput =
| VcsDiffCommandInput
| VcsShowCommandInput
| VcsStashShowCommandInput
| FileCommandInput
| PatchCommandInput
| DiffToolCommandInput;
| DiffToolCommandInput
| TutorCommandInput;

export interface MarkupRenderCommandInput {
kind: "markup-render";
Expand Down
Loading
Loading