diff --git a/CHANGELOG.md b/CHANGELOG.md index c3778684..8aefec1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable user-visible changes to Hunk are documented in this file. ### Added +- Added a `y` yank shortcut for copying the current text selection to the clipboard. + ### Changed ### Fixed diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 56b71523..29815aff 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -23,6 +23,7 @@ import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge"; import { useMenuController } from "./hooks/useMenuController"; import { useReviewController } from "./hooks/useReviewController"; import { buildAppMenus } from "./lib/appMenus"; +import { yankActiveSelection } from "./lib/clipboard"; import { fileRowId } from "./lib/ids"; import { resolveResponsiveLayout } from "./lib/responsive"; import { resizeSidebarWidth } from "./lib/sidebar"; @@ -31,6 +32,12 @@ import { resolveTheme, THEMES } from "./themes"; type FocusArea = "files" | "filter"; const FAST_CODE_HORIZONTAL_SCROLL_COLUMNS = 8; +const CLIPBOARD_NOTICE_DURATION_MS = 2_500; +const CLIPBOARD_NOTICE_MESSAGES = { + copied: "Yanked selection to clipboard.", + "no-selection": "No selection to yank.", + unavailable: "Clipboard unavailable.", +} as const; const LazyHelpDialog = lazy(async () => ({ default: (await import("./components/chrome/HelpDialog")).HelpDialog, @@ -77,6 +84,7 @@ export function App({ noticeText, onQuit = () => process.exit(0), onReloadSession, + showNotice, }: { bootstrap: AppBootstrap; hostClient?: HunkSessionBrokerClient; @@ -86,6 +94,7 @@ export function App({ nextInput: CliInput, options?: { resetApp?: boolean; sourcePath?: string }, ) => Promise; + showNotice: (message: string, durationMs: number) => void; }) { const SIDEBAR_MIN_WIDTH = 22; const DIFF_MIN_WIDTH = 48; @@ -226,6 +235,12 @@ export function App({ sidebarScrollRef.current?.scrollChildIntoView(fileRowId(selectedFile.id)); }, [selectedFile]); + /** Copy the active terminal text selection to the user's clipboard. */ + const yankSelection = useCallback(() => { + const result = yankActiveSelection(renderer); + showNotice(CLIPBOARD_NOTICE_MESSAGES[result], CLIPBOARD_NOTICE_DURATION_MS); + }, [renderer, showNotice]); + /** Scroll the main review pane by line steps, viewport fractions, or whole-content jumps. */ const scrollDiff = ( delta: number, @@ -494,6 +509,7 @@ export function App({ toggleLineWrap, toggleSidebar, wrapLines, + yankSelection, }), [ activeTheme.id, @@ -519,6 +535,7 @@ export function App({ toggleLineWrap, toggleSidebar, wrapLines, + yankSelection, ], ); @@ -566,6 +583,7 @@ export function App({ toggleLineWrap, toggleSidebar, triggerRefreshCurrentInput, + yankSelection, }); /** Start a mouse drag resize for the optional sidebar. */ diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index 3de7e396..c53f9b20 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -492,6 +492,47 @@ describe("App interactions", () => { } }); + test("yank shortcut copies the current text selection to the clipboard", async () => { + const setup = await testRender(, { + width: 240, + height: 24, + }); + const copiedTexts: string[] = []; + const copyToClipboard = mock((text: string) => { + copiedTexts.push(text); + return true; + }); + + setup.renderer.copyToClipboardOSC52 = copyToClipboard; + setup.renderer.getSelection = () => + ({ + bounds: { height: 1, width: 23, x: 1, y: 1 }, + getSelectedText: () => "export const alpha = 2;", + }) as ReturnType; + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + + try { + Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); + await flush(setup); + + await act(async () => { + await setup.mockInput.typeText("y"); + }); + await flush(setup); + + expect(copyToClipboard).toHaveBeenCalledTimes(1); + expect(copiedTexts[0]).toBe("export const alpha = 2;"); + expect(setup.captureCharFrame()).toContain("Yanked selection to clipboard."); + } finally { + if (platformDescriptor) { + Object.defineProperty(process, "platform", platformDescriptor); + } + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("keyboard shortcut can wrap long lines in the app", async () => { const setup = await testRender(, { width: 140, diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index 96aa9cf5..937be1cc 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -11,6 +11,7 @@ import { import type { HunkSessionBrokerClient } from "../hunk-session/types"; import { App } from "./App"; import { useStartupUpdateNotice } from "./hooks/useStartupUpdateNotice"; +import { useTransientNotice } from "./hooks/useTransientNotice"; /** Keep one live Hunk app mounted while allowing daemon-driven session reloads. */ export function AppHost({ @@ -26,9 +27,11 @@ export function AppHost({ }) { const [activeBootstrap, setActiveBootstrap] = useState(bootstrap); const [appVersion, setAppVersion] = useState(0); - const startupNoticeText = useStartupUpdateNotice({ + const { noticeText, showNotice } = useTransientNotice(); + useStartupUpdateNotice({ enabled: !bootstrap.input.options.pager, resolver: startupNoticeResolver, + showNotice, }); const reloadSession = useCallback( @@ -84,9 +87,10 @@ export function AppHost({ key={appVersion} bootstrap={activeBootstrap} hostClient={hostClient} - noticeText={startupNoticeText} + noticeText={noticeText} onQuit={onQuit} onReloadSession={reloadSession} + showNotice={showNotice} /> ); } diff --git a/src/ui/components/chrome/HelpDialog.tsx b/src/ui/components/chrome/HelpDialog.tsx index 1ed1f595..d6e3010e 100644 --- a/src/ui/components/chrome/HelpDialog.tsx +++ b/src/ui/components/chrome/HelpDialog.tsx @@ -26,6 +26,7 @@ export function HelpDialog({ ["Shift+Space", "page up (alt)"], ["d / u", "half page down / up"], ["[ / ]", "previous / next hunk"], + ["y", "yank selection to clipboard"], ["{ / }", "previous / next comment"], ["← / →", "scroll code left / right (Shift = faster)"], ["Home / End", "jump to top / bottom"], diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index 2fe167a4..0b4dd389 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -1519,6 +1519,7 @@ describe("UI components", () => { "Shift+Space page up (alt)", "d / u half page down / up", "[ / ] previous / next hunk", + "y yank selection to clipboard", "{ / } previous / next comment", "← / → scroll code left / right (Shift = faster)", "Home / End jump to top / bottom", diff --git a/src/ui/hooks/useAppKeyboardShortcuts.ts b/src/ui/hooks/useAppKeyboardShortcuts.ts index f2349d9e..69502359 100644 --- a/src/ui/hooks/useAppKeyboardShortcuts.ts +++ b/src/ui/hooks/useAppKeyboardShortcuts.ts @@ -47,6 +47,7 @@ export interface UseAppKeyboardShortcutsOptions { toggleLineWrap: () => void; toggleSidebar: () => void; triggerRefreshCurrentInput: () => void; + yankSelection: () => void; } /** Register the app's scoped keyboard handling while keeping mode precedence explicit. */ @@ -78,6 +79,7 @@ export function useAppKeyboardShortcuts({ toggleLineWrap, toggleSidebar, triggerRefreshCurrentInput, + yankSelection, }: UseAppKeyboardShortcutsOptions) { const activeMenuIdRef = useRef(activeMenuId); const focusAreaRef = useRef(focusArea); @@ -175,6 +177,11 @@ export function useAppKeyboardShortcuts({ if (key.name === "s" || key.sequence === "s") { toggleSidebar(); + return; + } + + if (key.name === "y" || key.sequence === "y") { + yankSelection(); } }; @@ -336,6 +343,11 @@ export function useAppKeyboardShortcuts({ return; } + if (key.name === "y" || key.sequence === "y") { + runAndCloseMenu(yankSelection); + return; + } + if ((key.name === "r" || key.sequence === "r") && canRefreshCurrentInput) { runAndCloseMenu(triggerRefreshCurrentInput); return; diff --git a/src/ui/hooks/useStartupUpdateNotice.test.tsx b/src/ui/hooks/useStartupUpdateNotice.test.tsx index 1a32cce6..8f9fa836 100644 --- a/src/ui/hooks/useStartupUpdateNotice.test.tsx +++ b/src/ui/hooks/useStartupUpdateNotice.test.tsx @@ -3,6 +3,7 @@ import { testRender } from "@opentui/react/test-utils"; import { act } from "react"; import { useEffect, useMemo, useState } from "react"; import { useStartupUpdateNotice } from "./useStartupUpdateNotice"; +import { useTransientNotice } from "./useTransientNotice"; function NoticeHarness({ delayMs = 1, @@ -19,12 +20,14 @@ function NoticeHarness({ resolver?: () => Promise<{ key: string; message: string } | null>; onNoticeText?: (value: string | null) => void; }) { - const noticeText = useStartupUpdateNotice({ + const { noticeText, showNotice } = useTransientNotice(); + useStartupUpdateNotice({ delayMs, durationMs, enabled, repeatMs, resolver, + showNotice, }); useEffect(() => { diff --git a/src/ui/hooks/useStartupUpdateNotice.ts b/src/ui/hooks/useStartupUpdateNotice.ts index 9f73a014..7bb471e6 100644 --- a/src/ui/hooks/useStartupUpdateNotice.ts +++ b/src/ui/hooks/useStartupUpdateNotice.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef } from "react"; import type { UpdateNotice } from "../../core/updateNotice"; const DEFAULT_STARTUP_NOTICE_DELAY_MS = 1200; @@ -11,37 +11,27 @@ interface StartupUpdateNoticeOptions { enabled: boolean; repeatMs?: number; resolver?: () => Promise; + showNotice: (message: string, durationMs: number) => void; } -/** Manage the session-lifetime background update notice without coupling it to chrome rendering. */ +/** Drive a session-lifetime background update check that publishes through the shared notice channel. */ export function useStartupUpdateNotice({ delayMs = DEFAULT_STARTUP_NOTICE_DELAY_MS, durationMs = DEFAULT_STARTUP_NOTICE_DURATION_MS, enabled, repeatMs = DEFAULT_STARTUP_NOTICE_REPEAT_MS, resolver, + showNotice, }: StartupUpdateNoticeOptions) { - const [noticeText, setNoticeText] = useState(null); const lastShownKeyRef = useRef(null); useEffect(() => { if (!enabled || !resolver) { - setNoticeText(null); return; } let cancelled = false; let inFlight = false; - let dismissTimer: ReturnType | null = null; - - const clearDismissTimer = () => { - if (!dismissTimer) { - return; - } - - clearTimeout(dismissTimer); - dismissTimer = null; - }; const runUpdateCheck = () => { if (cancelled || inFlight) { @@ -60,17 +50,7 @@ export function useStartupUpdateNotice({ } lastShownKeyRef.current = notice.key; - setNoticeText(notice.message); - clearDismissTimer(); - dismissTimer = setTimeout(() => { - if (cancelled) { - return; - } - - setNoticeText(null); - dismissTimer = null; - }, durationMs); - dismissTimer.unref?.(); + showNotice(notice.message, durationMs); }) .catch(() => { // Ignore non-blocking update-check failures. @@ -80,9 +60,7 @@ export function useStartupUpdateNotice({ }); }; - const delayTimer = setTimeout(() => { - runUpdateCheck(); - }, delayMs); + const delayTimer = setTimeout(runUpdateCheck, delayMs); delayTimer.unref?.(); const repeatTimer = setInterval(runUpdateCheck, repeatMs); @@ -93,9 +71,6 @@ export function useStartupUpdateNotice({ inFlight = false; clearTimeout(delayTimer); clearInterval(repeatTimer); - clearDismissTimer(); }; - }, [delayMs, durationMs, enabled, repeatMs, resolver]); - - return noticeText; + }, [delayMs, durationMs, enabled, repeatMs, resolver, showNotice]); } diff --git a/src/ui/hooks/useTransientNotice.ts b/src/ui/hooks/useTransientNotice.ts new file mode 100644 index 00000000..44e92db2 --- /dev/null +++ b/src/ui/hooks/useTransientNotice.ts @@ -0,0 +1,41 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface TransientNoticeChannel { + noticeText: string | null; + showNotice: (message: string, durationMs: number) => void; +} + +/** + * One transient status-bar notice channel shared by all app actions that need it. + * Last writer wins; the prior dismiss timer is cleared so notices never linger past + * their successor. + */ +export function useTransientNotice(): TransientNoticeChannel { + const [noticeText, setNoticeText] = useState(null); + const dismissTimerRef = useRef | null>(null); + + const clearDismissTimer = useCallback(() => { + if (dismissTimerRef.current) { + clearTimeout(dismissTimerRef.current); + dismissTimerRef.current = null; + } + }, []); + + useEffect(() => clearDismissTimer, [clearDismissTimer]); + + const showNotice = useCallback( + (message: string, durationMs: number) => { + clearDismissTimer(); + setNoticeText(message); + const timer = setTimeout(() => { + setNoticeText(null); + dismissTimerRef.current = null; + }, durationMs); + timer.unref?.(); + dismissTimerRef.current = timer; + }, + [clearDismissTimer], + ); + + return { noticeText, showNotice }; +} diff --git a/src/ui/lib/appMenus.ts b/src/ui/lib/appMenus.ts index 058bb33c..2fede8c6 100644 --- a/src/ui/lib/appMenus.ts +++ b/src/ui/lib/appMenus.ts @@ -27,6 +27,7 @@ export interface BuildAppMenusOptions { toggleLineWrap: () => void; toggleSidebar: () => void; wrapLines: boolean; + yankSelection: () => void; } /** Build the top-level app menus from the current app state and actions. */ @@ -55,6 +56,7 @@ export function buildAppMenus({ toggleLineWrap, toggleSidebar, wrapLines, + yankSelection, }: BuildAppMenusOptions): Record { const themeMenuEntries: MenuEntry[] = THEMES.map((theme) => ({ kind: "item", @@ -172,6 +174,12 @@ export function buildAppMenus({ hint: "]", action: () => moveToHunk(1), }, + { + kind: "item", + label: "Yank selection", + hint: "y", + action: yankSelection, + }, { kind: "separator" }, { kind: "item", diff --git a/src/ui/lib/clipboard.test.ts b/src/ui/lib/clipboard.test.ts new file mode 100644 index 00000000..96c8b4a8 --- /dev/null +++ b/src/ui/lib/clipboard.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, mock, test } from "bun:test"; +import { copyTextToClipboard, type TerminalClipboard } from "./clipboard"; + +describe("clipboard helpers", () => { + test("uses pbcopy first on macOS", () => { + const terminal: TerminalClipboard = { + copyToClipboardOSC52: mock(() => true), + }; + const spawnSync = mock(() => ({ status: 0 })); + + expect(copyTextToClipboard("selected text", terminal, { platform: "darwin", spawnSync })).toBe( + true, + ); + expect(spawnSync).toHaveBeenCalledWith("pbcopy", [], { + input: "selected text", + stdio: ["pipe", "ignore", "ignore"], + }); + expect(terminal.copyToClipboardOSC52).not.toHaveBeenCalled(); + }); + + test("falls back to OSC 52 when macOS pbcopy is unavailable", () => { + const terminal: TerminalClipboard = { + copyToClipboardOSC52: mock(() => true), + }; + const spawnSync = mock(() => ({ status: 1 })); + + expect(copyTextToClipboard("selected text", terminal, { platform: "darwin", spawnSync })).toBe( + true, + ); + expect(terminal.copyToClipboardOSC52).toHaveBeenCalledWith("selected text"); + }); + + test("uses OSC 52 on non-macOS terminals", () => { + const terminal: TerminalClipboard = { + copyToClipboardOSC52: mock(() => true), + }; + const spawnSync = mock(() => ({ status: 0 })); + + expect(copyTextToClipboard("selected text", terminal, { platform: "linux", spawnSync })).toBe( + true, + ); + expect(terminal.copyToClipboardOSC52).toHaveBeenCalledWith("selected text"); + expect(spawnSync).not.toHaveBeenCalled(); + }); + + test("returns false when no clipboard path is available", () => { + const terminal: TerminalClipboard = { + copyToClipboardOSC52: mock(() => false), + }; + const spawnSync = mock(() => ({ status: 0 })); + + expect(copyTextToClipboard("selected text", terminal, { platform: "linux", spawnSync })).toBe( + false, + ); + expect(spawnSync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/ui/lib/clipboard.ts b/src/ui/lib/clipboard.ts new file mode 100644 index 00000000..1ae5711b --- /dev/null +++ b/src/ui/lib/clipboard.ts @@ -0,0 +1,58 @@ +import { spawnSync as spawnClipboardCommand } from "node:child_process"; + +export interface TerminalClipboard { + copyToClipboardOSC52: (text: string) => boolean; +} + +interface RendererSelection { + getSelectedText: () => string; +} + +export interface SelectionRenderer extends TerminalClipboard { + getSelection: () => RendererSelection | null | undefined; +} + +type SpawnClipboardCommand = ( + command: string, + args: string[], + options: { input: string; stdio: ["pipe", "ignore", "ignore"] }, +) => { status: number | null }; + +interface ClipboardDeps { + platform?: NodeJS.Platform; + spawnSync?: SpawnClipboardCommand; +} + +export type YankResult = "copied" | "no-selection" | "unavailable"; + +/** Copy text to the clipboard, preferring macOS pbcopy over terminal OSC52 locally. */ +export function copyTextToClipboard( + text: string, + terminal: TerminalClipboard, + { platform = process.platform, spawnSync = spawnClipboardCommand }: ClipboardDeps = {}, +) { + if (platform === "darwin") { + try { + const result = spawnSync("pbcopy", [], { + input: text, + stdio: ["pipe", "ignore", "ignore"], + }); + if (result.status === 0) { + return true; + } + } catch { + // Fall through to OSC52 for remote or restricted environments. + } + } + + return terminal.copyToClipboardOSC52(text); +} + +/** Read the active terminal selection and copy it; report why if nothing reached the clipboard. */ +export function yankActiveSelection(renderer: SelectionRenderer, deps?: ClipboardDeps): YankResult { + const text = renderer.getSelection()?.getSelectedText() ?? ""; + if (text.length === 0) { + return "no-selection"; + } + return copyTextToClipboard(text, renderer, deps) ? "copied" : "unavailable"; +} diff --git a/src/ui/lib/ui-lib.test.ts b/src/ui/lib/ui-lib.test.ts index 98c77b8f..269bd907 100644 --- a/src/ui/lib/ui-lib.test.ts +++ b/src/ui/lib/ui-lib.test.ts @@ -145,6 +145,7 @@ describe("ui helpers", () => { toggleLineWrap: () => {}, toggleSidebar: () => {}, wrapLines: true, + yankSelection: () => {}, }); expect( @@ -165,6 +166,18 @@ describe("ui helpers", () => { ) .map((entry) => entry.label), ).toEqual(["Stacked view", "Agent notes", "Line numbers", "Line wrapping"]); + expect( + menus.navigate + .filter((entry): entry is Extract => entry.kind === "item") + .map((entry) => entry.label), + ).toEqual([ + "Previous hunk", + "Next hunk", + "Yank selection", + "Previous comment", + "Next comment", + "Focus filter", + ]); expect( menus.theme .filter((entry): entry is Extract => entry.kind === "item")