mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-26 06:01:49 +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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Right-click → route through the same path as Alt+V so the composer
|
// Right-click → copy active selection if any, otherwise paste.
|
||||||
// clipboard RPC (text or image) handles it.
|
|
||||||
if (e.button === 2) {
|
if (e.button === 2) {
|
||||||
e.stopImmediatePropagation?.()
|
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 })
|
emitPaste({ cursor: curRef.current, hotkey: true, text: '', value: vRef.current })
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|
@ -1045,6 +1050,34 @@ interface TextInputProps {
|
||||||
voiceRecordKey?: ParsedVoiceRecordKey
|
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 = (
|
export const shouldPassThroughToGlobalHandler = (
|
||||||
input: string,
|
input: string,
|
||||||
key: Key,
|
key: Key,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue