From 7b897b32c688899b739bf394d113fb9f6d6f2ced Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 6 Sep 2026 23:41:19 -0400 Subject: [PATCH 1/6] feat(ui): show history commit metadata in reviews --- .changeset/commit-review-info.md | 5 + docs/extension-architecture.md | 8 +- docs/extensions.md | 29 +++-- packages/hunk/src/app/delegatedReview.test.ts | 84 ++++++++++++- packages/hunk/src/app/delegatedReview.ts | 44 ++++++- packages/hunk/src/core/bootstrap.ts | 2 +- .../hunk/src/core/reviewDescriptor.test.ts | 14 ++- packages/hunk/src/core/reviewDescriptor.ts | 12 +- packages/hunk/src/extension-api/types.ts | 14 ++- .../src/extensions/default/ui/index.test.ts | 15 +++ .../default/ui/reviewInfo/index.test.tsx | 75 ++++++++++-- .../default/ui/reviewInfo/index.tsx | 110 ++++++++++++++---- .../ui/reviewInfo/presentation.test.ts | 46 +++++++- .../default/ui/reviewInfo/presentation.ts | 79 ++++++++++--- packages/hunk/src/session/types.ts | 2 +- packages/hunk/src/ui/App.tsx | 12 ++ .../src/ui/AppHost.review-metadata.test.tsx | 82 ++++++++++++- packages/hunk/src/ui/AppHost.tsx | 18 ++- .../components/panes/ExtensionPane.test.tsx | 8 ++ .../src/ui/components/panes/ExtensionPane.tsx | 9 +- .../src/ui/components/panes/FileListItem.tsx | 11 +- .../src/ui/components/ui-components.test.tsx | 4 +- .../hunk/src/ui/lib/extensionPaintTheme.ts | 1 + packages/hunk/src/ui/log/colorPolicy.test.ts | 5 +- packages/hunk/src/ui/log/colorPolicy.ts | 5 +- .../src/ui/session/HunkSessionHost.test.tsx | 12 +- .../hunk/src/ui/session/HunkSessionHost.tsx | 20 ++++ packages/hunk/src/ui/themes.ts | 2 + packages/hunk/src/ui/themes/types.ts | 2 + test/pty/log-integration.test.ts | 3 + .../docs/docs/extend/custom-sidebars.md | 6 +- .../content/docs/docs/extend/extension-api.md | 19 +-- 32 files changed, 650 insertions(+), 108 deletions(-) create mode 100644 .changeset/commit-review-info.md diff --git a/.changeset/commit-review-info.md b/.changeset/commit-review-info.md new file mode 100644 index 000000000..1662c3300 --- /dev/null +++ b/.changeset/commit-review-info.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Show history-style commit metadata in the review-info panel when opening a commit from interactive `hunk log`, with a copyable right-aligned revision and relative author time. diff --git a/docs/extension-architecture.md b/docs/extension-architecture.md index 78ade69d3..3dfbea02b 100644 --- a/docs/extension-architecture.md +++ b/docs/extension-architecture.md @@ -24,7 +24,7 @@ object and registry collection (`packages/hunk/src/extensions/runExtension.ts`): that list: the UI pane planner loads its bundled files and delegated review-info registrations through `runExtensionFactory`. -Git, built-in file navigation, and delegated change-request identity use the public +Git, built-in file navigation, and change-request or history-commit identity use the public `registerVcsAdapter` and `registerPane` paths. The external [Hunk Lens](https://github.com/modem-dev/hunk-lens) extension exercises current-line pane paint through that same public contract. @@ -106,9 +106,9 @@ the planner resolves it to an integer target before applying bounds and lets a session-local divider drag override that automatic size. `packages/hunk/src/ui/components/panes/ExtensionPane.tsx` mounts panes with guarded actions, -immutable delegated review metadata, and failure containment. The fixed three-row -`hunk:review-info` top pane uses one border row above two metadata rows and is available only for -delegated change requests, so ordinary reviews spend no geometry on it. `DiffPane` exposes optional current-line paint — the row +immutable review metadata, and failure containment. The fixed three-row `hunk:review-info` top +pane uses one border row above two metadata rows and is available for delegated change requests or +commits selected from interactive history, so ordinary reviews spend no geometry on it. `DiffPane` exposes optional current-line paint — the row painter plus the public `{ side, line }` address — without publishing Pierre rows, plans, cursor keys, or caches. Deprecated sidebar APIs normalize into this same registry and layout path. diff --git a/docs/extensions.md b/docs/extensions.md index 8f4d60e18..314fef85d 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -302,8 +302,9 @@ and retires the replaced instance at that explicit ownership boundary. ### `hunk.apiVersion` -The API generation this Hunk speaks (currently `19`). Branch on it if you want -one file to support several Hunk versions. Version 19 adds provider-owned history +The API generation this Hunk speaks (currently `20`). Branch on it if you want +one file to support several Hunk versions. Version 20 adds optional commit timestamps to review +metadata and pane clipboard actions; version 19 adds provider-owned history enumeration and review planning; version 18 lets lifecycle and custom-event handlers request a host-owned review reload; version 17 adds structured review metadata to delegated patch commands and projects it into pane availability and component props; version 16 adds pane-wide @@ -370,15 +371,18 @@ A delegated built-in `patch` command may include a provider-neutral `review` des strings with an optional credential-free HTTPS URL. Hunk rejects unknown fields, control characters, invalid types, unsafe URLs, fields over their byte limits, and descriptors over 4 KiB, then copies and freezes the accepted value. `provider` and change-request `id` allow 256 bytes; -`repository`, `author`, `base`, `head`, and `revision` allow 512; `title` and `url` allow 2 KiB. -Change requests may also carry `state` (`open`, `closed`, or `merged`) and boolean `draft`. Exit results and delegation to any built-in other than +`repository`, `author`, `base`, `head`, and `revision` allow 512; `authoredAt` allows 128; +`title` and `url` allow 2 KiB. Change requests may also carry `state` (`open`, `closed`, or +`merged`) and boolean `draft`; commits may carry an ISO `authoredAt` timestamp. Exit results and delegation to any built-in other than `patch` cannot carry review metadata. An ordinary `hunk patch` has no descriptor. The descriptor describes the review source rather than its diff contents: it stays on the app bootstrap and does not enter changeset transforms or `ReviewDocumentV1`. Refreshing the same file-backed patch preserves it, including watch and manual refresh; an explicit reload to a -different patch path or input kind clears it. Live-session list, context, and review JSON snapshots -project the same optional descriptor from registration metadata; it remains outside the semantic +different patch path or input kind clears it. Opening a commit from interactive `hunk log` attaches +a commit descriptor from the selected provider history row and preserves it while refreshing that +exact provider review request. Live-session list, context, and review JSON snapshots project the +same optional descriptor from registration metadata; it remains outside the semantic review document and grants no remote reload or provider capability. Delegation cannot target another extension command or change extension bootstrap @@ -872,9 +876,9 @@ registration owns that slot and later claims are skipped with a warning. `replaces` may also name another pane by its fully qualified `":"` key, and Hunk follows those replacement chains. Both `available(context)` and the mounted component receive `review`: immutable -metadata supplied by a delegated patch command, or `null` for ordinary reviews. -The bundled `hunk:review-info` top pane uses this to show change-request identity -without taking any rows when no change-request descriptor exists. Pane extensions that read +metadata supplied by a delegated patch command or an interactive history selection, or `null` for +ordinary reviews. The bundled `hunk:review-info` top pane uses this to show change-request and +commit identity without taking any rows when no supported descriptor exists. Pane extensions that read `review` should declare `"hunk": { "apiVersion": 17 }` in their manifest so older Hunk versions refuse them cleanly instead of mounting with an incomplete prop contract. @@ -912,7 +916,7 @@ The component receives fresh props as the app changes: | Prop | What it is | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `review` | immutable delegated review metadata (`change-request`, `commit`, or `comparison`), or `null` for ordinary reviews | +| `review` | immutable review-source metadata (`change-request`, `commit`, or `comparison`), or `null` for ordinary reviews | | `files` | the visible reviewed files, review-stream order, filtered, frozen views (each carries `changeType`, `statsTruncated`, and `hunks` summaries beside the usual file fields) | | `selectedFileId` | the selected file, or `null` | | `selectedHunkIndex` | the selected hunk within that file, or `null` | @@ -922,7 +926,7 @@ The component receives fresh props as the app changes: | `currentLine` | selected-row painter plus `{ side, line }` when the registration opts in, otherwise `null` | | `theme` | hex color tokens from the active theme, updated on theme switch | | `keybindings` | the current command bindings, resolved from defaults and the user's `[keybindings]` table | -| `actions` | navigation and notifications the pane may trigger | +| `actions` | navigation, clipboard, and notifications the pane may trigger | API-v3 sidebar names remain as deprecated aliases: use `registerPane`, `ExtensionPane*`, `ctx.panes`, and `replaces: "hunk:files"` in new code. @@ -931,7 +935,8 @@ API-v3 sidebar names remain as deprecated aliases: use `registerPane`, `actions.revealLine(fileId, side, line)` route through the same review controller as the built-in files pane and the keyboard shortcuts, so the review stream scrolls, selection updates, and the `selection_changed` event fires -exactly as if the user had clicked a built-in row. `actions.notify(message, +exactly as if the user had clicked a built-in row. `actions.copyText(text)` uses the terminal's +OSC 52 clipboard integration and returns `false` when unavailable. `actions.notify(message, type?)` shows a toast attributed to your extension. An action given a file id that is not currently visible is refused with a warning rather than corrupting the selection. A pane's `actions` carry the same navigation methods a command diff --git a/packages/hunk/src/app/delegatedReview.test.ts b/packages/hunk/src/app/delegatedReview.test.ts index 5d24260f1..145dde8d2 100644 --- a/packages/hunk/src/app/delegatedReview.test.ts +++ b/packages/hunk/src/app/delegatedReview.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import type { ExtensionReviewDescriptor } from "../extension-api/types"; -import { reviewDescriptorAfterReload } from "./delegatedReview"; +import { reviewDescriptorAfterReload, reviewDescriptorResourceCwd } from "./delegatedReview"; const review: ExtensionReviewDescriptor = { kind: "change-request", @@ -8,9 +8,28 @@ const review: ExtensionReviewDescriptor = { title: "PR title", id: "#123", }; +const commitReview: ExtensionReviewDescriptor = { + kind: "commit", + provider: "Git", + title: "Commit title", + revision: "abc1234", +}; const patch = (file?: string) => ({ kind: "patch" as const, file, options: {} }); describe("delegated review reload identity", () => { + test("uses the repository root for VCS reviews launched from a subdirectory", () => { + expect( + reviewDescriptorResourceCwd( + { kind: "show", ref: "revision-a", options: { vcs: "git" } }, + "/repo/packages/example", + "/repo", + ), + ).toBe("/repo"); + expect( + reviewDescriptorResourceCwd(patch("review.diff"), "/repo/packages/example", "/repo"), + ).toBe("/repo/packages/example"); + }); + test("preserves metadata while refreshing the same patch path", () => { expect( reviewDescriptorAfterReload( @@ -45,6 +64,69 @@ describe("delegated review reload identity", () => { expect(reviewDescriptorAfterReload(patch("-"), "/", review, patch("-"), "/")).toBeUndefined(); }); + test("preserves history commit metadata only for the same provider review request", () => { + const show = (ref: string, vcs = "git") => ({ + kind: "show" as const, + ref, + options: { vcs }, + }); + const range = (from: string, to: string, vcs = "git") => ({ + kind: "vcs" as const, + rangeEndpoints: { from, to }, + staged: false, + options: { vcs }, + }); + + expect( + reviewDescriptorAfterReload( + show("revision-a"), + "/repo", + commitReview, + show("revision-a"), + "/repo", + ), + ).toBe(commitReview); + expect( + reviewDescriptorAfterReload( + range("parent-a", "revision-a"), + "/repo", + commitReview, + range("parent-a", "revision-a"), + "/repo", + ), + ).toBe(commitReview); + expect( + reviewDescriptorAfterReload( + show("revision-a"), + "/repo", + commitReview, + show("revision-b"), + "/repo", + ), + ).toBeUndefined(); + expect( + reviewDescriptorAfterReload( + show("revision-a"), + "/repo", + commitReview, + show("revision-a", "jj"), + "/repo", + ), + ).toBeUndefined(); + }); + + test("does not preserve change-request metadata onto a VCS review", () => { + expect( + reviewDescriptorAfterReload( + { kind: "show", ref: "revision-a", options: { vcs: "git" } }, + "/repo", + review, + { kind: "show", ref: "revision-a", options: { vcs: "git" } }, + "/repo", + ), + ).toBeUndefined(); + }); + test("does not invent metadata for ordinary patches", () => { expect( reviewDescriptorAfterReload( diff --git a/packages/hunk/src/app/delegatedReview.ts b/packages/hunk/src/app/delegatedReview.ts index 8311dd7ed..d6dbf4fbf 100644 --- a/packages/hunk/src/app/delegatedReview.ts +++ b/packages/hunk/src/app/delegatedReview.ts @@ -2,6 +2,7 @@ import { resolve } from "node:path"; import type { ExtensionReviewDescriptor } from "../extension-api/types"; import { resolveCanonicalPath } from "../core/run/paths"; import type { CliInput } from "../core/run/commandInputs"; +import { isVcsReviewInput } from "../core/vcs"; /** Resolve the file identity already used by session reload bounds. */ function patchFileIdentity(input: CliInput, cwd: string): string | undefined { @@ -9,11 +10,38 @@ function patchFileIdentity(input: CliInput, cwd: string): string | undefined { return resolveCanonicalPath(resolve(cwd, input.file)); } +/** Use the repository-root source label as stable VCS identity across subdirectory reloads. */ +export function reviewDescriptorResourceCwd( + input: CliInput, + startupCwd: string, + sourceLabel: string, +): string { + return isVcsReviewInput(input) ? sourceLabel : startupCwd; +} + +/** Resolve one exact provider review identity used by commits opened from interactive history. */ +function historyCommitInputIdentity(input: CliInput, cwd: string): string | undefined { + const root = resolveCanonicalPath(cwd); + if (input.kind === "show" && input.ref && !input.pathspecs?.length) { + return JSON.stringify([root, input.options.vcs ?? null, "show", input.ref]); + } + if (input.kind === "vcs" && input.rangeEndpoints && !input.pathspecs?.length) { + return JSON.stringify([ + root, + input.options.vcs ?? null, + "range", + input.rangeEndpoints.from, + input.rangeEndpoints.to, + ]); + } + return undefined; +} + /** - * Preserve delegated review metadata only while reloading the same patch resource. + * Preserve review metadata only while reloading the same underlying review resource. * - * The canonical patch path is the reload boundary's existing input identity: changes to the file - * refresh the same remote review, while a different or non-file input starts an unrelated review. + * File-backed delegated patches use their canonical path. History-selected commits use the exact + * provider review request so refresh cannot transfer their identity to another revision or backend. */ export function reviewDescriptorAfterReload( previousInput: CliInput, @@ -23,8 +51,14 @@ export function reviewDescriptorAfterReload( nextCwd: string, ): ExtensionReviewDescriptor | undefined { if (!previousReview) return undefined; - const previousIdentity = patchFileIdentity(previousInput, previousCwd); - return previousIdentity && previousIdentity === patchFileIdentity(nextInput, nextCwd) + const previousPatchIdentity = patchFileIdentity(previousInput, previousCwd); + if (previousPatchIdentity && previousPatchIdentity === patchFileIdentity(nextInput, nextCwd)) { + return previousReview; + } + if (previousReview.kind !== "commit") return undefined; + const previousCommitIdentity = historyCommitInputIdentity(previousInput, previousCwd); + return previousCommitIdentity && + previousCommitIdentity === historyCommitInputIdentity(nextInput, nextCwd) ? previousReview : undefined; } diff --git a/packages/hunk/src/core/bootstrap.ts b/packages/hunk/src/core/bootstrap.ts index 80013de5d..8e5288180 100644 --- a/packages/hunk/src/core/bootstrap.ts +++ b/packages/hunk/src/core/bootstrap.ts @@ -55,7 +55,7 @@ export interface AppBootstrap { initialCopyDecorations?: boolean; initialCursorLine?: CursorLine; startupNotices?: readonly StartupNotice[]; - /** Validated metadata attached only by an extension-delegated patch review. */ + /** Validated metadata describing a delegated or history-selected review source. */ review?: ExtensionReviewDescriptor; viewPreferencesConfigPath?: string; /** The user's `[keybindings]` table, resolved against command defaults in App. */ diff --git a/packages/hunk/src/core/reviewDescriptor.test.ts b/packages/hunk/src/core/reviewDescriptor.test.ts index 3d46a51d3..8d1b47344 100644 --- a/packages/hunk/src/core/reviewDescriptor.test.ts +++ b/packages/hunk/src/core/reviewDescriptor.test.ts @@ -24,8 +24,13 @@ describe("delegated review descriptor validation", () => { provider: "GitHub", title: "Commit", revision: "abc1234", + authoredAt: "2026-01-01T00:00:00Z", }), - ).toMatchObject({ kind: "commit", revision: "abc1234" }); + ).toMatchObject({ + kind: "commit", + revision: "abc1234", + authoredAt: "2026-01-01T00:00:00Z", + }); expect( validateExtensionReviewDescriptor({ kind: "comparison", @@ -44,6 +49,13 @@ describe("delegated review descriptor validation", () => { { ...review, url: "http://github.com/modem-dev/hunk/pull/123" }, { ...review, title: "é".repeat(1025) }, { ...review, repository: "x".repeat(513) }, + { + kind: "commit", + provider: "GitHub", + title: "Commit", + revision: "abc1234", + authoredAt: "yesterday", + }, { ...review, provider: "p".repeat(256), diff --git a/packages/hunk/src/core/reviewDescriptor.ts b/packages/hunk/src/core/reviewDescriptor.ts index 2f8c170ff..313ac7a61 100644 --- a/packages/hunk/src/core/reviewDescriptor.ts +++ b/packages/hunk/src/core/reviewDescriptor.ts @@ -11,6 +11,7 @@ const REVIEW_DESCRIPTOR_FIELD_LIMITS = Object.freeze({ base: 512, head: 512, revision: 512, + authoredAt: 128, }); /** Measure a public descriptor string in transport bytes rather than UTF-16 code units. */ @@ -92,7 +93,7 @@ export function validateExtensionReviewDescriptor(value: unknown): ExtensionRevi kind === "change-request" ? ["id", "repository", "author", "base", "head", "state", "draft"] : kind === "commit" - ? ["revision", "author"] + ? ["revision", "author", "authoredAt"] : ["base", "head"]; const allowed = new Set([...common, ...kindFields]); const ownKeys = Reflect.ownKeys(value); @@ -130,6 +131,14 @@ export function validateExtensionReviewDescriptor(value: unknown): ExtensionRevi ...(draft === undefined ? {} : { draft }), }; } else if (kind === "commit") { + const authoredAt = validateDescriptorString(candidate, "authoredAt", false); + if ( + authoredAt !== undefined && + (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(authoredAt) || + Number.isNaN(Date.parse(authoredAt))) + ) { + throw new Error("delegate review authoredAt must be an ISO timestamp"); + } descriptor = { kind, provider, @@ -137,6 +146,7 @@ export function validateExtensionReviewDescriptor(value: unknown): ExtensionRevi ...(url === undefined ? {} : { url }), revision: validateDescriptorString(candidate, "revision", true)!, ...copyOptionalDescriptorFields(candidate, ["author"]), + ...(authoredAt === undefined ? {} : { authoredAt }), }; } else { descriptor = { diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index f716e31a4..8e55b3b4c 100644 --- a/packages/hunk/src/extension-api/types.ts +++ b/packages/hunk/src/extension-api/types.ts @@ -21,7 +21,7 @@ * Extensions can branch on `hunk.apiVersion` so a newer Hunk can keep loading * older extensions without guessing at their expectations. */ -export const HUNK_EXTENSION_API_VERSION = 19; +export const HUNK_EXTENSION_API_VERSION = 20; export type HunkExtensionApiVersion = typeof HUNK_EXTENSION_API_VERSION; export type ExtensionNotifyType = "info" | "warning" | "error"; @@ -1117,6 +1117,8 @@ export interface ExtensionPaintTheme { border: string; accent: string; accentMuted: string; + /** Bright foreground for clickable copy affordances. */ + copyAction: string; text: string; muted: string; /** Background highlighting the selected row or hunk. */ @@ -1180,6 +1182,8 @@ export interface ExtensionReviewNavigation { * Actions stay valid for as long as the component is mounted. */ export interface ExtensionPaneActions extends ExtensionReviewNavigation { + /** Copy text through the terminal clipboard integration, returning false when unavailable. */ + copyText(text: string): boolean; /** Show one toast, attributed to the owning extension. */ notify(message: string, type?: ExtensionNotifyType): void; } @@ -1257,7 +1261,7 @@ export interface ExtensionCurrentLinePaint { /** Immutable state used to decide whether an open pane is meaningful this frame. */ export interface ExtensionPaneAvailabilityContext { readonly placement: ExtensionPanePlacement; - /** Immutable delegated review metadata, or null for ordinary reviews. */ + /** Immutable review-source metadata, or null for ordinary reviews. */ readonly review: ExtensionReviewDescriptor | null; readonly files: readonly ExtensionDiffFile[]; readonly selectedFileId: string | null; @@ -1267,7 +1271,7 @@ export interface ExtensionPaneAvailabilityContext { /** Everything a custom pane component receives, refreshed as the app changes. */ export interface ExtensionPaneProps { - /** Immutable delegated review metadata, or null for ordinary reviews. */ + /** Immutable review-source metadata, or null for ordinary reviews. */ readonly review: ExtensionReviewDescriptor | null; readonly files: readonly ExtensionDiffFile[]; readonly selectedFileId: string | null; @@ -1426,6 +1430,8 @@ export interface ExtensionCommitReviewDescriptor extends ExtensionReviewDescript /** Provider revision identifier. */ readonly revision: string; readonly author?: string; + /** ISO timestamp used for relative commit time when available. */ + readonly authoredAt?: string; } /** Metadata for one comparison between two provider refs. */ @@ -1435,7 +1441,7 @@ export interface ExtensionComparisonReviewDescriptor extends ExtensionReviewDesc readonly head: string; } -/** Bounded provider-neutral metadata attached to an extension-delegated patch review. */ +/** Bounded provider-neutral metadata describing a delegated or history-selected review. */ export type ExtensionReviewDescriptor = | ExtensionChangeRequestReviewDescriptor | ExtensionCommitReviewDescriptor diff --git a/packages/hunk/src/extensions/default/ui/index.test.ts b/packages/hunk/src/extensions/default/ui/index.test.ts index 1d925701d..c02761d46 100644 --- a/packages/hunk/src/extensions/default/ui/index.test.ts +++ b/packages/hunk/src/extensions/default/ui/index.test.ts @@ -27,6 +27,21 @@ describe("bundled UI registry", () => { currentLine: null, }), ).toBeTrue(); + expect( + reviewInfo.available?.({ + review: { + kind: "commit", + provider: "GitHub", + title: "Commit title", + revision: "abc1234", + }, + placement: "top", + files: [], + selectedFileId: null, + selectedHunkIndex: null, + currentLine: null, + }), + ).toBeTrue(); expect( reviewInfo.available?.({ review: null, diff --git a/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx b/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx index 228793e8a..1195a8894 100644 --- a/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx @@ -1,8 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, mock, test } from "bun:test"; import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import { capturedTestColorToHex } from "../../../../../../../test/helpers/test-color-helpers"; import type { ExtensionPaneProps } from "../../../../extension-api/types"; +import { toExtensionPaintTheme } from "../../../../ui/lib/extensionPaintTheme"; import { resolveTheme } from "../../../../ui/themes"; import { ReviewInfoPane } from "."; import { reviewInfoLines } from "./presentation"; @@ -36,8 +37,9 @@ function backgroundsAtColumn( } describe("ReviewInfoPane", () => { - test("separates review chrome with an accent rail and panel background", async () => { - const theme = resolveTheme("github-dark-default", null); + test("separates review chrome with the diff's thin accent rail and panel background", async () => { + const appTheme = resolveTheme("github-dark-default", null); + const theme = toExtensionPaintTheme(appTheme); const width = 30; const setup = await testRender( { }); expect(backgroundsAtColumn(setup, 0)).toEqual([ theme.panel.toLowerCase(), - theme.accent.toLowerCase(), - theme.accent.toLowerCase(), + theme.panel.toLowerCase(), + theme.panel.toLowerCase(), ]); + for (const line of setup.captureSpans().lines.slice(1, 3)) { + const rail = line.spans.find((span) => span.text === "▌"); + expect(capturedTestColorToHex(rail?.fg)).toBe(theme.accent.toLowerCase()); + } expect(backgroundsAtColumn(setup, 1)).toEqual([ theme.panel.toLowerCase(), theme.panel.toLowerCase(), @@ -84,25 +90,76 @@ describe("ReviewInfoPane", () => { } }); + test("renders commit metadata with the same panel chrome", async () => { + const appTheme = resolveTheme("github-dark-default", null); + const theme = toExtensionPaintTheme(appTheme); + const width = 60; + const copyText = mock(() => true); + const setup = await testRender( + , + { width, height: 3 }, + ); + + try { + await act(async () => { + await setup.renderOnce(); + }); + const frame = setup.captureCharFrame(); + expect(frame).toContain("Render selected commit metadata"); + expect(frame).toContain("octocat · 10 hours ago"); + expect(frame.split("\n")[1]?.trimEnd()).toEndWith("abc1234 ⧉"); + expect(frame).not.toContain("GitHub"); + const revisionSpan = setup + .captureSpans() + .lines[1]?.spans.find((span) => span.text.includes("abc1234")); + expect(capturedTestColorToHex(revisionSpan?.fg)).toBe(theme.fileRenamed.toLowerCase()); + const copySpan = setup.captureSpans().lines[1]?.spans.find((span) => span.text === "⧉"); + expect(capturedTestColorToHex(copySpan?.fg)).toBe(appTheme.lineNumberFg.toLowerCase()); + await act(async () => setup.mockMouse.click(width - 2, 1)); + expect(copyText).toHaveBeenCalledWith("abc1234"); + expect(backgroundsAtColumn(setup, 0).slice(1)).toEqual([ + theme.panel.toLowerCase(), + theme.panel.toLowerCase(), + ]); + } finally { + setup.renderer.destroy(); + } + }); + test("keeps the border deterministic when no metadata text fits", async () => { - const theme = resolveTheme("github-dark-default", null); + const theme = toExtensionPaintTheme(resolveTheme("github-dark-default", null)); const setup = await testRender( , - { width: 1, height: 3 }, + { width: 3, height: 3 }, ); try { await act(async () => { await setup.renderOnce(); }); - expect(setup.captureCharFrame().split("\n").slice(0, 3)).toEqual(["─", " ", " "]); + expect(setup.captureCharFrame().split("\n").slice(0, 3)).toEqual(["───", "▌ ", "▌ "]); } finally { setup.renderer.destroy(); } diff --git a/packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx b/packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx index 5478bc762..61b77bbfa 100644 --- a/packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/index.tsx @@ -1,14 +1,50 @@ -import type { ReactNode } from "react"; +import type { MouseEvent as TuiMouseEvent } from "@opentui/core"; +import { useEffect, useState, type ReactNode } from "react"; import type { ExtensionFactory } from "../../../types"; -import type { ExtensionPaneProps } from "../../../../extension-api/types"; -import { reviewInfoLines } from "./presentation"; +import type { + ExtensionPaneProps, + ExtensionReviewDescriptor, +} from "../../../../extension-api/types"; +import { diffRailMarker } from "../../../../ui/diff/rowStyle"; +import { reviewInfoContent } from "./presentation"; export const BUNDLED_REVIEW_INFO_VIEW_ID = "review-info"; -/** Render delegated change-request identity above the review without duplicating diff facts. */ -export function ReviewInfoPane({ review, theme, width }: ExtensionPaneProps): ReactNode { - if (review?.kind !== "change-request") return null; - const [primary, secondary] = reviewInfoLines(review, Math.max(0, width - 3)); +/** Report whether the bundled pane has a concise projection for this review kind. */ +function supportsReviewInfo( + review: ExtensionReviewDescriptor | null, +): review is Extract { + return review?.kind === "change-request" || review?.kind === "commit"; +} + +/** Render change-request or commit identity above the review without duplicating diff facts. */ +export function ReviewInfoPane({ actions, review, theme, width }: ExtensionPaneProps): ReactNode { + const [now, setNow] = useState(() => Date.now()); + const commitAuthoredAt = review?.kind === "commit" ? review.authoredAt : undefined; + useEffect(() => { + if (!commitAuthoredAt) return; + setNow(Date.now()); + const timer = setInterval(() => setNow(Date.now()), 60_000); + return () => clearInterval(timer); + }, [commitAuthoredAt]); + + if (!supportsReviewInfo(review)) return null; + const { primary, secondary, trailing } = reviewInfoContent(review, Math.max(0, width - 3), now); + if (width <= 1) { + return ( + + + ─ + + + {diffRailMarker()} + + + {diffRailMarker()} + + + ); + } return ( - - - {primary} - {secondary} + + + {diffRailMarker()} + + + {diffRailMarker()} + + {width > 1 ? ( + = 2 ? 1 : 0, + paddingRight: width >= 3 ? 1 : 0, + flexDirection: "column", + backgroundColor: theme.panel, + }} + > + + {primary} + {trailing && review.kind === "commit" ? ( + { + event.stopPropagation(); + actions.copyText(review.revision); + }} + > + {trailing} + + + ) : null} + + {secondary} + + ) : null} ); } -/** Register the provider-neutral delegated change-request summary pane. */ +/** Register the provider-neutral review summary pane. */ const registerBundledReviewInfo: ExtensionFactory = (hunk) => { hunk.registerPane({ id: BUNDLED_REVIEW_INFO_VIEW_ID, @@ -49,7 +115,7 @@ const registerBundledReviewInfo: ExtensionFactory = (hunk) => { placement: "top", height: { preferred: 3, min: 3, max: 3 }, defaultOpen: true, - available: ({ review }) => review?.kind === "change-request", + available: ({ review }) => supportsReviewInfo(review), component: ReviewInfoPane, }); }; diff --git a/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts index d7e526c98..704699a68 100644 --- a/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { fitReviewInfoText, reviewInfoLines, sanitizeReviewInfoText } from "./presentation"; +import { + fitReviewInfoText, + reviewInfoContent, + reviewInfoLines, + sanitizeReviewInfoText, +} from "./presentation"; const review = { kind: "change-request" as const, @@ -24,6 +29,45 @@ describe("review info presentation", () => { ); }); + test("formats commit identity and provider facts into two lines", () => { + const content = reviewInfoContent( + { + kind: "commit", + provider: "GitHub", + title: "Render commit review metadata", + revision: "abc1234", + author: "octocat", + authoredAt: "2026-01-01T00:00:00Z", + }, + 200, + Date.parse("2026-01-01T10:00:00Z"), + ); + expect(content).toEqual({ + primary: "Render commit review metadata", + secondary: "octocat · 10 hours ago", + trailing: "abc1234", + }); + }); + + test("caps a long commit id so narrow layouts retain the title", () => { + expect( + reviewInfoContent( + { + kind: "commit", + provider: "Git", + title: "Visible title", + revision: "1234567890abcdef", + author: "ada", + }, + 20, + ), + ).toEqual({ + primary: "Visible ti…", + secondary: "ada", + trailing: "123456…", + }); + }); + test("omits unknown state while preserving explicit draft identity", () => { const { state: _state, ...withoutState } = review; expect(reviewInfoLines(withoutState, 200)[0]).toBe("#123 · Add delegated review metadata"); diff --git a/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts index 4845de90b..eeff30b68 100644 --- a/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts @@ -1,4 +1,8 @@ -import type { ExtensionChangeRequestReviewDescriptor } from "../../../../extension-api/types"; +import type { + ExtensionChangeRequestReviewDescriptor, + ExtensionCommitReviewDescriptor, +} from "../../../../extension-api/types"; +import { formatHistoryRelativeTime } from "../../../../ui/log/formatting"; import { measureClusterWidth, textClusters } from "../../../../ui/lib/text"; /** Collapse unsafe or layout-changing provider text into one deterministic terminal line. */ @@ -29,22 +33,69 @@ export function fitReviewInfoText(value: string, width: number): string { return `${fitted}…`; } -/** Derive the two concise rows rendered by the bundled change-request pane. */ -export function reviewInfoLines( - review: ExtensionChangeRequestReviewDescriptor, - width: number, -): readonly [string, string] { - const state = review.draft ? "DRAFT" : review.state?.toUpperCase(); - const first = [state, review.id, review.title] +type ReviewInfoDescriptor = + | ExtensionChangeRequestReviewDescriptor + | ExtensionCommitReviewDescriptor; + +/** Join one metadata row after sanitizing optional provider fields. */ +function reviewInfoRow(values: readonly (string | undefined)[]) { + return values .filter((value): value is string => typeof value === "string" && value.length > 0) .map(sanitizeReviewInfoText) .filter(Boolean) .join(" · "); +} + +export interface ReviewInfoContent { + primary: string; + secondary: string; + /** Right-aligned identity on the primary row. */ + trailing?: string; +} + +/** Measure sanitized display text in terminal cells. */ +function reviewInfoTextWidth(value: string) { + return textClusters(value).reduce((sum, cluster) => sum + measureClusterWidth(cluster), 0); +} + +/** Derive the concise rows and optional right edge rendered by the review-info pane. */ +export function reviewInfoContent( + review: ReviewInfoDescriptor, + width: number, + now = Date.now(), +): ReviewInfoContent { + if (review.kind === "commit") { + const trailingWidth = Math.max(0, Math.min(width, Math.floor(width * 0.35))); + const trailing = fitReviewInfoText(review.revision, trailingWidth); + // Reserve one cell each for the gap before the id and its adjacent copy action. + const primaryWidth = Math.max(0, width - reviewInfoTextWidth(trailing) - (trailing ? 2 : 0)); + const relativeTime = review.authoredAt + ? formatHistoryRelativeTime(review.authoredAt, now) + : undefined; + return { + primary: fitReviewInfoText(review.title, primaryWidth), + secondary: fitReviewInfoText(reviewInfoRow([review.author, relativeTime]), width), + ...(trailing ? { trailing } : {}), + }; + } + + const state = review.draft ? "DRAFT" : review.state?.toUpperCase(); const refs = review.base && review.head ? `${review.base} ← ${review.head}` : undefined; - const second = [review.author, review.provider, review.repository, refs] - .filter((value): value is string => typeof value === "string" && value.length > 0) - .map(sanitizeReviewInfoText) - .filter(Boolean) - .join(" · "); - return [fitReviewInfoText(first, width), fitReviewInfoText(second, width)]; + return { + primary: fitReviewInfoText(reviewInfoRow([state, review.id, review.title]), width), + secondary: fitReviewInfoText( + reviewInfoRow([review.author, review.provider, review.repository, refs]), + width, + ), + }; +} + +/** Return only the two left-aligned rows for callers that do not paint the trailing identity. */ +export function reviewInfoLines( + review: ReviewInfoDescriptor, + width: number, + now = Date.now(), +): readonly [string, string] { + const content = reviewInfoContent(review, width, now); + return [content.primary, content.secondary]; } diff --git a/packages/hunk/src/session/types.ts b/packages/hunk/src/session/types.ts index 10fd87560..ff2fde650 100644 --- a/packages/hunk/src/session/types.ts +++ b/packages/hunk/src/session/types.ts @@ -66,7 +66,7 @@ export interface HunkSessionInfo { title: string; sourceLabel: string; experimentalFeatures?: ExperimentalFeature[]; - /** Provider-neutral metadata attached by the CLI extension that delegated this review. */ + /** Provider-neutral metadata describing the review source. */ review?: ExtensionReviewDescriptor; files: SessionReviewFile[]; /** diff --git a/packages/hunk/src/ui/App.tsx b/packages/hunk/src/ui/App.tsx index 1f3ad5030..60ed1e954 100644 --- a/packages/hunk/src/ui/App.tsx +++ b/packages/hunk/src/ui/App.tsx @@ -1253,6 +1253,18 @@ export function App({ showTopChrome={showMenuBar} keybindings={paneKeybindings} notify={(message, type) => extensions?.context.notify(message, type)} + onCopyText={(text) => { + if ( + !renderer.isOsc52Supported?.() || + typeof renderer.copyToClipboardOSC52 !== "function" + ) { + showTransientNotice("Clipboard is unavailable in this terminal."); + return false; + } + renderer.copyToClipboardOSC52(text); + showTransientNotice("Copied text to clipboard"); + return true; + }} onSelectFile={(fileId) => { focusFiles(); jumpToFile(fileId, { alignFileHeaderTop: true }); diff --git a/packages/hunk/src/ui/AppHost.review-metadata.test.tsx b/packages/hunk/src/ui/AppHost.review-metadata.test.tsx index 29ec30dd3..3bfa868ff 100644 --- a/packages/hunk/src/ui/AppHost.review-metadata.test.tsx +++ b/packages/hunk/src/ui/AppHost.review-metadata.test.tsx @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, test } from "bun:test"; @@ -7,6 +7,7 @@ import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import { SESSION_BROKER_REGISTRATION_VERSION } from "@hunk/session-broker-core"; import { createWatchTestRuntime } from "../../../../test/helpers/watchTest"; +import { getBundledVcsCatalog } from "../app/vcsCatalog"; import type { AppBootstrap } from "../core/bootstrap"; import { loadAppBootstrap } from "../core/changeset/loaders"; import type { HunkSessionBrokerClient } from "../session/broker/brokerClient"; @@ -91,6 +92,57 @@ async function createTestBootstrap({ watch = false }: { watch?: boolean } = {}) return { bootstrap, directory, firstPatch, secondPatch }; } +/** Create a commit review launched below its repository root. */ +async function createHistoryCommitBootstrap() { + const directory = mkdtempSync(join(tmpdir(), "hunk-review-commit-host-")); + execFileSync("git", ["init", "-b", "main"], { cwd: directory, stdio: "ignore" }); + const file = join(directory, "example.txt"); + writeFileSync(file, "before\n"); + execFileSync("git", ["add", "example.txt"], { cwd: directory, stdio: "ignore" }); + execFileSync( + "git", + [ + "-c", + "user.name=History Tester", + "-c", + "user.email=history@example.com", + "commit", + "-m", + "Before", + ], + { cwd: directory, stdio: "ignore" }, + ); + writeFileSync(file, "after\n"); + execFileSync( + "git", + [ + "-c", + "user.name=History Tester", + "-c", + "user.email=history@example.com", + "commit", + "-am", + "After", + ], + { cwd: directory, stdio: "ignore" }, + ); + const nested = join(directory, "nested"); + mkdirSync(nested); + const bootstrap = await loadAppBootstrap( + { kind: "show", ref: "HEAD", options: { mode: "stack", vcs: "git" } }, + { cwd: nested, vcsCatalog: getBundledVcsCatalog() }, + ); + bootstrap.review = Object.freeze({ + kind: "commit", + provider: "Git", + title: "After", + revision: "abc1234", + author: "history", + authoredAt: "2026-01-01T00:00:00Z", + }); + return { bootstrap, directory }; +} + /** Settle mounted host work until a committed bootstrap observation arrives. */ async function flushUntil( setup: Awaited>, @@ -107,8 +159,8 @@ async function flushUntil( if (!predicate()) throw new Error(`Timed out waiting for ${description}.`); } -describe("delegated review metadata reloads", () => { - test("the bundled review pane occupies exactly three rows only for delegated change requests", async () => { +describe("review metadata reloads", () => { + test("the bundled review pane occupies exactly three rows only for supported metadata", async () => { const delegated = await createTestBootstrap(); const ordinary = await createTestBootstrap(); delete ordinary.bootstrap.review; @@ -211,6 +263,30 @@ describe("delegated review metadata reloads", () => { } }); + test("manual refresh preserves history metadata when launched from a repository subdirectory", async () => { + const fixture = await createHistoryCommitBootstrap(); + const committed: AppBootstrap[] = []; + const setup = await testRender( + committed.push(bootstrap)} + />, + { width: 100, height: 12 }, + ); + + try { + await flushUntil(setup, () => committed.length === 1, "the commit review to mount"); + await act(async () => setup.mockInput.typeText("r")); + await flushUntil(setup, () => committed.length >= 2, "the commit refresh to commit"); + expect(committed.at(-1)?.review).toBe(fixture.bootstrap.review); + expect(setup.captureCharFrame()).toContain("After"); + expect(setup.captureCharFrame()).toContain("history ·"); + } finally { + await act(async () => setup.renderer.destroy()); + rmSync(fixture.directory, { recursive: true, force: true }); + } + }); + test("watch refresh preserves delegated metadata for the same patch resource", async () => { const fixture = await createTestBootstrap({ watch: true }); const committed: AppBootstrap[] = []; diff --git a/packages/hunk/src/ui/AppHost.tsx b/packages/hunk/src/ui/AppHost.tsx index 5743634e4..cd5a2760f 100644 --- a/packages/hunk/src/ui/AppHost.tsx +++ b/packages/hunk/src/ui/AppHost.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { resolveConfiguredExtensions } from "../app/extensionBootstrap"; import { ReviewProducer } from "../app/review/producer"; -import { reviewDescriptorAfterReload } from "../app/delegatedReview"; +import { reviewDescriptorAfterReload, reviewDescriptorResourceCwd } from "../app/delegatedReview"; import { loadConfiguredSessionBootstrap } from "../app/sessionBootstrap"; import { getBundledVcsCatalog } from "../app/vcsCatalog"; import { restoreFileLanguageRegistrations } from "../core/changeset/fileLanguage"; @@ -114,7 +114,11 @@ export function AppHost({ const [activeBootstrap, setActiveBootstrap] = useState(initialBootstrap); const reviewIdentityRef = useRef({ input: initialBootstrap.input, - cwd: initialBootstrap.reloadContext.cwd, + cwd: reviewDescriptorResourceCwd( + initialBootstrap.input, + initialBootstrap.reloadContext.cwd, + initialBootstrap.changeset.sourceLabel, + ), review: initialBootstrap.review, }); const [producer] = useState( @@ -322,17 +326,23 @@ export function AppHost({ } let nextBootstrap!: AppBootstrap; + let nextReviewCwd!: string; let nextSnapshot!: ReturnType; let sessionId = "local-session"; try { const { applied, bootstrap, input: reloadInput, sessionVcs } = loaded; nextBootstrap = bootstrap; + nextReviewCwd = reviewDescriptorResourceCwd( + nextBootstrap.input, + cwd, + nextBootstrap.changeset.sourceLabel, + ); const preservedReview = reviewDescriptorAfterReload( reviewIdentityRef.current.input, reviewIdentityRef.current.cwd, reviewIdentityRef.current.review, nextBootstrap.input, - cwd, + nextReviewCwd, ); if (preservedReview) nextBootstrap.review = preservedReview; if (extensions) { @@ -398,7 +408,7 @@ export function AppHost({ reviewIdentityRef.current = { input: nextBootstrap.input, - cwd, + cwd: nextReviewCwd, review: nextBootstrap.review, }; setActiveBootstrap(nextBootstrap); diff --git a/packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx b/packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx index 88525ad84..5da5cc8f9 100644 --- a/packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx +++ b/packages/hunk/src/ui/components/panes/ExtensionPane.test.tsx @@ -106,6 +106,7 @@ describe("ExtensionPaneHost actions", () => { const files = createTestFiles(); const theme = resolveTheme("github-dark-default", null); const notifications: string[] = []; + const copied: string[] = []; const hunkSelections: Array<[string, number]> = []; let actions: ExtensionPaneActions | undefined; @@ -127,6 +128,10 @@ describe("ExtensionPaneHost actions", () => { currentLine={null} keybindings={TEST_KEYBINDINGS} notify={(message) => notifications.push(message)} + onCopyText={(text) => { + copied.push(text); + return true; + }} onSelectFile={() => {}} onSelectHunk={(fileId, hunkIndex) => hunkSelections.push([fileId, hunkIndex])} onRevealLine={() => "line"} @@ -136,6 +141,9 @@ describe("ExtensionPaneHost actions", () => { throw new Error("The probe view never received its actions."); } + expect(actions.copyText("revision-a")).toBeTrue(); + expect(copied).toEqual(["revision-a"]); + // Selection state, reveal scrolling, and selection_changed all carry // the index, so a non-finite value must be refused outright... actions.selectHunk("alpha", Number.NaN); diff --git a/packages/hunk/src/ui/components/panes/ExtensionPane.tsx b/packages/hunk/src/ui/components/panes/ExtensionPane.tsx index 651996dc8..348a15999 100644 --- a/packages/hunk/src/ui/components/panes/ExtensionPane.tsx +++ b/packages/hunk/src/ui/components/panes/ExtensionPane.tsx @@ -90,6 +90,7 @@ export interface ExtensionPaneHostProps { showTopChrome?: boolean; keybindings: ExtensionPaneKeybindings; notify: ExtensionNotifySink; + onCopyText?: (text: string) => boolean; onSelectFile: (fileId: string) => void; onSelectHunk: (fileId: string, hunkIndex: number) => void; onRevealLine: (fileId: string, side: "old" | "new", line: number) => "line" | "hunk" | "none"; @@ -112,6 +113,7 @@ function ExtensionPaneHostView({ showTopChrome = false, keybindings, notify, + onCopyText, onSelectFile, onSelectHunk, onRevealLine, @@ -122,8 +124,8 @@ function ExtensionPaneHostView({ // Selection rerenders the pane host, but it does not replace the capabilities these callbacks // represent. Keep the public actions stable so memoized extension rows do not all repaint when // only the selected file changed; ref indirection still invokes the latest host generation. - const actionTargetsRef = useRef({ notify, onSelectFile, onSelectHunk, onRevealLine }); - actionTargetsRef.current = { notify, onSelectFile, onSelectHunk, onRevealLine }; + const actionTargetsRef = useRef({ notify, onCopyText, onSelectFile, onSelectHunk, onRevealLine }); + actionTargetsRef.current = { notify, onCopyText, onSelectFile, onSelectHunk, onRevealLine }; const actions = useMemo( () => Object.freeze({ @@ -137,6 +139,9 @@ function ExtensionPaneHostView({ onRevealLine: (fileId, side, line) => actionTargetsRef.current.onRevealLine(fileId, side, line), }), + copyText(text: string) { + return actionTargetsRef.current.onCopyText?.(text) ?? false; + }, notify(message: string, type: ExtensionNotifyType = "info") { actionTargetsRef.current.notify(`${extensionId}: ${message}`, type); }, diff --git a/packages/hunk/src/ui/components/panes/FileListItem.tsx b/packages/hunk/src/ui/components/panes/FileListItem.tsx index a3b57b4c3..95f96b63b 100644 --- a/packages/hunk/src/ui/components/panes/FileListItem.tsx +++ b/packages/hunk/src/ui/components/panes/FileListItem.tsx @@ -1,6 +1,7 @@ import { MouseButton, type MouseEvent as TuiMouseEvent } from "@opentui/core"; import { memo } from "react"; import type { ExtensionSidebarTheme } from "../../../extension-api/types"; +import { diffRailMarker } from "../../diff/rowStyle"; import { fileRowId } from "../../lib/ids"; import { sidebarEntryStats, @@ -188,13 +189,9 @@ export const FileListItem = memo(function FileListItem({ }} onMouseUp={() => onSelectFile(entry.id)} > - + + {selected ? diffRailMarker() : " "} + { width={30} keybindings={{ matches: () => false, getKeys: () => [] }} actions={{ + copyText: () => false, selectFile: () => {}, selectHunk: () => {}, revealLine: () => {}, @@ -544,7 +545,7 @@ describe("UI components", () => { expect(frame).toContain("./"); expect(frame).toContain(" zzz-root.ts"); expect(frame.indexOf("src/ui/")).toBeLessThan(frame.indexOf("./")); - expect(frame).toContain(" App.tsx"); + expect(frame).toContain("▌ M App.tsx"); expect(frame).toContain(" MenuDropdown.tsx"); expect(frame).toContain(" signature.ts"); expect(frame).toContain("*1 +2 -1"); @@ -563,6 +564,7 @@ describe("UI components", () => { ]); const sharedProps = { actions: { + copyText: () => false, selectFile: () => {}, selectHunk: () => {}, revealLine: () => {}, diff --git a/packages/hunk/src/ui/lib/extensionPaintTheme.ts b/packages/hunk/src/ui/lib/extensionPaintTheme.ts index abe61630b..2888f44ee 100644 --- a/packages/hunk/src/ui/lib/extensionPaintTheme.ts +++ b/packages/hunk/src/ui/lib/extensionPaintTheme.ts @@ -11,6 +11,7 @@ export function toExtensionPaintTheme(theme: AppTheme): ExtensionPaintTheme { border: theme.border, accent: theme.accent, accentMuted: theme.accentMuted, + copyAction: theme.copyAction, text: theme.text, muted: theme.muted, selectedHunk: theme.selectedHunk, diff --git a/packages/hunk/src/ui/log/colorPolicy.test.ts b/packages/hunk/src/ui/log/colorPolicy.test.ts index df47826f2..ce94d8428 100644 --- a/packages/hunk/src/ui/log/colorPolicy.test.ts +++ b/packages/hunk/src/ui/log/colorPolicy.test.ts @@ -25,8 +25,8 @@ describe("interactive log color policy", () => { separator: selected.lineNumberFg, relativeTime: selected.muted, decoration: selected.addedSignColor, - commitId: selected.accent, - copyAction: selected.lineNumberFg, + commitId: selected.fileRenamed, + copyAction: selected.copyAction, graphLanes: [ selected.accent, selected.addedSignColor, @@ -35,6 +35,7 @@ describe("interactive log color policy", () => { selected.noteBorder, ], }); + expect(selected.copyAction).toBe(selected.lineNumberFg); expect(palette.decoration).not.toBe(palette.commitId); expect(new Set(Object.values(palette).flat()).size).toBeGreaterThanOrEqual(5); }); diff --git a/packages/hunk/src/ui/log/colorPolicy.ts b/packages/hunk/src/ui/log/colorPolicy.ts index 1ed68902b..98082ca86 100644 --- a/packages/hunk/src/ui/log/colorPolicy.ts +++ b/packages/hunk/src/ui/log/colorPolicy.ts @@ -24,8 +24,8 @@ export function resolveInteractiveLogPalette(theme: AppTheme): InteractiveLogPal separator: theme.lineNumberFg, relativeTime: theme.muted, decoration: theme.addedSignColor, - commitId: theme.accent, - copyAction: theme.lineNumberFg, + commitId: theme.fileRenamed, + copyAction: theme.copyAction, graphLanes: [ theme.accent, theme.addedSignColor, @@ -62,6 +62,7 @@ export function monochromeLogTheme(theme: AppTheme, terminalMode: ThemeMode): Ap border: foreground, accent: foreground, accentMuted: selection, + copyAction: foreground, text: foreground, muted: foreground, addedBg: background, diff --git a/packages/hunk/src/ui/session/HunkSessionHost.test.tsx b/packages/hunk/src/ui/session/HunkSessionHost.test.tsx index 031b66656..02d2396fb 100644 --- a/packages/hunk/src/ui/session/HunkSessionHost.test.tsx +++ b/packages/hunk/src/ui/session/HunkSessionHost.test.tsx @@ -117,7 +117,17 @@ test("routes repeated history reviews through fresh runtimes and returns instead await act(async () => setup.mockInput.pressEnter()); await settle(setup); - expect(setup.captureCharFrame()).not.toContain("History row"); + const reviewFrame = setup.captureCharFrame(); + expect(reviewFrame).not.toContain("Commits on"); + expect(reviewFrame).toContain("History row"); + expect( + reviewFrame + .split("\n") + .find((line) => line.includes("History row")) + ?.trimEnd(), + ).toEndWith("revision ⧉"); + expect(reviewFrame).toContain("Ada ·"); + expect(reviewFrame).not.toContain("Ada · Test"); await act(async () => setup.mockInput.pressKey("q")); await settle(setup); expect(setup.captureCharFrame()).toContain("History row"); diff --git a/packages/hunk/src/ui/session/HunkSessionHost.tsx b/packages/hunk/src/ui/session/HunkSessionHost.tsx index 1814885ec..dd7ea9785 100644 --- a/packages/hunk/src/ui/session/HunkSessionHost.tsx +++ b/packages/hunk/src/ui/session/HunkSessionHost.tsx @@ -10,6 +10,7 @@ import { } from "../../app/session/reviewRuntime"; import type { StartupNotice } from "../../core/process/startupNotice"; import type { AppBootstrap } from "../../core/bootstrap"; +import { parseExtensionReviewDescriptor } from "../../core/reviewDescriptor"; import type { ExtensionSession } from "../../extensions/session"; import type { ExtensionLoadResult } from "../../extensions/types"; import { AppHost } from "../AppHost"; @@ -17,6 +18,7 @@ import type { HistoryRuntime } from "../history/types"; import { interactiveLogUsesColor } from "../log/colorPolicy"; import { LogApp, type LogAppOutcome } from "../log/LogApp"; import type { LogController } from "../log/controller"; +import { resolveHistoryAuthorLabel } from "../log/formatting"; export interface HistorySurfaceRoute { kind: "history"; @@ -48,6 +50,22 @@ export interface HunkSessionHostDeps { createReviewRuntime?: typeof createReviewSessionRuntime; } +/** Describe the selected history commit with bounded metadata shared by every review surface. */ +function historyCommitReviewDescriptor( + runtime: HistoryRuntime, + outcome: Extract, +) { + const review = parseExtensionReviewDescriptor({ + kind: "commit", + provider: runtime.providerName, + title: outcome.commit.subject, + revision: outcome.commit.displayId, + author: resolveHistoryAuthorLabel(outcome.commit), + authoredAt: outcome.commit.authoredAt, + }); + return review?.kind === "commit" ? review : undefined; +} + /** * Route retained history and fresh review surfaces inside one stable React root. * @@ -181,6 +199,8 @@ export function HunkSessionHost({ themeMode: outcome.themeMode, }; plan = await prepareReview(request, { signal }); + const commitReview = historyCommitReviewDescriptor(historyRoute.runtime, outcome); + if (commitReview) plan.bootstrap.review = commitReview; if (!plan.bootstrap.extensions) { throw new Error("Embedded review startup did not provide extension authority."); } diff --git a/packages/hunk/src/ui/themes.ts b/packages/hunk/src/ui/themes.ts index 13ffaa372..8e17a5774 100644 --- a/packages/hunk/src/ui/themes.ts +++ b/packages/hunk/src/ui/themes.ts @@ -256,6 +256,7 @@ function buildShikiTheme(themeId: BundledShikiThemeId): AppTheme { removedSignColor, lineNumberBg: editorBackground, lineNumberFg: lineNumberForeground, + copyAction: lineNumberForeground, selectedHunk: blendHex(modifiedColor, editorBackground, selectedTint), noteBackground: neutralPanel, noteBorder: modifiedColor, @@ -321,6 +322,7 @@ function buildCustomTheme(customTheme: NamedCustomThemeConfig) { removedSignColor: customTheme.removedSignColor ?? baseTheme.removedSignColor, lineNumberBg: customTheme.lineNumberBg ?? baseTheme.lineNumberBg, lineNumberFg: customTheme.lineNumberFg ?? baseTheme.lineNumberFg, + copyAction: customTheme.lineNumberFg ?? baseTheme.copyAction, selectedHunk: customTheme.selectedHunk ?? baseTheme.selectedHunk, badgeAdded: customTheme.badgeAdded ?? baseTheme.badgeAdded, badgeRemoved: customTheme.badgeRemoved ?? baseTheme.badgeRemoved, diff --git a/packages/hunk/src/ui/themes/types.ts b/packages/hunk/src/ui/themes/types.ts index 0b5b0da84..783733773 100644 --- a/packages/hunk/src/ui/themes/types.ts +++ b/packages/hunk/src/ui/themes/types.ts @@ -8,6 +8,8 @@ export interface AppTheme { border: string; accent: string; accentMuted: string; + /** Bright foreground for clickable copy affordances. */ + copyAction: string; text: string; muted: string; addedBg: string; diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index 53834281d..c4cbeb4b5 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -162,6 +162,9 @@ describe("interactive hunk log", () => { timeout: 15_000, }); expect(review).toContain("history.ts"); + expect(review).toMatch(/Second history commit.*[0-9a-f]{8}\s+⧉/); + expect(review).toMatch(/history · (?:in .*|.* ago)/); + expect(review).not.toContain("history · Git"); expect(session.getRawOutput().slice(transitionOutputStart)).not.toContain("\x1b[?1049l"); const returnOutputStart = session.getRawOutput().length; diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index d7c3ae087..a9874f5c2 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -47,7 +47,7 @@ export default function (hunk: HunkExtensionAPI) { `fraction` opts into live responsive sizing until the user drags the divider. It must be greater than `0` and at most `1`; Hunk rounds that fraction of the full host body width or height to a terminal cell, then applies `min`, `max`, and the space required by the review. `preferred` remains the fixed-cell target when `fraction` is omitted. A divider drag establishes a session-local cell override: later terminal shrink may clamp it temporarily, and expanding restores it. Panes without `fraction` retain their fixed preferred startup size. Folder extensions that use `fraction` should declare `"hunk": { "apiVersion": 12 }` in their manifest. -Use `defaultOpen` to open a pane initially, `replaces: "hunk:files"` to replace it (and override `defaultOpen`), or `available(context)` to hide it conditionally. One pane may replace each named target; the first registration owns that slot and later claims are skipped with a warning. `replaces` may also name another pane by its fully qualified `":"` key, and Hunk follows those replacement chains. Both `available(context)` and the mounted component receive `review`: immutable delegated review metadata, or `null` for ordinary reviews. Hunk's bundled `hunk:review-info` top pane uses it for change requests and consumes no rows when absent. Pane extensions that read `review` should declare `"hunk": { "apiVersion": 17 }` in their manifest so older Hunk versions refuse them cleanly instead of mounting with an incomplete prop contract. +Use `defaultOpen` to open a pane initially, `replaces: "hunk:files"` to replace it (and override `defaultOpen`), or `available(context)` to hide it conditionally. One pane may replace each named target; the first registration owns that slot and later claims are skipped with a warning. `replaces` may also name another pane by its fully qualified `":"` key, and Hunk follows those replacement chains. Both `available(context)` and the mounted component receive `review`: immutable metadata from a delegated review or interactive history selection, or `null` for ordinary reviews. Hunk's bundled `hunk:review-info` top pane uses it for change requests and commits and consumes no rows when absent. Pane extensions that read `review` should declare `"hunk": { "apiVersion": 17 }` in their manifest so older Hunk versions refuse them cleanly instead of mounting with an incomplete prop contract. `onActivate()` observes a primary mouse press anywhere in the pane's content, including content nested in a ``. Use it to focus an extension-owned editor or update pane-local active state without adding mouse handlers to every row. Hunk does not stop propagation or prevent the press, so extension-local mouse behavior can continue. Other mouse buttons do not activate the pane. A thrown or rejected callback is contained and reported as an attributed warning. @@ -65,7 +65,7 @@ The component receives fresh props as the app changes: | Prop | What it is | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `review` | immutable delegated review metadata (`change-request`, `commit`, or `comparison`), or `null` for ordinary reviews | +| `review` | immutable review-source metadata (`change-request`, `commit`, or `comparison`), or `null` for ordinary reviews | | `files` | the visible reviewed files, review-stream order, filtered, frozen views (each carries `changeType`, `statsTruncated`, and `hunks` summaries beside the usual file fields) | | `selectedFileId` | the selected file, or `null` | | `selectedHunkIndex` | the selected hunk within that file, or `null` | @@ -77,7 +77,7 @@ The component receives fresh props as the app changes: | `keybindings` | the current command bindings, resolved from defaults and the user's `[keybindings]` table | | `actions` | guarded navigation and notifications the pane may trigger | -`actions.selectFile(fileId)` and `actions.selectHunk(fileId, hunkIndex)` route through the same review controller as the built-in files pane and the keyboard shortcuts, so the review stream scrolls, selection updates, and the `selection_changed` event fires exactly as if the user had clicked a built-in row. `actions.notify(message, type?)` shows a toast attributed to your extension. An action given a file id that is not currently visible is refused with a warning rather than corrupting the selection. +`actions.selectFile(fileId)` and `actions.selectHunk(fileId, hunkIndex)` route through the same review controller as the built-in files pane and the keyboard shortcuts, so the review stream scrolls, selection updates, and the `selection_changed` event fires exactly as if the user had clicked a built-in row. `actions.copyText(text)` uses the terminal's OSC 52 clipboard integration and returns `false` when unavailable. `actions.notify(message, type?)` shows a toast attributed to your extension. An action given a file id that is not currently visible is refused with a warning rather than corrupting the selection. The three hunk surfaces line up by design: each file's `hunks` lists public `ExtensionDiffHunk` summaries (`index`, the `@@` header, inclusive old/new line spans) in render order, `selectedHunkIndex` reports the same index, and `actions.selectHunk(fileId, hunkIndex)` accepts it. That is everything a hunk checklist, a per-hunk progress view, or an agent-annotation navigator needs — match an annotation's `oldRange`/`newRange` against the summaries' spans to find its hunk — without touching the opaque `metadata`. diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index 85bf6ad5e..f2015b04d 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -7,8 +7,10 @@ The extension factory receives one API object. Registration calls are only valid ## `hunk.apiVersion` -The API generation this Hunk speaks (currently `18`). Branch on it if you want -one file to support several Hunk versions. Version 18 lets lifecycle and custom-event handlers request a host-owned review reload; version 17 adds structured review metadata to delegated +The API generation this Hunk speaks (currently `20`). Branch on it if you want +one file to support several Hunk versions. Version 20 adds optional commit timestamps to review +metadata and pane clipboard actions; version 19 adds provider-owned history enumeration and review planning; version 18 lets +lifecycle and custom-event handlers request a host-owned review reload; version 17 adds structured review metadata to delegated patch commands and projects it into pane availability and component props; version 16 adds pane-wide `onActivate`; version 15 added `{ side, line }` to opted-in pane `currentLine` paint; version 14 added structured two-revision @@ -66,11 +68,14 @@ to a built-in Hunk command. A delegated `patch` command may also carry a provide `review` descriptor whose exact shape is `change-request`, `commit`, or `comparison`. Hunk bounds all strings and the 4 KiB payload, rejects control characters, unknown fields, and unsafe URLs, then copies and freezes it. `provider` and change-request `id` allow 256 bytes; `repository`, -`author`, `base`, `head`, and `revision` allow 512; `title` and `url` allow 2 KiB. Change requests -may also carry `state` (`open`, `closed`, or `merged`) and boolean `draft`. The descriptor remains app-bootstrap metadata rather than entering +`author`, `base`, `head`, and `revision` allow 512; `authoredAt` allows 128; `title` and `url` +allow 2 KiB. Change requests may also carry `state` (`open`, `closed`, or `merged`) and boolean +`draft`; commits may carry an ISO `authoredAt` timestamp. The descriptor remains app-bootstrap metadata rather than entering changeset transforms or `ReviewDocumentV1`; same-file refreshes preserve it, while unrelated -reloads clear it. Live-session list, context, and review JSON snapshots project the same optional -bounded descriptor without granting provider or remote-reload capabilities. Exit results and +reloads clear it. Commits opened from interactive `hunk log` receive the same metadata shape and +retain it while refreshing the exact provider review request. Live-session list, context, and review +JSON snapshots project the same optional bounded descriptor without granting provider or +remote-reload capabilities. Exit results and non-`patch` delegation cannot carry one. Delegation cannot follow stdout output or any stdin read, target another extension command, or @@ -160,7 +165,7 @@ Full contract: [VCS adapters](/docs/extend/vcs-adapters/). ## `hunk.registerPane(pane)` -Render a React component on the `left`, `right`, `top`, or `bottom` of the review. Panes receive their dimensions, review state, actions, keybindings, and optional current-line paint (including `{ side, line }` when opted in). `props.review` and `available(context).review` expose immutable metadata supplied by a delegated patch command, or `null` for ordinary reviews. `registerSidebarView` remains a deprecated alias. +Render a React component on the `left`, `right`, `top`, or `bottom` of the review. Panes receive their dimensions, review state, actions, keybindings, and optional current-line paint (including `{ side, line }` when opted in). `props.review` and `available(context).review` expose immutable metadata from a delegated patch command or interactive history selection, or `null` for ordinary reviews. `registerSidebarView` remains a deprecated alias. Full contract: [Custom panes](/docs/extend/custom-sidebars/). From c2a247308095af7008d9b9445b85198c41ab7a4f Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Sun, 6 Sep 2026 23:54:39 -0400 Subject: [PATCH 2/6] refactor(core): clarify review timestamp validation --- packages/hunk/src/core/reviewDescriptor.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/hunk/src/core/reviewDescriptor.ts b/packages/hunk/src/core/reviewDescriptor.ts index 313ac7a61..b6f942e83 100644 --- a/packages/hunk/src/core/reviewDescriptor.ts +++ b/packages/hunk/src/core/reviewDescriptor.ts @@ -132,12 +132,8 @@ export function validateExtensionReviewDescriptor(value: unknown): ExtensionRevi }; } else if (kind === "commit") { const authoredAt = validateDescriptorString(candidate, "authoredAt", false); - if ( - authoredAt !== undefined && - (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(authoredAt) || - Number.isNaN(Date.parse(authoredAt))) - ) { - throw new Error("delegate review authoredAt must be an ISO timestamp"); + if (authoredAt !== undefined && Number.isNaN(Date.parse(authoredAt))) { + throw new Error("delegate review authoredAt must be a valid timestamp"); } descriptor = { kind, From c881bef23c3e0e54e5b9de16bb49ef31573d3b8b Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 7 Sep 2026 00:17:20 -0400 Subject: [PATCH 3/6] fix(ui): preserve full history revision metadata --- .changeset/commit-review-info.md | 2 +- docs/extensions.md | 7 ++++--- packages/hunk/src/app/delegatedReview.ts | 6 +++--- packages/hunk/src/extension-api/types.ts | 2 +- .../default/ui/reviewInfo/index.test.tsx | 11 ++++++----- .../default/ui/reviewInfo/presentation.test.ts | 15 +++++++++++++++ .../default/ui/reviewInfo/presentation.ts | 7 ++++++- packages/hunk/src/ui/AppHost.tsx | 4 ++-- .../hunk/src/ui/session/HunkSessionHost.test.tsx | 2 +- packages/hunk/src/ui/session/HunkSessionHost.tsx | 2 +- test/pty/log-integration.test.ts | 2 +- .../content/docs/docs/extend/custom-sidebars.md | 4 ++-- .../src/content/docs/docs/extend/extension-api.md | 4 ++-- 13 files changed, 45 insertions(+), 23 deletions(-) diff --git a/.changeset/commit-review-info.md b/.changeset/commit-review-info.md index 1662c3300..35460fc44 100644 --- a/.changeset/commit-review-info.md +++ b/.changeset/commit-review-info.md @@ -2,4 +2,4 @@ "hunkdiff": minor --- -Show history-style commit metadata in the review-info panel when opening a commit from interactive `hunk log`, with a copyable right-aligned revision and relative author time. +Show history-style commit metadata in the review-info panel when opening a commit from interactive `hunk log`, with a copyable right-aligned revision and public pane clipboard/theme support. diff --git a/docs/extensions.md b/docs/extensions.md index 314fef85d..da974f9e4 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -304,7 +304,7 @@ and retires the replaced instance at that explicit ownership boundary. The API generation this Hunk speaks (currently `20`). Branch on it if you want one file to support several Hunk versions. Version 20 adds optional commit timestamps to review -metadata and pane clipboard actions; version 19 adds provider-owned history +metadata, pane clipboard actions, and the `theme.copyAction` paint token; version 19 adds provider-owned history enumeration and review planning; version 18 lets lifecycle and custom-event handlers request a host-owned review reload; version 17 adds structured review metadata to delegated patch commands and projects it into pane availability and component props; version 16 adds pane-wide @@ -373,7 +373,7 @@ characters, invalid types, unsafe URLs, fields over their byte limits, and descr then copies and freezes the accepted value. `provider` and change-request `id` allow 256 bytes; `repository`, `author`, `base`, `head`, and `revision` allow 512; `authoredAt` allows 128; `title` and `url` allow 2 KiB. Change requests may also carry `state` (`open`, `closed`, or -`merged`) and boolean `draft`; commits may carry an ISO `authoredAt` timestamp. Exit results and delegation to any built-in other than +`merged`) and boolean `draft`; commits may carry a parseable `authoredAt` date-time. Exit results and delegation to any built-in other than `patch` cannot carry review metadata. An ordinary `hunk patch` has no descriptor. The descriptor describes the review source rather than its diff contents: it stays on the app @@ -936,7 +936,8 @@ API-v3 sidebar names remain as deprecated aliases: use `registerPane`, controller as the built-in files pane and the keyboard shortcuts, so the review stream scrolls, selection updates, and the `selection_changed` event fires exactly as if the user had clicked a built-in row. `actions.copyText(text)` uses the terminal's -OSC 52 clipboard integration and returns `false` when unavailable. `actions.notify(message, +OSC 52 clipboard integration and returns `false` when unavailable. Extensions that call it or read +`theme.copyAction` should declare `"hunk": { "apiVersion": 20 }` in their manifest. `actions.notify(message, type?)` shows a toast attributed to your extension. An action given a file id that is not currently visible is refused with a warning rather than corrupting the selection. A pane's `actions` carry the same navigation methods a command diff --git a/packages/hunk/src/app/delegatedReview.ts b/packages/hunk/src/app/delegatedReview.ts index d6dbf4fbf..bf49a375c 100644 --- a/packages/hunk/src/app/delegatedReview.ts +++ b/packages/hunk/src/app/delegatedReview.ts @@ -10,13 +10,13 @@ function patchFileIdentity(input: CliInput, cwd: string): string | undefined { return resolveCanonicalPath(resolve(cwd, input.file)); } -/** Use the repository-root source label as stable VCS identity across subdirectory reloads. */ +/** Use the authoritative repository root as stable VCS identity across subdirectory reloads. */ export function reviewDescriptorResourceCwd( input: CliInput, startupCwd: string, - sourceLabel: string, + repoRoot: string | undefined, ): string { - return isVcsReviewInput(input) ? sourceLabel : startupCwd; + return isVcsReviewInput(input) ? (repoRoot ?? startupCwd) : startupCwd; } /** Resolve one exact provider review identity used by commits opened from interactive history. */ diff --git a/packages/hunk/src/extension-api/types.ts b/packages/hunk/src/extension-api/types.ts index 8e55b3b4c..92ef972c8 100644 --- a/packages/hunk/src/extension-api/types.ts +++ b/packages/hunk/src/extension-api/types.ts @@ -1430,7 +1430,7 @@ export interface ExtensionCommitReviewDescriptor extends ExtensionReviewDescript /** Provider revision identifier. */ readonly revision: string; readonly author?: string; - /** ISO timestamp used for relative commit time when available. */ + /** Date-time string used for relative commit time when available. */ readonly authoredAt?: string; } diff --git a/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx b/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx index 1195a8894..d4f60c1f6 100644 --- a/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/index.test.tsx @@ -90,7 +90,8 @@ describe("ReviewInfoPane", () => { } }); - test("renders commit metadata with the same panel chrome", async () => { + test("renders commit metadata with the same panel chrome and copies its full revision", async () => { + const fullRevision = "0123456789abcdef0123456789abcdef01234567"; const appTheme = resolveTheme("github-dark-default", null); const theme = toExtensionPaintTheme(appTheme); const width = 60; @@ -103,7 +104,7 @@ describe("ReviewInfoPane", () => { kind: "commit", provider: "GitHub", title: "Render selected commit metadata", - revision: "abc1234", + revision: fullRevision, author: "octocat", authoredAt: new Date(Date.now() - 10 * 60 * 60 * 1_000).toISOString(), }, @@ -122,16 +123,16 @@ describe("ReviewInfoPane", () => { const frame = setup.captureCharFrame(); expect(frame).toContain("Render selected commit metadata"); expect(frame).toContain("octocat · 10 hours ago"); - expect(frame.split("\n")[1]?.trimEnd()).toEndWith("abc1234 ⧉"); + expect(frame.split("\n")[1]?.trimEnd()).toEndWith("0123456789abcdef01… ⧉"); expect(frame).not.toContain("GitHub"); const revisionSpan = setup .captureSpans() - .lines[1]?.spans.find((span) => span.text.includes("abc1234")); + .lines[1]?.spans.find((span) => span.text.includes("0123456789abcdef01…")); expect(capturedTestColorToHex(revisionSpan?.fg)).toBe(theme.fileRenamed.toLowerCase()); const copySpan = setup.captureSpans().lines[1]?.spans.find((span) => span.text === "⧉"); expect(capturedTestColorToHex(copySpan?.fg)).toBe(appTheme.lineNumberFg.toLowerCase()); await act(async () => setup.mockMouse.click(width - 2, 1)); - expect(copyText).toHaveBeenCalledWith("abc1234"); + expect(copyText).toHaveBeenCalledWith(fullRevision); expect(backgroundsAtColumn(setup, 0).slice(1)).toEqual([ theme.panel.toLowerCase(), theme.panel.toLowerCase(), diff --git a/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts index 704699a68..b5d3f1c70 100644 --- a/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.test.ts @@ -68,6 +68,21 @@ describe("review info presentation", () => { }); }); + test("keeps the title instead of showing an unusable revision at tiny widths", () => { + const commit = { + kind: "commit" as const, + provider: "Git", + title: "Title", + revision: "1234567890abcdef", + }; + expect(reviewInfoContent(commit, 6)).toEqual({ primary: "Title", secondary: "" }); + expect(reviewInfoContent(commit, 12)).toEqual({ + primary: "Title", + secondary: "", + trailing: "123…", + }); + }); + test("omits unknown state while preserving explicit draft identity", () => { const { state: _state, ...withoutState } = review; expect(reviewInfoLines(withoutState, 200)[0]).toBe("#123 · Add delegated review metadata"); diff --git a/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts index eeff30b68..151f3a787 100644 --- a/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts +++ b/packages/hunk/src/extensions/default/ui/reviewInfo/presentation.ts @@ -5,6 +5,8 @@ import type { import { formatHistoryRelativeTime } from "../../../../ui/log/formatting"; import { measureClusterWidth, textClusters } from "../../../../ui/lib/text"; +const MIN_COMMIT_REVISION_DISPLAY_WIDTH = 4; + /** Collapse unsafe or layout-changing provider text into one deterministic terminal line. */ export function sanitizeReviewInfoText(value: string): string { return value @@ -66,7 +68,10 @@ export function reviewInfoContent( ): ReviewInfoContent { if (review.kind === "commit") { const trailingWidth = Math.max(0, Math.min(width, Math.floor(width * 0.35))); - const trailing = fitReviewInfoText(review.revision, trailingWidth); + const trailing = + trailingWidth >= MIN_COMMIT_REVISION_DISPLAY_WIDTH && width - trailingWidth - 2 >= 1 + ? fitReviewInfoText(review.revision, trailingWidth) + : ""; // Reserve one cell each for the gap before the id and its adjacent copy action. const primaryWidth = Math.max(0, width - reviewInfoTextWidth(trailing) - (trailing ? 2 : 0)); const relativeTime = review.authoredAt diff --git a/packages/hunk/src/ui/AppHost.tsx b/packages/hunk/src/ui/AppHost.tsx index cd5a2760f..6d710be77 100644 --- a/packages/hunk/src/ui/AppHost.tsx +++ b/packages/hunk/src/ui/AppHost.tsx @@ -117,7 +117,7 @@ export function AppHost({ cwd: reviewDescriptorResourceCwd( initialBootstrap.input, initialBootstrap.reloadContext.cwd, - initialBootstrap.changeset.sourceLabel, + initialBootstrap.reloadContext.repoRoot, ), review: initialBootstrap.review, }); @@ -335,7 +335,7 @@ export function AppHost({ nextReviewCwd = reviewDescriptorResourceCwd( nextBootstrap.input, cwd, - nextBootstrap.changeset.sourceLabel, + nextBootstrap.reloadContext.repoRoot, ); const preservedReview = reviewDescriptorAfterReload( reviewIdentityRef.current.input, diff --git a/packages/hunk/src/ui/session/HunkSessionHost.test.tsx b/packages/hunk/src/ui/session/HunkSessionHost.test.tsx index 02d2396fb..2ea4824ee 100644 --- a/packages/hunk/src/ui/session/HunkSessionHost.test.tsx +++ b/packages/hunk/src/ui/session/HunkSessionHost.test.tsx @@ -125,7 +125,7 @@ test("routes repeated history reviews through fresh runtimes and returns instead .split("\n") .find((line) => line.includes("History row")) ?.trimEnd(), - ).toEndWith("revision ⧉"); + ).toEndWith("revision-a ⧉"); expect(reviewFrame).toContain("Ada ·"); expect(reviewFrame).not.toContain("Ada · Test"); await act(async () => setup.mockInput.pressKey("q")); diff --git a/packages/hunk/src/ui/session/HunkSessionHost.tsx b/packages/hunk/src/ui/session/HunkSessionHost.tsx index dd7ea9785..ee66d4661 100644 --- a/packages/hunk/src/ui/session/HunkSessionHost.tsx +++ b/packages/hunk/src/ui/session/HunkSessionHost.tsx @@ -59,7 +59,7 @@ function historyCommitReviewDescriptor( kind: "commit", provider: runtime.providerName, title: outcome.commit.subject, - revision: outcome.commit.displayId, + revision: outcome.commit.revisionId, author: resolveHistoryAuthorLabel(outcome.commit), authoredAt: outcome.commit.authoredAt, }); diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index c4cbeb4b5..30276d3e0 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -162,7 +162,7 @@ describe("interactive hunk log", () => { timeout: 15_000, }); expect(review).toContain("history.ts"); - expect(review).toMatch(/Second history commit.*[0-9a-f]{8}\s+⧉/); + expect(review).toMatch(/Second history commit.*[0-9a-f]{8,}…\s+⧉/); expect(review).toMatch(/history · (?:in .*|.* ago)/); expect(review).not.toContain("history · Git"); expect(session.getRawOutput().slice(transitionOutputStart)).not.toContain("\x1b[?1049l"); diff --git a/website/src/content/docs/docs/extend/custom-sidebars.md b/website/src/content/docs/docs/extend/custom-sidebars.md index a9874f5c2..b5d8789b1 100644 --- a/website/src/content/docs/docs/extend/custom-sidebars.md +++ b/website/src/content/docs/docs/extend/custom-sidebars.md @@ -75,9 +75,9 @@ The component receives fresh props as the app changes: | `currentLine` | selected-row painter plus `{ side, line }` when the registration opts in, otherwise `null` | | `theme` | hex color tokens from the active theme, updated on theme switch | | `keybindings` | the current command bindings, resolved from defaults and the user's `[keybindings]` table | -| `actions` | guarded navigation and notifications the pane may trigger | +| `actions` | guarded navigation, clipboard, and notification actions the pane may trigger | -`actions.selectFile(fileId)` and `actions.selectHunk(fileId, hunkIndex)` route through the same review controller as the built-in files pane and the keyboard shortcuts, so the review stream scrolls, selection updates, and the `selection_changed` event fires exactly as if the user had clicked a built-in row. `actions.copyText(text)` uses the terminal's OSC 52 clipboard integration and returns `false` when unavailable. `actions.notify(message, type?)` shows a toast attributed to your extension. An action given a file id that is not currently visible is refused with a warning rather than corrupting the selection. +`actions.selectFile(fileId)` and `actions.selectHunk(fileId, hunkIndex)` route through the same review controller as the built-in files pane and the keyboard shortcuts, so the review stream scrolls, selection updates, and the `selection_changed` event fires exactly as if the user had clicked a built-in row. `actions.copyText(text)` uses the terminal's OSC 52 clipboard integration and returns `false` when unavailable. Extensions that call it or read `theme.copyAction` should declare `"hunk": { "apiVersion": 20 }` in their manifest. `actions.notify(message, type?)` shows a toast attributed to your extension. An action given a file id that is not currently visible is refused with a warning rather than corrupting the selection. The three hunk surfaces line up by design: each file's `hunks` lists public `ExtensionDiffHunk` summaries (`index`, the `@@` header, inclusive old/new line spans) in render order, `selectedHunkIndex` reports the same index, and `actions.selectHunk(fileId, hunkIndex)` accepts it. That is everything a hunk checklist, a per-hunk progress view, or an agent-annotation navigator needs — match an annotation's `oldRange`/`newRange` against the summaries' spans to find its hunk — without touching the opaque `metadata`. diff --git a/website/src/content/docs/docs/extend/extension-api.md b/website/src/content/docs/docs/extend/extension-api.md index f2015b04d..ec251825e 100644 --- a/website/src/content/docs/docs/extend/extension-api.md +++ b/website/src/content/docs/docs/extend/extension-api.md @@ -9,7 +9,7 @@ The extension factory receives one API object. Registration calls are only valid The API generation this Hunk speaks (currently `20`). Branch on it if you want one file to support several Hunk versions. Version 20 adds optional commit timestamps to review -metadata and pane clipboard actions; version 19 adds provider-owned history enumeration and review planning; version 18 lets +metadata, pane clipboard actions, and the `theme.copyAction` paint token; version 19 adds provider-owned history enumeration and review planning; version 18 lets lifecycle and custom-event handlers request a host-owned review reload; version 17 adds structured review metadata to delegated patch commands and projects it into pane availability and component props; version 16 adds pane-wide `onActivate`; version 15 added `{ side, line }` to opted-in pane `currentLine` @@ -70,7 +70,7 @@ all strings and the 4 KiB payload, rejects control characters, unknown fields, a then copies and freezes it. `provider` and change-request `id` allow 256 bytes; `repository`, `author`, `base`, `head`, and `revision` allow 512; `authoredAt` allows 128; `title` and `url` allow 2 KiB. Change requests may also carry `state` (`open`, `closed`, or `merged`) and boolean -`draft`; commits may carry an ISO `authoredAt` timestamp. The descriptor remains app-bootstrap metadata rather than entering +`draft`; commits may carry a parseable `authoredAt` date-time. The descriptor remains app-bootstrap metadata rather than entering changeset transforms or `ReviewDocumentV1`; same-file refreshes preserve it, while unrelated reloads clear it. Commits opened from interactive `hunk log` receive the same metadata shape and retain it while refreshing the exact provider review request. Live-session list, context, and review From 073ef7bedf70e54af29efb871094ef4c4d39a794 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 7 Sep 2026 08:31:45 -0400 Subject: [PATCH 4/6] test(ui): stabilize history review integration --- packages/hunk/src/ui/AppHost.review-metadata.test.tsx | 9 ++++++++- test/pty/log-integration.test.ts | 8 +++++--- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/hunk/src/ui/AppHost.review-metadata.test.tsx b/packages/hunk/src/ui/AppHost.review-metadata.test.tsx index 3bfa868ff..a8a08bd37 100644 --- a/packages/hunk/src/ui/AppHost.review-metadata.test.tsx +++ b/packages/hunk/src/ui/AppHost.review-metadata.test.tsx @@ -283,7 +283,14 @@ describe("review metadata reloads", () => { expect(setup.captureCharFrame()).toContain("history ·"); } finally { await act(async () => setup.renderer.destroy()); - rmSync(fixture.directory, { recursive: true, force: true }); + // Windows can retain the Git fixture as a child-process cwd briefly after renderer teardown. + if (process.platform === "win32") await Bun.sleep(100); + rmSync(fixture.directory, { + recursive: true, + force: true, + maxRetries: process.platform === "win32" ? 5 : 0, + retryDelay: 100, + }); } }); diff --git a/test/pty/log-integration.test.ts b/test/pty/log-integration.test.ts index 30276d3e0..96ed170fb 100644 --- a/test/pty/log-integration.test.ts +++ b/test/pty/log-integration.test.ts @@ -169,9 +169,11 @@ describe("interactive hunk log", () => { const returnOutputStart = session.getRawOutput().length; await session.press("q"); - const returned = await session.waitForText(/Second history commit/, { - timeout: 15_000, - }); + const returned = await harness.waitForSnapshot( + session, + (text) => text.includes("Second history commit") && text.includes("Enter open"), + 15_000, + ); expect(returned).toContain("Enter open"); expect(session.getRawOutput().slice(returnOutputStart)).not.toContain("\x1b[?1049l"); From 193f8335cd930b73b52ca1d12db0bdcbed1915cc Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 7 Sep 2026 08:39:47 -0400 Subject: [PATCH 5/6] test(ui): avoid locked Windows fixture cleanup --- .../hunk/src/ui/AppHost.review-metadata.test.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/hunk/src/ui/AppHost.review-metadata.test.tsx b/packages/hunk/src/ui/AppHost.review-metadata.test.tsx index a8a08bd37..4d9683a60 100644 --- a/packages/hunk/src/ui/AppHost.review-metadata.test.tsx +++ b/packages/hunk/src/ui/AppHost.review-metadata.test.tsx @@ -283,14 +283,11 @@ describe("review metadata reloads", () => { expect(setup.captureCharFrame()).toContain("history ·"); } finally { await act(async () => setup.renderer.destroy()); - // Windows can retain the Git fixture as a child-process cwd briefly after renderer teardown. - if (process.platform === "win32") await Bun.sleep(100); - rmSync(fixture.directory, { - recursive: true, - force: true, - maxRetries: process.platform === "win32" ? 5 : 0, - retryDelay: 100, - }); + // Bun can retain the Git fixture as a child-process cwd past renderer teardown on Windows; + // the ephemeral CI/user temp directory owns cleanup there. + if (process.platform !== "win32") { + rmSync(fixture.directory, { recursive: true, force: true }); + } } }); From a818f6767da43525c6cd329167deb67470225b35 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Mon, 7 Sep 2026 08:47:33 -0400 Subject: [PATCH 6/6] test(ui): await asynchronous review reloads --- packages/hunk/src/ui/AppHost.review-metadata.test.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/hunk/src/ui/AppHost.review-metadata.test.tsx b/packages/hunk/src/ui/AppHost.review-metadata.test.tsx index 4d9683a60..35b4e1a0e 100644 --- a/packages/hunk/src/ui/AppHost.review-metadata.test.tsx +++ b/packages/hunk/src/ui/AppHost.review-metadata.test.tsx @@ -149,10 +149,11 @@ async function flushUntil( predicate: () => boolean, description: string, ) { - for (let attempt = 0; attempt < 30 && !predicate(); attempt++) { + const deadline = Date.now() + 5_000; + while (!predicate() && Date.now() < deadline) { await act(async () => { await setup.renderOnce(); - await Promise.resolve(); + await Bun.sleep(10); await setup.renderOnce(); }); }