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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
52 changes: 52 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -97,7 +98,9 @@ function AppShell({
const [dismissedAgentNoteIds, setDismissedAgentNoteIds] = useState<string[]>([]);
const [selectedFileId, setSelectedFileId] = useState(bootstrap.changeset.files[0]?.id ?? "");
const [selectedHunkIndex, setSelectedHunkIndex] = useState(0);
const [statusMessage, setStatusMessage] = useState<string | undefined>();
const deferredFilter = useDeferredValue(filter);
const statusMessageTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

const pagerMode = Boolean(bootstrap.input.options.pager);
const activeTheme = resolveTheme(themeId, renderer.themeMode);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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({
Expand All @@ -360,6 +403,7 @@ function AppShell({
requestQuit,
selectLayoutMode: setLayoutMode,
selectThemeId: setThemeId,
sendSelectionToPi,
showAgentNotes,
showHelp,
showHunkHeaders,
Expand All @@ -379,6 +423,7 @@ function AppShell({
moveAnnotatedFile,
moveHunk,
requestQuit,
sendSelectionToPi,
showAgentNotes,
showHelp,
showHunkHeaders,
Expand Down Expand Up @@ -699,6 +744,12 @@ function AppShell({
return;
}

if (key.name === "p" || key.sequence === "p") {
sendSelectionToPi();
closeMenu();
return;
}

if (key.name === "[") {
moveHunk(-1);
closeMenu();
Expand Down Expand Up @@ -813,6 +864,7 @@ function AppShell({
canResizeDivider={showFilesPane}
filter={filter}
filterFocused={focusArea === "filter"}
message={statusMessage}
terminalWidth={terminal.width}
theme={activeTheme}
onCloseMenu={closeMenu}
Expand Down
4 changes: 3 additions & 1 deletion src/ui/components/chrome/HelpDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export function HelpDialog({
>
<text fg={theme.text}>Keyboard</text>
<text fg={theme.muted}>F10 menus arrows navigate menus Enter select Esc close menu</text>
<text fg={theme.muted}>1 split 2 stack 0 auto t theme a notes l lines w wrap m meta</text>
<text fg={theme.muted}>
1 split 2 stack 0 auto t theme a notes l lines w wrap m meta p pi
</text>
<text fg={theme.muted}>
↑/↓ line scroll space next page b previous page Home/End jump [ previous hunk ] next hunk
</text>
Expand Down
7 changes: 5 additions & 2 deletions src/ui/components/chrome/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export function StatusBar({
canResizeDivider = false,
filter,
filterFocused,
message,
terminalWidth,
theme,
onCloseMenu,
Expand All @@ -15,6 +16,7 @@ export function StatusBar({
canResizeDivider?: boolean;
filter: string;
filterFocused: boolean;
message?: string;
terminalWidth: number;
theme: AppTheme;
onCloseMenu: () => void;
Expand All @@ -37,6 +39,7 @@ export function StatusBar({
"l lines",
"w wrap",
"m meta",
"p pi",
"q quit",
);

Expand Down Expand Up @@ -68,9 +71,9 @@ export function StatusBar({
/>
</>
) : (
<text fg={theme.muted}>
<text fg={message ? theme.badgeNeutral : theme.muted}>
{fitText(
`${hintParts.join(" ")}${filter ? ` filter=${filter}` : ""}`,
message ?? `${hintParts.join(" ")}${filter ? ` filter=${filter}` : ""}`,
terminalWidth - 2,
)}
</text>
Expand Down
9 changes: 9 additions & 0 deletions src/ui/lib/appMenus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,6 +38,7 @@ export function buildAppMenus({
requestQuit,
selectLayoutMode,
selectThemeId,
sendSelectionToPi,
showAgentNotes,
showHelp,
showHunkHeaders,
Expand Down Expand Up @@ -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: [
{
Expand Down
125 changes: 125 additions & 0 deletions src/ui/lib/piSelection.ts
Original file line number Diff line number Diff line change
@@ -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;
}
49 changes: 49 additions & 0 deletions test/app-interactions.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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(<App bootstrap={bootstrap} />, {
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(<App bootstrap={createWrapBootstrap()} />, {
width: 140,
Expand Down
Loading
Loading