fix(tui): right-click copies selection, only pastes when no selection

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.
This commit is contained in:
Wesley Simplicio 2026-05-09 00:21:13 -03:00 committed by Teknium
parent 59d3f24f10
commit a2920b1762
2 changed files with 83 additions and 2 deletions

View file

@ -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 '
})
})
})

View file

@ -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,