diff --git a/.changeset/copy-agent-prompt.md b/.changeset/copy-agent-prompt.md new file mode 100644 index 000000000..b1bad1216 --- /dev/null +++ b/.changeset/copy-agent-prompt.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Replace the Agent skill dialog with a direct Copy agent prompt action when terminal clipboard support is available. diff --git a/.changeset/menus-from-command-table.md b/.changeset/menus-from-command-table.md index 579e0ac95..5c57efa27 100644 --- a/.changeset/menus-from-command-table.md +++ b/.changeset/menus-from-command-table.md @@ -6,4 +6,4 @@ Menus and the controls help dialog now show the keys your commands are actually Extensions get a menu of their own: registered commands are listed in a new **Extensions** menu with their titles and current keys, grouped per extension. A command whose chord was already taken — or that never declared one — is still reachable there with the mouse. The menu appears only when an extension registered a command. -Four actions that were previously menu-only are now named commands, so they can be bound to keys: `hunk.view.toggleCopyDecorations`, `hunk.app.openAgentSkill`, `hunk.review.nextAnnotatedFile`, and `hunk.review.previousAnnotatedFile`. +Four actions that were previously menu-only are now named commands, so they can be bound to keys: `hunk.view.toggleCopyDecorations`, `hunk.app.copyAgentPrompt`, `hunk.review.nextAnnotatedFile`, and `hunk.review.previousAnnotatedFile`. diff --git a/docs/keybindings.md b/docs/keybindings.md index dcf37d7af..3913f69fe 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -44,7 +44,7 @@ The built-in commands and the keys they ship with: | `hunk.app.refresh` | Refresh the review | `r` | | `hunk.app.toggleHelp` | Toggle help | `?` | | `hunk.app.toggleFocusArea` | Switch focus between files and filter | `tab` | -| `hunk.app.openAgentSkill` | Show agent skill | _(none)_ | +| `hunk.app.copyAgentPrompt` | Copy agent prompt | _(none)_ | | `hunk.review.focusFilter` | Focus the file filter | `/` | | `hunk.review.startNote` | Add a review note | `c` | | `hunk.review.editSelectedFile` | Open the selected file in your editor | `e` | diff --git a/src/hunk-review/agentPrompt.ts b/src/hunk-review/agentPrompt.ts new file mode 100644 index 000000000..26dde0f05 --- /dev/null +++ b/src/hunk-review/agentPrompt.ts @@ -0,0 +1,3 @@ +/** Prompt copied for coding agents that should join the current Hunk review. */ +export const AGENT_SKILL_PROMPT = + "Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path."; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c5c588ef5..d849ec992 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -34,6 +34,7 @@ import type { ReloadedSessionResult, ReloadSessionOptions, } from "../hunk-session/types"; +import { AGENT_SKILL_PROMPT } from "../hunk-review/agentPrompt"; import { MenuBar } from "./components/chrome/MenuBar"; import { ConfirmDialog, confirmDialogHeight } from "./components/chrome/ConfirmDialog"; import { ExtensionToast } from "./components/chrome/ExtensionToast"; @@ -97,9 +98,6 @@ const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8; */ const SELECTION_CHANGED_DEBOUNCE_MS = 150; -const LazyAgentSkillDialog = lazy(async () => ({ - default: (await import("./components/chrome/AgentSkillDialog")).AgentSkillDialog, -})); const LazyHelpDialog = lazy(async () => ({ default: (await import("./components/chrome/HelpDialog")).HelpDialog, })); @@ -210,7 +208,6 @@ export function App({ const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode); const [forceSidebarOpen, setForceSidebarOpen] = useState(false); const [showHelp, setShowHelp] = useState(false); - const [showAgentSkill, setShowAgentSkill] = useState(false); const [saveConfigPromptOpen, setSaveConfigPromptOpen] = useState(false); const [focusArea, setFocusArea] = useState("files"); const [activeAddNoteTarget, setActiveAddNoteTarget] = useState(null); @@ -1136,28 +1133,20 @@ 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)"); + const copyAgentPrompt = useCallback(() => { + renderer.copyToClipboardOSC52(AGENT_SKILL_PROMPT); + showTransientNotice("Copied agent prompt to clipboard"); }, [renderer, showTransientNotice]); + /** Query terminal clipboard support live because capability detection may finish after startup. */ + const isAgentPromptCopySupported = useCallback( + () => + (renderer.isOsc52Supported?.() ?? false) && + typeof renderer.copyToClipboardOSC52 === "function", + [renderer], + ); + /** Toggle the modal keyboard help overlay. */ const toggleHelp = useCallback(() => { setShowHelp((current) => !current); @@ -1228,7 +1217,8 @@ export function App({ moveToAnnotatedHunk, moveToFile, moveToHunk: review.moveToHunk, - openAgentSkill, + copyAgentPrompt, + isAgentPromptCopySupported, openThemeSelector, requestQuit, resolvedKeys: resolvedCommandKeys, @@ -1290,7 +1280,6 @@ export function App({ useAppKeyboardShortcuts({ activeMenuId, activateCurrentMenuItem, - closeAgentSkill, closeHelp, closeMenu, acceptThemeSelector, @@ -1312,7 +1301,6 @@ export function App({ neverAskToSaveViewPreferencesAndQuit, closeSaveConfigPrompt, saveDraftNote, - showAgentSkill, showHelp, switchMenu, toggleFocusArea, @@ -1621,19 +1609,6 @@ export function App({ ) : null} - {!pagerMode && showAgentSkill ? ( - - - - ) : null} - {!pagerMode && showHelp ? ( { } }); - test("Agent menu opens copyable agent skill guidance", async () => { + test("Agent menu copies the agent prompt and confirms the write", async () => { const bootstrap = createBootstrap(); const setup = await testRender(, { width: 120, height: 24, }); + let copiedText = ""; + setup.renderer.isOsc52Supported = () => true; + setup.renderer.copyToClipboardOSC52 = (text: string) => { + copiedText = text; + return true; + }; try { await flush(setup); @@ -1818,10 +1824,11 @@ describe("App interactions", () => { let frame = await waitForFrame( setup, (currentFrame) => - currentFrame.includes("Agent skill") && currentFrame.includes("Next annotated file"), + currentFrame.includes("Copy agent prompt") && + currentFrame.includes("Next annotated file"), 12, ); - expect(frame).toContain("Agent skill"); + expect(frame).toContain("Copy agent prompt"); expect(frame).toContain("Next annotated file"); await act(async () => { @@ -1834,17 +1841,51 @@ describe("App interactions", () => { frame = await waitForFrame( setup, - (currentFrame) => currentFrame.includes("Teach your agent"), + (currentFrame) => currentFrame.includes("Copied agent prompt to clipboard"), 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"); + expect(copiedText).toBe(AGENT_SKILL_PROMPT); + expect(frame).toContain("Copied agent prompt to clipboard"); + expect(frame).not.toContain("Teach your agent"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("Agent menu omits copy prompt when OSC 52 is unsupported", async () => { + const bootstrap = createBootstrap(); + const setup = await testRender(, { + width: 120, + height: 24, + }); + setup.renderer.isOsc52Supported = () => false; + + try { + await flush(setup); await act(async () => { - await setup.mockInput.pressEscape(); + 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); + } + + const frame = await waitForFrame( + setup, + (currentFrame) => + currentFrame.includes("Agent notes") && currentFrame.includes("Next annotated file"), + 12, + ); + expect(frame).not.toContain("Copy agent prompt"); + expect(frame).toContain("Agent notes"); + expect(frame).toContain("Next annotated file"); } finally { await act(async () => { setup.renderer.destroy(); diff --git a/src/ui/components/chrome/AgentSkillDialog.tsx b/src/ui/components/chrome/AgentSkillDialog.tsx deleted file mode 100644 index 52ccb038a..000000000 --- a/src/ui/components/chrome/AgentSkillDialog.tsx +++ /dev/null @@ -1,96 +0,0 @@ -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 a09496d15..438bd230b 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -10,7 +10,6 @@ type FocusArea = "files" | "filter" | "note"; export interface UseAppKeyboardShortcutsOptions { activeMenuId: MenuId | null; activateCurrentMenuItem: () => void; - closeAgentSkill: () => void; closeHelp: () => void; closeMenu: () => void; acceptThemeSelector: () => void; @@ -36,7 +35,6 @@ export interface UseAppKeyboardShortcutsOptions { neverAskToSaveViewPreferencesAndQuit: () => void; closeSaveConfigPrompt: () => void; saveDraftNote: () => void; - showAgentSkill: boolean; showHelp: boolean; switchMenu: (delta: number) => void; toggleFocusArea: () => void; @@ -56,7 +54,6 @@ export interface UseAppKeyboardShortcutsOptions { export function useAppKeyboardShortcuts({ activeMenuId, activateCurrentMenuItem, - closeAgentSkill, closeHelp, closeMenu, acceptThemeSelector, @@ -78,7 +75,6 @@ export function useAppKeyboardShortcuts({ neverAskToSaveViewPreferencesAndQuit, closeSaveConfigPrompt, saveDraftNote, - showAgentSkill, showHelp, switchMenu, toggleFocusArea, @@ -88,7 +84,6 @@ export function useAppKeyboardShortcuts({ const commandsRef = useRef(commands); const focusAreaRef = useRef(focusArea); const pagerModeRef = useRef(pagerMode); - const showAgentSkillRef = useRef(showAgentSkill); const showHelpRef = useRef(showHelp); const saveConfigPromptOpenRef = useRef(saveConfigPromptOpen); const themeSelectorOpenRef = useRef(themeSelectorOpen); @@ -98,7 +93,6 @@ export function useAppKeyboardShortcuts({ commandsRef.current = commands; focusAreaRef.current = focusArea; pagerModeRef.current = pagerMode; - showAgentSkillRef.current = showAgentSkill; showHelpRef.current = showHelp; saveConfigPromptOpenRef.current = saveConfigPromptOpen; themeSelectorOpenRef.current = themeSelectorOpen; @@ -132,11 +126,6 @@ export function useAppKeyboardShortcuts({ return false; } - if (showAgentSkillRef.current) { - closeAgentSkill(); - return true; - } - if (showHelpRef.current) { closeHelp(); return true; diff --git a/src/ui/lib/appCommands.test.ts b/src/ui/lib/appCommands.test.ts index 189ec1816..2451863da 100644 --- a/src/ui/lib/appCommands.test.ts +++ b/src/ui/lib/appCommands.test.ts @@ -39,7 +39,8 @@ function createTestCommands(resolvedKeys?: ResolvedCommandKeys) { moveToAnnotatedHunk: record("moveToAnnotatedHunk"), moveToFile: record("moveToFile"), moveToHunk: record("moveToHunk"), - openAgentSkill: record("openAgentSkill"), + copyAgentPrompt: record("copyAgentPrompt"), + isAgentPromptCopySupported: () => true, openThemeSelector: record("openThemeSelector"), requestQuit: record("requestQuit"), resolvedKeys, @@ -200,7 +201,7 @@ describe("builtinCommandKeyDefaults", () => { .map((entry) => entry.id) .sort(), ).toEqual([ - "hunk.app.openAgentSkill", + "hunk.app.copyAgentPrompt", "hunk.review.nextAnnotatedFile", "hunk.review.previousAnnotatedFile", "hunk.view.toggleCopyDecorations", @@ -242,8 +243,8 @@ describe("executeAppCommand", () => { expect(executeAppCommand(commands, "hunk.app.quit")).toBe(true); // Unbound commands are reachable only this way, which is why menus use it. - expect(executeAppCommand(commands, "hunk.app.openAgentSkill")).toBe(true); - expect(ran).toEqual(["requestQuit", "openAgentSkill"]); + expect(executeAppCommand(commands, "hunk.app.copyAgentPrompt")).toBe(true); + expect(ran).toEqual(["requestQuit", "copyAgentPrompt"]); }); test("passes the command's own first chord to handlers that read the event", () => { diff --git a/src/ui/lib/appCommands.ts b/src/ui/lib/appCommands.ts index b743cc214..62c1fceca 100644 --- a/src/ui/lib/appCommands.ts +++ b/src/ui/lib/appCommands.ts @@ -87,7 +87,8 @@ export interface BuildAppCommandsOptions { moveToAnnotatedHunk: (delta: number) => void; moveToFile: (delta: number) => void; moveToHunk: (delta: number) => void; - openAgentSkill: () => void; + copyAgentPrompt: () => void; + isAgentPromptCopySupported: () => boolean; openThemeSelector: () => void; requestQuit: () => void; /** Chords resolved against the user's `[keybindings]`; defaults apply where absent. */ @@ -160,11 +161,12 @@ function builtinCommandSpecs(options: BuildAppCommandsOptions): BuiltinCommandSp closesMenu: true, }, { - id: "hunk.app.openAgentSkill", - title: "Show agent skill", + id: "hunk.app.copyAgentPrompt", + title: "Copy agent prompt", scopes: REVIEW, defaultKeys: [], - run: () => options.openAgentSkill(), + isEnabled: () => options.isAgentPromptCopySupported(), + run: () => options.copyAgentPrompt(), closesMenu: true, }, { @@ -467,7 +469,8 @@ const NOOP_COMMAND_OPTIONS: BuildAppCommandsOptions = (() => { moveToAnnotatedHunk: noop, moveToFile: noop, moveToHunk: noop, - openAgentSkill: noop, + copyAgentPrompt: noop, + isAgentPromptCopySupported: () => true, openThemeSelector: noop, requestQuit: noop, scrollCodeHorizontally: noop, diff --git a/src/ui/lib/appMenus.test.ts b/src/ui/lib/appMenus.test.ts index ca8f2c2e3..bd25a42bb 100644 --- a/src/ui/lib/appMenus.test.ts +++ b/src/ui/lib/appMenus.test.ts @@ -42,7 +42,8 @@ function createTestCommands(overrides: Partial = {}) { moveToAnnotatedHunk: noop, moveToFile: noop, moveToHunk: noop, - openAgentSkill: record("openAgentSkill"), + copyAgentPrompt: record("copyAgentPrompt"), + isAgentPromptCopySupported: () => true, openThemeSelector: noop, requestQuit: record("requestQuit"), scrollCodeHorizontally: noop, @@ -125,7 +126,7 @@ describe("buildAppMenus", () => { expect(items(menus.view).map((item) => item.label)).toContain("Themes…"); expect(items(menus.agent).map((item) => item.label)).toEqual([ "Agent notes", - "Agent skill", + "Copy agent prompt", "Next annotated file", "Previous annotated file", ]); @@ -165,14 +166,14 @@ describe("buildAppMenus", () => { entry(menus, "view", "Sidebar").action(); entry(menus, "view", "Copy decorations").action(); - entry(menus, "agent", "Agent skill").action(); + entry(menus, "agent", "Copy agent prompt").action(); entry(menus, "agent", "Next annotated file").action(); entry(menus, "agent", "Previous annotated file").action(); expect(ran).toEqual([ "toggleSidebar", "toggleCopyDecorations", - "openAgentSkill", + "copyAgentPrompt", "moveToAnnotatedFile:1", "moveToAnnotatedFile:-1", ]); diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index 6a3f7b50b..aba919008 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -176,7 +176,7 @@ export function buildAppMenus({ ], agent: [ { commandId: "hunk.view.toggleAgentNotes", label: "Agent notes", checked: showAgentNotes }, - { commandId: "hunk.app.openAgentSkill", label: "Agent skill" }, + { commandId: "hunk.app.copyAgentPrompt", label: "Copy agent prompt" }, SEPARATOR, { commandId: "hunk.review.nextAnnotatedFile" }, { commandId: "hunk.review.previousAnnotatedFile" },