mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
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:
parent
59d3f24f10
commit
a2920b1762
2 changed files with 83 additions and 2 deletions
48
ui-tui/src/__tests__/textInputRightClick.test.ts
Normal file
48
ui-tui/src/__tests__/textInputRightClick.test.ts
Normal 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 '
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue