From 0ec0cafdd0ca842822a1ddbdd52f6018122949c0 Mon Sep 17 00:00:00 2001 From: brooklyn! Date: Mon, 25 May 2026 10:22:43 -0500 Subject: [PATCH] Merge pull request #31084 from NousResearch/bb/tui-right-click-copy-selection fix(tui): right-click copies active transcript selection --- .../hermes-ink/src/ink/app-mouse.test.ts | 90 +++++++++++++++++++ .../hermes-ink/src/ink/components/App.tsx | 26 ++++++ ui-tui/packages/hermes-ink/src/ink/ink.tsx | 8 +- 3 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts diff --git a/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts b/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts new file mode 100644 index 00000000000..a4c63d3ebed --- /dev/null +++ b/ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest' + +import { handleMouseEvent } from './components/App.js' +import { createSelectionState, startSelection, updateSelection } from './selection.js' + +const makeApp = () => { + const selection = createSelectionState() + + return { + clickCount: 1, + lastHoverCol: -1, + lastHoverRow: -1, + mouseCaptureTarget: undefined, + props: { + getSelectedText: vi.fn(() => 'selected text'), + onCopySelectionNoClear: vi.fn(async () => 'selected text'), + onHoverAt: vi.fn(), + onMouseDownAt: vi.fn(), + onMouseDragAt: vi.fn(), + onMouseUpAt: vi.fn(), + onSelectionChange: vi.fn(), + selection + } + } as any +} + +describe('handleMouseEvent right-click selection behavior', () => { + it('copies an active selection instead of dispatching right-click paste handlers', async () => { + const app = makeApp() + + startSelection(app.props.selection, 0, 0) + updateSelection(app.props.selection, 4, 0) + + handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 }) + await Promise.resolve() + + expect(app.props.onCopySelectionNoClear).toHaveBeenCalledOnce() + expect(app.props.onMouseDownAt).not.toHaveBeenCalled() + expect(app.clickCount).toBe(0) + }) + + it('falls back to right-click handlers when selection copy has no clipboard path', async () => { + const app = makeApp() + app.props.onCopySelectionNoClear.mockResolvedValue('') + + startSelection(app.props.selection, 0, 0) + updateSelection(app.props.selection, 4, 0) + + handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 }) + await Promise.resolve() + + expect(app.props.onCopySelectionNoClear).toHaveBeenCalledOnce() + expect(app.props.onMouseDownAt).toHaveBeenCalledWith(2, 0, 2) + }) + + it('does not paste when highlighted selection text is empty', async () => { + const app = makeApp() + app.props.getSelectedText.mockReturnValue('') + + startSelection(app.props.selection, 0, 0) + updateSelection(app.props.selection, 4, 0) + + handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 }) + await Promise.resolve() + + expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled() + expect(app.props.onMouseDownAt).not.toHaveBeenCalled() + }) + + it('does not repeatedly copy or paste during right-button motion events over a selection', () => { + const app = makeApp() + + startSelection(app.props.selection, 0, 0) + updateSelection(app.props.selection, 4, 0) + + handleMouseEvent(app, { action: 'press', button: 0x20 | 2, col: 3, kind: 'mouse', row: 1 }) + + expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled() + expect(app.props.onMouseDownAt).not.toHaveBeenCalled() + }) + + it('still dispatches right-click handlers when no text is selected', () => { + const app = makeApp() + + handleMouseEvent(app, { action: 'press', button: 2, col: 3, kind: 'mouse', row: 1 }) + + expect(app.props.onCopySelectionNoClear).not.toHaveBeenCalled() + expect(app.props.onMouseDownAt).toHaveBeenCalledWith(2, 0, 2) + }) +}) diff --git a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx index 54892e3b7b1..81d3a689f28 100644 --- a/ui-tui/packages/hermes-ink/src/ink/components/App.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/components/App.tsx @@ -76,6 +76,10 @@ type Props = { // DOM elements. Called for mode-1003 motion events with no button held. // No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive). readonly onHoverAt: (col: number, row: number) => void + // Copy the active fullscreen text selection without clearing the highlight. + // Used for terminal-native right-click-copy behaviour. + readonly onCopySelectionNoClear: () => Promise + readonly getSelectedText: () => string // Look up the OSC 8 hyperlink at (col, row) synchronously at click // time. Returns the URL or undefined. The browser-open is deferred by // MULTI_CLICK_TIMEOUT_MS so double-click can cancel it. @@ -631,6 +635,28 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { if (baseButton !== 0) { // Non-left press breaks the multi-click chain. app.clickCount = 0 + + if (baseButton === 2 && hasSelection(sel)) { + if ((m.button & 0x20) !== 0) { + return + } + + if (!app.props.getSelectedText()) { + return + } + + void app.props + .onCopySelectionNoClear() + .then(text => { + if (!text) { + app.props.onMouseDownAt(col, row, baseButton) + } + }) + .catch(() => app.props.onMouseDownAt(col, row, baseButton)) + + return + } + app.props.onMouseDownAt(col, row, baseButton) return diff --git a/ui-tui/packages/hermes-ink/src/ink/ink.tsx b/ui-tui/packages/hermes-ink/src/ink/ink.tsx index 485ef5ffca9..d8c95fcc703 100644 --- a/ui-tui/packages/hermes-ink/src/ink/ink.tsx +++ b/ui-tui/packages/hermes-ink/src/ink/ink.tsx @@ -1492,7 +1492,7 @@ export default class Ink { return '' } - const text = getSelectedText(this.selection, this.frontFrame.screen) + const text = this.getTextSelectionText() if (text) { try { @@ -1514,6 +1514,10 @@ export default class Ink { return '' } + getTextSelectionText(): string { + return hasSelection(this.selection) ? getSelectedText(this.selection, this.frontFrame.screen) : '' + } + /** * Copy the current text selection to the system clipboard via OSC 52 * and clear the selection. Returns the copied text (empty if no selection @@ -2332,7 +2336,9 @@ export default class Ink { dispatchKeyboardEvent={this.dispatchKeyboardEvent} exitOnCtrlC={this.options.exitOnCtrlC} getHyperlinkAt={this.getHyperlinkAt} + getSelectedText={this.getTextSelectionText} onClickAt={this.dispatchClick} + onCopySelectionNoClear={this.copySelectionNoClear} onCursorAdvance={this.noteExternalCursorAdvance} onCursorDeclaration={this.setCursorDeclaration} onExit={this.unmount}