Skip to content

Commit 7481f50

Browse files
committed
fix(terminal): retain selection through redraw
1 parent f58fd69 commit 7481f50

2 files changed

Lines changed: 107 additions & 1 deletion

File tree

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

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ type TerminalSelectionTarget = {
1717
readonly hasSelection: () => boolean
1818
}
1919

20+
type TerminalDisposable = {
21+
readonly dispose: () => void
22+
}
23+
2024
export type TerminalCopyKeyboardEvent = {
2125
readonly altKey: boolean
2226
readonly ctrlKey: boolean
@@ -32,6 +36,7 @@ export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & {
3236
readonly modes: {
3337
readonly mouseTrackingMode: TerminalMouseTrackingMode
3438
}
39+
readonly onSelectionChange?: (handler: () => void) => TerminalDisposable
3540
}
3641

3742
type TerminalCopyClipboardData = {
@@ -197,6 +202,7 @@ class TerminalSelectionContextSnapshot {
197202
class TerminalCopyInteractionController {
198203
private readonly selectionContext: TerminalSelectionContextSnapshot
199204
private readonly selectionDrag: ReturnType<typeof createTerminalSelectionDragController>
205+
private selectionChangeDisposable: TerminalDisposable | null = null
200206

201207
constructor(private readonly args: TerminalCopyInteractionArgs) {
202208
this.selectionContext = new TerminalSelectionContextSnapshot(args.terminal)
@@ -205,15 +211,36 @@ class TerminalCopyInteractionController {
205211

206212
readonly attach = (): { readonly dispose: () => void } => {
207213
this.args.terminal.attachCustomKeyEventHandler?.(this.onTerminalKeyEvent)
214+
this.selectionChangeDisposable = this.args.terminal.onSelectionChange?.(this.onTerminalSelectionChange) ?? null
208215
this.args.host.addEventListener("mousedown", this.onMouseDown, true)
209216
this.args.host.addEventListener("mouseup", this.onMouseUp, true)
210217
this.args.host.addEventListener("contextmenu", this.onContextMenu, true)
211218
this.args.host.addEventListener("copy", this.onCopy, true)
212219
return { dispose: this.dispose }
213220
}
214221

222+
private readonly shouldLetBrowserHandleCopyShortcut = (event: TerminalCopyKeyboardEvent): boolean =>
223+
shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal) ||
224+
(isKeyboardCopyShortcut(event) && this.selectionContext.has())
225+
215226
private readonly onTerminalKeyEvent = (event: TerminalCopyKeyboardEvent): boolean =>
216-
!shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal)
227+
!this.shouldLetBrowserHandleCopyShortcut(event)
228+
229+
private readonly onTerminalSelectionChange = (): void => {
230+
// CHANGE: keep a copyable snapshot before terminal redraws can drop xterm's live selection.
231+
// WHY: Claude Code periodically repaints the TUI; xterm selection is buffer-bound and may vanish during repaint.
232+
// QUOTE(ТЗ): "когда очистился выделение бы не спадало"
233+
// REF: user-message-2026-06-15-terminal-redraw-selection
234+
// SOURCE: n/a
235+
// FORMAT THEOREM: selected(t) before redraw(t) => cachedSelection(t)
236+
// PURITY: SHELL
237+
// EFFECT: reads terminal.hasSelection() and terminal.getSelection().
238+
// INVARIANT: empty redraw selection events do not erase the last user-created non-empty selection snapshot.
239+
// COMPLEXITY: O(n)/O(1) where n = selected text length.
240+
if (this.args.terminal.hasSelection()) {
241+
this.selectionContext.refresh()
242+
}
243+
}
217244

218245
private readonly hasProtectedSelectionContext = (): boolean =>
219246
hasActiveMouseTracking(this.args.terminal) &&
@@ -295,6 +322,8 @@ class TerminalCopyInteractionController {
295322
}
296323

297324
private readonly dispose = (): void => {
325+
this.selectionChangeDisposable?.dispose()
326+
this.selectionChangeDisposable = null
298327
this.selectionDrag.dispose()
299328
this.selectionContext.clear()
300329
this.args.host.removeEventListener("mousedown", this.onMouseDown, true)
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { describe, expect, it } from "@effect/vitest"
2+
3+
import {
4+
attachTerminalCopyInteraction,
5+
type TerminalCopyInteractionTerminal,
6+
type TerminalCopyKeyboardEvent
7+
} from "../../src/web/terminal-copy-interaction.js"
8+
import { copyEvent, FakeTerminalCopyHost, mouseEvent } from "./fixtures/terminal-copy-interaction.js"
9+
10+
const keyboardCopyEvent: TerminalCopyKeyboardEvent = {
11+
altKey: false,
12+
ctrlKey: true,
13+
key: "c",
14+
metaKey: false,
15+
type: "keydown"
16+
}
17+
18+
describe("terminal copy redraw interaction", () => {
19+
it("keeps selection snapshot copyable after terminal redraw clears live selection", () => {
20+
let terminalSelection = ""
21+
const keyHandlers: Array<(event: TerminalCopyKeyboardEvent) => boolean> = []
22+
const selectionChangeHandlers: Array<() => void> = []
23+
const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = []
24+
const host = new FakeTerminalCopyHost(null)
25+
const terminal: TerminalCopyInteractionTerminal = {
26+
attachCustomKeyEventHandler: (handler) => {
27+
keyHandlers.push(handler)
28+
},
29+
getSelection: () => terminalSelection,
30+
hasSelection: () => terminalSelection.length > 0,
31+
modes: { mouseTrackingMode: "any" },
32+
onSelectionChange: (handler) => {
33+
selectionChangeHandlers.push(handler)
34+
return {
35+
dispose: () => {
36+
const handlerIndex = selectionChangeHandlers.indexOf(handler)
37+
if (handlerIndex !== -1) {
38+
selectionChangeHandlers.splice(handlerIndex, 1)
39+
}
40+
}
41+
}
42+
}
43+
}
44+
const disposable = attachTerminalCopyInteraction({ host, terminal })
45+
46+
terminalSelection = "selected before redraw"
47+
for (const handler of selectionChangeHandlers) {
48+
handler()
49+
}
50+
terminalSelection = ""
51+
52+
expect(keyHandlers).toHaveLength(1)
53+
const handleKey = keyHandlers[0] ?? expect.fail("Expected terminal copy key handler to be registered.")
54+
expect(handleKey(keyboardCopyEvent)).toBe(false)
55+
56+
const contextMenu = mouseEvent(0, "contextmenu")
57+
const copy = copyEvent({
58+
setData: (format: string, data: string) => {
59+
clipboardWrites.push({ data, format })
60+
}
61+
})
62+
host.dispatchMouse("contextmenu", contextMenu)
63+
host.dispatchCopy(copy)
64+
65+
expect(contextMenu.shiftKey).toBe(true)
66+
expect(contextMenu.stopImmediatePropagationCalls).toBe(1)
67+
expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1)
68+
expect(clipboardWrites).toEqual([{ data: "selected before redraw", format: "text/plain" }])
69+
expect(copy.preventDefaultCalls).toBe(1)
70+
expect(copy.stopPropagationCalls).toBe(1)
71+
expect(selectionChangeHandlers).toHaveLength(1)
72+
expect(handleKey(keyboardCopyEvent)).toBe(true)
73+
74+
disposable.dispose()
75+
expect(selectionChangeHandlers).toHaveLength(0)
76+
})
77+
})

0 commit comments

Comments
 (0)