Merge pull request #31084 from NousResearch/bb/tui-right-click-copy-selection

fix(tui): right-click copies active transcript selection
This commit is contained in:
brooklyn! 2026-05-25 10:22:43 -05:00 committed by GitHub
parent 4117fc3645
commit 0ec0cafdd0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 123 additions and 1 deletions

View file

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

View file

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

View file

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