From a2920b17623e2903bd9481721f60c2bf26c6f97a Mon Sep 17 00:00:00 2001 From: Wesley Simplicio Date: Sat, 9 May 2026 00:21:13 -0300 Subject: [PATCH] fix(tui): right-click copies selection, only pastes when no selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-issue 5 of #22034. Right-click on the composer always pasted from the clipboard, even when the user had highlighted text — diverging from terminal-native behavior (xterm/iTerm/gnome-terminal) where right-click copies an active selection and only pastes when nothing is selected. Extract a small pure helper, decideRightClickAction(value, range), and route the existing onMouseDown right-click branch through it. Selection present and non-empty -> writeClipboardText(slice). Otherwise fall back to the existing emitPaste path. --- .../src/__tests__/textInputRightClick.test.ts | 48 +++++++++++++++++++ ui-tui/src/components/textInput.tsx | 37 +++++++++++++- 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 ui-tui/src/__tests__/textInputRightClick.test.ts diff --git a/ui-tui/src/__tests__/textInputRightClick.test.ts b/ui-tui/src/__tests__/textInputRightClick.test.ts new file mode 100644 index 00000000000..bf37b412236 --- /dev/null +++ b/ui-tui/src/__tests__/textInputRightClick.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { decideRightClickAction } from '../components/textInput.js' + +describe('decideRightClickAction', () => { + it('returns paste when there is no selection', () => { + expect(decideRightClickAction('hello world', null)).toEqual({ action: 'paste' }) + }) + + it('returns paste for a collapsed (empty) range', () => { + expect(decideRightClickAction('hello world', { end: 5, start: 5 })).toEqual({ + action: 'paste' + }) + }) + + it('copies the slice when range covers non-empty text', () => { + expect(decideRightClickAction('hello world', { end: 5, start: 0 })).toEqual({ + action: 'copy', + text: 'hello' + }) + }) + + it('copies a middle slice', () => { + expect(decideRightClickAction('hello world', { end: 11, start: 6 })).toEqual({ + action: 'copy', + text: 'world' + }) + }) + + it('falls back to paste when slice is empty (out-of-range indices)', () => { + expect(decideRightClickAction('', { end: 5, start: 0 })).toEqual({ action: 'paste' }) + }) + + it('handles unicode (emoji, CJK) in the slice', () => { + const value = 'hi 你好 🎉' + expect(decideRightClickAction(value, { end: 5, start: 3 })).toEqual({ + action: 'copy', + text: '你好' + }) + }) + + it('preserves leading/trailing whitespace in the copied slice', () => { + expect(decideRightClickAction(' spaced ', { end: 10, start: 0 })).toEqual({ + action: 'copy', + text: ' spaced ' + }) + }) +}) diff --git a/ui-tui/src/components/textInput.tsx b/ui-tui/src/components/textInput.tsx index d8151e72b72..0c63ceb93c8 100644 --- a/ui-tui/src/components/textInput.tsx +++ b/ui-tui/src/components/textInput.tsx @@ -970,10 +970,15 @@ export function TextInput({ return } - // Right-click → route through the same path as Alt+V so the composer - // clipboard RPC (text or image) handles it. + // Right-click → copy active selection if any, otherwise paste. if (e.button === 2) { e.stopImmediatePropagation?.() + const decision = decideRightClickAction(vRef.current, selRange()) + if (decision.action === 'copy') { + void writeClipboardText(decision.text) + + return + } emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current }) return @@ -1045,6 +1050,34 @@ interface TextInputProps { voiceRecordKey?: ParsedVoiceRecordKey } +export type RightClickDecision = + | { action: 'copy'; text: string } + | { action: 'paste' } + +/** + * Decide what right-click should do on the composer: + * - non-empty selection → copy that text to the clipboard + * - no selection (or empty/collapsed range) → fall through to paste + * + * Mirrors terminal-native behavior (xterm, iTerm, gnome-terminal) where + * right-click pastes only when there is nothing selected to copy. + * + * Callers pass the already-normalized range from `selRange()` (start <= end, + * or null when collapsed), so this helper does not need to re-normalize. + */ +export function decideRightClickAction( + value: string, + range: { end: number; start: number } | null +): RightClickDecision { + if (range && range.end > range.start) { + const text = value.slice(range.start, range.end) + if (text) { + return { action: 'copy', text } + } + } + return { action: 'paste' } +} + export const shouldPassThroughToGlobalHandler = ( input: string, key: Key,