From e0c377632c96689f96c6bb6ee491a1dd85e51695 Mon Sep 17 00:00:00 2001 From: Ricardo-Ceia Date: Mon, 11 May 2026 10:32:55 +0100 Subject: [PATCH] feat(agents): export focused hunk prompts --- CHANGELOG.md | 1 + README.md | 2 + docs/agent-workflows.md | 14 ++ skills/hunk-review/SKILL.md | 16 +- src/core/agentPrompt.test.ts | 67 ++++++ src/core/agentPrompt.ts | 138 ++++++++++++ src/core/cli.test.ts | 27 +++ src/core/cli.ts | 43 ++++ src/core/types.ts | 10 + src/session/commands.test.ts | 48 ++++ src/session/commands.ts | 25 +++ src/ui/App.tsx | 206 +++++++++++++++++- .../components/chrome/AgentPromptDialog.tsx | 111 ++++++++++ src/ui/components/chrome/HelpDialog.tsx | 2 + src/ui/hooks/useAppKeyboardShortcuts.ts | 36 +++ src/ui/lib/appMenus.ts | 17 ++ src/ui/lib/ui-lib.test.ts | 13 ++ 17 files changed, 768 insertions(+), 8 deletions(-) create mode 100644 src/core/agentPrompt.test.ts create mode 100644 src/core/agentPrompt.ts create mode 100644 src/ui/components/chrome/AgentPromptDialog.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 06c7e176..b5d5ee1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable user-visible changes to Hunk are documented in this file. ### Added +- Added focused-hunk agent prompt export from the TUI (`p` / `c`) and `hunk session prompt`. - Added Windows x64 prebuilt artifact publishing to the release workflow. ### Changed diff --git a/README.md b/README.md index d1bdce87..5cfe83bc 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,8 @@ A good generic prompt is: Load the Hunk skill and use it for this review. ``` +While reviewing in the TUI, press `p` to copy the focused hunk as a paste-ready agent prompt, or `c` to add a short human comment and copy that commented prompt. Agents and scripts can use the same export through `hunk session prompt --repo .`. + For the full live-session and `--agent-context` workflow guide, see [docs/agent-workflows.md](docs/agent-workflows.md). ## Feature comparison diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index ae4da543..2f57d724 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -79,6 +79,19 @@ Notes: - `--hunk` is 1-based - `--next-comment` and `--prev-comment` are handy when an agent is walking the user through existing notes +### Publish a prompt from the focused hunk + +Inside the TUI, press `p` to copy a paste-ready prompt for the focused hunk. If you have selected text with the mouse, Hunk includes that selected text alongside the hunk diff. Press `c` to add a short human comment, attach it as a live inline note, and copy the same prompt with your comment included. + +From another terminal, agents or scripts can export the same focused-hunk prompt: + +```bash +hunk session prompt --repo . +hunk session prompt --repo . --comment "Please simplify this path" +``` + +Use `--json` when another tool should consume the prompt programmatically. + ### Add comments For one note, use `comment add`: @@ -139,6 +152,7 @@ For a compact real example, see [`examples/3-agent-review-demo/agent-context.jso ## Practical defaults - start with `hunk session review --repo . --json` +- use `hunk session prompt --repo .` when the user wants paste-ready agent context - only add `--include-patch` when the raw patch is actually needed - use `comment add` for one-off notes and `comment apply` for batches - prefer `--repo` over `--session-path` unless you have a specific advanced reload case diff --git a/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index d1e45713..3acba151 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -17,10 +17,11 @@ If no session exists, ask the user to launch Hunk in their terminal first. 3. hunk session review --repo . --json # inspect file/hunk structure first 4. hunk session review --repo . --include-patch --json # opt into raw diff text only when needed 5. hunk session context --repo . # check current focus when needed -6. hunk session navigate ... # move to the right place -7. hunk session reload -- # swap contents if needed -8. hunk session comment add ... # leave one review note -9. hunk session comment apply ... # apply many agent notes in one stdin batch +6. hunk session prompt --repo . # export focused hunk as paste-ready prompt +7. hunk session navigate ... # move to the right place +8. hunk session reload -- # swap contents if needed +9. hunk session comment add ... # leave one review note +10. hunk session comment apply ... # apply many agent notes in one stdin batch ``` ## Session selection @@ -47,11 +48,13 @@ hunk session list [--json] hunk session get (--repo . | ) [--json] hunk session context (--repo . | ) [--json] hunk session review (--repo . | ) [--json] [--include-patch] +hunk session prompt (--repo . | ) [--comment "..."] [--selected-text "..."] [--json] ``` - `get` shows the session `Path`, `Repo`, and `Source`, which helps when choosing between `--repo` and `--session-path` - `Repo` is what `--repo` matches; `Path` is what `--session-path` matches - `review --json` returns file and hunk structure by default; add `--include-patch` only when a caller truly needs raw unified diff text +- `prompt` returns a paste-ready coding-agent prompt for the currently focused hunk; add `--comment` or `--selected-text` when relaying user feedback ### Navigate @@ -130,8 +133,9 @@ Typical flow: 1. Load the right content (`reload` if needed) 2. Navigate to the first interesting file / hunk 3. Add a comment explaining what's happening and why -4. If you already have several notes ready, prefer one `comment apply` batch over many separate shell invocations -5. Summarize when done +4. If the user asks for context they can paste back to an agent, use `hunk session prompt --repo .` +5. If you already have several notes ready, prefer one `comment apply` batch over many separate shell invocations +6. Summarize when done Guidelines: diff --git a/src/core/agentPrompt.test.ts b/src/core/agentPrompt.test.ts new file mode 100644 index 00000000..bb10c4b8 --- /dev/null +++ b/src/core/agentPrompt.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { buildAgentPrompt, createAgentPromptFile, extractHunkPatch } from "./agentPrompt"; +import { createTestDiffFile, lines } from "../../test/helpers/diff-helpers"; + +const patch = lines( + "diff --git a/example.ts b/example.ts", + "index 1111111..2222222 100644", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -1 +1 @@", + "-const one = 1;", + "+const one = 2;", + "@@ -10 +10 @@", + "-const ten = 10;", + "+const ten = 11;", +); + +describe("agent prompt export", () => { + test("extracts one hunk with file headers from a raw patch", () => { + expect(extractHunkPatch(patch, 1)).toBe( + lines( + "diff --git a/example.ts b/example.ts", + "index 1111111..2222222 100644", + "--- a/example.ts", + "+++ b/example.ts", + "@@ -10 +10 @@", + "-const ten = 10;", + "+const ten = 11;", + ).trimEnd(), + ); + }); + + test("builds a paste-ready prompt with comment, selected text, and diff hunk", () => { + const file = createTestDiffFile({ + before: "export const value = 1;\n", + after: "export const value = 2;\n", + path: "src/example.ts", + }); + const prompt = buildAgentPrompt({ + title: "demo working tree", + repoRoot: "/repo/demo", + file: createAgentPromptFile({ ...file, patch }), + hunkIndex: 0, + selectedText: "export const value = 2;", + comment: "Please make this configurable.", + }); + + expect(prompt).toContain("Please use this Hunk review context"); + expect(prompt).toContain("- Repo: /repo/demo"); + expect(prompt).toContain("- File: src/example.ts"); + expect(prompt).toContain("My comment:\nPlease make this configurable."); + expect(prompt).toContain("Selected text from Hunk:\n```text\nexport const value = 2;\n```"); + expect(prompt).toContain("```diff\ndiff --git a/example.ts b/example.ts"); + expect(prompt).toContain("@@ -1 +1 @@"); + }); + + test("falls back to the hunk header when raw patch text is unavailable", () => { + const file = createTestDiffFile({ path: "src/example.ts" }); + const prompt = buildAgentPrompt({ + file: createAgentPromptFile(file), + hunkIndex: 0, + }); + + expect(prompt).toContain("Diff hunk:"); + expect(prompt).toContain("@@"); + }); +}); diff --git a/src/core/agentPrompt.ts b/src/core/agentPrompt.ts new file mode 100644 index 00000000..f26299c2 --- /dev/null +++ b/src/core/agentPrompt.ts @@ -0,0 +1,138 @@ +import type { DiffFile } from "./types"; +import { formatHunkHeader } from "./hunkHeader"; +import { hunkLineRange } from "./liveComments"; + +export interface AgentPromptHunk { + index: number; + header: string; + oldRange?: [number, number]; + newRange?: [number, number]; +} + +export interface AgentPromptFile { + path: string; + previousPath?: string; + patch?: string; + hunks: AgentPromptHunk[]; +} + +export interface AgentPromptInput { + title?: string; + sourceLabel?: string; + repoRoot?: string; + file: AgentPromptFile; + hunkIndex: number; + selectedText?: string; + comment?: string; +} + +function trimTrailingNewlines(value: string) { + return value.replace(/\n+$/, ""); +} + +function codeFence(language: string, value: string) { + const longestFence = Math.max( + 2, + ...Array.from(value.matchAll(/`+/g), (match) => match[0].length), + ); + const fence = "`".repeat(longestFence + 1); + return `${fence}${language}\n${trimTrailingNewlines(value)}\n${fence}`; +} + +function formatRange(range: [number, number] | undefined) { + if (!range) { + return "-"; + } + + return range[0] === range[1] ? `${range[0]}` : `${range[0]}..${range[1]}`; +} + +/** Convert one loaded diff file into the generic prompt-export shape. */ +export function createAgentPromptFile(file: DiffFile): AgentPromptFile { + return { + path: file.path, + previousPath: file.previousPath, + patch: file.patch, + hunks: file.metadata.hunks.map((hunk, index) => ({ + index, + header: formatHunkHeader(hunk), + ...hunkLineRange(hunk), + })), + }; +} + +/** Extract one raw unified-diff hunk from a per-file patch, preserving file headers. */ +export function extractHunkPatch(patch: string | undefined, hunkIndex: number) { + if (!patch) { + return undefined; + } + + const normalizedPatch = patch.replaceAll("\r\n", "\n"); + const lines = normalizedPatch.split("\n"); + const hunkLineIndexes = lines.reduce((indexes, line, index) => { + if (line.startsWith("@@ ")) { + indexes.push(index); + } + + return indexes; + }, []); + const hunkStart = hunkLineIndexes[hunkIndex]; + if (hunkStart === undefined) { + return undefined; + } + + const firstHunkStart = hunkLineIndexes[0] ?? hunkStart; + const hunkEnd = hunkLineIndexes[hunkIndex + 1] ?? lines.length; + const headerLines = lines.slice(0, firstHunkStart).filter((line) => line.length > 0); + const hunkLines = lines.slice(hunkStart, hunkEnd); + const selectedLines = [...headerLines, ...hunkLines]; + + return trimTrailingNewlines(selectedLines.join("\n")); +} + +/** Build a paste-ready prompt for sending the focused Hunk context to a coding agent. */ +export function buildAgentPrompt({ + title, + sourceLabel, + repoRoot, + file, + hunkIndex, + selectedText, + comment, +}: AgentPromptInput) { + const hunk = file.hunks[hunkIndex]; + if (!hunk) { + throw new Error(`No hunk ${hunkIndex + 1} exists in ${file.path}.`); + } + + const normalizedComment = comment?.trim(); + const normalizedSelection = selectedText?.trim(); + const diffSnippet = extractHunkPatch(file.patch, hunkIndex) ?? hunk.header; + const locationLines = [ + `- Repo: ${repoRoot ?? sourceLabel ?? "(unknown)"}`, + ...(title ? [`- Review: ${title}`] : []), + `- File: ${file.path}`, + ...(file.previousPath ? [`- Previous file: ${file.previousPath}`] : []), + `- Hunk: ${hunk.index + 1}`, + `- Old lines: ${formatRange(hunk.oldRange)}`, + `- New lines: ${formatRange(hunk.newRange)}`, + ]; + + return trimTrailingNewlines( + [ + "Please use this Hunk review context to help me update the code.", + "", + "Context:", + ...locationLines, + ...(normalizedComment ? ["", "My comment:", normalizedComment] : []), + ...(normalizedSelection + ? ["", "Selected text from Hunk:", codeFence("text", normalizedSelection)] + : []), + "", + "Diff hunk:", + codeFence("diff", diffSnippet), + "", + "Please address my comment against this diff. If you need more surrounding code, ask before editing.", + ].join("\n"), + ); +} diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 0e752027..9601bcad 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -301,6 +301,33 @@ describe("parseCli", () => { }); }); + test("parses session prompt with comment and selected text", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "prompt", + "--repo", + ".", + "--comment", + "Please simplify this.", + "--selected-text", + "const value = 1;", + "--json", + ]); + + expect(parsed).toEqual({ + kind: "session", + action: "prompt", + selector: { + repoRoot: process.cwd(), + }, + output: "json", + comment: "Please simplify this.", + selectedText: "const value = 1;", + }); + }); + test("parses session navigate by hunk number", async () => { const parsed = await parseCli([ "bun", diff --git a/src/core/cli.ts b/src/core/cli.ts index 0b94564c..6ce026ae 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -598,6 +598,7 @@ async function parseSessionCommand(tokens: string[]): Promise { " hunk session context --repo ", " hunk session review [--include-patch]", " hunk session review --repo [--include-patch]", + " hunk session prompt ( | --repo ) [--comment ]", " hunk session navigate ( | --repo ) --file (--hunk | --old-line | --new-line )", " hunk session navigate ( | --repo ) (--next-comment | --prev-comment)", " hunk session reload ( | --repo | --session-path ) [--source ] -- diff [ref] [-- ]", @@ -689,6 +690,48 @@ async function parseSessionCommand(tokens: string[]): Promise { }; } + if (subcommand === "prompt") { + const command = new Command("session prompt") + .description("export a paste-ready prompt for the focused hunk") + .argument("[sessionId]") + .option("--repo ", "target the live session whose repo root matches this path") + .option("--comment ", "include a user comment in the prompt") + .option("--selected-text ", "include explicit selected text in the prompt") + .option("--json", "emit structured JSON"); + + let parsedSessionId: string | undefined; + let parsedOptions: { + repo?: string; + comment?: string; + selectedText?: string; + json?: boolean; + } = {}; + + command.action( + ( + sessionId: string | undefined, + options: { repo?: string; comment?: string; selectedText?: string; json?: boolean }, + ) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + return { + kind: "session", + action: "prompt", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + comment: parsedOptions.comment, + selectedText: parsedOptions.selectedText, + }; + } + if (subcommand === "navigate") { const command = new Command("session navigate") .description("move a live Hunk session to one diff hunk") diff --git a/src/core/types.ts b/src/core/types.ts index b79fa395..892c62d9 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -121,6 +121,15 @@ export interface SessionReviewCommandInput { includePatch: boolean; } +export interface SessionPromptCommandInput { + kind: "session"; + action: "prompt"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + comment?: string; + selectedText?: string; +} + export interface SessionNavigateCommandInput { kind: "session"; action: "navigate"; @@ -204,6 +213,7 @@ export type SessionCommandInput = | SessionListCommandInput | SessionGetCommandInput | SessionReviewCommandInput + | SessionPromptCommandInput | SessionNavigateCommandInput | SessionReloadCommandInput | SessionCommentAddCommandInput diff --git a/src/session/commands.test.ts b/src/session/commands.test.ts index 6afdba4c..de8f21cf 100644 --- a/src/session/commands.test.ts +++ b/src/session/commands.test.ts @@ -708,6 +708,54 @@ describe("session command compatibility checks", () => { }); }); + test("exports a paste-ready prompt for the selected session hunk", async () => { + setSessionCommandTestHooks({ + createClient: () => + createClient({ + getSessionReview: async (input) => { + expect(input.selector).toEqual({ sessionId: "session-1" }); + expect(input.includePatch).toBe(true); + return createTestSessionReview(true); + }, + }), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "prompt", + selector: { sessionId: "session-1" }, + output: "text", + comment: "Please make this clearer.", + selectedText: "selected code", + } satisfies SessionCommandInput); + + expect(output).toContain("Please use this Hunk review context"); + expect(output).toContain("- Repo: /repo"); + expect(output).toContain("- File: README.md"); + expect(output).toContain("My comment:\nPlease make this clearer."); + expect(output).toContain("Selected text from Hunk:\n```text\nselected code\n```"); + expect(output).toContain("```diff\n@@ -1,1 +1,2 @@\n```"); + }); + + test("exports selected session hunk prompt as JSON", async () => { + setSessionCommandTestHooks({ + createClient: () => createClient({}), + resolveDaemonAvailability: async () => true, + }); + + const output = await runSessionCommand({ + kind: "session", + action: "prompt", + selector: { sessionId: "session-1" }, + output: "json", + } satisfies SessionCommandInput); + + const parsed = JSON.parse(output); + expect(parsed.prompt).toContain("- File: README.md"); + expect(parsed.prompt).toContain("Diff hunk:"); + }); + test("runs comment-apply commands through the daemon and formats the applied batch", async () => { setSessionCommandTestHooks({ createClient: () => diff --git a/src/session/commands.ts b/src/session/commands.ts index b76d0c94..e6564db2 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -1,3 +1,4 @@ +import { buildAgentPrompt } from "../core/agentPrompt"; import type { SessionCommandInput, SessionCommandOutput, @@ -36,6 +37,7 @@ const REQUIRED_ACTION_BY_COMMAND: Record formatReviewOutput(review)); } + case "prompt": { + const review = await client.getSessionReview({ + kind: "session", + action: "review", + output: "json", + selector: normalizedSelector!, + includePatch: true, + }); + if (!review.selectedFile || !review.selectedHunk) { + throw new Error("The selected Hunk session does not have a focused hunk to export."); + } + + const prompt = buildAgentPrompt({ + title: review.title, + sourceLabel: review.sourceLabel, + repoRoot: review.repoRoot, + file: review.selectedFile, + hunkIndex: review.selectedHunk.index, + comment: input.comment, + selectedText: input.selectedText, + }); + return renderOutput(input.output, { prompt }, () => `${prompt}\n`); + } case "navigate": { const result = await client.navigateToHunk({ ...input, diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 56b71523..ac817740 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,3 +1,5 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; import { MouseButton, type MouseEvent as TuiMouseEvent, @@ -5,9 +7,15 @@ import { } from "@opentui/core"; import { useRenderer, useTerminalDimensions } from "@opentui/react"; import { Suspense, lazy, useCallback, useEffect, useMemo, useState, useRef } from "react"; +import { buildAgentPrompt, createAgentPromptFile } from "../core/agentPrompt"; +import { resolveUserConfigDir } from "../core/paths"; import type { AppBootstrap, CliInput, LayoutMode } from "../core/types"; import { canReloadInput, computeWatchSignature } from "../core/watch"; import type { HunkSessionBrokerClient, ReloadedSessionResult } from "../hunk-session/types"; +import { + AgentCommentDialog, + AgentPromptPreviewDialog, +} from "./components/chrome/AgentPromptDialog"; import { MenuBar } from "./components/chrome/MenuBar"; import { StatusBar } from "./components/chrome/StatusBar"; import { DiffPane } from "./components/panes/DiffPane"; @@ -70,6 +78,20 @@ function withCurrentViewOptions( }; } +/** Persist the latest generated prompt outside the repo as a clipboard fallback. */ +function writeAgentPromptFallback(prompt: string) { + const configDir = resolveUserConfigDir(); + if (!configDir) { + return undefined; + } + + const hunkDir = join(configDir, "hunk"); + mkdirSync(hunkDir, { recursive: true }); + const promptPath = join(hunkDir, "agent-prompt.md"); + writeFileSync(promptPath, `${prompt.replace(/\n*$/, "")}\n`); + return promptPath; +} + /** Orchestrate global app state, layout, navigation, and pane coordination. */ export function App({ bootstrap, @@ -113,6 +135,13 @@ export function App({ const [sidebarVisible, setSidebarVisible] = useState(() => !pagerMode); const [forceSidebarOpen, setForceSidebarOpen] = useState(false); const [showHelp, setShowHelp] = useState(false); + const [commentDialogOpen, setCommentDialogOpen] = useState(false); + const [commentDraft, setCommentDraft] = useState(""); + const [commentDialogSelectedText, setCommentDialogSelectedText] = useState(); + const [promptPreview, setPromptPreview] = useState<{ prompt: string; savedPath?: string } | null>( + null, + ); + const [statusNotice, setStatusNotice] = useState(null); const [focusArea, setFocusArea] = useState("files"); const [sidebarWidth, setSidebarWidth] = useState(34); const [resizeDragOriginX, setResizeDragOriginX] = useState(null); @@ -139,6 +168,144 @@ export function App({ setShowAgentNotes(true); }, []); + const readSelectedText = useCallback(() => { + const text = renderer.getSelection()?.getSelectedText().trim(); + return text && text.length > 0 ? text : undefined; + }, [renderer]); + + const publishStatus = useCallback((message: string) => { + setStatusNotice(message); + }, []); + + const closeAgentModal = useCallback(() => { + setCommentDialogOpen(false); + setPromptPreview(null); + }, []); + + const publishAgentPrompt = useCallback( + ({ comment, selectedText }: { comment?: string; selectedText?: string } = {}) => { + if (!selectedFile) { + publishStatus("No selected hunk to publish."); + return; + } + + let prompt: string; + try { + prompt = buildAgentPrompt({ + title: bootstrap.changeset.title, + sourceLabel: bootstrap.changeset.sourceLabel, + repoRoot: bootstrap.changeset.sourceLabel, + file: createAgentPromptFile(selectedFile), + hunkIndex: selectedHunkIndex, + selectedText, + comment, + }); + } catch (error) { + publishStatus(error instanceof Error ? error.message : "No selected hunk to publish."); + return; + } + + let copied = false; + try { + copied = renderer.copyToClipboardOSC52(prompt); + } catch { + copied = false; + } + + let savedPath: string | undefined; + try { + savedPath = writeAgentPromptFallback(prompt); + } catch { + savedPath = undefined; + } + + if (copied) { + setPromptPreview(null); + publishStatus( + savedPath + ? `Copied agent prompt to clipboard. Saved fallback: ${savedPath}` + : "Copied agent prompt to clipboard.", + ); + return; + } + + setPromptPreview({ prompt, savedPath }); + publishStatus( + savedPath + ? `Clipboard unavailable. Saved agent prompt to ${savedPath}.` + : "Clipboard unavailable. Select/copy the prompt from the dialog.", + ); + }, + [ + bootstrap.changeset.sourceLabel, + bootstrap.changeset.title, + publishStatus, + renderer, + selectedFile, + selectedHunkIndex, + ], + ); + + const copyAgentPrompt = useCallback(() => { + publishAgentPrompt({ selectedText: readSelectedText() }); + }, [publishAgentPrompt, readSelectedText]); + + const openAgentCommentDialog = useCallback(() => { + if (!selectedFile) { + publishStatus("No selected hunk to comment on."); + return; + } + + setCommentDraft(""); + setCommentDialogSelectedText(readSelectedText()); + setCommentDialogOpen(true); + }, [publishStatus, readSelectedText, selectedFile]); + + const submitAgentComment = useCallback(() => { + const summary = commentDraft.trim(); + if (!summary || !selectedFile) { + setCommentDialogOpen(false); + return; + } + + try { + review.addLiveComment( + { + filePath: selectedFile.path, + hunkIndex: selectedHunkIndex, + summary, + author: "user", + }, + `ui:${Date.now()}:${Math.random().toString(36).slice(2)}`, + ); + setShowAgentNotes(true); + } catch (error) { + publishStatus(error instanceof Error ? error.message : "Failed to add live comment."); + setCommentDialogOpen(false); + return; + } + + publishAgentPrompt({ comment: summary, selectedText: commentDialogSelectedText }); + setCommentDialogOpen(false); + }, [ + commentDialogSelectedText, + commentDraft, + publishAgentPrompt, + publishStatus, + review, + selectedFile, + selectedHunkIndex, + ]); + + useEffect(() => { + if (!statusNotice) { + return; + } + + const timeout = setTimeout(() => setStatusNotice(null), 6_000); + return () => clearTimeout(timeout); + }, [statusNotice]); + useHunkSessionBridge({ addLiveComment: review.addLiveComment, addLiveCommentBatch: review.addLiveCommentBatch, @@ -472,11 +639,13 @@ export function App({ buildAppMenus({ activeThemeId: activeTheme.id, canRefreshCurrentInput, + copyAgentPrompt, focusFilter, layoutMode, moveToAnnotatedFile, moveToAnnotatedHunk, moveToHunk: review.moveToHunk, + openAgentCommentDialog, refreshCurrentInput: triggerRefreshCurrentInput, requestQuit, selectLayoutMode, @@ -498,11 +667,13 @@ export function App({ [ activeTheme.id, canRefreshCurrentInput, + copyAgentPrompt, focusFilter, layoutMode, moveToAnnotatedFile, moveToAnnotatedHunk, requestQuit, + openAgentCommentDialog, review.moveToHunk, selectLayoutMode, triggerRefreshCurrentInput, @@ -544,12 +715,16 @@ export function App({ canRefreshCurrentInput, closeHelp, closeMenu, + closeModal: closeAgentModal, + copyAgentPrompt, cycleTheme, focusArea, focusFilter, + modalOpen: commentDialogOpen || promptPreview !== null, moveToAnnotatedHunk, moveToHunk: review.moveToHunk, moveMenuItem, + openAgentCommentDialog, openMenu, pagerMode, requestQuit, @@ -625,6 +800,7 @@ export function App({ const diffHeaderStatsWidth = Math.min(24, Math.max(16, Math.floor(diffContentWidth / 3))); const diffHeaderLabelWidth = Math.max(8, diffContentWidth - diffHeaderStatsWidth - 1); const diffSeparatorWidth = Math.max(4, diffContentWidth - 2); + const effectiveNoticeText = statusNotice ?? noticeText ?? undefined; return ( - {!pagerMode && (focusArea === "filter" || Boolean(review.filter) || Boolean(noticeText)) ? ( + {!pagerMode && + (focusArea === "filter" || Boolean(review.filter) || Boolean(effectiveNoticeText)) ? ( ) : null} + + {!pagerMode && commentDialogOpen && selectedFile ? ( + setCommentDialogOpen(false)} + onChange={setCommentDraft} + onSubmit={submitAgentComment} + /> + ) : null} + + {!pagerMode && promptPreview ? ( + setPromptPreview(null)} + /> + ) : null} ); } diff --git a/src/ui/components/chrome/AgentPromptDialog.tsx b/src/ui/components/chrome/AgentPromptDialog.tsx new file mode 100644 index 00000000..41a5212f --- /dev/null +++ b/src/ui/components/chrome/AgentPromptDialog.tsx @@ -0,0 +1,111 @@ +import { isEscapeKey } from "../../lib/keyboard"; +import type { AppTheme } from "../../themes"; +import { ModalFrame } from "./ModalFrame"; + +export function AgentCommentDialog({ + comment, + selectedTextAvailable, + targetLabel, + terminalHeight, + terminalWidth, + theme, + onCancel, + onChange, + onSubmit, +}: { + comment: string; + selectedTextAvailable: boolean; + targetLabel: string; + terminalHeight: number; + terminalWidth: number; + theme: AppTheme; + onCancel: () => void; + onChange: (value: string) => void; + onSubmit: () => void; +}) { + const width = Math.min(82, Math.max(48, terminalWidth - 8)); + const bodyWidth = Math.max(1, width - 4); + + return ( + + + {targetLabel} + { + if (!isEscapeKey(key)) { + return; + } + + key.preventDefault(); + key.stopPropagation(); + onCancel(); + }} + /> + + {selectedTextAvailable + ? "Enter submits. Esc cancels. Your selected text will be included." + : "Enter submits. Esc cancels. The focused hunk will be included."} + + + + ); +} + +export function AgentPromptPreviewDialog({ + prompt, + savedPath, + terminalHeight, + terminalWidth, + theme, + onClose, +}: { + prompt: string; + savedPath?: string; + terminalHeight: number; + terminalWidth: number; + theme: AppTheme; + onClose: () => void; +}) { + const width = Math.min(96, Math.max(56, terminalWidth - 6)); + const height = Math.min(Math.max(12, terminalHeight - 4), 28); + + return ( + + + + {savedPath + ? `Clipboard unavailable. Prompt saved to ${savedPath}. Select/copy below if needed.` + : "Clipboard unavailable. Select/copy the prompt below."} + + + + {prompt} + + + Esc closes + + + ); +} diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index 1ed1f595..fe0cb8a6 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -51,6 +51,8 @@ export function HelpDialog({ title: "Review", items: [ ["/", "focus file filter"], + ["p", "copy focused hunk prompt"], + ["c", "comment and copy prompt"], ["Tab", "toggle files/filter focus"], ["F10", "open menus"], [canRefresh ? "r / q" : "q", canRefresh ? "reload / quit" : "quit"], diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index f2349d9e..95f3078e 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -25,12 +25,16 @@ export interface UseAppKeyboardShortcutsOptions { canRefreshCurrentInput: boolean; closeHelp: () => void; closeMenu: () => void; + closeModal: () => void; + copyAgentPrompt: () => void; cycleTheme: () => void; focusArea: FocusArea; focusFilter: () => void; + modalOpen: boolean; moveToAnnotatedHunk: (delta: number) => void; moveToHunk: (delta: number) => void; moveMenuItem: (delta: number) => void; + openAgentCommentDialog: () => void; openMenu: (menuId: MenuId) => void; pagerMode: boolean; requestQuit: () => void; @@ -56,12 +60,16 @@ export function useAppKeyboardShortcuts({ canRefreshCurrentInput, closeHelp, closeMenu, + closeModal, + copyAgentPrompt, cycleTheme, focusArea, focusFilter, + modalOpen, moveToAnnotatedHunk, moveToHunk, moveMenuItem, + openAgentCommentDialog, openMenu, pagerMode, requestQuit, @@ -81,11 +89,13 @@ export function useAppKeyboardShortcuts({ }: UseAppKeyboardShortcutsOptions) { const activeMenuIdRef = useRef(activeMenuId); const focusAreaRef = useRef(focusArea); + const modalOpenRef = useRef(modalOpen); const pagerModeRef = useRef(pagerMode); const showHelpRef = useRef(showHelp); activeMenuIdRef.current = activeMenuId; focusAreaRef.current = focusArea; + modalOpenRef.current = modalOpen; pagerModeRef.current = pagerMode; showHelpRef.current = showHelp; @@ -178,6 +188,18 @@ export function useAppKeyboardShortcuts({ } }; + const handleModalShortcut = (key: KeyEvent) => { + if (!modalOpenRef.current) { + return false; + } + + if (isEscapeKey(key)) { + closeModal(); + } + + return true; + }; + const handleHelpShortcut = (key: KeyEvent) => { if (!showHelpRef.current || !isEscapeKey(key)) { return false; @@ -351,6 +373,16 @@ export function useAppKeyboardShortcuts({ return; } + if (key.name === "p" || key.sequence === "p") { + runAndCloseMenu(copyAgentPrompt); + return; + } + + if (key.name === "c" || key.sequence === "c") { + runAndCloseMenu(openAgentCommentDialog); + return; + } + if (key.name === "l" || key.sequence === "l") { runAndCloseMenu(toggleLineNumbers); return; @@ -387,6 +419,10 @@ export function useAppKeyboardShortcuts({ }; useKeyboard((key: KeyEvent) => { + if (handleModalShortcut(key)) { + return; + } + if (handleMenuToggleShortcut(key)) { return; } diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index 058bb33c..e03b619c 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -5,11 +5,13 @@ import { THEMES } from "../themes"; export interface BuildAppMenusOptions { activeThemeId: string; canRefreshCurrentInput: boolean; + copyAgentPrompt: () => void; focusFilter: () => void; layoutMode: LayoutMode; moveToAnnotatedFile: (delta: number) => void; moveToAnnotatedHunk: (delta: number) => void; moveToHunk: (delta: number) => void; + openAgentCommentDialog: () => void; refreshCurrentInput: () => void; requestQuit: () => void; selectLayoutMode: (mode: LayoutMode) => void; @@ -33,11 +35,13 @@ export interface BuildAppMenusOptions { export function buildAppMenus({ activeThemeId, canRefreshCurrentInput, + copyAgentPrompt, focusFilter, layoutMode, moveToAnnotatedFile, moveToAnnotatedHunk, moveToHunk, + openAgentCommentDialog, refreshCurrentInput, requestQuit, selectLayoutMode, @@ -195,6 +199,19 @@ export function buildAppMenus({ ], theme: themeMenuEntries, agent: [ + { + kind: "item", + label: "Copy focused hunk prompt", + hint: "p", + action: copyAgentPrompt, + }, + { + kind: "item", + label: "Comment and copy prompt", + hint: "c", + action: openAgentCommentDialog, + }, + { kind: "separator" }, { kind: "item", label: "Agent notes", diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 98c77b8f..3abc9bf8 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -123,11 +123,13 @@ describe("ui helpers", () => { const menus = buildAppMenus({ activeThemeId: "graphite", canRefreshCurrentInput: true, + copyAgentPrompt: () => {}, focusFilter: () => {}, layoutMode: "stack", moveToAnnotatedFile: () => {}, moveToAnnotatedHunk: () => {}, moveToHunk: () => {}, + openAgentCommentDialog: () => {}, refreshCurrentInput: () => {}, requestQuit: () => {}, selectLayoutMode: () => {}, @@ -175,6 +177,17 @@ describe("ui helpers", () => { (entry) => entry.kind === "item" && entry.label === "Graphite" && entry.checked, ), ).toBe(true); + expect( + menus.agent + .filter((entry): entry is Extract => entry.kind === "item") + .map((entry) => entry.label), + ).toEqual([ + "Copy focused hunk prompt", + "Comment and copy prompt", + "Agent notes", + "Next annotated file", + "Previous annotated file", + ]); }); test("keyboard alias helpers normalize the shared scroll shortcut keys", () => {