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:
parent
4117fc3645
commit
0ec0cafdd0
90
ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts
Normal file
90
ui-tui/packages/hermes-ink/src/ink/app-mouse.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -76,6 +76,10 @@ type Props = {
|
|||||||
// DOM elements. Called for mode-1003 motion events with no button held.
|
// DOM elements. Called for mode-1003 motion events with no button held.
|
||||||
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
|
// No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive).
|
||||||
readonly onHoverAt: (col: number, row: number) => void
|
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
|
// Look up the OSC 8 hyperlink at (col, row) synchronously at click
|
||||||
// time. Returns the URL or undefined. The browser-open is deferred by
|
// time. Returns the URL or undefined. The browser-open is deferred by
|
||||||
// MULTI_CLICK_TIMEOUT_MS so double-click can cancel it.
|
// 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) {
|
if (baseButton !== 0) {
|
||||||
// Non-left press breaks the multi-click chain.
|
// Non-left press breaks the multi-click chain.
|
||||||
app.clickCount = 0
|
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)
|
app.props.onMouseDownAt(col, row, baseButton)
|
||||||
|
|
||||||
return
|
return
|
||||||
|
|||||||
@ -1492,7 +1492,7 @@ export default class Ink {
|
|||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = getSelectedText(this.selection, this.frontFrame.screen)
|
const text = this.getTextSelectionText()
|
||||||
|
|
||||||
if (text) {
|
if (text) {
|
||||||
try {
|
try {
|
||||||
@ -1514,6 +1514,10 @@ export default class Ink {
|
|||||||
return ''
|
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
|
* Copy the current text selection to the system clipboard via OSC 52
|
||||||
* and clear the selection. Returns the copied text (empty if no selection
|
* and clear the selection. Returns the copied text (empty if no selection
|
||||||
@ -2332,7 +2336,9 @@ export default class Ink {
|
|||||||
dispatchKeyboardEvent={this.dispatchKeyboardEvent}
|
dispatchKeyboardEvent={this.dispatchKeyboardEvent}
|
||||||
exitOnCtrlC={this.options.exitOnCtrlC}
|
exitOnCtrlC={this.options.exitOnCtrlC}
|
||||||
getHyperlinkAt={this.getHyperlinkAt}
|
getHyperlinkAt={this.getHyperlinkAt}
|
||||||
|
getSelectedText={this.getTextSelectionText}
|
||||||
onClickAt={this.dispatchClick}
|
onClickAt={this.dispatchClick}
|
||||||
|
onCopySelectionNoClear={this.copySelectionNoClear}
|
||||||
onCursorAdvance={this.noteExternalCursorAdvance}
|
onCursorAdvance={this.noteExternalCursorAdvance}
|
||||||
onCursorDeclaration={this.setCursorDeclaration}
|
onCursorDeclaration={this.setCursorDeclaration}
|
||||||
onExit={this.unmount}
|
onExit={this.unmount}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user