diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 29910f522516..71e576e5c7e4 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -38,3 +38,4 @@ github:jappyjan github:justsomelegs github:UtkarshUsername github:SunkenInTime +github:bil0000 diff --git a/AGENTS.md b/AGENTS.md index 1b41f833ce58..12f357747991 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,7 +114,6 @@ An empty database is a bad test. Seed your worktree's `.t3` with a copy of real - Never make a PR unless the developer explicitly asks you to do so. - Conventional commit titles, plain language: `fix(web): new threads no longer spike CPU`. - Body: the problem in a sentence or two, then how you fixed it. End with the model and harness that did the work. -- **Rebase onto latest main before opening.** Stale branches conflict and burn a review round. - UI changes need before/after images. Motion or timing needs a short video. - One concern per PR. If the description says "also", split it. - When babysitting: poll checks and comments newer than the last push, verify each bot finding against the source, fix real ones, dismiss false positives with a written reason. Stay quiet when nothing is new. Stop when the bots are green on the latest commit. diff --git a/apps/desktop/src/electron/ElectronMenu.test.ts b/apps/desktop/src/electron/ElectronMenu.test.ts index 58870bbab1db..40e246722f2e 100644 --- a/apps/desktop/src/electron/ElectronMenu.test.ts +++ b/apps/desktop/src/electron/ElectronMenu.test.ts @@ -77,7 +77,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(), - items: [{ id: "copy", label: "Copy" }], + items: [{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }], position: Option.none(), }); @@ -98,7 +98,7 @@ describe("ElectronMenu", () => { const electronMenu = yield* ElectronMenu.ElectronMenu; const selectedItemId = yield* electronMenu.showContextMenu({ window: makeWindow(2), - items: [{ id: "copy", label: "Copy" }], + items: [{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }], position: Option.some({ x: 10.8, y: 20.2 }), }); @@ -107,6 +107,7 @@ describe("ElectronMenu", () => { assert.equal(popupOptions?.y, 40); assert.deepEqual(buildFromTemplateMock.mock.calls[0]?.[0][0], { label: "Copy", + accelerator: "Ctrl+Shift+C", enabled: true, click: buildFromTemplateMock.mock.calls[0]?.[0][0].click, }); diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 4d3e5a1c2416..eed4d9bceae4 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -76,6 +76,7 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM const normalizedItem: ContextMenuItem = { id: sourceItem.id, label: sourceItem.label, + ...(sourceItem.accelerator ? { accelerator: sourceItem.accelerator } : {}), destructive: sourceItem.destructive === true, disabled: sourceItem.disabled === true, }; @@ -150,6 +151,7 @@ export const make = Effect.gen(function* () { const itemOption: Electron.MenuItemConstructorOptions = { label: item.label, + ...(item.accelerator ? { accelerator: item.accelerator } : {}), enabled: !item.disabled, }; if (item.children && item.children.length > 0) { diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx index e6a045b3cd97..5fbe6d4dff44 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownBlock.ios.tsx @@ -4,16 +4,13 @@ import type { MarkdownNode } from "react-native-nitro-markdown/headless"; import { CopyTextButton } from "./CopyTextButton"; import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; -import { - nativeMarkdownDocumentRuns, - nativeMarkdownListItemBlocks, - nativeMarkdownTextRuns, -} from "./nativeMarkdownText"; +import { nativeMarkdownDocumentRuns, nativeMarkdownListItemBlocks } from "./nativeMarkdownText"; import { NativeMarkdownSelectableText } from "./NativeMarkdownSelectableText.ios"; import type { MarkdownCodeHighlighter, MarkdownHighlightedToken, NativeMarkdownTextStyle, + SelectableMarkdownSkill, } from "./SelectableMarkdownText.types"; type HighlightedCode = ReadonlyArray>; @@ -48,12 +45,13 @@ function documentFor(node: MarkdownNode): MarkdownNode { function SelectableNode(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { return ( @@ -322,6 +320,7 @@ function collectTableRows(node: MarkdownNode): MarkdownNode[] { function NativeTable(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { @@ -359,7 +358,7 @@ function NativeTable(props: { }} > + runs={nativeMarkdownDocumentRuns(documentFor(cell), props.skills).map((run) => rowIndex === 0 || cell.isHeader ? { ...run, bold: true } : run, )} textStyle={props.textStyle} @@ -376,6 +375,7 @@ function NativeTable(props: { function NativeMarkdownImage(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { @@ -384,6 +384,7 @@ function NativeMarkdownImage(props: { return ( @@ -445,6 +446,7 @@ function inlineGroups(nodes: ReadonlyArray): MarkdownNode[] { function NativeMixedParagraph(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly onLinkPress?: (href: string) => void; }) { @@ -455,6 +457,7 @@ function NativeMixedParagraph(props: { @@ -462,6 +465,7 @@ function NativeMixedParagraph(props: { @@ -473,6 +477,7 @@ function NativeMixedParagraph(props: { function NativeList(props: { readonly node: MarkdownNode; + readonly skills: ReadonlyArray; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; readonly onLinkPress?: (href: string) => void; @@ -534,6 +539,7 @@ function NativeList(props: { ; readonly textStyle: NativeMarkdownTextStyle; readonly highlightCode: MarkdownCodeHighlighter; readonly onLinkPress?: (href: string) => void; @@ -566,6 +573,7 @@ export function NativeMarkdownBlock(props: { @@ -595,6 +604,7 @@ export function NativeMarkdownBlock(props: { return ( @@ -624,6 +634,7 @@ export function NativeMarkdownBlock(props: { child.type === "image") ? ( ) : ( @@ -673,6 +687,7 @@ export function NativeMarkdownBlock(props: { > @@ -690,6 +705,7 @@ export function NativeMarkdownBlock(props: { diff --git a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx index 56321ba01ada..7860ff592a69 100644 --- a/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/SelectableMarkdownText.ios.tsx @@ -69,6 +69,7 @@ export function SelectableMarkdownText({ chunk.kind === "rich" ? ( ]*)?>/gi; +function decodeCodePoint(codePoint: number, entity: string): string { + if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) { + return entity; + } + return String.fromCodePoint(codePoint); +} + function decodeHtmlEntitiesOnce(value: string): string { return value.replace( /&(?:#(\d+)|#x([0-9a-f]+)|amp|apos|gt|lt|nbsp|quot);/gi, (entity, decimal: string | undefined, hexadecimal: string | undefined) => { if (decimal) { - return String.fromCodePoint(Number.parseInt(decimal, 10)); + return decodeCodePoint(Number.parseInt(decimal, 10), entity); } if (hexadecimal) { - return String.fromCodePoint(Number.parseInt(hexadecimal, 16)); + return decodeCodePoint(Number.parseInt(hexadecimal, 16), entity); } switch (entity.toLowerCase()) { case "&": @@ -661,6 +668,7 @@ function appendDocumentBlock( function containsRichBlock(node: MarkdownNode): boolean { if ( node.type === "code_block" || + node.type === "blockquote" || node.type === "table" || node.type === "image" || node.type === "horizontal_rule" || diff --git a/apps/mobile/src/lib/nativeMarkdownText.test.ts b/apps/mobile/src/lib/nativeMarkdownText.test.ts index 6e41f2243a93..867d9e983017 100644 --- a/apps/mobile/src/lib/nativeMarkdownText.test.ts +++ b/apps/mobile/src/lib/nativeMarkdownText.test.ts @@ -126,6 +126,22 @@ describe("nativeMarkdownTextRuns", () => { ]); }); + it.each([ + ["😀", "😀"], + ["🚀", "🚀"], + ["�", "�"], + ["�", "�"], + ["&#9999999999;", "�"], + ["&#x110000;", "�"], + ])("normalizes numeric entity %s without throwing", (content, expected) => { + const node: MarkdownNode = { + type: "paragraph", + children: [{ type: "text", content }], + }; + + expect(nativeMarkdownTextRuns(node)).toEqual([{ text: expected }]); + }); + it("reads inline content from nested text nodes", () => { const node: MarkdownNode = { type: "paragraph", @@ -173,6 +189,25 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); + it("decorates known skill references inside blockquotes", () => { + const node: MarkdownNode = { + type: "blockquote", + children: [ + { + type: "paragraph", + children: [{ type: "text", content: "Use $ui for this." }], + }, + ], + }; + + expect(nativeMarkdownDocumentRuns(node, [{ name: "ui", displayName: "UI" }])).toContainEqual({ + text: "$ui", + role: "body", + skillName: "ui", + skillLabel: "UI", + }); + }); + it("leaves unknown skill-like text unchanged", () => { const node: MarkdownNode = { type: "document", @@ -328,7 +363,7 @@ describe("nativeMarkdownDocumentRuns", () => { ]); }); - it("includes quotes and fenced code in the same selectable string", () => { + it("preserves quotes and fenced code in document runs", () => { const node: MarkdownNode = { type: "document", children: [ @@ -414,6 +449,39 @@ describe("nativeMarkdownListItemBlocks", () => { }); describe("nativeMarkdownDocumentChunks", () => { + it("renders plain blockquotes as rich blocks so their marker spans wrapped lines", () => { + const blockquote: MarkdownNode = { + type: "blockquote", + beg: 0, + end: 120, + children: [ + { + type: "paragraph", + children: [ + { + type: "text", + content: + "Persistent random per-result keys are the strongest design, even when this text wraps.", + }, + ], + }, + ], + }; + + expect( + nativeMarkdownDocumentChunks({ + type: "document", + children: [blockquote], + }), + ).toEqual([ + { + kind: "rich", + key: "rich:blockquote:0:120", + node: blockquote, + }, + ]); + }); + it("keeps headings and plain lists in one selectable document", () => { const document: MarkdownNode = { type: "document", diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index e60d1d71678f..75fec2e45e94 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -4,10 +4,55 @@ import { resolveTerminalSelectionActionPosition, shouldHandleTerminalExit, shouldHandleTerminalSelectionMouseUp, + shouldRestoreTerminalFocusAfterMenuAction, + terminalContextMenuItems, terminalSelectionActionDelayForClickCount, terminalSelectionLineRange, } from "./ThreadTerminalDrawer"; +describe("shouldRestoreTerminalFocusAfterMenuAction", () => { + it("restores focus only after terminal-local actions", () => { + expect(shouldRestoreTerminalFocusAfterMenuAction("copy")).toBe(true); + expect(shouldRestoreTerminalFocusAfterMenuAction("paste")).toBe(true); + expect(shouldRestoreTerminalFocusAfterMenuAction("add-to-chat")).toBe(false); + expect(shouldRestoreTerminalFocusAfterMenuAction(null)).toBe(false); + }); +}); + +describe("terminalContextMenuItems", () => { + it("offers terminal actions and disables selection-only actions without a selection", () => { + expect(terminalContextMenuItems({ canAddToChat: false, canCopy: false }, "Win32")).toEqual([ + { id: "add-to-chat", label: "Add to chat", disabled: true }, + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: true }, + { id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }, + ]); + }); + + it("enables copy and add to chat when terminal text is selected", () => { + expect(terminalContextMenuItems({ canAddToChat: true, canCopy: true }, "Win32")).toEqual([ + { id: "add-to-chat", label: "Add to chat", disabled: false }, + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: false }, + { id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }, + ]); + }); + + it("keeps Copy enabled for selections that cannot be added to chat", () => { + expect(terminalContextMenuItems({ canAddToChat: false, canCopy: true }, "Win32")).toEqual([ + { id: "add-to-chat", label: "Add to chat", disabled: true }, + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: false }, + { id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }, + ]); + }); + + it("uses native Command shortcuts on macOS", () => { + expect(terminalContextMenuItems({ canAddToChat: true, canCopy: true }, "MacIntel")).toEqual([ + { id: "add-to-chat", label: "Add to chat", disabled: false }, + { id: "copy", label: "Copy", accelerator: "Command+C", disabled: false }, + { id: "paste", label: "Paste", accelerator: "Command+V" }, + ]); + }); +}); + describe("resolveTerminalSelectionActionPosition", () => { it("prefers the selection rect over the last pointer position", () => { expect( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index c59f682c415e..9569f459f632 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -13,6 +13,7 @@ import { XIcon, } from "lucide-react"; import { + type ContextMenuItem, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -32,7 +33,7 @@ import { } from "react"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { writeTextToClipboard } from "~/hooks/useCopyToClipboard"; -import { cn } from "~/lib/utils"; +import { cn, isMacPlatform } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { GhosttyTerminalSurface, @@ -76,6 +77,37 @@ const MIN_DRAWER_HEIGHT = 180; const MAX_DRAWER_HEIGHT_RATIO = 0.75; const MULTI_CLICK_SELECTION_ACTION_DELAY_MS = 260; +export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; + +export function shouldRestoreTerminalFocusAfterMenuAction( + action: TerminalContextMenuAction | null, +): boolean { + return action === "copy" || action === "paste"; +} + +interface TerminalSelectionAction { + readonly position: { x: number; y: number }; + readonly clipboardText: string; + readonly selection: TerminalContextSelection | null; +} + +export function terminalContextMenuItems( + availability: { readonly canAddToChat: boolean; readonly canCopy: boolean }, + platform = navigator.platform, +): readonly ContextMenuItem[] { + const isMac = isMacPlatform(platform); + return [ + { id: "add-to-chat", label: "Add to chat", disabled: !availability.canAddToChat }, + { + id: "copy", + label: "Copy", + accelerator: isMac ? "Command+C" : "Ctrl+Shift+C", + disabled: !availability.canCopy, + }, + { id: "paste", label: "Paste", accelerator: isMac ? "Command+V" : "Ctrl+Shift+V" }, + ]; +} + function maxDrawerHeight(): number { if (typeof window === "undefined") return DEFAULT_THREAD_TERMINAL_HEIGHT; return Math.max(MIN_DRAWER_HEIGHT, Math.floor(window.innerHeight * MAX_DRAWER_HEIGHT_RATIO)); @@ -441,6 +473,7 @@ export function TerminalViewport({ onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), onCopy: (text) => handleCopy(text), + onContextMenu: (event) => handleContextMenu(event), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), }; @@ -473,8 +506,12 @@ export function TerminalViewport({ synchronizeTerminalStatus(terminal, latestSession.status); if (autoFocus) window.requestAnimationFrame(() => terminal.focus()); + let terminalMenuAbortController: AbortController | null = null; const clearSelectionAction = () => { selectionActionRequestIdRef.current += 1; + terminalMenuAbortController?.abort(); + terminalMenuAbortController = null; + selectionActionMenuOpenRef.current = false; if (selectionActionTimerRef.current !== null) { window.clearTimeout(selectionActionTimerRef.current); selectionActionTimerRef.current = null; @@ -482,11 +519,7 @@ export function TerminalViewport({ }; setupCleanups.push(clearSelectionAction); - const readSelectionAction = (): { - position: { x: number; y: number }; - clipboardText: string; - selection: TerminalContextSelection; - } | null => { + const readSelectionAction = (): TerminalSelectionAction | null => { const activeTerminal = terminalRef.current; const mountElement = containerRef.current; if (!activeTerminal || !mountElement || !activeTerminal.hasSelection()) { @@ -495,10 +528,9 @@ export function TerminalViewport({ const selectionText = activeTerminal.getSelection(); const selectionPosition = activeTerminal.getSelectionPosition(); const normalizedText = selectionText.replace(/\r\n/g, "\n").replace(/^\n+|\n+$/g, ""); - if (!selectionPosition || normalizedText.length === 0) { + if (selectionText.length === 0) { return null; } - const { lineStart, lineEnd } = terminalSelectionLineRange(selectionPosition); const bounds = mountElement.getBoundingClientRect(); const position = resolveTerminalSelectionActionPosition({ bounds, @@ -508,21 +540,67 @@ export function TerminalViewport({ return { position, clipboardText: selectionText, - selection: { - terminalId, - terminalLabel: readTerminalLabel(), - lineStart, - lineEnd, - text: normalizedText, - }, + selection: + selectionPosition && normalizedText.length > 0 + ? { + terminalId, + terminalLabel: readTerminalLabel(), + ...terminalSelectionLineRange(selectionPosition), + text: normalizedText, + } + : null, }; }; - const showSelectionAction = async () => { - if (!localApi) { - clearSelectionAction(); - return; + const performTerminalMenuAction = async ( + clicked: TerminalContextMenuAction | null, + selectionAction: TerminalSelectionAction | null, + isCurrent: () => boolean = () => true, + ) => { + switch (clicked) { + case "add-to-chat": + if (!selectionAction?.selection || !isCurrent()) return; + handleAddTerminalContext(selectionAction.selection); + terminalRef.current?.clearSelection(); + return; + case "copy": + if (!selectionAction || !isCurrent()) return; + try { + await writeTextToClipboard(selectionAction.clipboardText, "terminal selection"); + } catch (error) { + if (!isCurrent()) return; + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to copy terminal selection", + ); + } + } + return; + case "paste": + if (!isCurrent()) return; + try { + const text = await navigator.clipboard.readText(); + if (isCurrent()) terminalRef.current?.paste(text); + } catch (error) { + if (!isCurrent()) return; + const activeTerminal = terminalRef.current; + if (activeTerminal) { + writeSystemMessage( + activeTerminal, + error instanceof Error ? error.message : "Unable to read clipboard text", + ); + } + } + return; + case null: + return; } + }; + + const showSelectionAction = async () => { + if (!localApi) return; if (selectionActionMenuOpenRef.current) { return; } @@ -532,46 +610,67 @@ export function TerminalViewport({ return; } const requestId = ++selectionActionRequestIdRef.current; + terminalMenuAbortController?.abort(); + const abortController = new AbortController(); + terminalMenuAbortController = abortController; selectionActionMenuOpenRef.current = true; const clicked = await localApi.contextMenu .show( - [ - { id: "add-to-chat", label: "Add to chat" }, - { id: "copy", label: "Copy" }, - ], + terminalContextMenuItems({ + canAddToChat: nextAction.selection !== null, + canCopy: true, + }), nextAction.position, + { presentation: "styled", signal: abortController.signal }, ) .finally(() => { - selectionActionMenuOpenRef.current = false; + if (terminalMenuAbortController === abortController) { + terminalMenuAbortController = null; + selectionActionMenuOpenRef.current = false; + } }); if (requestId !== selectionActionRequestIdRef.current || clicked === null) { return; } - switch (clicked) { - case "add-to-chat": - handleAddTerminalContext(nextAction.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); - return; - case "copy": - try { - await writeTextToClipboard(nextAction.clipboardText, "terminal selection"); - } catch (error) { - if (requestId !== selectionActionRequestIdRef.current) { - return; - } - const activeTerminal = terminalRef.current; - if (activeTerminal) { - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : "Unable to copy terminal selection", - ); - } - } - if (requestId === selectionActionRequestIdRef.current) { - terminalRef.current?.focus(); + const isCurrent = () => + requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted; + await performTerminalMenuAction(clicked, nextAction, isCurrent); + if (shouldRestoreTerminalFocusAfterMenuAction(clicked) && isCurrent()) { + terminalRef.current?.focus(); + } + }; + + const showTerminalContextMenu = async (event: MouseEvent) => { + if (!localApi) return; + const selectionAction = readSelectionAction(); + const requestId = ++selectionActionRequestIdRef.current; + terminalMenuAbortController?.abort(); + const abortController = new AbortController(); + terminalMenuAbortController = abortController; + selectionActionMenuOpenRef.current = true; + const clicked = await localApi.contextMenu + .show( + terminalContextMenuItems({ + canAddToChat: selectionAction?.selection != null, + canCopy: selectionAction !== null, + }), + { + x: event.clientX, + y: event.clientY, + }, + { presentation: "styled", signal: abortController.signal }, + ) + .finally(() => { + if (terminalMenuAbortController === abortController) { + terminalMenuAbortController = null; + selectionActionMenuOpenRef.current = false; } - return; + }); + const isCurrent = () => + requestId === selectionActionRequestIdRef.current && !abortController.signal.aborted; + await performTerminalMenuAction(clicked, selectionAction, isCurrent); + if (shouldRestoreTerminalFocusAfterMenuAction(clicked) && isCurrent()) { + terminalRef.current?.focus(); } }; @@ -668,6 +767,11 @@ export function TerminalViewport({ })(); } + function handleContextMenu(event: MouseEvent): void { + clearSelectionAction(); + void showTerminalContextMenu(event); + } + function handleCopy(text: string): void { void writeTextToClipboard(text, "terminal selection").catch((error: unknown) => { const activeTerminal = terminalRef.current; diff --git a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx index 09021aaad51c..00f20e53fbe1 100644 --- a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx +++ b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx @@ -19,7 +19,7 @@ export function ClerkUserProfilePage({ }) { return (
-
+

{title}

{description ? ( diff --git a/apps/web/src/components/preview/PreviewPanelShell.test.ts b/apps/web/src/components/preview/PreviewPanelShell.test.ts index 4ac086157a2f..31258b166bdf 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.test.ts +++ b/apps/web/src/components/preview/PreviewPanelShell.test.ts @@ -1,6 +1,8 @@ +import { jsx } from "react/jsx-runtime"; +import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; -import { getPreviewPanelMaxWidth } from "./PreviewPanelShell"; +import { getPreviewPanelMaxWidth, PreviewPanelShell } from "./PreviewPanelShell"; describe("getPreviewPanelMaxWidth", () => { it("allows the panel to use 70% of an ultra-wide viewport without a pixel ceiling", () => { @@ -10,4 +12,12 @@ describe("getPreviewPanelMaxWidth", () => { it("rounds fractional CSS pixels down", () => { expect(getPreviewPanelMaxWidth(2_001)).toBe(1_400); }); + + it("keeps inline panels inside their containing workspace", () => { + const markup = renderToStaticMarkup( + jsx(PreviewPanelShell, { mode: "inline", defaultWidth: 1_000, children: "Panel" }), + ); + + expect(markup).toContain("max-w-full"); + }); }); diff --git a/apps/web/src/components/preview/PreviewPanelShell.tsx b/apps/web/src/components/preview/PreviewPanelShell.tsx index 30a0c9eed0ff..17ca389feab2 100644 --- a/apps/web/src/components/preview/PreviewPanelShell.tsx +++ b/apps/web/src/components/preview/PreviewPanelShell.tsx @@ -51,7 +51,7 @@ export function PreviewPanelShell(props: { return (
void; @@ -17,6 +17,8 @@ class FakeDomEvent { preventDefault() { this.defaultPrevented = true; } + + stopPropagation() {} } class FakeElement { @@ -139,6 +141,13 @@ class FakeDocument { } } + dispatchEvent(event: FakeDomEvent) { + for (const listener of this.listeners.get(event.type) ?? []) { + listener(event); + } + return true; + } + querySelectorAll(tagName: string) { return this.body.querySelectorAll(tagName); } @@ -196,6 +205,18 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + it("renders shortcut hints next to menu labels", () => { + void showContextMenuFallback([ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }, + { id: "paste", label: "Paste", accelerator: "Command+V" }, + ]); + + const shortcuts = (document as unknown as FakeDocument) + .querySelectorAll("kbd") + .map((element) => element.textContent); + expect(shortcuts).toEqual(["Ctrl+Shift+C", "⌘V"]); + }); + it("ignores a click from the gesture that opened the menu", async () => { let enablePointerSelection: ((time: number) => void) | undefined; vi.stubGlobal("requestAnimationFrame", (callback: (time: number) => void) => { @@ -213,6 +234,40 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename"); }); + it("closes without an action when its owner aborts", async () => { + const abortController = new AbortController(); + const selectionPromise = showContextMenuFallback( + [{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }], + undefined, + { signal: abortController.signal }, + ); + + abortController.abort(); + + await expect(selectionPromise).resolves.toBeNull(); + expect((document as unknown as FakeDocument).querySelectorAll("button")).toHaveLength(0); + }); + + it("closes when the page or terminal is scrolled", async () => { + const selectionPromise = showContextMenuFallback([{ id: "copy", label: "Copy" }]); + + (document as unknown as FakeDocument).dispatchEvent(new FakeDomEvent("wheel")); + + await expect(selectionPromise).resolves.toBeNull(); + expect((document as unknown as FakeDocument).querySelectorAll("button")).toHaveLength(0); + }); + + it("stays open while its own scrollable contents are scrolled", () => { + void showContextMenuFallback([{ id: "copy", label: "Copy" }]); + const menu = (document as unknown as FakeDocument).body.children[0]; + + (document as unknown as FakeDocument).dispatchEvent( + new FakeDomEvent("wheel", { target: menu }), + ); + + expect(findButton("Copy")).toBeTruthy(); + }); + it("opens nested submenus and resolves the clicked leaf id", async () => { const selectionPromise = showContextMenuFallback([ { @@ -236,3 +291,56 @@ describe("showContextMenuFallback", () => { await expect(selectionPromise).resolves.toBe("rename:project-b"); }); }); + +describe("contextMenuAcceleratorAction", () => { + const event = (overrides: Partial[1]> = {}) => ({ + altKey: false, + ctrlKey: false, + isComposing: false, + key: "", + metaKey: false, + shiftKey: false, + ...overrides, + }); + + it("matches Windows and Linux terminal shortcuts", () => { + const items = [ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }, + { id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }, + ] as const; + expect( + contextMenuAcceleratorAction(items, event({ key: "c", ctrlKey: true, shiftKey: true })), + ).toBe("copy"); + expect( + contextMenuAcceleratorAction(items, event({ key: "V", ctrlKey: true, shiftKey: true })), + ).toBe("paste"); + }); + + it("matches macOS Command shortcuts", () => { + expect( + contextMenuAcceleratorAction( + [{ id: "copy", label: "Copy", accelerator: "Command+C" }], + event({ key: "c", metaKey: true }), + ), + ).toBe("copy"); + }); + + it("does not activate disabled or partially matched shortcuts", () => { + const items = [ + { id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C", disabled: true }, + ] as const; + expect( + contextMenuAcceleratorAction(items, event({ key: "c", ctrlKey: true, shiftKey: true })), + ).toBeNull(); + expect(contextMenuAcceleratorAction(items, event({ key: "c", ctrlKey: true }))).toBeNull(); + }); + + it("does not activate shortcuts during IME composition", () => { + expect( + contextMenuAcceleratorAction( + [{ id: "copy", label: "Copy", accelerator: "Ctrl+Shift+C" }], + event({ key: "c", ctrlKey: true, shiftKey: true, isComposing: true }), + ), + ).toBeNull(); + }); +}); diff --git a/apps/web/src/contextMenuFallback.ts b/apps/web/src/contextMenuFallback.ts index 50f4340e22dc..f527351b9410 100644 --- a/apps/web/src/contextMenuFallback.ts +++ b/apps/web/src/contextMenuFallback.ts @@ -101,6 +101,34 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H return false; } +export function contextMenuAcceleratorAction( + items: readonly ContextMenuItem[], + event: Pick, +): T | null { + if (event.isComposing) return null; + for (const item of items) { + if (item.disabled) continue; + if (item.children) { + const childAction = contextMenuAcceleratorAction(item.children, event); + if (childAction !== null) return childAction; + } + if (!item.accelerator) continue; + + const parts = item.accelerator.toLowerCase().split("+"); + const key = parts.at(-1); + if ( + event.key.toLowerCase() === key && + event.altKey === parts.includes("alt") && + event.ctrlKey === parts.includes("ctrl") && + event.metaKey === (parts.includes("command") || parts.includes("cmd")) && + event.shiftKey === parts.includes("shift") + ) { + return item.id; + } + } + return null; +} + /** * Imperative DOM-based context menu for non-Electron environments. * Supports nested submenus and resolves with the clicked leaf item id. @@ -108,8 +136,13 @@ function isNodeWithinMenuStack(target: EventTarget | null, menuStack: readonly H export function showContextMenuFallback( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { readonly signal?: AbortSignal }, ): Promise { return new Promise((resolve) => { + if (options?.signal?.aborted) { + resolve(null); + return; + } const menuStack: HTMLDivElement[] = []; let isDisposed = false; let canDismissFromPointer = false; @@ -119,19 +152,31 @@ export function showContextMenuFallback( return; } isDisposed = true; - document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keydown", onKeyDown, true); document.removeEventListener("pointerdown", onPointerDown, true); document.removeEventListener("contextmenu", onContextMenu, true); + document.removeEventListener("wheel", onWheel, true); + options?.signal?.removeEventListener("abort", onAbort); for (const menu of menuStack) { menu.remove(); } resolve(result); }; + const onAbort = () => cleanup(null); + const onKeyDown = (event: KeyboardEvent) => { + if (event.isComposing) return; if (event.key === "Escape") { event.preventDefault(); cleanup(null); + return; + } + const acceleratorAction = contextMenuAcceleratorAction(items, event); + if (acceleratorAction !== null) { + event.preventDefault(); + event.stopPropagation(); + cleanup(acceleratorAction); } }; @@ -150,6 +195,13 @@ export function showContextMenuFallback( cleanup(null); }; + const onWheel = (event: WheelEvent) => { + if (isNodeWithinMenuStack(event.target, menuStack)) { + return; + } + cleanup(null); + }; + const closeMenusFromLevel = (level: number) => { while (menuStack.length > level) { menuStack.pop()?.remove(); @@ -164,20 +216,27 @@ export function showContextMenuFallback( ) => { closeMenusFromLevel(level); + const usesCompactShortcutLayout = entries.some((item) => item.accelerator); const menu = document.createElement("div"); - menu.className = - "dropdown-glass fixed z-[10000] min-w-32 max-w-sm overflow-hidden rounded-lg bg-clip-padding text-popover-foreground outline-none"; - menu.style.cssText = - "position:fixed;z-index:10000;min-width:8rem;max-width:24rem;overflow:hidden;border-radius:var(--radius-lg);background-clip:padding-box;color:var(--popover-foreground);outline:none;pointer-events:auto;"; + menu.className = `dropdown-glass fixed z-[10000] max-w-sm overflow-hidden bg-clip-padding text-popover-foreground outline-none ${ + usesCompactShortcutLayout ? "min-w-[14.25rem] rounded-[11px]" : "min-w-32 rounded-lg" + }`; + menu.style.cssText = `position:fixed;z-index:10000;min-width:${ + usesCompactShortcutLayout ? "min(14.25rem,calc(100vw - 0.75rem))" : "8rem" + };max-width:24rem;overflow:hidden;border-radius:${ + usesCompactShortcutLayout ? "0.6875rem" : "var(--radius-lg)" + };background-clip:padding-box;color:var(--popover-foreground);outline:none;pointer-events:auto;`; menu.style.left = `${preferredLeft}px`; menu.style.top = `${preferredTop}px`; menu.dataset.level = String(level); const inner = document.createElement("div"); inner.className = - "max-h-[min(24rem,70vh)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1"; - inner.style.cssText = - "max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:0.25rem;"; + "max-h-[min(24rem,70vh)] min-w-0 max-w-sm overflow-y-auto overflow-x-hidden p-1 data-[compact=true]:p-[5px]"; + inner.dataset.compact = String(usesCompactShortcutLayout); + inner.style.cssText = `max-height:min(24rem,70vh);min-width:0;max-width:24rem;overflow-x:hidden;overflow-y:auto;padding:${ + usesCompactShortcutLayout ? "0.3125rem" : "0.25rem" + };`; for (const item of entries) { if (item.header === true) { @@ -196,21 +255,31 @@ export function showContextMenuFallback( button.type = "button"; const isDisabled = item.disabled === true; button.disabled = isDisabled; - const rowBase = - "flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left outline-none transition-colors sm:min-h-7 sm:text-sm min-h-8 text-base"; + const rowBase = usesCompactShortcutLayout + ? "flex min-h-[2.125rem] w-full cursor-default select-none items-center gap-[9px] rounded-[7px] px-2 py-1.5 text-left text-xs outline-none transition-colors" + : "flex w-full cursor-default select-none items-center gap-2 rounded-sm px-2 py-1 text-left outline-none transition-colors sm:min-h-7 sm:text-sm min-h-8 text-base"; button.className = isDisabled - ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground opacity-64` + ? `${rowBase} pointer-events-none cursor-not-allowed text-muted-foreground ${ + usesCompactShortcutLayout ? "opacity-52" : "opacity-64" + }` : isLeafDestructive ? `${rowBase} text-destructive-foreground hover:bg-destructive/10 hover:text-destructive-foreground` : `${rowBase} text-foreground hover:bg-accent hover:text-accent-foreground`; - button.style.cssText = - "display:flex;width:100%;min-height:1.75rem;align-items:center;gap:0.5rem;border:0;border-radius:var(--radius-sm);background:transparent;padding:0.25rem 0.5rem;color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:0.875rem;line-height:1.25rem;text-align:left;cursor:default;"; + button.style.cssText = `display:flex;width:100%;min-height:${ + usesCompactShortcutLayout ? "2.125rem" : "1.75rem" + };align-items:center;gap:${usesCompactShortcutLayout ? "0.5625rem" : "0.5rem"};border:0;border-radius:${ + usesCompactShortcutLayout ? "0.4375rem" : "var(--radius-sm)" + };background:transparent;padding:${ + usesCompactShortcutLayout ? "0.375rem 0.5rem" : "0.25rem 0.5rem" + };color:var(--foreground);font-family:var(--font-sans,system-ui,sans-serif);font-size:${ + usesCompactShortcutLayout ? "0.75rem" : "0.875rem" + };font-weight:${usesCompactShortcutLayout ? "450" : "400"};line-height:1.25rem;text-align:left;cursor:default;`; if (isLeafDestructive) { button.style.color = "var(--destructive-foreground)"; } if (isDisabled) { button.style.color = "var(--muted-foreground)"; - button.style.opacity = "0.64"; + button.style.opacity = usesCompactShortcutLayout ? "0.52" : "0.64"; button.style.pointerEvents = "none"; } @@ -226,6 +295,16 @@ export function showContextMenuFallback( label.textContent = item.label; button.appendChild(label); + if (item.accelerator) { + const accelerator = document.createElement("kbd"); + accelerator.className = + "ms-auto shrink-0 rounded border border-border/70 bg-muted/70 px-[5px] font-mono text-[10px] text-muted-foreground leading-[1.55]"; + accelerator.style.cssText = + "margin-inline-start:auto;flex-shrink:0;border:1px solid color-mix(in srgb,var(--border) 70%,transparent);border-radius:0.25rem;background:color-mix(in srgb,var(--muted) 70%,transparent);padding:0 0.3125rem;color:var(--muted-foreground);font-family:var(--font-mono,monospace);font-size:0.625rem;line-height:1.55;"; + accelerator.textContent = item.accelerator.replace(/^Command\+/u, "⌘"); + button.appendChild(accelerator); + } + if (hasChildren) { const chevron = document.createElement("span"); chevron.className = "ms-auto shrink-0 text-muted-foreground/80 text-sm leading-none"; @@ -295,9 +374,11 @@ export function showContextMenuFallback( }); }; - document.addEventListener("keydown", onKeyDown); + document.addEventListener("keydown", onKeyDown, true); document.addEventListener("pointerdown", onPointerDown, true); document.addEventListener("contextmenu", onContextMenu, true); + document.addEventListener("wheel", onWheel, { capture: true, passive: true }); + options?.signal?.addEventListener("abort", onAbort, { once: true }); openMenu(items, position?.x ?? 0, position?.y ?? 0, 0); requestAnimationFrame(() => { diff --git a/apps/web/src/localApi.test.ts b/apps/web/src/localApi.test.ts index 064b927031d6..7f7d166c5aa8 100644 --- a/apps/web/src/localApi.test.ts +++ b/apps/web/src/localApi.test.ts @@ -11,6 +11,7 @@ const showContextMenuFallbackMock = ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { readonly signal?: AbortSignal }, ) => Promise >(); @@ -82,7 +83,7 @@ describe("LocalApi", () => { const items = [{ id: "rename", label: "Rename" }] as const; await expect(createLocalApi().contextMenu.show(items, { x: 4, y: 5 })).resolves.toBe("rename"); - expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }); + expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 }, undefined); }); it("uses the themed confirmation host when it is available", async () => { @@ -132,6 +133,55 @@ describe("LocalApi", () => { expect(setClientSettings).toHaveBeenCalledWith(DEFAULT_CLIENT_SETTINGS); }); + it("keeps context menus native on desktop when fallback cancellation is available", async () => { + const showContextMenu = vi.fn().mockResolvedValue("copy"); + showContextMenuFallbackMock.mockResolvedValue("paste"); + testWindow().desktopBridge = { showContextMenu } as unknown as DesktopBridge; + const abortController = new AbortController(); + const { createLocalApi } = await import("./localApi"); + const items = [{ id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }] as const; + + await expect( + createLocalApi().contextMenu.show( + items, + { x: 4, y: 5 }, + { + signal: abortController.signal, + }, + ), + ).resolves.toBe("copy"); + expect(showContextMenu).toHaveBeenCalledWith(items, { x: 4, y: 5 }); + expect(showContextMenuFallbackMock).not.toHaveBeenCalled(); + }); + + it("uses the styled context menu on desktop when requested", async () => { + const showContextMenu = vi.fn().mockResolvedValue("copy"); + showContextMenuFallbackMock.mockResolvedValue("paste"); + testWindow().desktopBridge = { showContextMenu } as unknown as DesktopBridge; + const abortController = new AbortController(); + const { createLocalApi } = await import("./localApi"); + const items = [{ id: "paste", label: "Paste", accelerator: "Ctrl+Shift+V" }] as const; + + await expect( + createLocalApi().contextMenu.show( + items, + { x: 4, y: 5 }, + { + presentation: "styled", + signal: abortController.signal, + }, + ), + ).resolves.toBe("paste"); + expect(showContextMenu).not.toHaveBeenCalled(); + expect(showContextMenuFallbackMock).toHaveBeenCalledWith( + items, + { x: 4, y: 5 }, + { + signal: abortController.signal, + }, + ); + }); + it("persists client settings in browser storage", async () => { const { createLocalApi } = await import("./localApi"); const api = createLocalApi(); diff --git a/apps/web/src/localApi.ts b/apps/web/src/localApi.ts index 5c8f4ec9da8c..cf0b4e5697bd 100644 --- a/apps/web/src/localApi.ts +++ b/apps/web/src/localApi.ts @@ -35,11 +35,19 @@ function createBrowserLocalApi(): LocalApi { show: async ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { + readonly presentation?: "native" | "styled"; + readonly signal?: AbortSignal; + }, ): Promise => { - if (window.desktopBridge) { + if (window.desktopBridge && options?.presentation !== "styled") { return window.desktopBridge.showContextMenu(items, position) as Promise; } - return showContextMenuFallback(items, position); + return showContextMenuFallback( + items, + position, + options?.signal ? { signal: options.signal } : undefined, + ); }, }, persistence: { diff --git a/apps/web/src/rpc/requestLatencyState.test.ts b/apps/web/src/rpc/requestLatencyState.test.ts index e5b3144d2520..68433035fd18 100644 --- a/apps/web/src/rpc/requestLatencyState.test.ts +++ b/apps/web/src/rpc/requestLatencyState.test.ts @@ -59,6 +59,16 @@ describe("requestLatencyState", () => { expect(getSlowRpcAckRequests()).toEqual([]); }); + it.each(Object.values(WS_METHODS).filter((method) => method.startsWith("pullRequests.")))( + "ignores pull request workspace request %s", + (method) => { + trackRpcRequestSent("1", method); + vi.advanceTimersByTime(SLOW_RPC_ACK_THRESHOLD_MS * 2); + + expect(getSlowRpcAckRequests()).toEqual([]); + }, + ); + it("keeps ignoring untracked methods when a display tag is supplied", () => { trackRpcRequestSent( "1", diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 4736d3783c3b..4ec5b56f9e2b 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -49,7 +49,11 @@ function getSlowRpcAckRequestsValue(): ReadonlyArray { } function shouldTrackRpcAck(method: string): boolean { - return !method.includes("subscribe") && !untrackedRpcAckMethods.has(method); + return ( + !method.includes("subscribe") && + !method.startsWith("pullRequests.") && + !untrackedRpcAckMethods.has(method) + ); } function rpcAckThresholdMs(method: string): number { diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 31bc47bdff79..7b8cf04f68d1 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -4,6 +4,7 @@ import type { GhosttyCell, GhosttyRow } from "./core"; import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, + GhosttyTerminalSurface, advanceTerminalSelectionClickSequence, ghosttyMouseButton, isTerminalAltGraphText, @@ -28,6 +29,17 @@ import { terminalWheelDeltaRows, } from "./surface"; +const terminalSurfaceForPaste = (onData: (data: string) => void) => { + const surface = Object.create(GhosttyTerminalSurface.prototype) as GhosttyTerminalSurface; + Object.assign(surface, { + disposed: false, + pasteShortcutToken: 0, + options: { onData }, + core: { encodePaste: (data: string) => `[paste]${data}` }, + }); + return surface; +}; + const cell = (text: string): GhosttyCell => ({ text, wide: 0, @@ -254,6 +266,18 @@ describe("isTerminalPasteShortcut", () => { }); }); +describe("GhosttyTerminalSurface paste", () => { + it("invalidates an outstanding keyboard clipboard read", () => { + const onData = vi.fn(); + const surface = terminalSurfaceForPaste(onData); + + surface.paste("from context menu"); + + expect(onData).toHaveBeenCalledWith("[paste]from context menu"); + expect((surface as unknown as { pasteShortcutToken: number }).pasteShortcutToken).toBe(1); + }); +}); + describe("isTerminalCompositionCommitInput", () => { it("identifies browser composition follow-up input", () => { expect(isTerminalCompositionCommitInput({ inputType: "" })).toBe(true); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index fc7a89c6d31e..8e931eef8c32 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -464,6 +464,7 @@ export interface GhosttyTerminalSurfaceOptions { readonly onResize: (cols: number, rows: number) => void; readonly onSelectionChange: () => void; readonly onCopy: (text: string) => void; + readonly onContextMenu?: (event: MouseEvent) => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; } @@ -845,6 +846,12 @@ export class GhosttyTerminalSurface { this.requestRender(); } + paste(data: string): void { + if (this.disposed || data.length === 0) return; + this.pasteShortcutToken += 1; + this.options.onData(this.core.encodePaste(data)); + } + scrollToBottom(): void { this.core.scrollToBottom(); this.forceFullRender = true; @@ -919,8 +926,7 @@ export class GhosttyTerminalSurface { void clipboard.readText().then( (text) => { if (this.disposed || this.pasteShortcutToken !== token) return; - this.pasteShortcutToken += 1; - if (text.length > 0) this.options.onData(this.core.encodePaste(text)); + this.paste(text); }, () => { // Clipboard read denied; the native paste event remains the path. @@ -998,8 +1004,7 @@ export class GhosttyTerminalSurface { if (data.length === 0) return; // The native paste won the race with actual text; a pending clipboard read // must not double. An empty native paste leaves the read as the only path. - this.pasteShortcutToken += 1; - this.options.onData(this.core.encodePaste(data)); + this.paste(data); }; private readonly onCompositionStart = () => { @@ -1308,9 +1313,11 @@ export class GhosttyTerminalSurface { }; private readonly onContextMenu = (event: MouseEvent) => { + event.preventDefault(); if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { - event.preventDefault(); + return; } + this.options.onContextMenu?.(event); }; private readonly onScrollbarPointerDown = (event: PointerEvent) => { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 7f9fdb271d47..86cd55e89e81 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -104,6 +104,8 @@ import type { export interface ContextMenuItem { id: T; label: string; + /** Shortcut notation displayed alongside the label by menu hosts that support accelerators. */ + accelerator?: string; destructive?: boolean; disabled?: boolean; /** Renders as a non-interactive section header label. Web fallback only — stripped on desktop native menus. */ @@ -116,6 +118,7 @@ export interface ContextMenuItem { export interface ContextMenuItemSchemaType { readonly id: string; readonly label: string; + readonly accelerator?: string; readonly destructive?: boolean; readonly disabled?: boolean; readonly header?: boolean; @@ -126,6 +129,7 @@ export interface ContextMenuItemSchemaType { export const ContextMenuItemSchema: Schema.Codec = Schema.Struct({ id: Schema.String, label: Schema.String, + accelerator: Schema.optionalKey(Schema.String), destructive: Schema.optionalKey(Schema.Boolean), disabled: Schema.optionalKey(Schema.Boolean), header: Schema.optionalKey(Schema.Boolean), @@ -1164,6 +1168,10 @@ export interface LocalApi { show: ( items: readonly ContextMenuItem[], position?: { x: number; y: number }, + options?: { + readonly presentation?: "native" | "styled"; + readonly signal?: AbortSignal; + }, ) => Promise; }; persistence: {