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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/agent-skill-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hunkdiff": patch
---

Add an Agent menu dialog that shows and copies the Hunk review skill setup prompt.
43 changes: 43 additions & 0 deletions src/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ type ThemeSelectorState = {

const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8;

const LazyAgentSkillDialog = lazy(async () => ({
default: (await import("./components/chrome/AgentSkillDialog")).AgentSkillDialog,
}));
const LazyHelpDialog = lazy(async () => ({
default: (await import("./components/chrome/HelpDialog")).HelpDialog,
}));
Expand Down Expand Up @@ -139,6 +142,7 @@ export function App({
const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode);
const [forceSidebarOpen, setForceSidebarOpen] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [showAgentSkill, setShowAgentSkill] = useState(false);
const [focusArea, setFocusArea] = useState<FocusArea>("files");
const [activeAddNoteTarget, setActiveAddNoteTarget] = useState<ActiveAddNoteTarget | null>(null);
const [sidebarWidth, setSidebarWidth] = useState(34);
Expand Down Expand Up @@ -640,6 +644,28 @@ export function App({
setShowHelp(false);
}, []);

/** Close the agent skill setup overlay. */
const closeAgentSkill = useCallback(() => {
setShowAgentSkill(false);
}, []);

/** Open the agent skill setup overlay. */
const openAgentSkill = useCallback(() => {
setShowAgentSkill(true);
}, []);

/** Copy the agent skill prompt through the terminal clipboard integration. */
const copyAgentSkillPrompt = useCallback(async () => {
const { AGENT_SKILL_PROMPT } = await import("./components/chrome/AgentSkillDialog");
if (renderer.isOsc52Supported?.() && typeof renderer.copyToClipboardOSC52 === "function") {
renderer.copyToClipboardOSC52(AGENT_SKILL_PROMPT);
showTransientNotice("Copied agent skill prompt to clipboard");
return;
}

showTransientNotice("Clipboard copy unsupported in this terminal (enable OSC 52)");
}, [renderer, showTransientNotice]);

/** Toggle the modal keyboard help overlay. */
const toggleHelp = useCallback(() => {
setShowHelp((current) => !current);
Expand Down Expand Up @@ -721,6 +747,7 @@ export function App({
toggleCopyDecorations,
toggleAgentNotes,
toggleFocusArea,
openAgentSkill,
toggleHelp,
toggleHunkHeaders,
toggleLineNumbers,
Expand All @@ -738,6 +765,7 @@ export function App({
moveToAnnotatedHunk,
requestQuit,
review.moveToHunk,
openAgentSkill,
selectLayoutMode,
openThemeSelector,
triggerRefreshCurrentInput,
Expand Down Expand Up @@ -779,6 +807,7 @@ export function App({
activeMenuId,
activateCurrentMenuItem,
canRefreshCurrentInput,
closeAgentSkill,
closeHelp,
closeMenu,
acceptThemeSelector,
Expand All @@ -799,6 +828,7 @@ export function App({
saveDraftNote,
scrollDiff,
selectLayoutMode,
showAgentSkill,
showHelp,
startUserNote: () => startUserNote(),
switchMenu,
Expand Down Expand Up @@ -1041,6 +1071,19 @@ export function App({
</Suspense>
) : null}

{!pagerMode && showAgentSkill ? (
<Suspense fallback={null}>
<LazyAgentSkillDialog
copySupported={renderer.isOsc52Supported?.() ?? false}
terminalHeight={terminal.height}
terminalWidth={terminal.width}
theme={baseTheme}
onClose={closeAgentSkill}
onCopyPrompt={copyAgentSkillPrompt}
/>
</Suspense>
) : null}

{!pagerMode && showHelp ? (
<Suspense fallback={null}>
<LazyHelpDialog
Expand Down
60 changes: 60 additions & 0 deletions src/ui/AppHost.interactions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { AppBootstrap, LayoutMode } from "../core/types";
import { createTestVcsAppBootstrap } from "../../test/helpers/app-bootstrap";
import { capturedTestColorToHex } from "../../test/helpers/test-color-helpers";
import { createTestDiffFile as buildTestDiffFile, lines } from "../../test/helpers/diff-helpers";
import { AGENT_SKILL_COMMAND, AGENT_SKILL_PROMPT } from "./components/chrome/AgentSkillDialog";
import { resolveTheme } from "./themes";

const { loadAppBootstrap } = await import("../core/loaders");
Expand Down Expand Up @@ -1728,6 +1729,65 @@ describe("App interactions", () => {
}
});

test("Agent menu opens copyable agent skill guidance", async () => {
const bootstrap = createBootstrap();
const setup = await testRender(<AppHost bootstrap={bootstrap} />, {
width: 120,
height: 24,
});

try {
await flush(setup);

await act(async () => {
await setup.mockInput.pressKey("F10");
});
await waitForFrame(setup, (frame) => frame.includes("Toggle files/filter focus"), 12);

for (let index = 0; index < 3; index += 1) {
await act(async () => {
await setup.mockInput.pressArrow("right");
});
await flush(setup);
}

let frame = await waitForFrame(
setup,
(currentFrame) =>
currentFrame.includes("Agent skill") && currentFrame.includes("Next annotated file"),
12,
);
expect(frame).toContain("Agent skill");
expect(frame).toContain("Next annotated file");

await act(async () => {
await setup.mockInput.pressArrow("down");
});
await flush(setup);
await act(async () => {
await setup.mockInput.pressEnter();
});

frame = await waitForFrame(
setup,
(currentFrame) => currentFrame.includes("Teach your agent"),
12,
);
expect(frame).toContain("Load the Hunk skill and use it for this review");
expect(AGENT_SKILL_PROMPT).toContain(AGENT_SKILL_COMMAND);
expect(frame).toContain(AGENT_SKILL_COMMAND);
expect(frame).toContain("Copy");

await act(async () => {
await setup.mockInput.pressEscape();
});
} finally {
await act(async () => {
setup.renderer.destroy();
});
}
});

test("a shows notes that are visible in the current review viewport", async () => {
const bootstrap = createBootstrap();
bootstrap.changeset.files[1]!.agent = {
Expand Down
96 changes: 96 additions & 0 deletions src/ui/components/chrome/AgentSkillDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import type { MouseEvent as TuiMouseEvent } from "@opentui/core";
import { fitText, padText } from "../../lib/text";
import type { AppTheme } from "../../themes";
import { ModalFrame } from "./ModalFrame";

export const AGENT_SKILL_COMMAND = "hunk skill path";
export const AGENT_SKILL_PROMPT_ROWS = [
"Load the Hunk skill and use it for this review.",
"Run `hunk skill path` to get the skill path.",
];
export const AGENT_SKILL_PROMPT = AGENT_SKILL_PROMPT_ROWS.join(" ");

/** Render copyable setup guidance for connecting an agent to the live Hunk session. */
export function AgentSkillDialog({
copySupported,
terminalHeight,
terminalWidth,
theme,
onClose,
onCopyPrompt,
}: {
copySupported: boolean;
terminalHeight: number;
terminalWidth: number;
theme: AppTheme;
onClose: () => void;
onCopyPrompt: () => void;
}) {
const width = Math.min(84, Math.max(58, terminalWidth - 8));
const bodyWidth = Math.max(1, width - 4);
const promptWidth = Math.max(1, bodyWidth - 4);
const promptRows = AGENT_SKILL_PROMPT_ROWS;
const cardWidth = Math.max(1, bodyWidth - 4);
const cardTextWidth = Math.max(1, cardWidth - 4);
const requiredModalHeight = promptRows.length + 11;
const modalHeight = Math.min(requiredModalHeight, Math.max(10, terminalHeight - 2));

const copyLabel = copySupported ? " ⧉ Copy prompt " : " Copy unavailable ";
return (
<ModalFrame
height={modalHeight}
terminalHeight={terminalHeight}
terminalWidth={terminalWidth}
theme={theme}
title="Agent skill"
width={width}
onClose={onClose}
>
<box style={{ width: "100%", height: "100%", flexDirection: "column" }}>
<box style={{ width: "100%", height: 1 }}>
<text fg={theme.text}>
{fitText("Teach your agent how to review this Hunk session.", bodyWidth)}
</text>
</box>
<box style={{ width: "100%", height: 1 }} />
<box style={{ width: "100%", height: 1, paddingLeft: 1 }}>
<text fg={theme.badgeNeutral}>{fitText("Prompt", promptWidth)}</text>
</box>
<box style={{ width: "100%", height: promptRows.length + 2, paddingLeft: 1 }}>
<box
style={{
width: cardWidth,
height: promptRows.length + 2,
border: true,
borderColor: theme.border,
flexDirection: "column",
paddingLeft: 1,
paddingRight: 1,
}}
>
{promptRows.map((line, index) => (
<box key={`prompt:${index}:${line}`} style={{ width: "100%", height: 1 }}>
<text fg={theme.text}>{fitText(line, cardTextWidth)}</text>
</box>
))}
</box>
</box>
<box style={{ width: "100%", height: 1 }} />
<box style={{ width: "100%", height: 1, flexDirection: "row" }}>
<box
style={{ backgroundColor: copySupported ? theme.accentMuted : theme.panelAlt }}
onMouseUp={(event: TuiMouseEvent) => {
event.stopPropagation();
if (copySupported) {
onCopyPrompt();
}
}}
>
<text fg={copySupported ? theme.text : theme.muted}>{copyLabel}</text>
</box>
<text fg={theme.muted}>{padText("", Math.max(1, bodyWidth - copyLabel.length))}</text>
</box>
</box>
</ModalFrame>
);
}
25 changes: 20 additions & 5 deletions src/ui/hooks/useAppKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface UseAppKeyboardShortcutsOptions {
activeMenuId: MenuId | null;
activateCurrentMenuItem: () => void;
canRefreshCurrentInput: boolean;
closeAgentSkill: () => void;
closeHelp: () => void;
closeMenu: () => void;
acceptThemeSelector: () => void;
Expand All @@ -66,6 +67,7 @@ export interface UseAppKeyboardShortcutsOptions {
scrollDiff: (delta: number, unit: ScrollUnit) => void;
saveDraftNote: () => void;
selectLayoutMode: (mode: LayoutMode) => void;
showAgentSkill: boolean;
showHelp: boolean;
startUserNote: () => void;
switchMenu: (delta: number) => void;
Expand All @@ -87,6 +89,7 @@ export function useAppKeyboardShortcuts({
activeMenuId,
activateCurrentMenuItem,
canRefreshCurrentInput,
closeAgentSkill,
closeHelp,
closeMenu,
acceptThemeSelector,
Expand All @@ -107,6 +110,7 @@ export function useAppKeyboardShortcuts({
saveDraftNote,
scrollDiff,
selectLayoutMode,
showAgentSkill,
showHelp,
startUserNote,
switchMenu,
Expand All @@ -125,12 +129,14 @@ export function useAppKeyboardShortcuts({
const activeMenuIdRef = useRef(activeMenuId);
const focusAreaRef = useRef(focusArea);
const pagerModeRef = useRef(pagerMode);
const showAgentSkillRef = useRef(showAgentSkill);
const showHelpRef = useRef(showHelp);
const themeSelectorOpenRef = useRef(themeSelectorOpen);

activeMenuIdRef.current = activeMenuId;
focusAreaRef.current = focusArea;
pagerModeRef.current = pagerMode;
showAgentSkillRef.current = showAgentSkill;
showHelpRef.current = showHelp;
themeSelectorOpenRef.current = themeSelectorOpen;

Expand Down Expand Up @@ -251,13 +257,22 @@ export function useAppKeyboardShortcuts({
}
};

const handleHelpShortcut = (key: KeyEvent) => {
if (!showHelpRef.current || !isEscapeKey(key)) {
const handleDialogShortcut = (key: KeyEvent) => {
if (!isEscapeKey(key)) {
return false;
}

closeHelp();
return true;
if (showAgentSkillRef.current) {
closeAgentSkill();
return true;
}

if (showHelpRef.current) {
closeHelp();
return true;
}

return false;
};

const handleThemeSelectorShortcut = (key: KeyEvent) => {
Expand Down Expand Up @@ -560,7 +575,7 @@ export function useAppKeyboardShortcuts({
return;
}

if (handleHelpShortcut(key)) {
if (handleDialogShortcut(key)) {
return;
}

Expand Down
8 changes: 8 additions & 0 deletions src/ui/lib/appMenus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export interface BuildAppMenusOptions {
toggleCopyDecorations: () => void;
toggleAgentNotes: () => void;
toggleFocusArea: () => void;
openAgentSkill: () => void;
toggleHelp: () => void;
toggleHunkHeaders: () => void;
toggleLineNumbers: () => void;
Expand Down Expand Up @@ -51,6 +52,7 @@ export function buildAppMenus({
toggleCopyDecorations,
toggleAgentNotes,
toggleFocusArea,
openAgentSkill,
toggleHelp,
toggleHunkHeaders,
toggleLineNumbers,
Expand Down Expand Up @@ -216,6 +218,12 @@ export function buildAppMenus({
checked: showAgentNotes,
action: toggleAgentNotes,
},
{
kind: "item",
label: "Agent skill",
action: openAgentSkill,
},
Comment on lines +221 to +225

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 The checked property is used on the "Agent skill" item, but "Agent skill" is a dialog launcher, not a persistent toggle. In practice the menu is never rendered while the dialog is open, so showAgentSkill is always false here — the checkmark is dead UI. Action-only items like "Next annotated file" above carry no checked field; "Agent skill" should follow the same pattern.

Suggested change
{
kind: "item",
label: "Agent skill",
checked: showAgentSkill,
action: openAgentSkill,
},
{
kind: "item",
label: "Agent skill",
action: openAgentSkill,
},
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/lib/appMenus.ts
Line: 229-234

Comment:
The `checked` property is used on the "Agent skill" item, but "Agent skill" is a dialog launcher, not a persistent toggle. In practice the menu is never rendered while the dialog is open, so `showAgentSkill` is always `false` here — the checkmark is dead UI. Action-only items like "Next annotated file" above carry no `checked` field; "Agent skill" should follow the same pattern.

```suggestion
      {
        kind: "item",
        label: "Agent skill",
        action: openAgentSkill,
      },
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed — Agent skill is now an action-only menu item without a checked state, and the menu test was updated accordingly.

This comment was generated by Pi using GPT-5 Codex

{ kind: "separator" },
{
kind: "item",
label: "Next annotated file",
Expand Down
Loading