diff --git a/.changeset/tidy-eels-report.md b/.changeset/tidy-eels-report.md new file mode 100644 index 00000000..a35c1da2 --- /dev/null +++ b/.changeset/tidy-eels-report.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Add an interactive Hunk Tutor whose instructional diff teaches the review workflow and live keybindings. diff --git a/README.md b/README.md index a272103c..fb9c6733 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 76ce470e..0778683c 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -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. diff --git a/docs/extensions.md b/docs/extensions.md index 7f4728e4..0957b950 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -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. @@ -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.]` config table. - They stay loaded under `--no-extensions` and `[extensions] enabled = false`. diff --git a/src/app/startup.test.ts b/src/app/startup.test.ts index bdf35d56..29088c76 100644 --- a/src/app/startup.test.ts +++ b/src/app/startup.test.ts @@ -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; diff --git a/src/app/startup.ts b/src/app/startup.ts index 4b927e1e..e1df1063 100644 --- a/src/app/startup.ts +++ b/src/app/startup.ts @@ -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 { diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 39a70af3..8e9e32d6 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -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:"); @@ -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"]); @@ -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 () => { diff --git a/src/core/cli.ts b/src/core/cli.ts index e4ccca78..05d7c73b 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -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", @@ -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 [path] review Git difftool file pairs", + " hunk tutor learn Hunk in an interactive guided changeset", " hunk session inspect or control a live Hunk session", " hunk markup render ( | -) preview experimental STML note markup", " hunk markup guide print the experimental STML authoring guide", @@ -837,6 +844,23 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise

{ + const command = createCliReferenceCommand("tutor"); + let parsedOptions: Record = {}; + + command.action((options: Record) => { + 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" || @@ -1624,7 +1648,7 @@ export async function parseCli(argv: string[]): Promise { 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."); } @@ -1640,6 +1664,8 @@ export async function parseCli(argv: string[]): Promise { return parsePagerCommand(rest, argv); case "difftool": return parseDifftoolCommand(rest, argv); + case "tutor": + return parseTutorCommand(rest, argv); case "stash": return parseStashCommand(rest, argv); case "session": diff --git a/src/core/config.test.ts b/src/core/config.test.ts index 531b951e..37c47b46 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -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 }); diff --git a/src/core/config.ts b/src/core/config.ts index 9c09d872..972fc596 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -361,8 +361,14 @@ export const CONFIG_COMMAND_SECTIONS = { diff: "two-file comparisons (`hunk diff `)", patch: "patch-file reviews (`hunk patch`)", difftool: "Git difftool pair reviews (`hunk difftool`)", + tutor: "interactive tutorial reviews (`hunk tutor`)", } as const satisfies Record; +/** Command-specific defaults applied before user, repo, and CLI preference layers. */ +const CONFIG_COMMAND_DEFAULTS: Partial> = { + tutor: { theme: "hunk-tutor" }, +}; + /** Reference metadata for the root-only custom-theme tables. */ export const CONFIG_REFERENCE_CUSTOM_THEME = { table: "custom_theme", @@ -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, diff --git a/src/core/loaders.test.ts b/src/core/loaders.test.ts index a58615b9..b834e5cf 100644 --- a/src/core/loaders.test.ts +++ b/src/core/loaders.test.ts @@ -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"); diff --git a/src/core/loaders.ts b/src/core/loaders.ts index cb695b54..873f7b68 100644 --- a/src/core/loaders.ts +++ b/src/core/loaders.ts @@ -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, @@ -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, @@ -495,6 +508,9 @@ export async function loadAppBootstrap( case "difftool": changeset = await loadFileDiffChangeset(input, agentContext, cwd); break; + case "tutor": + changeset = loadTutorChangeset(agentContext); + break; } changeset = { diff --git a/src/core/types.ts b/src/core/types.ts index f19510db..9c05a7b8 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -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"; diff --git a/src/core/watch.ts b/src/core/watch.ts index c056da5e..f45b8f92 100644 --- a/src/core/watch.ts +++ b/src/core/watch.ts @@ -57,6 +57,9 @@ export function computeWatchSignature(input: CliInput, context: WatchSignatureCo } parts.push(statSignature(resolveInputPath(input.file))); break; + case "tutor": + parts.push("bundled:tutor"); + break; } if (input.options.agentContext && input.options.agentContext !== "-") { diff --git a/src/core/watchPlan.ts b/src/core/watchPlan.ts index 21134f4f..cee937f6 100644 --- a/src/core/watchPlan.ts +++ b/src/core/watchPlan.ts @@ -114,6 +114,8 @@ export function resolveWatchPlan(input: CliInput, context: WatchPlanContext): Wa } fileTargets.push({ path: input.file, source: "content" }); break; + case "tutor": + return null; case "vcs": case "show": case "stash-show": { diff --git a/src/extensions/default/ui/tutor/index.tsx b/src/extensions/default/ui/tutor/index.tsx new file mode 100644 index 00000000..4002eff5 --- /dev/null +++ b/src/extensions/default/ui/tutor/index.tsx @@ -0,0 +1,557 @@ +import { useMemo, useSyncExternalStore, type ReactNode } from "react"; +import type { + ExtensionChangeset, + ExtensionCommandContext, + ExtensionEventContext, + ExtensionPaneProps, + HunkExtensionAPI, +} from "../../../../extension-api/types"; +import { runExtensionFactory } from "../../../runExtension"; +import type { ExtensionLoadResult, ExtensionMetadata } from "../../../types"; + +const TUTOR_EXTENSION_ID = "hunk-tutor"; + +interface TutorTask { + id: string; + commandId?: string; + label: string; + literalKey?: string; +} + +interface TutorLesson { + id: string; + title: string; + subtitle: string; + targetPath: string; + tasks: readonly TutorTask[]; +} + +const TUTOR_LESSONS: readonly TutorLesson[] = [ + { + id: "orientation", + title: "01 · Move through a review", + subtitle: "Watch the highlighted row, then let each navigation step reveal its explanation.", + targetPath: "01-moving-through-a-review.md", + tasks: [ + { id: "help", commandId: "hunk.app.toggleHelp", label: "open the controls card" }, + { id: "down", commandId: "hunk.review.stepDown", label: "move down one row" }, + { id: "up", commandId: "hunk.review.stepUp", label: "move up one row" }, + { id: "next-hunk", commandId: "hunk.review.nextHunk", label: "visit the next hunk" }, + { id: "previous-hunk", commandId: "hunk.review.previousHunk", label: "return one hunk" }, + { id: "next-file", commandId: "hunk.review.nextFile", label: "visit the next file" }, + { id: "previous-file", commandId: "hunk.review.previousFile", label: "return one file" }, + { id: "top", commandId: "hunk.review.jumpToTop", label: "jump to the beginning" }, + { id: "bottom", commandId: "hunk.review.jumpToBottom", label: "jump to the end" }, + ], + }, + { + id: "momentum", + title: "02 · Cover distance", + subtitle: + "Find the labeled checkpoints, then practice moving across a line wider than the pane.", + targetPath: "02-scrolling-and-panning.md", + tasks: [ + { + id: "page-down", + commandId: "hunk.review.pageDown", + label: "page down until the PAGE CHECKPOINT appears", + }, + { id: "page-up", commandId: "hunk.review.pageUp", label: "return to the lesson heading" }, + { + id: "half-down", + commandId: "hunk.review.halfPageDown", + label: "find the HALF-PAGE CHECKPOINT", + }, + { id: "half-up", commandId: "hunk.review.halfPageUp", label: "return to the wide line" }, + { + id: "right", + commandId: "hunk.review.scrollCodeRight", + label: "pan right across the wide line (Shift is faster)", + }, + { + id: "left", + commandId: "hunk.review.scrollCodeLeft", + label: "pan left toward the line's beginning", + }, + { + id: "context", + commandId: "hunk.review.toggleHunkGap", + label: "expand the folded explanation", + }, + ], + }, + { + id: "shape", + title: "03 · Shape the view", + subtitle: "Change the presentation and read the line that explains what each choice buys you.", + targetPath: "03-shaping-the-view.md", + tasks: [ + { id: "split", commandId: "hunk.view.layoutSplit", label: "choose split diff" }, + { id: "stack", commandId: "hunk.view.layoutStack", label: "choose stacked diff" }, + { id: "auto", commandId: "hunk.view.layoutAuto", label: "restore responsive auto" }, + { id: "lines", commandId: "hunk.view.toggleLineNumbers", label: "toggle line numbers" }, + { id: "wrap", commandId: "hunk.view.toggleLineWrap", label: "wrap the long explanation" }, + { id: "metadata", commandId: "hunk.view.toggleHunkHeaders", label: "toggle source ranges" }, + { + id: "theme", + commandId: "hunk.view.openThemeSelector", + label: "choose a theme with Enter", + }, + { id: "menu", commandId: "hunk.view.toggleMenuBar", label: "hide or show the menu bar" }, + { + id: "sidebar", + commandId: "hunk.view.toggleSidebar", + label: "hide this pane; finishing the lesson brings it back", + }, + ], + }, + { + id: "focus", + title: "04 · Find a file", + subtitle: + "Filter the review to the file named needle, then clear it to restore the full guide.", + targetPath: "04-find-a-file/needle.md", + tasks: [ + { id: "focus-filter", commandId: "hunk.review.focusFilter", label: "focus the file filter" }, + { + id: "filter-text", + label: "type needle, read the isolated file, then press Escape", + literalKey: "needle → Esc", + }, + { + id: "focus-area", + commandId: "hunk.app.toggleFocusArea", + label: "switch files/filter focus", + }, + { id: "refresh", commandId: "hunk.app.refresh", label: "reload this safe synthetic review" }, + { + id: "editor", + commandId: "hunk.review.editSelectedFile", + label: "try the editor handoff; this tutorial has no real file", + }, + ], + }, + { + id: "context", + title: "05 · Review with context", + subtitle: "Agent rationale and human questions belong beside the exact lines they explain.", + targetPath: "05-context-and-notes.md", + tasks: [ + { id: "agent-notes", commandId: "hunk.view.toggleAgentNotes", label: "reveal agent notes" }, + { + id: "next-annotation", + commandId: "hunk.review.nextAnnotatedHunk", + label: "jump to the next annotated hunk", + }, + { + id: "previous-annotation", + commandId: "hunk.review.previousAnnotatedHunk", + label: "jump to the previous annotated hunk", + }, + { id: "start-note", commandId: "hunk.review.startNote", label: "start a human review note" }, + { id: "save-note", label: "type a thought and save it", literalKey: "Ctrl+S" }, + ], + }, + { + id: "extensions", + title: "06 · How the tutor works", + subtitle: "The guide itself explains how public extensions can add behavior to Hunk.", + targetPath: "06-how-the-tutor-works.md", + tasks: [{ id: "finished", label: "open the finish dialog", literalKey: "Ctrl+G" }], + }, +]; + +interface TutorSnapshot { + completed: ReadonlySet; + lastCompleted: string | null; +} + +let snapshot: TutorSnapshot = { completed: new Set(), lastCompleted: null }; +const listeners = new Set<() => void>(); +const tutorFileIds = new Map(); +let needleFilterArmed = false; + +/** Publish one immutable progress snapshot even while the tutorial pane is closed. */ +function updateSnapshot(update: (current: TutorSnapshot) => TutorSnapshot) { + const next = update(snapshot); + if (next === snapshot) { + return; + } + + snapshot = next; + for (const listener of listeners) { + listener(); + } +} + +/** Subscribe the sidebar to progress recorded by extension event handlers. */ +function useTutorSnapshot() { + return useSyncExternalStore( + (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + () => snapshot, + ); +} + +/** Navigate to the lesson's curated example when its file is currently reviewable. */ +function navigateToLesson( + lesson: TutorLesson, + ctx: ExtensionEventContext | ExtensionCommandContext, +) { + const fileId = tutorFileIds.get(lesson.targetPath); + if (fileId) { + ctx.navigation.selectFile(fileId); + } +} + +/** Mark one task complete, reveal the guide, and stage the next lesson's example. */ +function completeTask(taskId: string, ctx?: ExtensionEventContext | ExtensionCommandContext) { + if (snapshot.completed.has(taskId)) { + return; + } + + const completedBefore = new Set(snapshot.completed); + const completed = new Set(completedBefore).add(taskId); + updateSnapshot((current) => ({ ...current, completed, lastCompleted: taskId })); + + const finishedLesson = TUTOR_LESSONS.find( + (lesson) => + lesson.tasks.some((task) => task.id === taskId) && + lesson.tasks.every((task) => completed.has(task.id)) && + !lesson.tasks.every((task) => completedBefore.has(task.id)), + ); + if (finishedLesson) { + ctx?.notify(`${finishedLesson.title} complete ✨`); + ctx?.panes.open("guide"); + const nextLesson = TUTOR_LESSONS.find( + (lesson) => !lesson.tasks.every((task) => completed.has(task.id)), + ); + if (ctx && nextLesson) { + navigateToLesson(nextLesson, ctx); + } + } +} + +/** Reset progress without disturbing the user's review state. */ +function resetProgress() { + needleFilterArmed = false; + updateSnapshot(() => ({ completed: new Set(), lastCompleted: null })); +} + +/** Return every useful key label from the user's effective keymap. */ +function taskKeys(task: TutorTask, keybindings: ExtensionPaneProps["keybindings"]) { + if (task.literalKey) { + return [task.literalKey]; + } + + const keys = task.commandId ? keybindings.getKeys(task.commandId) : []; + return keys.length > 0 ? [...keys] : ["menu"]; +} + +/** Keep one compact tutor line inside its pane. */ +function fitLine(text: string, width: number) { + if (text.length <= width) { + return text; + } + + return width <= 1 ? text.slice(0, width) : `${text.slice(0, width - 1)}…`; +} + +/** Wrap prose into deterministic sidebar-width lines without splitting words unnecessarily. */ +function wrapLines(text: string, width: number) { + const lines: string[] = []; + let current = ""; + for (const word of text.split(/\s+/).filter(Boolean)) { + const next = current.length === 0 ? word : `${current} ${word}`; + if (next.length <= width) { + current = next; + continue; + } + + if (current.length > 0) { + lines.push(current); + } + current = fitLine(word, width); + } + if (current.length > 0) { + lines.push(current); + } + return lines.length > 0 ? lines : [""]; +} + +/** Render a live, clickable lesson pane driven entirely through public pane props. */ +function TutorSidebar({ + files, + width, + theme, + keybindings, + actions, +}: ExtensionPaneProps): ReactNode { + const state = useTutorSnapshot(); + const allTasks = useMemo(() => TUTOR_LESSONS.flatMap((lesson) => lesson.tasks), []); + const completeCount = allTasks.filter((task) => state.completed.has(task.id)).length; + const activeTask = allTasks.find((task) => !state.completed.has(task.id)); + const activeLesson = + TUTOR_LESSONS.find((lesson) => !lesson.tasks.every((task) => state.completed.has(task.id))) ?? + TUTOR_LESSONS.at(-1)!; + const target = files.find((file) => file.path === activeLesson.targetPath); + const innerWidth = Math.max(8, width - 2); + const barWidth = Math.max(4, Math.min(14, innerWidth - 10)); + const filled = Math.round((completeCount / allTasks.length) * barWidth); + const lessonIndex = TUTOR_LESSONS.indexOf(activeLesson); + const taskKeysLabel = activeTask ? taskKeys(activeTask, keybindings).join(" · ") : ""; + const quitKeys = keybindings.getKeys("hunk.app.quit").join(" · ") || "menu"; + + return ( + + + + + + target && actions.selectFile(target.id)} + /> + + + {activeTask ? ( + <> + + + + {wrapLines(activeTask.label, Math.max(1, innerWidth - 2)).map((line, index) => ( + + ))} + + + {wrapLines(activeLesson.subtitle, Math.max(1, innerWidth - 2)).map((line, index) => ( + + ))} + + + + ) : ( + <> + + + + + )} + + + + + + ); +} + +/** Add curated rationale to the synthetic files without reaching into renderer metadata. */ +function annotateTutorChangeset(changeset: ExtensionChangeset): ExtensionChangeset { + if (changeset.sourceLabel !== "hunk tutor") { + return changeset; + } + + return { + ...changeset, + agentSummary: + "The tutor is ordered so each navigation step reveals the explanation for why it is useful.", + files: changeset.files.map((file) => { + const annotations = + file.path === "05-context-and-notes.md" + ? [ + { + newRange: [4, 5] as [number, number], + summary: "Agent explanations stay attached to the changed lines they describe.", + rationale: "Annotation navigation skips directly between changes with rationale.", + confidence: "high" as const, + author: "Hunk Tutor", + }, + { + newRange: [21, 23] as [number, number], + summary: "Human notes preserve the reviewer's question beside its target hunk.", + rationale: "Start a note here, write a thought, and save it without leaving Hunk.", + confidence: "high" as const, + author: "Hunk Tutor", + }, + ] + : file.path === "06-how-the-tutor-works.md" + ? [ + { + newRange: [9, 11] as [number, number], + summary: "The tutor observes named commands, not hard-coded keys.", + rationale: + "That is why this pane follows your personal [keybindings] configuration.", + confidence: "high" as const, + author: "Hunk", + }, + ] + : []; + + return annotations.length === 0 + ? file + : { + ...file, + agent: { + path: file.path, + summary: "An instructional example with rationale attached to its exact lesson.", + annotations, + }, + }; + }), + }; +} + +/** Register the interactive tutor using the same public surface third-party extensions receive. */ +export default function registerTutor(hunk: HunkExtensionAPI) { + hunk.configureSession({ viewPreferences: "transient" }); + hunk.registerTheme({ + id: "hunk-tutor", + label: "Hunk Tutor", + base: "catppuccin-mocha", + accent: "#7dd3fc", + badgeAdded: "#86efac", + badgeRemoved: "#f0abfc", + }); + hunk.registerPane({ + id: "guide", + title: "Hunk Tutor", + placement: "left", + width: { preferred: 34, min: 22 }, + defaultOpen: true, + replaces: "hunk:files", + component: TutorSidebar, + }); + hunk.registerCommand( + { id: "finish", title: "Finish Hunk Tutor…", key: "ctrl+g" }, + async (ctx) => { + const ready = await ctx.dialogs.confirm({ + title: "Finish Hunk Tutor?", + body: "Mark the tutorial complete, then use your quit key to leave. You can return anytime with `hunk tutor`.", + confirmLabel: "finish", + cancelLabel: "keep learning", + }); + if (ready) { + completeTask("finished", ctx); + ctx.notify("Hunk Tutor complete — you are ready for a real review ✓"); + } + }, + ); + hunk.registerCommand({ id: "restart", title: "Restart tutor progress" }, (ctx) => { + resetProgress(); + ctx.panes.open("guide"); + navigateToLesson(TUTOR_LESSONS[0]!, ctx); + ctx.notify("Tutor progress reset"); + }); + hunk.transformChangeset(annotateTutorChangeset); + + hunk.on("command_executed", ({ commandId }, ctx) => { + for (const task of TUTOR_LESSONS.flatMap((lesson) => lesson.tasks)) { + // Theme selection completes only after the user accepts a preview. + if (task.commandId === commandId && task.id !== "theme") { + if (task.id === "editor") { + ctx.notify("Tutorial handoff only • in a real review, this opens the file in $EDITOR"); + } + completeTask(task.id, ctx); + } + } + }); + hunk.on("filter_changed", ({ filter }, ctx) => { + if (filter.trim().toLowerCase().includes("needle")) { + needleFilterArmed = true; + return; + } + if (needleFilterArmed && filter.trim().length === 0) { + needleFilterArmed = false; + completeTask("filter-text", ctx); + } + }); + hunk.on("theme_changed", (_event, ctx) => completeTask("theme", ctx)); + hunk.on("note_created", (_event, ctx) => completeTask("save-note", ctx)); + hunk.on("changeset_loaded", ({ changeset }) => { + tutorFileIds.clear(); + for (const file of changeset.files) { + tutorFileIds.set(file.path, file.id); + } + }); + hunk.on("startup", async (_event, ctx) => { + ctx.panes.open("guide"); + navigateToLesson(TUTOR_LESSONS[0]!, ctx); + await ctx.dialogs.confirm({ + title: "Welcome to Hunk Tutor", + body: "The diff itself is the guide. Follow one step in the Tutor pane, then read the explanation that your movement or view change reveals.", + confirmLabel: "start lesson 1", + cancelLabel: "skip intro", + }); + ctx.notify("Lesson 1 is ready • open controls help"); + }); +} + +/** Install the bundled tutor into one interactive tutor session's existing registry. */ +export function installBundledTutorExtension(result: ExtensionLoadResult) { + const metadata: ExtensionMetadata = { + id: TUTOR_EXTENSION_ID, + sourcePath: "hunk:bundled/tutor", + origin: "bundled", + }; + runExtensionFactory({ + metadata, + registry: result.registry, + issues: result.issues, + factory: registerTutor, + }); + if (result.registry.extensions.includes(metadata)) { + result.loaded.push(metadata); + } +} diff --git a/src/session/app/reloadBounds.ts b/src/session/app/reloadBounds.ts index 23caaab8..f981343e 100644 --- a/src/session/app/reloadBounds.ts +++ b/src/session/app/reloadBounds.ts @@ -100,6 +100,8 @@ export function createSessionReloadBounds( ) : []; break; + case "tutor": + break; } return { @@ -200,6 +202,7 @@ export function validateSessionReloadWithinBounds( case "vcs": case "show": case "stash-show": + case "tutor": break; } diff --git a/src/session/broker/wire.ts b/src/session/broker/wire.ts index a6fd58ee..e9c329c8 100644 --- a/src/session/broker/wire.ts +++ b/src/session/broker/wire.ts @@ -28,6 +28,7 @@ const REVIEW_INPUT_KINDS = new Set([ "diff", "patch", "difftool", + "tutor", ]); const EXPERIMENTAL_FEATURE_SET = new Set(EXPERIMENTAL_FEATURES); diff --git a/src/tutor/content.test.ts b/src/tutor/content.test.ts new file mode 100644 index 00000000..9567b1e0 --- /dev/null +++ b/src/tutor/content.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import { getTutorDocumentText, TUTOR_PATCH, TUTOR_PATHS } from "./content"; + +describe("tutor content", () => { + test("keeps the synthetic review entirely instructional", () => { + expect(TUTOR_PATHS.every((path) => path.endsWith(".md"))).toBe(true); + expect(TUTOR_PATCH).toContain("This diff is the tutorial."); + expect(TUTOR_PATCH).toContain("You are not editing a project."); + expect(TUTOR_PATCH).toContain("Shortcuts serve one question:"); + expect(TUTOR_PATCH).not.toContain("starship"); + expect(TUTOR_PATCH).not.toContain("autopilot"); + }); + + test("keeps ordinary guide lines readable beside the tutor pane", () => { + const intentionalOverflow = ["PAN RIGHT", "WRAP THIS LONG EXPLANATION"]; + const ordinaryLines = TUTOR_PATHS.flatMap((path) => + (["old", "new"] as const).flatMap((side) => + (getTutorDocumentText(path, side) ?? "") + .split("\n") + .filter((line) => !intentionalOverflow.some((prefix) => line.startsWith(prefix))), + ), + ); + + expect(ordinaryLines.every((line) => line.length <= 36)).toBe(true); + }); + + test("puts the panning payoff beyond a normal viewport", () => { + const revealLine = TUTOR_PATCH.split("\n").find((line) => line.includes("YOU FOUND IT")); + + expect(revealLine).toBeDefined(); + expect(revealLine!.indexOf("YOU FOUND IT")).toBeGreaterThan(120); + expect(revealLine).toContain("horizontal panning reveals columns"); + expect(revealLine).toEndWith("◆ YOU FOUND IT ◆"); + }); + + test("hides an explanation inside unchanged context for the expansion lesson", () => { + const hiddenSource = getTutorDocumentText("02-scrolling-and-panning.md", "new"); + + expect(hiddenSource).toContain("YOU REVEALED THE FOLDED GUIDE"); + expect(TUTOR_PATCH).not.toContain("YOU REVEALED THE FOLDED GUIDE"); + expect(TUTOR_PATCH).toContain("The collapsed section hides a guide"); + }); + + test("makes the filter exercise describe the visible result", () => { + expect(TUTOR_PATHS).toContain("04-find-a-file/needle.md"); + expect(TUTOR_PATCH).toContain("This becomes the only visible file"); + expect(TUTOR_PATCH).toContain("Press Escape to clear the query"); + }); +}); diff --git a/src/tutor/content.ts b/src/tutor/content.ts new file mode 100644 index 00000000..68c4e884 --- /dev/null +++ b/src/tutor/content.ts @@ -0,0 +1,331 @@ +import { createTwoFilesPatch } from "diff"; + +/** One before/after document used to build the bundled tutor's synthetic changeset. */ +interface TutorDocument { + path: string; + before: string; + after: string; +} + +/** Join source lines with the final newline real documents normally carry. */ +function lines(...source: string[]) { + return `${source.join("\n")}\n`; +} + +/** Build numbered instructional rows that make page-sized movement visible. */ +function scrollingRows(prefix: string, count: number) { + return Array.from( + { length: count }, + (_, index) => `${prefix} ${String(index + 1).padStart(2, "0")}`, + ); +} + +const NAVIGATION_BRIDGE = [ + "Tip 01 — highlight = your place.", + "Tip 02 — movement never edits.", + "Tip 03 — files form one stream.", + "Tip 04 — context frames changes.", + "Tip 05 — sidebar is an index.", + "Tip 06 — pane shows your keys.", + "Tip 07 — mouse shares this state.", + "Tip 08 — selection anchors jumps.", + "Tip 09 — big reviews reward jumps.", + "Tip 10 — next change is below.", +]; + +const HIDDEN_CONTEXT = [ + "Hidden 01 — long context folds.", + "Hidden 02 — changes stay central.", + "Hidden 03 — setup can be evidence.", + "Hidden 04 — expand it in Hunk.", + "Hidden 05 — toggle to collapse.", + "YOU REVEALED THE FOLDED GUIDE.", + "Hidden 07 — inspect dependencies.", + "Hidden 08 — the ellipsis is a clue.", + "Hidden 09 — expansion is local.", + "Hidden 10 — context can matter.", +]; + +const beforeScrollRows = scrollingRows("Practice row", 42); +const afterScrollRows = beforeScrollRows.map((_, index) => { + const row = index + 1; + if (row === 1) return "START — row steps are precise."; + if (row === 10) return "CHECKPOINT A — pages move fast."; + if (row === 20) return "PAGE CHECKPOINT — page-down works."; + if (row === 27) return "HALF-PAGE CHECKPOINT — less jump."; + if (row === 34) return "SCAN TIP — page, then row."; + if (row === 42) return "END — page-up returns."; + return `Row ${String(row).padStart(2, "0")} — distance made visible.`; +}); + +const PAN_REVEAL = + "PAN RIGHT → this sentence keeps going beyond the viewport ................................................................................ horizontal panning reveals columns that do not fit on screen. ◆ YOU FOUND IT ◆"; + +/** The curated files are themselves the guide, in the order shortcuts reveal them. */ +const TUTOR_DOCUMENTS: readonly TutorDocument[] = [ + { + path: "00-start-here.md", + before: lines("# Hunk Tutor", "", "This is a practice review."), + after: lines( + "# Hunk Tutor", + "", + "This diff is the tutorial.", + "You are not editing a project.", + "Changed lines teach Hunk.", + "Each shortcut has a purpose.", + "", + "Keep the Tutor pane open.", + "It shows one configured key.", + "Navigate, then read the reveal.", + "", + "> Lost? Open controls help.", + "> Restart from Extensions.", + ), + }, + { + path: "01-moving-through-a-review.md", + before: lines( + "# Lesson 1 — Move through a review", + "", + "## Nearby rows", + "The highlight marks your place.", + "Move around the document.", + "", + ...NAVIGATION_BRIDGE, + "", + "## Changed blocks and files", + "A hunk groups nearby changed lines.", + "Move between changes and files.", + ), + after: lines( + "# Lesson 1 — Move through a review", + "", + "## Nearby rows", + "Open controls help for your keymap.", + "The highlight marks your place.", + "Move down once, then back up.", + "Watch the highlight follow.", + "", + ...NAVIGATION_BRIDGE, + "", + "## Changed blocks and files", + "A hunk groups nearby changes.", + "Hunk jumps skip between groups.", + "Next-file reaches Lesson 2.", + "Previous-file returns here.", + "Top reveals Start Here.", + "Bottom previews the final page.", + ), + }, + { + path: "02-scrolling-and-panning.md", + before: lines( + "# Lesson 2 — Cover distance", + "", + "One line below is extra wide.", + "Pan across this placeholder.", + "", + ...beforeScrollRows, + "", + ...HIDDEN_CONTEXT, + "", + "## Folded context", + "Expand context when setup matters.", + ), + after: lines( + "# Lesson 2 — Cover distance", + "", + "One line below is extra wide.", + "Keep wrapping off for this hunt.", + PAN_REVEAL, + "", + ...afterScrollRows, + "", + ...HIDDEN_CONTEXT, + "", + "## Folded context", + "The collapsed section hides a guide.", + "Expand it to reveal the message.", + "Pages expose distant rows.", + "Panning exposes distant columns.", + "Expansion exposes folded context.", + ), + }, + { + path: "03-shaping-the-view.md", + before: lines( + "# Lesson 3 — View settings", + "", + "Choose a presentation.", + "Show useful chrome.", + "Try a color theme.", + ), + after: lines( + "# Lesson 3 — Shape the view", + "", + "Split: old and new side by side.", + "Stack: old above new.", + "Auto: adapts to terminal width.", + "", + "Line numbers locate source rows.", + "WRAP THIS LONG EXPLANATION → line wrapping trades horizontal scanning for extra vertical rows, which is useful when prose or code extends beyond the available pane width.", + "Hunk headers show source ranges.", + "", + "Themes change presentation only.", + "Added and removed stay distinct.", + "The menu makes features findable.", + "F10 works while the bar is hidden.", + "Hide Tutor to give the diff room.", + "It reopens after this lesson.", + ), + }, + { + path: "04-find-a-file/haystack-a.md", + before: lines("# Filtering", "", "Many files can share one review."), + after: lines( + "# Filtering: haystack A", + "", + "This file vanishes for `needle`.", + "Filtering narrows the review.", + "It does not alter the changeset.", + ), + }, + { + path: "04-find-a-file/needle.md", + before: lines("# Filtering", "", "Find one file."), + after: lines( + "# Lesson 4 — Find the signal", + "", + "Focus the filter. Type `needle`.", + "This becomes the only visible file.", + "The review now contains one match.", + "Press Escape to clear the query.", + "The complete tutorial returns.", + "", + "Focus switches files and filter.", + "It does not change selection.", + "Refresh reloads a real source.", + "This safe tutorial stays the same.", + "Editor handoff opens `$EDITOR`.", + "This tutorial has no file to edit.", + ), + }, + { + path: "04-find-a-file/haystack-b.md", + before: lines("# Filtering", "", "Many files can share one review."), + after: lines( + "# Filtering: haystack B", + "", + "This also vanishes for `needle`.", + "It returns when the query clears.", + "File order remains stable.", + ), + }, + { + path: "05-context-and-notes.md", + before: lines( + "# Lesson 5 — Context and notes", + "", + "## Agent explanations", + "A changed hunk can have rationale.", + "Read the change alone.", + "", + ...NAVIGATION_BRIDGE, + "", + "## Human review notes", + "A reviewer can ask a question.", + "Keep the question elsewhere.", + ), + after: lines( + "# Lesson 5 — Review with context", + "", + "## Agent explanations", + "Agent notes explain why.", + "They sit beside exact lines.", + "Toggle them, then jump between.", + "You land on explained changes.", + "", + ...NAVIGATION_BRIDGE, + "", + "## Human review notes", + "Start a note on this hunk.", + "Type a real review thought.", + "Save it beside the target code.", + "The composer owns the keyboard.", + "Save or cancel to return.", + ), + }, + { + path: "06-how-the-tutor-works.md", + before: lines("# Lesson 6 — Extensions", "", "Extensions add behavior."), + after: lines( + "# Lesson 6 — How the tutor works", + "", + "Tutor is a bundled extension.", + "It uses Hunk's public API.", + "It adds this sidebar and theme.", + "It adds commands and event handlers.", + "It transforms the guide changeset.", + "", + "Tutor listens for command names.", + "It does not hard-code keys.", + "Steps follow live `[keybindings]`.", + "Other extensions use these APIs.", + "They can add views and dialogs.", + "They can add review tools.", + "", + "Use Finish when you are ready.", + "It also lives in Extensions.", + ), + }, + { + path: "07-finish-and-next-steps.md", + before: lines("# Next", "", "Open a review."), + after: lines( + "# Finish — use Hunk on a real change", + "", + "You can move through a review.", + "You can shape and filter it.", + "You can add review context.", + "Shortcuts serve one question:", + "what changed, and why?", + "", + "Try one after leaving Tutor:", + "", + "- working tree: `hunk diff`", + "- staged: `hunk diff --staged`", + "- commit: `hunk show HEAD~1`", + "- stash: `hunk stash show`", + "- patch: `hunk patch change.diff`", + "- rationale: `--agent-context`", + "- live reload: `hunk diff --watch`", + "", + "Use the mouse when it is natural.", + "Primary actions keep key parity.", + "Open controls for a reminder.", + "Quit when the diff is understood.", + ), + }, +]; + +/** Return one complete synthetic tutorial document side for context expansion. */ +export function getTutorDocumentText(path: string, side: "old" | "new") { + const document = TUTOR_DOCUMENTS.find((candidate) => candidate.path === path); + return document?.[side === "old" ? "before" : "after"] ?? null; +} + +/** Paths in the exact narrative order Hunk should render and navigate. */ +export const TUTOR_PATHS = TUTOR_DOCUMENTS.map((document) => document.path); + +/** A deterministic multi-file patch that can be loaded without touching the user's repository. */ +export const TUTOR_PATCH = TUTOR_DOCUMENTS.map((document) => + createTwoFilesPatch( + document.path, + document.path, + document.before, + document.after, + "before", + "after", + { context: 3 }, + ), +).join("\n"); diff --git a/src/ui/AppHost.tutor.test.tsx b/src/ui/AppHost.tutor.test.tsx new file mode 100644 index 00000000..0bdd62e6 --- /dev/null +++ b/src/ui/AppHost.tutor.test.tsx @@ -0,0 +1,149 @@ +import { describe, expect, mock, test } from "bun:test"; +import { testRender } from "@opentui/react/test-utils"; +import { act } from "react"; +import { prepareStartupPlan } from "../app/startup"; +import type { HunkConfigResolution } from "../core/config"; +import { loadAppBootstrap } from "../core/loaders"; +import type { CliInput } from "../core/types"; +import { createEmptyExtensionLoadResult } from "../extensions/types"; +import { AppHost } from "./AppHost"; + +/** Flush effects and OpenTUI rendering until the public frame reflects current state. */ +async function flush(setup: Awaited>) { + await act(async () => { + await setup.renderOnce(); + await Bun.sleep(0); + await setup.renderOnce(); + }); +} + +describe("bundled tutor", () => { + test("renders one focused step with the user's live command binding", async () => { + const input: CliInput = { + kind: "tutor", + options: { mode: "stack", theme: "hunk-tutor" }, + }; + const configured: HunkConfigResolution = { + input, + customThemes: [], + extensions: { enabled: false, paths: [], repoPaths: [], extensionConfigs: {} }, + keybindings: { "hunk.app.toggleHelp": "ctrl+h" }, + }; + const extensions = createEmptyExtensionLoadResult(); + const plan = await prepareStartupPlan(["bun", "hunk", "tutor"], { + parseCliImpl: async () => input, + resolveRuntimeCliInputImpl: (parsed) => parsed, + resolveConfiguredCliInputImpl: () => configured, + loadStartupExtensionsImpl: async () => extensions, + usesPipedPatchInputImpl: () => false, + stdinIsTTY: true, + stdoutIsTTY: false, + }); + if (plan.kind !== "app") { + throw new Error("Expected tutor startup to produce an app plan."); + } + + const setup = await testRender(, { + width: 80, + height: 24, + }); + try { + await flush(setup); + expect(setup.captureCharFrame()).toContain("HUNK TUTOR"); + expect(setup.captureCharFrame()).toContain("0/36"); + expect(setup.captureCharFrame()).toContain("Welcome to Hunk Tutor"); + expect(setup.captureCharFrame()).toContain("The diff itself is the guide"); + expect(setup.captureCharFrame()).toContain("read the explanation"); + expect(setup.captureCharFrame()).not.toContain("ext hunk-tutor"); + + await act(async () => { + await setup.mockInput.pressEnter(); + }); + await flush(setup); + expect(setup.captureCharFrame()).toContain("NEXT STEP"); + expect(setup.captureCharFrame()).toContain("open the controls card"); + expect(setup.captureCharFrame()).toContain("ctrl+h"); + expect(setup.captureCharFrame()).not.toContain("visit the next hunk"); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("treats lesson view changes as transient when quitting", async () => { + const input: CliInput = { kind: "tutor", options: { mode: "stack" } }; + const configured: HunkConfigResolution = { + input: { ...input, options: { ...input.options, promptSaveViewPreferences: true } }, + customThemes: [], + extensions: { enabled: false, paths: [], repoPaths: [], extensionConfigs: {} }, + keybindings: {}, + }; + const plan = await prepareStartupPlan(["bun", "hunk", "tutor"], { + parseCliImpl: async () => input, + resolveRuntimeCliInputImpl: (parsed) => parsed, + resolveConfiguredCliInputImpl: () => configured, + loadStartupExtensionsImpl: async () => createEmptyExtensionLoadResult(), + usesPipedPatchInputImpl: () => false, + stdinIsTTY: true, + stdoutIsTTY: false, + }); + if (plan.kind !== "app") { + throw new Error("Expected tutor startup to produce an app plan."); + } + + const quit = mock(() => undefined); + const setup = await testRender(, { + width: 120, + height: 28, + }); + try { + await flush(setup); + await act(async () => { + await setup.mockInput.pressEnter(); + await setup.mockInput.typeText("l"); + await setup.mockInput.typeText("q"); + }); + await flush(setup); + + expect(setup.captureCharFrame()).not.toContain("Save view preferences?"); + expect(quit).toHaveBeenCalledTimes(1); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); + + test("reveals the panning payoff at the real rightmost viewport", async () => { + const bootstrap = await loadAppBootstrap({ + kind: "tutor", + options: { mode: "stack" }, + }); + const scrollingLesson = bootstrap.changeset.files.find( + (file) => file.path === "02-scrolling-and-panning.md", + ); + if (!scrollingLesson) { + throw new Error("Expected the tutor scrolling lesson."); + } + bootstrap.changeset = { ...bootstrap.changeset, files: [scrollingLesson] }; + + const setup = await testRender(, { + width: 80, + height: 24, + }); + try { + await flush(setup); + expect(setup.captureCharFrame()).toContain("PAN RIGHT"); + expect(setup.captureCharFrame()).not.toContain("YOU FOUND IT"); + + for (let index = 0; index < 24; index += 1) { + await act(async () => { + await setup.mockInput.pressArrow("right", { shift: true }); + }); + await flush(setup); + } + + expect(setup.captureCharFrame()).toContain("YOU FOUND IT"); + expect(setup.captureCharFrame()).not.toContain("PAN RIGHT"); + } finally { + await act(async () => setup.renderer.destroy()); + } + }); +}); diff --git a/src/ui/lib/extensionWorkspace.ts b/src/ui/lib/extensionWorkspace.ts index ed10f2f8..121f30c1 100644 --- a/src/ui/lib/extensionWorkspace.ts +++ b/src/ui/lib/extensionWorkspace.ts @@ -88,6 +88,8 @@ function nonWorkingTreeReview(input: CliInput): string | null { return "a stash entry"; case "patch": return "patch input"; + case "tutor": + return "the bundled tutorial"; case "diff": case "difftool": return "a file comparison"; diff --git a/test/pty/tutor.test.ts b/test/pty/tutor.test.ts new file mode 100644 index 00000000..9be9109c --- /dev/null +++ b/test/pty/tutor.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, setDefaultTimeout, test } from "bun:test"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { createPtyHarness } from "./harness"; + +const harness = createPtyHarness(); + +/** Give the compiled-style startup and OpenTUI redraw loop room on slower CI workers. */ +setDefaultTimeout(30_000); + +afterEach(() => { + harness.cleanup(); +}); + +describe("PTY tutor", () => { + test("teaches the live keymap and advances one instructional step at a time", async () => { + const configHome = harness.createIsolatedConfigHome(); + mkdirSync(join(configHome, "hunk"), { recursive: true }); + writeFileSync( + join(configHome, "hunk", "config.toml"), + '[keybindings]\n"hunk.review.stepDown" = "ctrl+n"\n', + ); + + const session = await harness.launchHunk({ + args: ["tutor", "--mode", "stack"], + cols: 180, + rows: 30, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await session.waitForData({ timeout: 20_000 }); + const welcome = await harness.waitForSnapshot( + session, + (text) => text.includes("Welcome to Hunk Tutor") && text.includes("start lesson 1"), + 20_000, + ); + expect(welcome).toContain("The diff itself is the guide"); + await session.press("enter"); + await harness.ensureKeyboardIsLive(session); + const initial = await harness.waitForSnapshot( + session, + (text) => + text.includes("HUNK TUTOR") && text.includes("NEXT STEP") && text.includes("1/36"), + 20_000, + ); + expect(initial).toContain("1/36"); + expect(initial).toContain("ctrl+n"); + expect(initial).toContain("Extensions"); + expect(initial).toContain("Hunk Tutor"); + + session.sendKey(["ctrl", "n"]); + await Bun.sleep(200); + const moved = await session.text({ immediate: true }); + expect(moved).toContain("2/36"); + expect(moved).toContain("move up one row"); + + const lessonOneKeys: Array[0]> = [ + "up", + "]", + "[", + ".", + ",", + "g", + ["shift", "g"], + ]; + for (const key of lessonOneKeys) { + await session.press(key); + await Bun.sleep(120); + } + await session.waitForText(/02 · Cover distance/, { timeout: 5_000 }); + + const distanceKeys: Array[0]> = ["space", "b", "d", "u"]; + for (const key of distanceKeys) { + await session.press(key); + await Bun.sleep(120); + } + const panRight = await session.waitForText(/pan right across the wide line/i, { + timeout: 5_000, + }); + expect(panRight).toContain("13/36"); + + await session.press("right"); + const panLeft = await session.waitForText(/pan left toward/i, { + timeout: 5_000, + }); + expect(panLeft).toContain("14/36"); + + await session.press("left"); + const context = await session.waitForText(/expand the folded explanation/i, { + timeout: 5_000, + }); + expect(context).toContain("15/36"); + + await session.press(["ctrl", "g"]); + const finish = await session.waitForText(/Finish Hunk Tutor\?/, { + timeout: 5_000, + }); + expect(finish).toContain("hunk tutor"); + expect(finish).not.toContain("ext hunk-tutor"); + } finally { + session.close(); + } + }); + + test("keeps a drafted note readable in the focused 80-column layout", async () => { + const configHome = harness.createIsolatedConfigHome(); + const session = await harness.launchHunk({ + args: ["tutor", "--mode", "stack"], + cols: 80, + rows: 24, + env: { XDG_CONFIG_HOME: configHome }, + }); + + try { + await harness.waitForSnapshot( + session, + (text) => text.includes("Welcome to Hunk Tutor") && text.includes("start lesson 1"), + 20_000, + ); + await session.press("enter"); + await harness.waitForSnapshot( + session, + (text) => text.includes("HUNK TUTOR") && text.includes("NEXT STEP"), + 20_000, + ); + + await session.press("c"); + const draft = await session.waitForText(/Draft note/, { timeout: 5_000 }); + expect(draft).toContain("Write a note"); + + await session.type("Reserve math deserves a test."); + await session.waitForText(/Reserve math deserves a test\./, { timeout: 5_000 }); + await session.press(["ctrl", "s"]); + + const saved = await session.waitForText(/Your note/, { timeout: 5_000 }); + expect(saved).toContain("Reserve math deserves a test."); + } finally { + session.close(); + } + }); +}); diff --git a/website/src/content/docs/docs/extend/extensions.md b/website/src/content/docs/docs/extend/extensions.md index 472a1252..fba93340 100644 --- a/website/src/content/docs/docs/extend/extensions.md +++ b/website/src/content/docs/docs/extend/extensions.md @@ -98,9 +98,9 @@ Test the exact layout users will get with `hunk extension install /path/to/check ## Bundled extensions -Hunk's Git, Jujutsu, Sapling, and file-navigation pane use the same public extension API. Bundled extensions differ from yours in three ways: +Hunk's own Git, Jujutsu, and Sapling backends, built-in file-navigation pane, and the interactive `hunk tutor` guide are themselves extensions, registered through the same public API — which is what keeps that API honest. They differ from yours in three ways: -- statically imported, so they load before config resolution picks the session's VCS +- VCS adapters and the default pane load with their host surfaces; the UI-backed tutor is imported only for `hunk tutor` - implicitly trusted, with no `[extension.]` config table - still loaded under `--no-extensions` and `[extensions] enabled = false` — those switches triage extensions _you_ installed diff --git a/website/src/content/docs/docs/reference/cli.md b/website/src/content/docs/docs/reference/cli.md index 2aa4a124..73b61043 100644 --- a/website/src/content/docs/docs/reference/cli.md +++ b/website/src/content/docs/docs/reference/cli.md @@ -133,6 +133,18 @@ Also accepts `--watch`: auto-reload when the current diff input changes. Also accepts every [common review option](#common-review-options). +## `hunk tutor` + +learn Hunk inside an interactive guided changeset + +### Usage + +```bash +hunk tutor +``` + +Also accepts every [common review option](#common-review-options). + ## `hunk markup render` preview experimental STML markup as terminal text diff --git a/website/src/content/docs/docs/reference/config.md b/website/src/content/docs/docs/reference/config.md index a8800062..a9f1c6ae 100644 --- a/website/src/content/docs/docs/reference/config.md +++ b/website/src/content/docs/docs/reference/config.md @@ -189,6 +189,7 @@ Enable moved-line coloring when the renderer supports it. | `[diff]` | two-file comparisons (`hunk diff `) | | `[patch]` | patch-file reviews (`hunk patch`) | | `[difftool]` | Git difftool pair reviews (`hunk difftool`) | +| `[tutor]` | interactive tutorial reviews (`hunk tutor`) | `[pager]` is an additional overlay for any review opened with pager-style chrome. It is applied after the matching command table in the same file. diff --git a/website/src/content/docs/docs/start/quick-start.md b/website/src/content/docs/docs/start/quick-start.md index c51f7571..aefeb1af 100644 --- a/website/src/content/docs/docs/start/quick-start.md +++ b/website/src/content/docs/docs/start/quick-start.md @@ -13,6 +13,17 @@ From a repository: hunk diff ``` +If this is your first review, start with the vimtutor-inspired interactive guide: + +```bash +hunk tutor +``` + +It opens a safe synthetic changeset whose diff is the guide, plus a focused lesson pane that +follows your configured keybindings. Each step reveals an explanation in the review while +introducing navigation, layouts, filtering, agent notes, human review notes, themes, menus, mouse +support, and extensions. + This includes tracked changes and untracked files. Use `--exclude-untracked` when you intentionally want tracked changes only. Inside Hunk: