From 6f325de06ae620b1382ada03558cc517057a5cf5 Mon Sep 17 00:00:00 2001 From: Amir Date: Sat, 9 May 2026 17:54:52 +0200 Subject: [PATCH] feat(ui): add y shortcut to yank selection to clipboard Press y to copy the active terminal text selection to the clipboard. Uses pbcopy on macOS and falls back to OSC52 elsewhere. The action also appears in the Navigate menu and the help dialog. Routes the acknowledgement through a new useTransientNotice channel so the yank message and the startup update notice share one status-bar slot with one dismiss timer instead of competing. --- CHANGELOG.md | 2 + src/ui/App.tsx | 18 ++++++ src/ui/AppHost.interactions.test.tsx | 41 ++++++++++++++ src/ui/AppHost.tsx | 8 ++- src/ui/components/chrome/HelpDialog.tsx | 1 + src/ui/components/ui-components.test.tsx | 1 + src/ui/hooks/useAppKeyboardShortcuts.ts | 12 ++++ src/ui/hooks/useStartupUpdateNotice.test.tsx | 5 +- src/ui/hooks/useStartupUpdateNotice.ts | 39 +++---------- src/ui/hooks/useTransientNotice.ts | 41 ++++++++++++++ src/ui/lib/appMenus.ts | 8 +++ src/ui/lib/clipboard.test.ts | 57 +++++++++++++++++++ src/ui/lib/clipboard.ts | 58 ++++++++++++++++++++ src/ui/lib/ui-lib.test.ts | 13 +++++ 14 files changed, 269 insertions(+), 35 deletions(-) create mode 100644 src/ui/hooks/useTransientNotice.ts create mode 100644 src/ui/lib/clipboard.test.ts create mode 100644 src/ui/lib/clipboard.ts 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")