From 3ad3f21024f2fe414ab0630fea77ea103ba7c91b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 12:04:35 +0000 Subject: [PATCH 1/3] feat(ui): add Ctrl/Cmd+L to send selected text to chat Bind mod+l to append the current selection into the chat composer (Cursor-style), and move session sidebar toggle to mod+alt+l so desktop menus stay in sync. Closes #208. Co-authored-by: Serhii Dziupin --- packages/electron/main.mjs | 6 +- .../chat/message/TextSelectionMenu.tsx | 21 ++- packages/ui/src/components/ui/HelpDialog.tsx | 6 + packages/ui/src/hooks/useKeyboardShortcuts.ts | 7 + packages/ui/src/hooks/useMenuActions.ts | 6 + .../ui/src/lib/addSelectionToChat.test.ts | 137 ++++++++++++++++ packages/ui/src/lib/addSelectionToChat.ts | 153 ++++++++++++++++++ .../ui/src/lib/i18n/messages/de.settings.ts | 1 + packages/ui/src/lib/i18n/messages/de.ts | 1 + .../ui/src/lib/i18n/messages/en.settings.ts | 1 + packages/ui/src/lib/i18n/messages/en.ts | 1 + .../ui/src/lib/i18n/messages/es.settings.ts | 1 + packages/ui/src/lib/i18n/messages/es.ts | 1 + .../ui/src/lib/i18n/messages/fr.settings.ts | 1 + packages/ui/src/lib/i18n/messages/fr.ts | 1 + .../ui/src/lib/i18n/messages/ja.settings.ts | 1 + packages/ui/src/lib/i18n/messages/ja.ts | 1 + .../ui/src/lib/i18n/messages/ko.settings.ts | 1 + packages/ui/src/lib/i18n/messages/ko.ts | 1 + .../ui/src/lib/i18n/messages/pl.settings.ts | 1 + packages/ui/src/lib/i18n/messages/pl.ts | 1 + .../src/lib/i18n/messages/pt-BR.settings.ts | 1 + packages/ui/src/lib/i18n/messages/pt-BR.ts | 1 + .../ui/src/lib/i18n/messages/uk.settings.ts | 1 + packages/ui/src/lib/i18n/messages/uk.ts | 1 + .../src/lib/i18n/messages/zh-CN.settings.ts | 1 + packages/ui/src/lib/i18n/messages/zh-CN.ts | 1 + .../src/lib/i18n/messages/zh-TW.settings.ts | 1 + packages/ui/src/lib/i18n/messages/zh-TW.ts | 1 + packages/ui/src/lib/shortcuts.ts | 9 +- 30 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/lib/addSelectionToChat.test.ts create mode 100644 packages/ui/src/lib/addSelectionToChat.ts diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index d82fbb3b..31731358 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -4559,6 +4559,7 @@ const buildMacMenu = () => { { type: 'separator' }, { role: 'cut' }, { label: 'Copy', accelerator: 'Cmd+C', click: () => handleCopyAction() }, + { label: 'Add Selection to Chat', accelerator: 'Cmd+L', registerAccelerator: false, click: () => dispatchAction('add-selection-to-chat') }, { role: 'paste' }, { role: 'selectAll' }, ], @@ -4577,7 +4578,7 @@ const buildMacMenu = () => { { label: 'Dark Theme', click: () => dispatchAction('theme-dark') }, { label: 'System Theme', click: () => dispatchAction('theme-system') }, { type: 'separator' }, - { label: 'Toggle Session Sidebar', accelerator: 'Cmd+L', click: () => dispatchAction('toggle-sidebar') }, + { label: 'Toggle Session Sidebar', accelerator: 'Cmd+Alt+L', click: () => dispatchAction('toggle-sidebar') }, { label: 'Toggle Memory Debug', accelerator: 'Cmd+Shift+D', click: () => dispatchAction('toggle-memory-debug') }, { type: 'separator' }, { role: 'togglefullscreen' }, @@ -4656,6 +4657,7 @@ const buildAutoHiddenMenu = () => { { type: 'separator' }, { role: 'cut' }, { label: 'Copy', accelerator: 'Ctrl+C', click: () => handleCopyAction() }, + { label: 'Add Selection to Chat', accelerator: 'Ctrl+L', registerAccelerator: false, click: () => dispatchAction('add-selection-to-chat') }, { role: 'paste' }, { role: 'selectAll' }, ], @@ -4678,7 +4680,7 @@ const buildAutoHiddenMenu = () => { { label: 'Dark Theme', click: () => dispatchAction('theme-dark') }, { label: 'System Theme', click: () => dispatchAction('theme-system') }, { type: 'separator' }, - { label: 'Toggle Session Sidebar', accelerator: 'Ctrl+L', click: () => dispatchAction('toggle-sidebar') }, + { label: 'Toggle Session Sidebar', accelerator: 'Ctrl+Alt+L', click: () => dispatchAction('toggle-sidebar') }, { label: 'Toggle Memory Debug', accelerator: 'Ctrl+Shift+D', click: () => dispatchAction('toggle-memory-debug') }, { type: 'separator' }, { role: 'togglefullscreen' }, diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index b8d1486c..3bac2934 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -16,6 +16,8 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; +import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; +import { focusChatInput } from '@/components/chat/composer/editor/dom'; interface TextSelectionMenuProps { containerRef: React.RefObject; @@ -63,6 +65,11 @@ export const TextSelectionMenu: React.FC = ({ containerR const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setPendingInputText = useInputStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); + const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); + const addToChatShortcut = React.useMemo( + () => formatShortcutForDisplay(getEffectiveShortcutCombo('add_selection_to_chat', shortcutOverrides)), + [shortcutOverrides], + ); const projects = useProjectsStore((state) => state.projects); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const effectiveDirectory = useEffectiveDirectory(); @@ -309,6 +316,9 @@ export const TextSelectionMenu: React.FC = ({ containerR // Clear selection window.getSelection()?.removeAllRanges(); + queueMicrotask(() => { + focusChatInput(); + }); }, [selectedTextMarkdown, setPendingInputText, hideMenu]); const handleCreateNewSession = React.useCallback(async () => { @@ -415,7 +425,9 @@ export const TextSelectionMenu: React.FC = ({ containerR 'active:opacity-80', 'transition-opacity duration-150' )} - title={t('chat.textSelection.title.addToCurrentChat')} + title={addToChatShortcut + ? `${t('chat.textSelection.title.addToCurrentChat')} (${addToChatShortcut})` + : t('chat.textSelection.title.addToCurrentChat')} type="button" > @@ -508,11 +520,16 @@ export const TextSelectionMenu: React.FC = ({ containerR 'hover:bg-[var(--interactive-hover)]', 'transition-colors duration-150' )} - title={t('chat.textSelection.title.addToCurrentChat')} + title={addToChatShortcut + ? `${t('chat.textSelection.title.addToCurrentChat')} (${addToChatShortcut})` + : t('chat.textSelection.title.addToCurrentChat')} type="button" > {t('chat.textSelection.actions.addToChat')} + {addToChatShortcut ? ( + {addToChatShortcut} + ) : null}
diff --git a/packages/ui/src/components/ui/HelpDialog.tsx b/packages/ui/src/components/ui/HelpDialog.tsx index f74b9ee6..027e8833 100644 --- a/packages/ui/src/components/ui/HelpDialog.tsx +++ b/packages/ui/src/components/ui/HelpDialog.tsx @@ -65,6 +65,12 @@ export const HelpDialog: React.FC = () => { icon: "layout-left", keys: '', }, + { + id: 'add_selection_to_chat', + descriptionKey: "helpDialog.item.addSelectionToChat", + icon: "add", + keys: '', + }, { id: 'cycle_agent', keys: '', diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index c3f5a6f4..87cabf01 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -16,6 +16,7 @@ import { useDirectoryStore } from '@/stores/useDirectoryStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; +import { addSelectionToChat } from '@/lib/addSelectionToChat'; import { hasOpenDropdown } from './keyboard-shortcut-dom'; export const useKeyboardShortcuts = () => { @@ -337,6 +338,12 @@ export const useKeyboardShortcuts = () => { return; } + if (eventMatchesShortcut(e, combo('add_selection_to_chat'))) { + e.preventDefault(); + addSelectionToChat(); + return; + } + if (eventMatchesShortcut(e, combo('toggle_sidebar'))) { e.preventDefault(); const { isMobile, isSessionSwitcherOpen } = useUIStore.getState(); diff --git a/packages/ui/src/hooks/useMenuActions.ts b/packages/ui/src/hooks/useMenuActions.ts index 6b6abc09..d74243ec 100644 --- a/packages/ui/src/hooks/useMenuActions.ts +++ b/packages/ui/src/hooks/useMenuActions.ts @@ -10,6 +10,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem'; import { sessionEvents } from '@/lib/sessionEvents'; import { createWorktreeSession } from '@/lib/worktreeSessionCreator'; import { showOpenCodeStatus } from '@/lib/openCodeStatus'; +import { addSelectionToChat } from '@/lib/addSelectionToChat'; const getActiveElementSelectedText = (): string => { if (typeof document === 'undefined') { @@ -77,6 +78,7 @@ type MenuAction = | 'toggle-terminal' | 'toggle-terminal-expanded' | 'copy' + | 'add-selection-to-chat' | 'theme-light' | 'theme-dark' | 'theme-system' @@ -278,6 +280,10 @@ export const useMenuActions = ( setThemeMode('system'); break; + case 'add-selection-to-chat': + addSelectionToChat(); + break; + case 'toggle-sidebar': toggleSidebar(); break; diff --git a/packages/ui/src/lib/addSelectionToChat.test.ts b/packages/ui/src/lib/addSelectionToChat.test.ts new file mode 100644 index 00000000..c95cf09f --- /dev/null +++ b/packages/ui/src/lib/addSelectionToChat.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; + +const focusChatInputCalls: number[] = []; +const pendingInputCalls: Array<{ text: string | null; mode?: string }> = []; +const activeMainTabCalls: string[] = []; +const sessionSwitcherCalls: boolean[] = []; + +mock.module('@/components/chat/composer/editor/dom', () => ({ + focusChatInput: () => { + focusChatInputCalls.push(1); + }, +})); + +mock.module('@/sync/input-store', () => ({ + useInputStore: { + getState: () => ({ + setPendingInputText: (text: string | null, mode?: string) => { + pendingInputCalls.push({ text, mode }); + }, + }), + }, +})); + +mock.module('@/stores/useUIStore', () => ({ + useUIStore: { + getState: () => ({ + setActiveMainTab: (tab: string) => { + activeMainTabCalls.push(tab); + }, + setSessionSwitcherOpen: (open: boolean) => { + sessionSwitcherCalls.push(open); + }, + }), + }, +})); + +const { addSelectionToChat, captureSelectionMarkdownForChat } = await import('./addSelectionToChat'); + +const originalDocument = globalThis.document; +const originalWindow = globalThis.window; + +const installEmptySelectionEnvironment = (activeElement: Element | null = null) => { + const documentLike = { + activeElement, + querySelector: () => null, + }; + const windowLike = { + getSelection: () => null, + }; + Object.defineProperty(globalThis, 'document', { value: documentLike, configurable: true }); + Object.defineProperty(globalThis, 'window', { value: windowLike, configurable: true }); +}; + +const clearCalls = () => { + focusChatInputCalls.length = 0; + pendingInputCalls.length = 0; + activeMainTabCalls.length = 0; + sessionSwitcherCalls.length = 0; +}; + +afterEach(() => { + Object.defineProperty(globalThis, 'document', { value: originalDocument, configurable: true }); + Object.defineProperty(globalThis, 'window', { value: originalWindow, configurable: true }); +}); + +describe('captureSelectionMarkdownForChat', () => { + beforeEach(() => { + clearCalls(); + }); + + test('returns null when nothing is selected', () => { + installEmptySelectionEnvironment(); + expect(captureSelectionMarkdownForChat()).toBeNull(); + }); + + test('captures a textarea selection outside the composer', () => { + const textarea = { + tagName: 'TEXTAREA', + value: 'alpha beta gamma', + selectionStart: 6, + selectionEnd: 10, + closest: () => null, + } as unknown as HTMLTextAreaElement; + + installEmptySelectionEnvironment(textarea); + expect(captureSelectionMarkdownForChat()).toBe('```md\nbeta\n```'); + }); + + test('ignores selections inside the chat composer', () => { + const textarea = { + tagName: 'TEXTAREA', + value: 'draft text', + selectionStart: 0, + selectionEnd: 5, + closest: (selector: string) => (selector === '[data-chat-input="true"]' ? textarea : null), + } as unknown as HTMLTextAreaElement; + + installEmptySelectionEnvironment(textarea); + expect(captureSelectionMarkdownForChat()).toBeNull(); + }); +}); + +describe('addSelectionToChat', () => { + beforeEach(() => { + clearCalls(); + }); + + test('appends captured selection and focuses chat input', async () => { + const textarea = { + tagName: 'TEXTAREA', + value: 'selected', + selectionStart: 0, + selectionEnd: 8, + closest: () => null, + } as unknown as HTMLTextAreaElement; + installEmptySelectionEnvironment(textarea); + + expect(addSelectionToChat()).toBe(true); + expect(activeMainTabCalls).toEqual(['chat']); + expect(sessionSwitcherCalls).toEqual([false]); + expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]); + + await Promise.resolve(); + expect(focusChatInputCalls.length).toBe(1); + }); + + test('focuses chat input when nothing is selected', async () => { + installEmptySelectionEnvironment(); + + expect(addSelectionToChat()).toBe(false); + expect(pendingInputCalls).toEqual([]); + expect(activeMainTabCalls).toEqual(['chat']); + + await Promise.resolve(); + expect(focusChatInputCalls.length).toBe(1); + }); +}); diff --git a/packages/ui/src/lib/addSelectionToChat.ts b/packages/ui/src/lib/addSelectionToChat.ts new file mode 100644 index 00000000..6fc8c1ad --- /dev/null +++ b/packages/ui/src/lib/addSelectionToChat.ts @@ -0,0 +1,153 @@ +import { EditorView } from '@codemirror/view'; +import { focusChatInput } from '@/components/chat/composer/editor/dom'; +import { + formatCodeSelectionMarkdown, + rangeToMarkdown, + trimSelectionValue, + wrapMarkdownSelectionForChat, +} from '@/components/chat/message/selectionMarkdown'; +import { useInputStore } from '@/sync/input-store'; +import { useUIStore } from '@/stores/useUIStore'; + +const CHAT_INPUT_HOST_SELECTOR = '[data-chat-input="true"]'; + +const isInsideChatComposer = (node: Node | null): boolean => { + if (!node) { + return false; + } + const asElement = node as Element; + const element = typeof asElement.closest === 'function' + ? asElement + : (node as Node).parentElement; + return Boolean(element?.closest(CHAT_INPUT_HOST_SELECTOR)); +}; + +const readTextControlSelection = (element: Element): string | null => { + if (isInsideChatComposer(element)) { + return null; + } + + const tag = element.tagName?.toLowerCase(); + if (tag === 'textarea') { + const control = element as HTMLTextAreaElement; + return trimSelectionValue( + control.value.slice(control.selectionStart ?? 0, control.selectionEnd ?? 0), + ) || null; + } + + if (tag === 'input') { + const control = element as HTMLInputElement; + const type = control.type?.toLowerCase() ?? 'text'; + if (!['text', 'search', 'url', 'tel', 'password'].includes(type)) { + return null; + } + return trimSelectionValue( + control.value.slice(control.selectionStart ?? 0, control.selectionEnd ?? 0), + ) || null; + } + + return null; +}; + +const captureActiveElementSelection = (): string | null => { + if (typeof document === 'undefined') { + return null; + } + + const activeElement = document.activeElement; + if (!activeElement || typeof (activeElement as Element).tagName !== 'string') { + return null; + } + + const text = readTextControlSelection(activeElement as Element); + return text ? wrapMarkdownSelectionForChat(text) : null; +}; + +const captureCodeMirrorSelection = (): string | null => { + if (typeof document === 'undefined') { + return null; + } + + const focusedEditor = document.querySelector('.cm-editor.cm-focused'); + if (!focusedEditor || isInsideChatComposer(focusedEditor)) { + return null; + } + + const view = EditorView.findFromDOM(focusedEditor); + if (!view) { + return null; + } + + const { from, to } = view.state.selection.main; + if (from === to) { + return null; + } + + const text = trimSelectionValue(view.state.sliceDoc(from, to)); + if (!text) { + return null; + } + + view.dispatch({ + selection: { anchor: to }, + }); + + return formatCodeSelectionMarkdown(text); +}; + +const captureDomSelection = (): string | null => { + if (typeof window === 'undefined') { + return null; + } + + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return null; + } + + const range = selection.getRangeAt(0); + if (isInsideChatComposer(range.commonAncestorContainer)) { + return null; + } + + const plainText = trimSelectionValue(selection.toString()); + if (!plainText) { + return null; + } + + const markdown = rangeToMarkdown(range, plainText); + selection.removeAllRanges(); + return wrapMarkdownSelectionForChat(markdown); +}; + +/** + * Capture the current non-composer selection as chat-ready markdown. + * Returns null when nothing usable is selected. + */ +export const captureSelectionMarkdownForChat = (): string | null => { + return captureCodeMirrorSelection() + ?? captureActiveElementSelection() + ?? captureDomSelection(); +}; + +/** + * Append the current selection to the chat composer. + * When nothing is selected, focuses the chat input (Cursor-style Ctrl/Cmd+L). + * Returns true when selected text was appended. + */ +export const addSelectionToChat = (): boolean => { + const markdown = captureSelectionMarkdownForChat(); + + useUIStore.getState().setActiveMainTab('chat'); + useUIStore.getState().setSessionSwitcherOpen(false); + + if (markdown) { + useInputStore.getState().setPendingInputText(markdown, 'append'); + } + + queueMicrotask(() => { + focusChatInput(); + }); + + return markdown !== null; +}; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 1291cf6d..51c57a17 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1039,6 +1039,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Einstellungen öffnen', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Terminal-Dock umschalten', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal erweitert umschalten', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Auswahl zum Chat hinzufügen', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Seitenleiste umschalten', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Rechte Seitenleiste umschalten', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git-Tab der rechten Seitenleiste öffnen', diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index f9d325b9..b631f826 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1531,6 +1531,7 @@ export const dict = { 'helpDialog.item.openCommandPalette': 'Befehlspalette öffnen', 'helpDialog.item.showKeyboardShortcuts': 'Tastaturkürzel anzeigen (dieses Dialogfeld)', 'helpDialog.item.toggleSessionSidebar': 'Sitzungs-Seitenleiste umschalten', + 'helpDialog.item.addSelectionToChat': 'Auswahl zum Chat hinzufügen', 'helpDialog.item.cycleAgent': 'Agent wechseln (Chat-Eingabe)', 'helpDialog.item.openModelSelector': 'Modell-Auswahldialog öffnen', 'helpDialog.item.navigateModels': 'Modelle navigieren (in Auswahl)', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 924965d1..4c453515 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1104,6 +1104,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Open settings', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Toggle terminal dock', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Toggle terminal expanded', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Add selection to chat', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Toggle sidebar', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Toggle context panel', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Open Git surface', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 7b63e05e..5501f15b 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1678,6 +1678,7 @@ export const dict = { 'helpDialog.item.openCommandPalette': 'Open Command Palette', 'helpDialog.item.showKeyboardShortcuts': 'Show Keyboard Shortcuts (this dialog)', 'helpDialog.item.toggleSessionSidebar': 'Toggle Session Sidebar', + 'helpDialog.item.addSelectionToChat': 'Add Selection to Chat', 'helpDialog.item.cycleAgent': 'Cycle Agent (chat input)', 'helpDialog.item.openModelSelector': 'Open Model Selector', 'helpDialog.item.navigateModels': 'Navigate Models (in picker)', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 88f12bfe..1178accc 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1071,6 +1071,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.open_settings.label": "Abrir configuración", "settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Mostrar u ocultar panel de terminal", "settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir o contraer terminal", + "settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Agregar selección al chat", "settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar u ocultar barra lateral", "settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar panel de contexto', "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superficie de Git', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 08a3f727..c7a94320 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1656,6 +1656,7 @@ export const dict: Record = { "helpDialog.item.openCommandPalette": "Abrir paleta de comandos", "helpDialog.item.showKeyboardShortcuts": "Mostrar atajos de teclado (este diálogo)", "helpDialog.item.toggleSessionSidebar": "Mostrar u ocultar barra lateral de sesión", + "helpDialog.item.addSelectionToChat": "Agregar selección al chat", "helpDialog.item.cycleAgent": "Cambiar agente (entrada de chat)", "helpDialog.item.openModelSelector": "Abrir selector de modelos", "helpDialog.item.navigateModels": "Navegar modelos (en selector)", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 30cfe251..ad5e7561 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -992,6 +992,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': 'Ouvrir les paramètres', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Basculer la station d\'accueil du terminal', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Terminal à bascule étendu', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Ajouter la sélection au chat', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Basculer la barre latérale', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Afficher/masquer le panneau de contexte', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Ouvrir la surface Git', diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 2f99e4f4..8303c247 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1491,6 +1491,7 @@ export const dict = { 'helpDialog.item.openCommandPalette': 'Ouvrir la palette de commandes', 'helpDialog.item.showKeyboardShortcuts': 'Afficher les raccourcis clavier (cette boîte de dialogue)', 'helpDialog.item.toggleSessionSidebar': 'Toggle la barre latérale de la session', + 'helpDialog.item.addSelectionToChat': 'Ajouter la sélection au chat', 'helpDialog.item.cycleAgent': 'Agent de cycle (entrée de chat)', 'helpDialog.item.openModelSelector': 'Ouvrir le sélecteur de modèle', 'helpDialog.item.navigateModels': 'Naviguer dans les modèles (dans le sélecteur)', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 6ad20690..50571a78 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1104,6 +1104,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '設定を開く', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'ターミナルドックの切替', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'ターミナル拡大の切替', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '選択範囲をチャットに追加', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'サイドバーの切替', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'コンテキストパネルの表示切替', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git サーフェスを開く', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index e7c7d111..12d57006 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1674,6 +1674,7 @@ export const dict: Record = { 'helpDialog.item.openCommandPalette': 'コマンドパレットを開く', 'helpDialog.item.showKeyboardShortcuts': 'キーボードショートカットを表示(このダイアログ)', 'helpDialog.item.toggleSessionSidebar': 'セッションサイドバーの切り替え', + 'helpDialog.item.addSelectionToChat': '選択範囲をチャットに追加', 'helpDialog.item.cycleAgent': 'エージェント切り替え(チャット入力)', 'helpDialog.item.openModelSelector': 'モデルセレクターを開く', 'helpDialog.item.navigateModels': 'モデルを移動(ピッカー内)', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index a46f1b2e..246b1427 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1071,6 +1071,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '설정 열기', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '터미널 dock 토글', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '터미널 확장 토글', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '선택 내용을 채팅에 추가', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '사이드바 토글', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '컨텍스트 패널 표시 전환', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': 'Git 서피스 열기', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 5e2983be..2f9eb896 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1680,6 +1680,7 @@ export const dict: Record = { 'helpDialog.item.openCommandPalette': '명령 팔레트 열기', 'helpDialog.item.showKeyboardShortcuts': '키보드 단축키 보기(이 대화상자)', 'helpDialog.item.toggleSessionSidebar': '토글 세션 사이드바', + 'helpDialog.item.addSelectionToChat': '선택 내용을 채팅에 추가', 'helpDialog.item.cycleAgent': '에이전트 순환(채팅 입력)', 'helpDialog.item.openModelSelector': '모델 선택기 열기', 'helpDialog.item.navigateModels': '모델 이동(선택기)', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 93a9e901..2ddb59f9 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -825,6 +825,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.toggle_context_plan.label': 'Przełącz panel kontekstu planu', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': 'Przełącz panel kontekstu', 'settings.openchamber.keyboardShortcuts.action.toggle_services_menu.label': 'Przełącz menu usług', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': 'Dodaj zaznaczenie do czatu', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': 'Przełącz pasek boczny', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': 'Przełącz dokowanie terminala', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': 'Przełącz rozszerzony terminal', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index f4129c47..ae54407b 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2325,6 +2325,7 @@ export const dict: Record = { 'helpDialog.item.toggleRightSidebar': 'Przełącz panel kontekstu', 'helpDialog.item.toggleServicesMenu': 'Przełącz menu usług', 'helpDialog.item.toggleSessionSidebar': 'Przełącz panel sesji', + 'helpDialog.item.addSelectionToChat': 'Dodaj zaznaczenie do czatu', 'helpDialog.item.toggleTerminalDock': 'Przełącz dolny terminal', 'helpDialog.item.toggleTerminalExpanded': 'Przełącz rozszerzenie terminala', 'helpDialog.keyCombiner.or': 'lub', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index eebdc398..91fcdbd1 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1071,6 +1071,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.open_settings.label": "Abrir configurações", "settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Mostrar ou ocultar painel de terminal", "settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Expandir ou recolher terminal", + "settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Adicionar seleção ao chat", "settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Mostrar ou ocultar barra lateral", "settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Alternar painel de contexto', "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Abrir superfície do Git', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index a9c3bca9..789ac65d 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1656,6 +1656,7 @@ export const dict: Record = { "helpDialog.item.openCommandPalette": "Abrir paleta de comandos", "helpDialog.item.showKeyboardShortcuts": "Mostrar atalhos de teclado (este diálogo)", "helpDialog.item.toggleSessionSidebar": "Mostrar ou ocultar barra lateral de sessão", + "helpDialog.item.addSelectionToChat": "Adicionar seleção ao chat", "helpDialog.item.cycleAgent": "Alternar agente (entrada do chat)", "helpDialog.item.openModelSelector": "Abrir seletor de modelos", "helpDialog.item.navigateModels": "Navegar por modelos (no seletor)", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index b7f18d2e..a6c70f9b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1071,6 +1071,7 @@ export const settingsDict = { "settings.openchamber.keyboardShortcuts.action.open_settings.label": "Відкрити налаштування", "settings.openchamber.keyboardShortcuts.action.toggle_terminal.label": "Перемкнути панель терміналу", "settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label": "Розгорнути або згорнути термінал", + "settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label": "Додати виділення в чат", "settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label": "Перемкнути бічну панель", "settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label": 'Перемкнути контекстну панель', "settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label": 'Відкрити поверхню Git', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index c1c3d7cc..911387f4 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1656,6 +1656,7 @@ export const dict: Record = { "helpDialog.item.openCommandPalette": "Відкрити палітру команд", "helpDialog.item.showKeyboardShortcuts": "Показати комбінації клавіш (це діалогове вікно)", "helpDialog.item.toggleSessionSidebar": "Перемкнути бічну панель сесій", + "helpDialog.item.addSelectionToChat": "Додати виділення в чат", "helpDialog.item.cycleAgent": "Перемкнути агента (введення в чат)", "helpDialog.item.openModelSelector": "Відкрити засіб вибору моделі", "helpDialog.item.navigateModels": "Навігація моделями (у засобі вибору)", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 8fe889c7..1010ce46 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1071,6 +1071,7 @@ export const settingsDict = { 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '打开设置', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切换终端停靠区', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切换终端展开', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '将选中内容添加到聊天', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切换侧边栏', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切换上下文面板', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '打开 Git 界面', diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 9fef7f48..01748f14 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1644,6 +1644,7 @@ export const dict: Record = { 'helpDialog.item.openCommandPalette': '打开命令面板', 'helpDialog.item.showKeyboardShortcuts': '显示键盘快捷键(此对话框)', 'helpDialog.item.toggleSessionSidebar': '切换会话侧边栏', + 'helpDialog.item.addSelectionToChat': '将选中内容添加到聊天', 'helpDialog.item.cycleAgent': '循环切换智能体(聊天输入)', 'helpDialog.item.openModelSelector': '打开模型选择器', 'helpDialog.item.navigateModels': '导航模型(选择器中)', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index a7405089..1ebe3d6c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -978,6 +978,7 @@ 'settings.openchamber.keyboardShortcuts.action.open_settings.label': '開啟設定', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal.label': '切換終端機停靠區', 'settings.openchamber.keyboardShortcuts.action.toggle_terminal_expanded.label': '切換終端機展開', + 'settings.openchamber.keyboardShortcuts.action.add_selection_to_chat.label': '將選取內容加入聊天', 'settings.openchamber.keyboardShortcuts.action.toggle_sidebar.label': '切換側邊欄', 'settings.openchamber.keyboardShortcuts.action.toggle_right_sidebar.label': '切換上下文面板', 'settings.openchamber.keyboardShortcuts.action.open_right_sidebar_git.label': '開啟 Git 介面', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 2615292a..84ce8f87 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1648,6 +1648,7 @@ export const dict: Record = { 'helpDialog.item.openCommandPalette': '開啟命令面板', 'helpDialog.item.showKeyboardShortcuts': '顯示鍵盤快速鍵(此對話方塊)', 'helpDialog.item.toggleSessionSidebar': '切換會話側邊欄', + 'helpDialog.item.addSelectionToChat': '將選取內容加入聊天', 'helpDialog.item.cycleAgent': '循環切換 Agent(聊天輸入)', 'helpDialog.item.openModelSelector': '開啟模型選擇器', 'helpDialog.item.navigateModels': '導覽模型(選擇器中)', diff --git a/packages/ui/src/lib/shortcuts.ts b/packages/ui/src/lib/shortcuts.ts index e6e16e29..6afb4656 100644 --- a/packages/ui/src/lib/shortcuts.ts +++ b/packages/ui/src/lib/shortcuts.ts @@ -159,8 +159,15 @@ const SHORTCUT_ACTIONS: ReadonlyArray = [ description: 'Toggle the files panel', }, { - id: 'toggle_sidebar', + id: 'add_selection_to_chat', defaultCombo: 'mod+l', + label: 'Add selection to chat', + description: 'Add the selected text to the chat input', + customizable: true, + }, + { + id: 'toggle_sidebar', + defaultCombo: 'mod+alt+l', label: 'Toggle sidebar', description: 'Toggle the session sidebar', customizable: true, From 682c42df8f0ed282026aee152c9fefab9de3896e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 14:24:22 +0000 Subject: [PATCH 2/3] fix(ui): harden Ctrl+L selection capture and menu delivery Cover CodeMirror and DOM capture paths in tests, collapse text-control selections after capture, and deliver the desktop Edit-menu action over a single IPC channel so append cannot double-fire. Co-authored-by: Serhii Dziupin --- packages/electron/main.mjs | 11 +- .../ui/src/lib/addSelectionToChat.test.ts | 179 +++++++++++++++++- packages/ui/src/lib/addSelectionToChat.ts | 25 ++- 3 files changed, 198 insertions(+), 17 deletions(-) diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index 31731358..c1004947 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -2250,6 +2250,13 @@ const dispatchMenuAction = (action) => { dispatchDomEventToWindow(target, 'openchamber:menu-action', action); }; +// Append-style menu actions must reach the renderer exactly once. Dual IPC+DOM +// delivery (dispatchMenuAction) would insert the selection twice. +const dispatchAddSelectionToChat = () => { + const target = getMenuTargetWindow(); + if (target) emitToWindow(target, 'openchamber:menu-action', 'add-selection-to-chat'); +}; + // Mini-chat draft windows are not deduplicated, so this must reach the renderer // exactly once — emitToWindow alone (no DOM-event double dispatch). The renderer // resolves the active directory/project and opens the window. @@ -4559,7 +4566,7 @@ const buildMacMenu = () => { { type: 'separator' }, { role: 'cut' }, { label: 'Copy', accelerator: 'Cmd+C', click: () => handleCopyAction() }, - { label: 'Add Selection to Chat', accelerator: 'Cmd+L', registerAccelerator: false, click: () => dispatchAction('add-selection-to-chat') }, + { label: 'Add Selection to Chat', accelerator: 'Cmd+L', registerAccelerator: false, click: () => dispatchAddSelectionToChat() }, { role: 'paste' }, { role: 'selectAll' }, ], @@ -4657,7 +4664,7 @@ const buildAutoHiddenMenu = () => { { type: 'separator' }, { role: 'cut' }, { label: 'Copy', accelerator: 'Ctrl+C', click: () => handleCopyAction() }, - { label: 'Add Selection to Chat', accelerator: 'Ctrl+L', registerAccelerator: false, click: () => dispatchAction('add-selection-to-chat') }, + { label: 'Add Selection to Chat', accelerator: 'Ctrl+L', registerAccelerator: false, click: () => dispatchAddSelectionToChat() }, { role: 'paste' }, { role: 'selectAll' }, ], diff --git a/packages/ui/src/lib/addSelectionToChat.test.ts b/packages/ui/src/lib/addSelectionToChat.test.ts index c95cf09f..6acc225a 100644 --- a/packages/ui/src/lib/addSelectionToChat.test.ts +++ b/packages/ui/src/lib/addSelectionToChat.test.ts @@ -4,6 +4,23 @@ const focusChatInputCalls: number[] = []; const pendingInputCalls: Array<{ text: string | null; mode?: string }> = []; const activeMainTabCalls: string[] = []; const sessionSwitcherCalls: boolean[] = []; +const codeMirrorDispatches: Array<{ selection: { anchor: number } }> = []; + +type MockCodeMirrorView = { + state: { + selection: { main: { from: number; to: number } }; + sliceDoc: (from: number, to: number) => string; + }; + dispatch: (transaction: { selection: { anchor: number } }) => void; +}; + +let codeMirrorView: MockCodeMirrorView | null = null; + +mock.module('@codemirror/view', () => ({ + EditorView: { + findFromDOM: () => codeMirrorView, + }, +})); mock.module('@/components/chat/composer/editor/dom', () => ({ focusChatInput: () => { @@ -39,13 +56,28 @@ const { addSelectionToChat, captureSelectionMarkdownForChat } = await import('./ const originalDocument = globalThis.document; const originalWindow = globalThis.window; -const installEmptySelectionEnvironment = (activeElement: Element | null = null) => { +const installSelectionEnvironment = (options: { + activeElement?: Element | null; + focusedCodeMirror?: Element | null; + selection?: Selection | null; +} = {}) => { + const { + activeElement = null, + focusedCodeMirror = null, + selection = null, + } = options; + const documentLike = { activeElement, - querySelector: () => null, + querySelector: (selector: string) => { + if (selector === '.cm-editor.cm-focused') { + return focusedCodeMirror; + } + return null; + }, }; const windowLike = { - getSelection: () => null, + getSelection: () => selection, }; Object.defineProperty(globalThis, 'document', { value: documentLike, configurable: true }); Object.defineProperty(globalThis, 'window', { value: windowLike, configurable: true }); @@ -56,6 +88,8 @@ const clearCalls = () => { pendingInputCalls.length = 0; activeMainTabCalls.length = 0; sessionSwitcherCalls.length = 0; + codeMirrorDispatches.length = 0; + codeMirrorView = null; }; afterEach(() => { @@ -69,11 +103,11 @@ describe('captureSelectionMarkdownForChat', () => { }); test('returns null when nothing is selected', () => { - installEmptySelectionEnvironment(); + installSelectionEnvironment(); expect(captureSelectionMarkdownForChat()).toBeNull(); }); - test('captures a textarea selection outside the composer', () => { + test('captures a textarea selection outside the composer and collapses it', () => { const textarea = { tagName: 'TEXTAREA', value: 'alpha beta gamma', @@ -82,8 +116,10 @@ describe('captureSelectionMarkdownForChat', () => { closest: () => null, } as unknown as HTMLTextAreaElement; - installEmptySelectionEnvironment(textarea); + installSelectionEnvironment({ activeElement: textarea }); expect(captureSelectionMarkdownForChat()).toBe('```md\nbeta\n```'); + expect(textarea.selectionStart).toBe(10); + expect(textarea.selectionEnd).toBe(10); }); test('ignores selections inside the chat composer', () => { @@ -95,7 +131,117 @@ describe('captureSelectionMarkdownForChat', () => { closest: (selector: string) => (selector === '[data-chat-input="true"]' ? textarea : null), } as unknown as HTMLTextAreaElement; - installEmptySelectionEnvironment(textarea); + installSelectionEnvironment({ activeElement: textarea }); + expect(captureSelectionMarkdownForChat()).toBeNull(); + }); + + test('captures a focused CodeMirror selection outside the composer and collapses it', () => { + const focusedEditor = { + closest: () => null, + } as unknown as HTMLElement; + + codeMirrorView = { + state: { + selection: { main: { from: 4, to: 11 } }, + sliceDoc: (from: number, to: number) => 'const x'.slice(0, to - from), + }, + dispatch: (transaction) => { + codeMirrorDispatches.push(transaction); + codeMirrorView!.state.selection.main = { + from: transaction.selection.anchor, + to: transaction.selection.anchor, + }; + }, + }; + + // sliceDoc should return the selected slice; use explicit text instead of slice math. + codeMirrorView.state.sliceDoc = () => 'const x'; + + installSelectionEnvironment({ focusedCodeMirror: focusedEditor }); + expect(captureSelectionMarkdownForChat()).toBe('```\nconst x\n```'); + expect(codeMirrorDispatches).toEqual([{ selection: { anchor: 11 } }]); + expect(codeMirrorView.state.selection.main).toEqual({ from: 11, to: 11 }); + }); + + test('ignores a focused CodeMirror editor inside the chat composer', () => { + const focusedEditor = { + closest: (selector: string) => (selector === '[data-chat-input="true"]' ? focusedEditor : null), + } as unknown as HTMLElement; + + codeMirrorView = { + state: { + selection: { main: { from: 0, to: 5 } }, + sliceDoc: () => 'draft', + }, + dispatch: (transaction) => { + codeMirrorDispatches.push(transaction); + }, + }; + + installSelectionEnvironment({ focusedCodeMirror: focusedEditor }); + expect(captureSelectionMarkdownForChat()).toBeNull(); + expect(codeMirrorDispatches).toEqual([]); + }); + + test('captures a DOM selection from chat-message content and clears it', () => { + const parent = { + closest: (selector: string) => (selector === 'pre code' ? null : null), + }; + const textNode = { + nodeType: 3, + parentElement: parent, + }; + let rangeCount = 1; + let collapsed = false; + const selection = { + get rangeCount() { + return rangeCount; + }, + get isCollapsed() { + return collapsed; + }, + toString: () => 'Hello world', + getRangeAt: () => ({ + commonAncestorContainer: textNode, + startContainer: textNode, + endContainer: textNode, + cloneContents: () => ({ childNodes: [] }), + }), + removeAllRanges: () => { + rangeCount = 0; + collapsed = true; + }, + } as unknown as Selection; + + installSelectionEnvironment({ selection }); + expect(captureSelectionMarkdownForChat()).toBe('```md\nHello world\n```'); + expect(selection.rangeCount).toBe(0); + expect(selection.isCollapsed).toBe(true); + }); + + test('ignores a DOM selection inside the chat composer', () => { + const composerHost = {}; + const parent = { + closest: (selector: string) => (selector === '[data-chat-input="true"]' ? composerHost : null), + }; + const textNode = { + nodeType: 3, + parentElement: parent, + }; + const selection = { + rangeCount: 1, + isCollapsed: false, + toString: () => 'draft', + getRangeAt: () => ({ + commonAncestorContainer: textNode, + startContainer: textNode, + endContainer: textNode, + cloneContents: () => ({ childNodes: [] }), + }), + removeAllRanges: () => undefined, + } as unknown as Selection; + + installSelectionEnvironment({ selection }); expect(captureSelectionMarkdownForChat()).toBeNull(); }); }); @@ -113,7 +259,7 @@ describe('addSelectionToChat', () => { selectionEnd: 8, closest: () => null, } as unknown as HTMLTextAreaElement; - installEmptySelectionEnvironment(textarea); + installSelectionEnvironment({ activeElement: textarea }); expect(addSelectionToChat()).toBe(true); expect(activeMainTabCalls).toEqual(['chat']); @@ -124,8 +270,23 @@ describe('addSelectionToChat', () => { expect(focusChatInputCalls.length).toBe(1); }); + test('second capture after textarea collapse does not append again', () => { + const textarea = { + tagName: 'TEXTAREA', + value: 'selected', + selectionStart: 0, + selectionEnd: 8, + closest: () => null, + } as unknown as HTMLTextAreaElement; + installSelectionEnvironment({ activeElement: textarea }); + + expect(addSelectionToChat()).toBe(true); + expect(addSelectionToChat()).toBe(false); + expect(pendingInputCalls).toEqual([{ text: '```md\nselected\n```', mode: 'append' }]); + }); + test('focuses chat input when nothing is selected', async () => { - installEmptySelectionEnvironment(); + installSelectionEnvironment(); expect(addSelectionToChat()).toBe(false); expect(pendingInputCalls).toEqual([]); diff --git a/packages/ui/src/lib/addSelectionToChat.ts b/packages/ui/src/lib/addSelectionToChat.ts index 6fc8c1ad..ce296569 100644 --- a/packages/ui/src/lib/addSelectionToChat.ts +++ b/packages/ui/src/lib/addSelectionToChat.ts @@ -30,9 +30,16 @@ const readTextControlSelection = (element: Element): string | null => { const tag = element.tagName?.toLowerCase(); if (tag === 'textarea') { const control = element as HTMLTextAreaElement; - return trimSelectionValue( - control.value.slice(control.selectionStart ?? 0, control.selectionEnd ?? 0), - ) || null; + const start = control.selectionStart ?? 0; + const end = control.selectionEnd ?? 0; + const text = trimSelectionValue(control.value.slice(start, end)); + if (!text) { + return null; + } + // Collapse so a duplicate menu delivery cannot append the same range twice. + control.selectionStart = end; + control.selectionEnd = end; + return text; } if (tag === 'input') { @@ -41,9 +48,15 @@ const readTextControlSelection = (element: Element): string | null => { if (!['text', 'search', 'url', 'tel', 'password'].includes(type)) { return null; } - return trimSelectionValue( - control.value.slice(control.selectionStart ?? 0, control.selectionEnd ?? 0), - ) || null; + const start = control.selectionStart ?? 0; + const end = control.selectionEnd ?? 0; + const text = trimSelectionValue(control.value.slice(start, end)); + if (!text) { + return null; + } + control.selectionStart = end; + control.selectionEnd = end; + return text; } return null; From f4f23704b8b91b00f7e6d28a119d77148f6ff678 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 4 Aug 2026 08:21:14 +0000 Subject: [PATCH 3/3] fix(ui): drop Ctrl+L hint from text selection menu Keep the shortcut in Help/Settings only; the floating selection chip should stay quiet so the Add to Chat action is easier to scan. Co-authored-by: Serhii Dziupin --- .../chat/message/TextSelectionMenu.tsx | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx index 3bac2934..c8ecc9f4 100644 --- a/packages/ui/src/components/chat/message/TextSelectionMenu.tsx +++ b/packages/ui/src/components/chat/message/TextSelectionMenu.tsx @@ -16,7 +16,6 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { isVSCodeRuntime } from '@/lib/desktop'; import { useI18n } from '@/lib/i18n'; import { rangeToMarkdown, trimSelectionValue, wrapMarkdownSelectionForChat } from './selectionMarkdown'; -import { formatShortcutForDisplay, getEffectiveShortcutCombo } from '@/lib/shortcuts'; import { focusChatInput } from '@/components/chat/composer/editor/dom'; interface TextSelectionMenuProps { @@ -65,11 +64,6 @@ export const TextSelectionMenu: React.FC = ({ containerR const currentSessionId = useSessionUIStore((state) => state.currentSessionId); const setPendingInputText = useInputStore((state) => state.setPendingInputText); const isMobile = useUIStore((state) => state.isMobile); - const shortcutOverrides = useUIStore((state) => state.shortcutOverrides); - const addToChatShortcut = React.useMemo( - () => formatShortcutForDisplay(getEffectiveShortcutCombo('add_selection_to_chat', shortcutOverrides)), - [shortcutOverrides], - ); const projects = useProjectsStore((state) => state.projects); const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); const effectiveDirectory = useEffectiveDirectory(); @@ -425,9 +419,7 @@ export const TextSelectionMenu: React.FC = ({ containerR 'active:opacity-80', 'transition-opacity duration-150' )} - title={addToChatShortcut - ? `${t('chat.textSelection.title.addToCurrentChat')} (${addToChatShortcut})` - : t('chat.textSelection.title.addToCurrentChat')} + title={t('chat.textSelection.title.addToCurrentChat')} type="button" > @@ -520,16 +512,11 @@ export const TextSelectionMenu: React.FC = ({ containerR 'hover:bg-[var(--interactive-hover)]', 'transition-colors duration-150' )} - title={addToChatShortcut - ? `${t('chat.textSelection.title.addToCurrentChat')} (${addToChatShortcut})` - : t('chat.textSelection.title.addToCurrentChat')} + title={t('chat.textSelection.title.addToCurrentChat')} type="button" > {t('chat.textSelection.actions.addToChat')} - {addToChatShortcut ? ( - {addToChatShortcut} - ) : null}