diff --git a/.gitignore b/.gitignore index bbeec4a..c34aae7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,4 +39,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json tmp .hunk/latest.json .hunk/config.toml +.hunk/pi-selection.json .pi/ diff --git a/src/ui/App.tsx b/src/ui/App.tsx index a010d6a..2e7d136 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -38,6 +38,7 @@ import { buildAppMenus } from "./lib/appMenus"; import { buildFileListEntry } from "./lib/files"; import { buildHunkCursors, findNextHunkCursor } from "./lib/hunks"; import { fileRowId } from "./lib/ids"; +import { buildPiSelectionPayload, writePiSelectionPayload } from "./lib/piSelection"; import { resolveResponsiveLayout } from "./lib/responsive"; import { resizeSidebarWidth } from "./lib/sidebar"; import { resolveTheme, THEMES } from "./themes"; @@ -97,7 +98,9 @@ function AppShell({ const [dismissedAgentNoteIds, setDismissedAgentNoteIds] = useState([]); const [selectedFileId, setSelectedFileId] = useState(bootstrap.changeset.files[0]?.id ?? ""); const [selectedHunkIndex, setSelectedHunkIndex] = useState(0); + const [statusMessage, setStatusMessage] = useState(); const deferredFilter = useDeferredValue(filter); + const statusMessageTimeoutRef = useRef | null>(null); const pagerMode = Boolean(bootstrap.input.options.pager); const activeTheme = resolveTheme(themeId, renderer.themeMode); @@ -211,6 +214,14 @@ function AppShell({ renderer.intermediateRender(); }, [renderer, resolvedLayout, showFilesPane, terminal.height, terminal.width]); + useEffect(() => { + return () => { + if (statusMessageTimeoutRef.current) { + clearTimeout(statusMessageTimeoutRef.current); + } + }; + }, []); + useEffect(() => { if (!selectedFile && filteredFiles[0]) { setSelectedFileId(filteredFiles[0].id); @@ -348,6 +359,38 @@ function AppShell({ onQuit(); }, [onQuit]); + /** Show one short-lived footer message after local actions like exporting a hunk to pi. */ + const flashStatusMessage = useCallback((message: string) => { + setStatusMessage(message); + if (statusMessageTimeoutRef.current) { + clearTimeout(statusMessageTimeoutRef.current); + } + + statusMessageTimeoutRef.current = setTimeout(() => { + setStatusMessage(undefined); + statusMessageTimeoutRef.current = null; + }, 2200); + }, []); + + /** Export the focused hunk to the project-local pi selection bridge file. */ + const sendSelectionToPi = useCallback(() => { + if (!selectedFile) { + flashStatusMessage("No file selected for Pi."); + return; + } + + const payload = buildPiSelectionPayload(bootstrap.changeset, selectedFile, selectedHunkIndex); + if (!payload) { + flashStatusMessage("No hunk selected for Pi."); + return; + } + + const selectionPath = writePiSelectionPayload(payload); + flashStatusMessage( + `Sent ${selectedFile.path} hunk ${selectedHunkIndex + 1} to Pi (${selectionPath}).`, + ); + }, [bootstrap.changeset, flashStatusMessage, selectedFile, selectedHunkIndex]); + const menus = useMemo( () => buildAppMenus({ @@ -360,6 +403,7 @@ function AppShell({ requestQuit, selectLayoutMode: setLayoutMode, selectThemeId: setThemeId, + sendSelectionToPi, showAgentNotes, showHelp, showHunkHeaders, @@ -379,6 +423,7 @@ function AppShell({ moveAnnotatedFile, moveHunk, requestQuit, + sendSelectionToPi, showAgentNotes, showHelp, showHunkHeaders, @@ -699,6 +744,12 @@ function AppShell({ return; } + if (key.name === "p" || key.sequence === "p") { + sendSelectionToPi(); + closeMenu(); + return; + } + if (key.name === "[") { moveHunk(-1); closeMenu(); @@ -813,6 +864,7 @@ function AppShell({ canResizeDivider={showFilesPane} filter={filter} filterFocused={focusArea === "filter"} + message={statusMessage} terminalWidth={terminal.width} theme={activeTheme} onCloseMenu={closeMenu} diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index d5ee036..bc196d2 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -32,7 +32,9 @@ export function HelpDialog({ > Keyboard F10 menus arrows navigate menus Enter select Esc close menu - 1 split 2 stack 0 auto t theme a notes l lines w wrap m meta + + 1 split 2 stack 0 auto t theme a notes l lines w wrap m meta p pi + ↑/↓ line scroll space next page b previous page Home/End jump [ previous hunk ] next hunk diff --git a/src/ui/components/chrome/StatusBar.tsx b/src/ui/components/chrome/StatusBar.tsx index a97d417..79e42ec 100644 --- a/src/ui/components/chrome/StatusBar.tsx +++ b/src/ui/components/chrome/StatusBar.tsx @@ -6,6 +6,7 @@ export function StatusBar({ canResizeDivider = false, filter, filterFocused, + message, terminalWidth, theme, onCloseMenu, @@ -15,6 +16,7 @@ export function StatusBar({ canResizeDivider?: boolean; filter: string; filterFocused: boolean; + message?: string; terminalWidth: number; theme: AppTheme; onCloseMenu: () => void; @@ -37,6 +39,7 @@ export function StatusBar({ "l lines", "w wrap", "m meta", + "p pi", "q quit", ); @@ -68,9 +71,9 @@ export function StatusBar({ /> ) : ( - + {fitText( - `${hintParts.join(" ")}${filter ? ` filter=${filter}` : ""}`, + message ?? `${hintParts.join(" ")}${filter ? ` filter=${filter}` : ""}`, terminalWidth - 2, )} diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index e2c47f0..bd7a5d8 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -12,6 +12,7 @@ export interface BuildAppMenusOptions { requestQuit: () => void; selectLayoutMode: (mode: LayoutMode) => void; selectThemeId: (themeId: string) => void; + sendSelectionToPi: () => void; showAgentNotes: boolean; showHelp: boolean; showHunkHeaders: boolean; @@ -37,6 +38,7 @@ export function buildAppMenus({ requestQuit, selectLayoutMode, selectThemeId, + sendSelectionToPi, showAgentNotes, showHelp, showHunkHeaders, @@ -179,6 +181,13 @@ export function buildAppMenus({ label: "Previous annotated file", action: () => moveAnnotatedFile(-1), }, + { kind: "separator" }, + { + kind: "item", + label: "Send hunk to Pi", + hint: "p", + action: sendSelectionToPi, + }, ], help: [ { diff --git a/src/ui/lib/piSelection.ts b/src/ui/lib/piSelection.ts new file mode 100644 index 0000000..3487968 --- /dev/null +++ b/src/ui/lib/piSelection.ts @@ -0,0 +1,125 @@ +import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, resolve } from "node:path"; +import { hunkLineRange } from "../../core/liveComments"; +import type { Changeset, DiffFile } from "../../core/types"; + +const PI_SELECTION_RELATIVE_PATH = ".hunk/pi-selection.json"; + +type DiffHunk = DiffFile["metadata"]["hunks"][number]; + +export interface PiSelectionPayload { + version: 1; + source: "hunk"; + createdAt: string; + repoRoot?: string; + selectionPath: string; + changesetTitle: string; + filePath: string; + previousPath?: string; + hunkIndex: number; + oldRange: [number, number]; + newRange: [number, number]; + patch: string; + prompt: string; +} + +/** Reuse the git repo root source label when this changeset came from a repo-backed review. */ +function resolveRepoRoot(changeset: Changeset) { + if (!isAbsolute(changeset.sourceLabel) || !existsSync(changeset.sourceLabel)) { + return undefined; + } + + return statSync(changeset.sourceLabel).isDirectory() ? changeset.sourceLabel : undefined; +} + +/** Match the visible hunk header text used by the review stream. */ +function hunkHeader(hunk: DiffHunk) { + const specs = + hunk.hunkSpecs ?? + `@@ -${hunk.deletionStart},${hunk.deletionLines} +${hunk.additionStart},${hunk.additionLines} @@`; + return hunk.hunkContext ? `${specs} ${hunk.hunkContext}` : specs; +} + +/** Rebuild one hunk as a compact diff snippet suitable for pasting into pi. */ +export function buildPiSelectionPatch(file: DiffFile, hunkIndex: number) { + const hunk = file.metadata.hunks[hunkIndex]; + if (!hunk) { + return null; + } + + const lines = [hunkHeader(hunk)]; + let deletionLineIndex = hunk.deletionLineIndex; + let additionLineIndex = hunk.additionLineIndex; + + for (const content of hunk.hunkContent) { + if (content.type === "context") { + for (let offset = 0; offset < content.lines; offset += 1) { + lines.push(` ${file.metadata.additionLines[additionLineIndex + offset] ?? ""}`); + } + + deletionLineIndex += content.lines; + additionLineIndex += content.lines; + continue; + } + + for (let offset = 0; offset < content.deletions; offset += 1) { + lines.push(`-${file.metadata.deletionLines[deletionLineIndex + offset] ?? ""}`); + } + + for (let offset = 0; offset < content.additions; offset += 1) { + lines.push(`+${file.metadata.additionLines[additionLineIndex + offset] ?? ""}`); + } + + deletionLineIndex += content.deletions; + additionLineIndex += content.additions; + } + + return lines.join("\n"); +} + +/** Build the payload the pi extension watches and pastes into the editor. */ +export function buildPiSelectionPayload(changeset: Changeset, file: DiffFile, hunkIndex: number) { + const hunk = file.metadata.hunks[hunkIndex]; + const patch = buildPiSelectionPatch(file, hunkIndex); + if (!hunk || !patch) { + return null; + } + + const repoRoot = resolveRepoRoot(changeset); + const selectionPath = resolve(repoRoot ?? process.cwd(), PI_SELECTION_RELATIVE_PATH); + const { oldRange, newRange } = hunkLineRange(hunk); + const prompt = [ + `Selected hunk from Hunk: ${file.path}`, + `Hunk: ${hunkIndex + 1}`, + `Old lines: ${oldRange[0]}-${oldRange[1]}`, + `New lines: ${newRange[0]}-${newRange[1]}`, + "", + "```diff", + patch, + "```", + "", + ].join("\n"); + + return { + version: 1 as const, + source: "hunk" as const, + createdAt: new Date().toISOString(), + repoRoot, + selectionPath, + changesetTitle: changeset.title, + filePath: file.path, + previousPath: file.previousPath, + hunkIndex, + oldRange, + newRange, + patch, + prompt, + } satisfies PiSelectionPayload; +} + +/** Persist the current Hunk selection where the project-local pi extension can pick it up. */ +export function writePiSelectionPayload(payload: PiSelectionPayload) { + mkdirSync(dirname(payload.selectionPath), { recursive: true }); + writeFileSync(payload.selectionPath, `${JSON.stringify(payload, null, 2)}\n`); + return payload.selectionPath; +} diff --git a/test/app-interactions.test.tsx b/test/app-interactions.test.tsx index d76ccc2..7f01fc1 100644 --- a/test/app-interactions.test.tsx +++ b/test/app-interactions.test.tsx @@ -1,4 +1,7 @@ import { describe, expect, mock, test } from "bun:test"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { testRender } from "@opentui/react/test-utils"; import { parseDiffFromFile } from "@pierre/diffs"; import { act } from "react"; @@ -237,6 +240,52 @@ describe("App interactions", () => { } }); + test("keyboard shortcut exports the selected hunk to the pi selection bridge file", async () => { + const tempRoot = mkdtempSync(join(tmpdir(), "hunk-pi-bridge-")); + const baseBootstrap = createSingleFileBootstrap(); + const bootstrap = { + ...baseBootstrap, + changeset: { + ...baseBootstrap.changeset, + sourceLabel: tempRoot, + }, + } satisfies AppBootstrap; + const setup = await testRender(, { + width: 240, + height: 24, + }); + + try { + await flush(setup); + + await act(async () => { + await setup.mockInput.typeText("p"); + }); + await flush(setup); + + const selectionPath = join(tempRoot, ".hunk", "pi-selection.json"); + expect(existsSync(selectionPath)).toBe(true); + + const payload = JSON.parse(readFileSync(selectionPath, "utf8")) as { + filePath: string; + hunkIndex: number; + prompt: string; + patch: string; + }; + expect(payload.filePath).toBe("alpha.ts"); + expect(payload.hunkIndex).toBe(0); + expect(payload.prompt).toContain("Selected hunk from Hunk: alpha.ts"); + expect(payload.patch).toContain("@@ -1,1 +1,2 @@"); + expect(payload.patch).toContain("-export const alpha = 1;"); + expect(payload.patch).toContain("+export const alpha = 2;"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + rmSync(tempRoot, { recursive: true, force: true }); + } + }); + test("keyboard shortcut can wrap long lines in the app shell", async () => { const setup = await testRender(, { width: 140, diff --git a/test/pi-selection.test.ts b/test/pi-selection.test.ts new file mode 100644 index 0000000..1228491 --- /dev/null +++ b/test/pi-selection.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { parseDiffFromFile } from "@pierre/diffs"; +import type { Changeset, DiffFile } from "../src/core/types"; +import { buildPiSelectionPatch, buildPiSelectionPayload } from "../src/ui/lib/piSelection"; + +function createDiffFile(id: string, path: string, before: string, after: string): DiffFile { + const metadata = parseDiffFromFile( + { + name: path, + contents: before, + cacheKey: `${id}:before`, + }, + { + name: path, + contents: after, + cacheKey: `${id}:after`, + }, + { context: 3 }, + true, + ); + + let additions = 0; + let deletions = 0; + for (const hunk of metadata.hunks) { + for (const content of hunk.hunkContent) { + if (content.type === "change") { + additions += content.additions; + deletions += content.deletions; + } + } + } + + return { + id, + path, + patch: "", + language: "typescript", + stats: { additions, deletions }, + metadata, + agent: null, + }; +} + +describe("pi selection bridge payloads", () => { + test("serializes the selected hunk as a compact diff snippet and prompt", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "hunk-pi-selection-")); + + try { + const file = createDiffFile( + "alpha", + "alpha.ts", + "export const alpha = 1;\n", + "export const alpha = 2;\nexport const add = true;\n", + ); + const changeset: Changeset = { + id: "changeset:pi-selection", + sourceLabel: repoRoot, + title: "repo working tree", + files: [file], + }; + + const patch = buildPiSelectionPatch(file, 0); + expect(patch).toContain("@@ -1,1 +1,2 @@"); + expect(patch).toContain("-export const alpha = 1;"); + expect(patch).toContain("+export const alpha = 2;"); + expect(patch).toContain("+export const add = true;"); + + const payload = buildPiSelectionPayload(changeset, file, 0); + expect(payload).not.toBeNull(); + expect(payload?.filePath).toBe("alpha.ts"); + expect(payload?.hunkIndex).toBe(0); + expect(payload?.oldRange).toEqual([1, 1]); + expect(payload?.newRange).toEqual([1, 2]); + expect(payload?.selectionPath).toBe(join(repoRoot, ".hunk", "pi-selection.json")); + expect(payload?.prompt).toContain("Selected hunk from Hunk: alpha.ts"); + expect(payload?.prompt).toContain("```diff"); + } finally { + rmSync(repoRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/test/ui-lib.test.ts b/test/ui-lib.test.ts index e1e7563..d0d1d0f 100644 --- a/test/ui-lib.test.ts +++ b/test/ui-lib.test.ts @@ -105,6 +105,7 @@ describe("ui helpers", () => { requestQuit: () => {}, selectLayoutMode: () => {}, selectThemeId: () => {}, + sendSelectionToPi: () => {}, showAgentNotes: true, showHelp: false, showHunkHeaders: false,