diff --git a/.changeset/agent-skill-menu.md b/.changeset/agent-skill-menu.md new file mode 100644 index 00000000..0f1f814c --- /dev/null +++ b/.changeset/agent-skill-menu.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Add an Agent menu dialog that shows and copies the Hunk review skill setup prompt. diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d8717ca3..3b291313 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -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, })); @@ -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("files"); const [activeAddNoteTarget, setActiveAddNoteTarget] = useState(null); const [sidebarWidth, setSidebarWidth] = useState(34); @@ -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); @@ -721,6 +747,7 @@ export function App({ toggleCopyDecorations, toggleAgentNotes, toggleFocusArea, + openAgentSkill, toggleHelp, toggleHunkHeaders, toggleLineNumbers, @@ -738,6 +765,7 @@ export function App({ moveToAnnotatedHunk, requestQuit, review.moveToHunk, + openAgentSkill, selectLayoutMode, openThemeSelector, triggerRefreshCurrentInput, @@ -779,6 +807,7 @@ export function App({ activeMenuId, activateCurrentMenuItem, canRefreshCurrentInput, + closeAgentSkill, closeHelp, closeMenu, acceptThemeSelector, @@ -799,6 +828,7 @@ export function App({ saveDraftNote, scrollDiff, selectLayoutMode, + showAgentSkill, showHelp, startUserNote: () => startUserNote(), switchMenu, @@ -1041,6 +1071,19 @@ export function App({ ) : null} + {!pagerMode && showAgentSkill ? ( + + + + ) : null} + {!pagerMode && showHelp ? ( { } }); + test("Agent menu opens copyable agent skill guidance", async () => { + const bootstrap = createBootstrap(); + const setup = await testRender(, { + 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 = { diff --git a/src/ui/components/chrome/AgentSkillDialog.tsx b/src/ui/components/chrome/AgentSkillDialog.tsx new file mode 100644 index 00000000..52ccb038 --- /dev/null +++ b/src/ui/components/chrome/AgentSkillDialog.tsx @@ -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 ( + + + + + {fitText("Teach your agent how to review this Hunk session.", bodyWidth)} + + + + + {fitText("Prompt", promptWidth)} + + + + {promptRows.map((line, index) => ( + + {fitText(line, cardTextWidth)} + + ))} + + + + + { + event.stopPropagation(); + if (copySupported) { + onCopyPrompt(); + } + }} + > + {copyLabel} + + {padText("", Math.max(1, bodyWidth - copyLabel.length))} + + + + ); +} diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index 6c6465d5..62669531 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -46,6 +46,7 @@ export interface UseAppKeyboardShortcutsOptions { activeMenuId: MenuId | null; activateCurrentMenuItem: () => void; canRefreshCurrentInput: boolean; + closeAgentSkill: () => void; closeHelp: () => void; closeMenu: () => void; acceptThemeSelector: () => void; @@ -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; @@ -87,6 +89,7 @@ export function useAppKeyboardShortcuts({ activeMenuId, activateCurrentMenuItem, canRefreshCurrentInput, + closeAgentSkill, closeHelp, closeMenu, acceptThemeSelector, @@ -107,6 +110,7 @@ export function useAppKeyboardShortcuts({ saveDraftNote, scrollDiff, selectLayoutMode, + showAgentSkill, showHelp, startUserNote, switchMenu, @@ -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; @@ -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) => { @@ -560,7 +575,7 @@ export function useAppKeyboardShortcuts({ return; } - if (handleHelpShortcut(key)) { + if (handleDialogShortcut(key)) { return; } diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index 00cc1f28..eaf2eaaa 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -21,6 +21,7 @@ export interface BuildAppMenusOptions { toggleCopyDecorations: () => void; toggleAgentNotes: () => void; toggleFocusArea: () => void; + openAgentSkill: () => void; toggleHelp: () => void; toggleHunkHeaders: () => void; toggleLineNumbers: () => void; @@ -51,6 +52,7 @@ export function buildAppMenus({ toggleCopyDecorations, toggleAgentNotes, toggleFocusArea, + openAgentSkill, toggleHelp, toggleHunkHeaders, toggleLineNumbers, @@ -216,6 +218,12 @@ export function buildAppMenus({ checked: showAgentNotes, action: toggleAgentNotes, }, + { + kind: "item", + label: "Agent skill", + action: openAgentSkill, + }, + { kind: "separator" }, { kind: "item", label: "Next annotated file", diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index bce1ee76..5749ae1f 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -149,6 +149,7 @@ describe("ui helpers", () => { toggleCopyDecorations: () => {}, toggleAgentNotes: () => {}, toggleFocusArea: () => {}, + openAgentSkill: () => {}, toggleHelp: () => {}, toggleHunkHeaders: () => {}, toggleLineNumbers: () => {}, @@ -187,6 +188,11 @@ describe("ui helpers", () => { .filter((entry): entry is Extract => entry.kind === "item") .map((entry) => entry.label), ).toContain("Themes…"); + expect( + menus.agent + .filter((entry): entry is Extract => entry.kind === "item") + .map((entry) => entry.label), + ).toEqual(["Agent notes", "Agent skill", "Next annotated file", "Previous annotated file"]); }); test("keyboard alias helpers normalize the shared scroll shortcut keys", () => {