Skip to content

Commit c5e3073

Browse files
authored
Merge pull request #407 from ProverCoderAI/issue-404-5a7f728e1091
fix(terminal): preserve terminal selection
2 parents 5cdb97f + 6a33842 commit c5e3073

8 files changed

Lines changed: 1268 additions & 182 deletions

packages/terminal/src/web/terminal-copy-interaction.ts

Lines changed: 111 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,18 @@
1+
import {
2+
clearNativeBrowserCopyMenu,
3+
prepareNativeBrowserCopyMenu,
4+
type TerminalCopyTextarea,
5+
type TerminalNativeCopyMenuHost
6+
} from "./terminal-copy-native-menu.js"
7+
import {
8+
hasActiveMouseTracking,
9+
isKeyboardCopyShortcut,
10+
shouldForceBrowserTerminalSelection,
11+
shouldLetBrowserHandleTerminalCopyShortcut,
12+
type TerminalCopyKeyboardEvent,
13+
type TerminalMouseTrackingMode,
14+
writeTerminalSelectionToClipboardData
15+
} from "./terminal-copy-rules.js"
116
import {
217
createTerminalSelectionDragController,
318
forceTerminalSelectionModifier,
@@ -7,22 +22,24 @@ import {
722
type TerminalMouseButtonEvent,
823
type TerminalSelectionDragTarget
924
} from "./terminal-copy-selection-drag.js"
10-
25+
import {
26+
type TerminalCopyClipboardData,
27+
TerminalSelectionContextSnapshot,
28+
type TerminalSelectionRestoreTarget,
29+
type TerminalSelectionTarget
30+
} from "./terminal-copy-selection-snapshot.js"
31+
32+
export {
33+
shouldForceBrowserTerminalSelection,
34+
shouldForceTerminalSelectionContext,
35+
shouldLetBrowserHandleTerminalCopyShortcut,
36+
writeTerminalSelectionToClipboardData
37+
} from "./terminal-copy-rules.js"
38+
export type { TerminalCopyKeyboardEvent, TerminalMouseTrackingMode } from "./terminal-copy-rules.js"
1139
export { forceTerminalSelectionModifier } from "./terminal-copy-selection-drag.js"
1240

13-
export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10"
14-
15-
type TerminalSelectionTarget = {
16-
readonly getSelection: () => string
17-
readonly hasSelection: () => boolean
18-
}
19-
20-
export type TerminalCopyKeyboardEvent = {
21-
readonly altKey: boolean
22-
readonly ctrlKey: boolean
23-
readonly key: string
24-
readonly metaKey: boolean
25-
readonly type: string
41+
type TerminalDisposable = {
42+
readonly dispose: () => void
2643
}
2744

2845
export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & {
@@ -32,11 +49,9 @@ export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & {
3249
readonly modes: {
3350
readonly mouseTrackingMode: TerminalMouseTrackingMode
3451
}
35-
}
36-
37-
type TerminalCopyClipboardData = {
38-
readonly setData: (format: string, data: string) => void
39-
}
52+
readonly onSelectionChange?: (handler: () => void) => TerminalDisposable
53+
readonly textarea?: TerminalCopyTextarea | undefined
54+
} & TerminalSelectionRestoreTarget
4055

4156
type TerminalCopyClipboardEvent = {
4257
readonly clipboardData: TerminalCopyClipboardData | null
@@ -49,7 +64,7 @@ type TerminalCopyListenerRegistration = {
4964
(type: TerminalCopyMouseEventType, listener: (event: TerminalCopyMouseEvent) => void, options: true): void
5065
}
5166

52-
type TerminalCopyInteractionHost = {
67+
type TerminalCopyInteractionHost = TerminalNativeCopyMenuHost & {
5368
readonly ownerDocument?: TerminalSelectionDragTarget | null
5469
readonly addEventListener: TerminalCopyListenerRegistration
5570
readonly removeEventListener: TerminalCopyListenerRegistration
@@ -62,141 +77,15 @@ type TerminalCopyInteractionArgs = {
6277

6378
const primaryMouseButton = 0
6479
const secondaryMouseButton = 2
65-
const terminalSelectionContextSnapshotTtlMs = 10_000
6680

6781
const isPrimaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === primaryMouseButton
6882

6983
const isSecondaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === secondaryMouseButton
7084

71-
const hasActiveMouseTracking = (terminal: TerminalCopyInteractionTerminal): boolean =>
72-
terminal.modes.mouseTrackingMode !== "none"
73-
74-
const isKeyboardCopyShortcut = (event: TerminalCopyKeyboardEvent): boolean =>
75-
event.type === "keydown" &&
76-
!event.altKey &&
77-
(event.ctrlKey || event.metaKey) &&
78-
event.key.toLowerCase() === "c"
79-
80-
/**
81-
* Decides whether xterm key processing must step aside for native browser copy.
82-
*
83-
* @param event - Keyboard event seen by xterm before it translates keys into pty input.
84-
* @param terminal - Terminal selection facade.
85-
* @returns True iff the event is a system copy shortcut and selected terminal text is non-empty.
86-
* @pure true
87-
* @effect terminal.hasSelection(), terminal.getSelection().
88-
* @invariant result => no ETX input is sent for selected terminal text copy.
89-
* @precondition `event` and `terminal` are non-null.
90-
* @postcondition True means xterm should return false from its custom key handler.
91-
* @complexity O(n) where n = selected text length.
92-
* @throws Never
93-
*/
94-
// CHANGE: keep keyboard copy shortcuts out of terminal input when text is selected
95-
// WHY: Ctrl/Cmd+C must copy the selected terminal text instead of sending SIGINT to the pty
96-
// QUOTE(ТЗ): "Text easy coping"
97-
// REF: issue-353
98-
// SOURCE: n/a
99-
// FORMAT THEOREM: selected(t) and copyShortcut(e) => browserCopy(e,t)
100-
// PURITY: CORE
101-
// EFFECT: reads terminal selection through the injected terminal facade
102-
// INVARIANT: empty selection never blocks terminal Ctrl+C semantics
103-
// COMPLEXITY: O(n)/O(1)
104-
export const shouldLetBrowserHandleTerminalCopyShortcut = (
105-
event: TerminalCopyKeyboardEvent,
106-
terminal: TerminalSelectionTarget
107-
): boolean => isKeyboardCopyShortcut(event) && terminal.hasSelection() && terminal.getSelection().length > 0
108-
109-
export const shouldForceBrowserTerminalSelection = (
110-
event: TerminalMouseButtonEvent,
111-
terminal: TerminalCopyInteractionTerminal
112-
): boolean => isPrimaryMouseButton(event) && hasActiveMouseTracking(terminal)
113-
114-
/**
115-
* Decides whether a secondary-button event must preserve the terminal selection context.
116-
*
117-
* @param event - Mouse button event captured before xterm/tmux handlers can clear the selection.
118-
* @param terminal - Terminal selection and mouse-tracking facade.
119-
* @returns True iff the event is a secondary click, mouse tracking is active, and a selection exists.
120-
* @pure true
121-
* @effect isSecondaryMouseButton(event), hasActiveMouseTracking(terminal), terminal.hasSelection().
122-
* @invariant result <=> secondary(event) and tracking(terminal) and selected(terminal).
123-
* @precondition `event` and `terminal` are non-null; mouse tracking may be `none`, which disables forcing.
124-
* @postcondition True means the caller may snapshot selection text before suppressing terminal mouse reporting.
125-
* @complexity O(1)
126-
* @throws Never
127-
*/
128-
// CHANGE: document the guarded right-click selection preservation predicate
129-
// WHY: selection protection is valid only while terminal mouse tracking can consume right-click events
130-
// QUOTE(ТЗ): "right-click with selection should remain copyable in the terminal"
131-
// REF: issue-340
132-
// SOURCE: n/a
133-
// FORMAT THEOREM: forall e,t: force(e,t) <-> secondary(e) and tracking(t) and hasSelection(t)
134-
// PURITY: CORE
135-
// EFFECT: reads terminal.hasSelection through the injected terminal facade
136-
// INVARIANT: mouseTrackingMode = none always yields false
137-
// COMPLEXITY: O(1)
138-
export const shouldForceTerminalSelectionContext = (
139-
event: TerminalMouseButtonEvent,
140-
terminal: TerminalCopyInteractionTerminal
141-
): boolean => isSecondaryMouseButton(event) && hasActiveMouseTracking(terminal) && terminal.hasSelection()
142-
143-
export const writeTerminalSelectionToClipboardData = (
144-
terminal: TerminalSelectionTarget,
145-
clipboardData: TerminalCopyClipboardData | null
146-
): boolean => {
147-
if (clipboardData === null || !terminal.hasSelection()) {
148-
return false
149-
}
150-
const selection = terminal.getSelection()
151-
if (selection.length === 0) {
152-
return false
153-
}
154-
clipboardData.setData("text/plain", selection)
155-
return true
156-
}
157-
158-
class TerminalSelectionContextSnapshot {
159-
private selection = ""
160-
private timer: ReturnType<typeof setTimeout> | null = null
161-
162-
constructor(private readonly terminal: TerminalSelectionTarget) {}
163-
164-
readonly clear = (): void => {
165-
this.selection = ""
166-
if (this.timer !== null) {
167-
clearTimeout(this.timer)
168-
this.timer = null
169-
}
170-
}
171-
172-
readonly has = (): boolean => this.selection.length > 0
173-
174-
readonly refresh = (): boolean => {
175-
const selection = this.terminal.getSelection()
176-
if (selection.length === 0) {
177-
this.clear()
178-
return false
179-
}
180-
this.selection = selection
181-
if (this.timer !== null) {
182-
clearTimeout(this.timer)
183-
}
184-
this.timer = setTimeout(this.clear, terminalSelectionContextSnapshotTtlMs)
185-
return true
186-
}
187-
188-
readonly writeToClipboardData = (clipboardData: TerminalCopyClipboardData | null): boolean => {
189-
if (clipboardData === null || this.selection.length === 0) {
190-
return false
191-
}
192-
clipboardData.setData("text/plain", this.selection)
193-
return true
194-
}
195-
}
196-
19785
class TerminalCopyInteractionController {
19886
private readonly selectionContext: TerminalSelectionContextSnapshot
19987
private readonly selectionDrag: ReturnType<typeof createTerminalSelectionDragController>
88+
private selectionChangeDisposable: TerminalDisposable | null = null
20089

20190
constructor(private readonly args: TerminalCopyInteractionArgs) {
20291
this.selectionContext = new TerminalSelectionContextSnapshot(args.terminal)
@@ -205,21 +94,63 @@ class TerminalCopyInteractionController {
20594

20695
readonly attach = (): { readonly dispose: () => void } => {
20796
this.args.terminal.attachCustomKeyEventHandler?.(this.onTerminalKeyEvent)
97+
this.selectionChangeDisposable = this.args.terminal.onSelectionChange?.(this.onTerminalSelectionChange) ?? null
20898
this.args.host.addEventListener("mousedown", this.onMouseDown, true)
20999
this.args.host.addEventListener("mouseup", this.onMouseUp, true)
210100
this.args.host.addEventListener("contextmenu", this.onContextMenu, true)
211101
this.args.host.addEventListener("copy", this.onCopy, true)
212102
return { dispose: this.dispose }
213103
}
214104

215-
private readonly onTerminalKeyEvent = (event: TerminalCopyKeyboardEvent): boolean =>
216-
!shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal)
105+
private readonly shouldLetBrowserHandleCopyShortcut = (event: TerminalCopyKeyboardEvent): boolean =>
106+
shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal) ||
107+
(isKeyboardCopyShortcut(event) && this.selectionContext.has())
217108

218-
private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean =>
219-
isSecondaryMouseButton(event) &&
109+
private readonly onTerminalKeyEvent = (event: TerminalCopyKeyboardEvent): boolean => {
110+
const shouldLetBrowserHandleCopy = this.shouldLetBrowserHandleCopyShortcut(event)
111+
if (!shouldLetBrowserHandleCopy && event.type === "keydown") {
112+
this.selectionContext.clear()
113+
}
114+
return !shouldLetBrowserHandleCopy
115+
}
116+
117+
private readonly onTerminalSelectionChange = (): void => {
118+
// CHANGE: keep a copyable snapshot before terminal redraws can drop xterm's live selection.
119+
// WHY: Claude Code periodically repaints the TUI; xterm selection is buffer-bound and may vanish during repaint.
120+
// QUOTE(ТЗ): "когда очистился выделение бы не спадало"
121+
// REF: user-message-2026-06-15-terminal-redraw-selection
122+
// SOURCE: n/a
123+
// FORMAT THEOREM: selected(t) before redraw(t) => cachedSelection(t)
124+
// PURITY: SHELL
125+
// EFFECT: reads terminal.hasSelection() and terminal.getSelection().
126+
// INVARIANT: empty redraw selection events do not erase the last user-created non-empty selection snapshot.
127+
// COMPLEXITY: O(n)/O(1) where n = selected text length.
128+
if (!this.args.terminal.hasSelection()) {
129+
this.selectionContext.restore()
130+
return
131+
}
132+
this.selectionContext.refresh()
133+
}
134+
135+
private readonly hasProtectedSelectionContext = (): boolean =>
220136
hasActiveMouseTracking(this.args.terminal) &&
221137
(this.selectionContext.has() || this.args.terminal.hasSelection())
222138

139+
private readonly prepareNativeBrowserCopyMenu = (event: TerminalCopyMouseEvent): boolean =>
140+
prepareNativeBrowserCopyMenu({
141+
event,
142+
host: this.args.host,
143+
selection: this.selectionContext.read(),
144+
textarea: this.args.terminal.textarea
145+
})
146+
147+
private readonly clearNativeBrowserCopyMenu = (): void => {
148+
clearNativeBrowserCopyMenu(this.args.terminal.textarea)
149+
}
150+
151+
private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean =>
152+
isSecondaryMouseButton(event) && this.hasProtectedSelectionContext()
153+
223154
private readonly onSelectionContextMouseEvent = (event: TerminalCopyMouseEvent): boolean => {
224155
if (!this.shouldProtectSelectionContext(event)) {
225156
return false
@@ -234,18 +165,23 @@ class TerminalCopyInteractionController {
234165
private readonly onMouseDown = (event: TerminalCopyMouseEvent): void => {
235166
if (isPrimaryMouseButton(event)) {
236167
this.selectionContext.clear()
168+
this.clearNativeBrowserCopyMenu()
237169
}
238170
const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, this.args.terminal)
239-
const forceSelectionContext = shouldForceTerminalSelectionContext(event, this.args.terminal)
171+
const forceSelectionContext = this.shouldProtectSelectionContext(event)
240172
if (!forceBrowserSelection && !forceSelectionContext) {
241173
if (isSecondaryMouseButton(event)) {
242174
this.selectionContext.clear()
175+
this.clearNativeBrowserCopyMenu()
243176
}
244177
return
245178
}
246179
forceTerminalSelectionModifier(event)
247180
if (forceSelectionContext) {
248-
this.selectionContext.refresh()
181+
if (this.args.terminal.hasSelection()) {
182+
this.selectionContext.refresh()
183+
}
184+
this.prepareNativeBrowserCopyMenu(event)
249185
suppressTerminalMouseReport(event)
250186
return
251187
}
@@ -262,7 +198,24 @@ class TerminalCopyInteractionController {
262198
}
263199

264200
private readonly onContextMenu = (event: TerminalCopyMouseEvent): void => {
265-
this.onSelectionContextMouseEvent(event)
201+
// CHANGE: stop protected context menus before xterm can rewrite terminal selection.
202+
// WHY: xterm's rightClickHandler prepares a textarea for copy and can clear TUI selections while mouse tracking is active.
203+
// QUOTE(ТЗ): "Когда я выделяю что-то и нажимаю правую кнопку ... выделение слетает"
204+
// REF: user-message-2026-06-15-claude-code-selection
205+
// SOURCE: n/a
206+
// FORMAT THEOREM: protected(e,t) => stopped(e) and not defaultPrevented(e)
207+
// PURITY: SHELL
208+
// INVARIANT: empty selection context menus still pass through.
209+
// COMPLEXITY: O(1)/O(1)
210+
if (!this.hasProtectedSelectionContext()) {
211+
return
212+
}
213+
forceTerminalSelectionModifier(event)
214+
if (this.args.terminal.hasSelection()) {
215+
this.selectionContext.refresh()
216+
}
217+
this.prepareNativeBrowserCopyMenu(event)
218+
suppressTerminalMouseReport(event)
266219
}
267220

268221
private readonly onCopy = (event: TerminalCopyClipboardEvent): void => {
@@ -272,13 +225,17 @@ class TerminalCopyInteractionController {
272225
return
273226
}
274227
this.selectionContext.clear()
228+
this.clearNativeBrowserCopyMenu()
275229
event.preventDefault()
276230
event.stopPropagation()
277231
}
278232

279233
private readonly dispose = (): void => {
234+
this.selectionChangeDisposable?.dispose()
235+
this.selectionChangeDisposable = null
280236
this.selectionDrag.dispose()
281237
this.selectionContext.clear()
238+
this.clearNativeBrowserCopyMenu()
282239
this.args.host.removeEventListener("mousedown", this.onMouseDown, true)
283240
this.args.host.removeEventListener("mouseup", this.onMouseUp, true)
284241
this.args.host.removeEventListener("contextmenu", this.onContextMenu, true)

0 commit comments

Comments
 (0)