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"
116import {
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"
1139export { 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
2845export 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
4156type 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
6378const primaryMouseButton = 0
6479const secondaryMouseButton = 2
65- const terminalSelectionContextSnapshotTtlMs = 10_000
6680
6781const isPrimaryMouseButton = ( event : TerminalMouseButtonEvent ) : boolean => event . button === primaryMouseButton
6882
6983const 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-
19785class 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