Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
50 changes: 50 additions & 0 deletions packages/terminal/src/web/terminal-copy-interaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,18 @@ type TerminalSelectionTarget = {
readonly hasSelection: () => boolean
}

export type TerminalCopyKeyboardEvent = {
readonly altKey: boolean
readonly ctrlKey: boolean
readonly key: string
readonly metaKey: boolean
readonly type: string
}

export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & {
readonly attachCustomKeyEventHandler?: (
handler: (event: TerminalCopyKeyboardEvent) => boolean
) => void
readonly modes: {
readonly mouseTrackingMode: TerminalMouseTrackingMode
}
Expand Down Expand Up @@ -60,6 +71,41 @@ const isSecondaryMouseButton = (event: TerminalMouseButtonEvent): boolean => eve
const hasActiveMouseTracking = (terminal: TerminalCopyInteractionTerminal): boolean =>
terminal.modes.mouseTrackingMode !== "none"

const isKeyboardCopyShortcut = (event: TerminalCopyKeyboardEvent): boolean =>
event.type === "keydown" &&
!event.altKey &&
(event.ctrlKey || event.metaKey) &&
event.key.toLowerCase() === "c"

/**
* Decides whether xterm key processing must step aside for native browser copy.
*
* @param event - Keyboard event seen by xterm before it translates keys into pty input.
* @param terminal - Terminal selection facade.
* @returns True iff the event is a system copy shortcut and selected terminal text is non-empty.
* @pure true
* @effect terminal.hasSelection(), terminal.getSelection().
* @invariant result => no ETX input is sent for selected terminal text copy.
* @precondition `event` and `terminal` are non-null.
* @postcondition True means xterm should return false from its custom key handler.
* @complexity O(n) where n = selected text length.
* @throws Never
*/
// CHANGE: keep keyboard copy shortcuts out of terminal input when text is selected
// WHY: Ctrl/Cmd+C must copy the selected terminal text instead of sending SIGINT to the pty
// QUOTE(ТЗ): "Text easy coping"
// REF: issue-353
// SOURCE: n/a
// FORMAT THEOREM: selected(t) and copyShortcut(e) => browserCopy(e,t)
// PURITY: CORE
// EFFECT: reads terminal selection through the injected terminal facade
// INVARIANT: empty selection never blocks terminal Ctrl+C semantics
// COMPLEXITY: O(n)/O(1)
export const shouldLetBrowserHandleTerminalCopyShortcut = (
event: TerminalCopyKeyboardEvent,
terminal: TerminalSelectionTarget
): boolean => isKeyboardCopyShortcut(event) && terminal.hasSelection() && terminal.getSelection().length > 0
Comment on lines +104 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Проверка: есть ли случаи, где hasSelection() и getSelection() рассогласованы
rg -n "hasSelection|getSelection" --type=ts -C2

Repository: ProverCoderAI/docker-git

Length of output: 9008


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Прочитать ключевые участки файла с экспортом/логикой и dispose()
sed -n '1,220p' packages/terminal/src/web/terminal-copy-interaction.ts
echo "-----"
sed -n '220,360p' packages/terminal/src/web/terminal-copy-interaction.ts
echo "-----"

# 2) Быстрый поиск по символам/контексту обработчика ключей
rg -n "attachCustomKeyEventHandler|dispose\\(|unregister|keyHandler|onTerminalKeyEvent" packages/terminal/src/web/terminal-copy-interaction.ts

Repository: ProverCoderAI/docker-git

Length of output: 11451


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Прочитать ключевые участки файла с экспортом/логикой и dispose()
sed -n '1,240p' packages/terminal/src/web/terminal-copy-interaction.ts
echo "-----"
sed -n '240,360p' packages/terminal/src/web/terminal-copy-interaction.ts

echo "-----"

# 2) Быстрый поиск по символам/контексту обработчика ключей
rg -n "attachCustomKeyEventHandler|dispose\\(|unregister|keyHandler|onTerminalKeyEvent" packages/terminal/src/web/terminal-copy-interaction.ts

Repository: ProverCoderAI/docker-git

Length of output: 11461


🏁 Script executed:

#!/bin/bash
set -euo pipefail

sed -n '1,240p' packages/terminal/src/web/terminal-copy-interaction.ts
echo "-----"
sed -n '240,420p' packages/terminal/src/web/terminal-copy-interaction.ts
echo "-----"

rg -n "attachCustomKeyEventHandler|dispose\\(|unregister|keyHandler|onTerminalKeyEvent|customKey" packages/terminal/src/web/terminal-copy-interaction.ts

Repository: ProverCoderAI/docker-git

Length of output: 11461


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "attachCustomKeyEventHandler" -S packages/terminal | head -n 50

rg -n "attachTerminalCopyInteraction" -S packages/terminal | head -n 50

rg -n "shouldLetBrowserHandleTerminalCopyShortcut" -S packages/terminal | head -n 50

# Посмотреть, как устроена реализация хост/terminal facade в тестах
fd "terminal-copy-interaction" packages/terminal/tests -t f | head -n 20
fd "copy-interaction" packages/terminal/tests -t f | head -n 20

Repository: ProverCoderAI/docker-git

Length of output: 4178


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) прочитать fixture с терминалами для hasSelection/getSelection
sed -n '1,220p' packages/terminal/tests/web/fixtures/terminal-copy-interaction.ts
echo "-----"
sed -n '220,420p' packages/terminal/tests/web/fixtures/terminal-copy-interaction.ts

echo "====="

# 2) прочитать тесты shouldLetBrowserHandleTerminalCopyShortcut (важно для проверки кейсов пустой выделенной строки)
sed -n '1,140p' packages/terminal/tests/web/terminal-copy-interaction.test.ts
echo "-----"
sed -n '140,260p' packages/terminal/tests/web/terminal-copy-interaction.test.ts

echo "====="

# 3) прочитать terminal-panel-runtime.ts участок где хранится copyInteractionDisposable и вызывается dispose
sed -n '1,220p' packages/terminal/src/web/terminal-panel-runtime.ts
echo "-----"
rg -n "copyInteractionDisposable|dispose\\(" packages/terminal/src/web/terminal-panel-runtime.ts

Repository: ProverCoderAI/docker-git

Length of output: 28278


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) найти, где объявлен attachCustomKeyEventHandler в реализации terminal facade
rg -n "attachCustomKeyEventHandler" packages/terminal/src -S

# 2) найти реализацию createTerminalRuntime и cleanupTerminalResources (чтобы понять, что происходит с handlers при dispose терминала)
rg -n "function createTerminalRuntime|createTerminalRuntime\\(" packages/terminal/src/web -S
rg -n "cleanupTerminalResources\\(" packages/terminal/src/web -S

# 3) посмотреть файл terminal-panel-runtime-core.ts для cleanupTerminalResources и терминал dispose
sed -n '1,260p' packages/terminal/src/web/terminal-panel-runtime-core.ts

Repository: ProverCoderAI/docker-git

Length of output: 8326


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) найти cleanupTerminalResources и где там dispose terminal
rg -n "cleanupTerminalResources|terminal\\.dispose\\(|detachCustomKeyEventHandler|detach.*KeyEvent" packages/terminal/src/web -S

# 2) прочитать файл cleanupTerminalResources
sed -n '1,260p' packages/terminal/src/web/terminal-panel-cleanup-runtime.ts 2>/dev/null || true
sed -n '1,260p' packages/terminal/src/web/terminal-panel-cleanup-runtime.js 2>/dev/null || true
ls packages/terminal/src/web | rg "cleanup.*runtime"

# 3) поиск по detachCustomKeyEventHandler в repo
rg -n "detachCustomKeyEventHandler" -S packages/terminal/src packages/terminal/tests

Repository: ProverCoderAI/docker-git

Length of output: 2126


Подправить оба замечания: длина selection тут не лишняя, а про key-handler важно контекстно

  • Проверка terminal.getSelection().length > 0 в shouldLetBrowserHandleTerminalCopyShortcut не выглядит избыточной: в этом же модуле writeTerminalSelectionToClipboardData после terminal.hasSelection() дополнительно делает selection.length === 0 и отклоняет кейс рассогласования, значит контракт hasSelection() ⇒ non-empty getSelection() не гарантируется типом/инвариантом.
  • dispose() в terminal-copy-interaction.ts действительно не “отсоединяет” attachCustomKeyEventHandler, но при cleanup терминала в cleanupTerminalResources вызывается args.terminal.dispose() (после copyInteractionDisposable.dispose()), поэтому key-handler cleanup фактически делегируется terminal.dispose(). Лучше документировать это ожидание/контрактом вызовов либо (если возможно) добавить явную detachment-операцию, если copyInteractionDisposable.dispose() может вызываться без последующего terminal.dispose().
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/terminal/src/web/terminal-copy-interaction.ts` around lines 104 -
107, Keep the explicit selection length check in
shouldLetBrowserHandleTerminalCopyShortcut (don't remove
terminal.getSelection().length > 0) because
writeTerminalSelectionToClipboardData relies on the possibility that
terminal.hasSelection() may be true with an empty getSelection(); update the
function accordingly. Also update dispose() in terminal-copy-interaction.ts to
explicitly detach the custom key handler registered via
attachCustomKeyEventHandler (store and call the handler-disposer returned by
attachCustomKeyEventHandler) so copyInteractionDisposable.dispose() removes the
key handler even if terminal.dispose() is not called; if removal API is not
available, add a clear comment documenting the required cleanup contract
(cleanupTerminalResources must call terminal.dispose() after
copyInteractionDisposable.dispose()) and ensure callers respect that order.


export const shouldForceBrowserTerminalSelection = (
event: TerminalMouseButtonEvent,
terminal: TerminalCopyInteractionTerminal
Expand Down Expand Up @@ -158,13 +204,17 @@ class TerminalCopyInteractionController {
}

readonly attach = (): { readonly dispose: () => void } => {
this.args.terminal.attachCustomKeyEventHandler?.(this.onTerminalKeyEvent)
this.args.host.addEventListener("mousedown", this.onMouseDown, true)
this.args.host.addEventListener("mouseup", this.onMouseUp, true)
this.args.host.addEventListener("contextmenu", this.onContextMenu, true)
this.args.host.addEventListener("copy", this.onCopy, true)
return { dispose: this.dispose }
}

private readonly onTerminalKeyEvent = (event: TerminalCopyKeyboardEvent): boolean =>
!shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal)

private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean =>
isSecondaryMouseButton(event) &&
hasActiveMouseTracking(this.args.terminal) &&
Expand Down
65 changes: 65 additions & 0 deletions packages/terminal/tests/web/terminal-copy-interaction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
forceTerminalSelectionModifier,
shouldForceBrowserTerminalSelection,
shouldForceTerminalSelectionContext,
shouldLetBrowserHandleTerminalCopyShortcut,
type TerminalCopyInteractionTerminal,
type TerminalCopyKeyboardEvent,
type TerminalMouseTrackingMode,
writeTerminalSelectionToClipboardData
} from "../../src/web/terminal-copy-interaction.js"
Expand All @@ -27,6 +29,26 @@ const terminalWithSelection = (
modes: { mouseTrackingMode }
})

const keyboardEvent = (
key: string,
options: Partial<Pick<TerminalCopyKeyboardEvent, "altKey" | "ctrlKey" | "metaKey" | "type">> = {}
): TerminalCopyKeyboardEvent => ({
altKey: options.altKey ?? false,
ctrlKey: options.ctrlKey ?? false,
key,
metaKey: options.metaKey ?? false,
type: options.type ?? "keydown"
})

const requireKeyHandler = (
keyHandler: ((event: TerminalCopyKeyboardEvent) => boolean) | null
): (event: TerminalCopyKeyboardEvent) => boolean => {
if (keyHandler === null) {
return expect.fail("Expected terminal copy key handler to be registered.")
}
return keyHandler
}

describe("terminal copy interaction", () => {
it("forces browser selection for primary mouse input while terminal mouse tracking is active", () => {
expect(shouldForceBrowserTerminalSelection({ button: 0 }, terminalWithSelection("any", ""))).toBe(true)
Expand All @@ -42,6 +64,49 @@ describe("terminal copy interaction", () => {
expect(shouldForceTerminalSelectionContext({ button: 0 }, terminalWithSelection("any", "selected"))).toBe(false)
})

it("lets browser copy handle Ctrl/Cmd+C only for selected terminal text", () => {
const selectedTerminal = terminalWithSelection("any", "selected")
const emptyTerminal = terminalWithSelection("any", "")

expect(shouldLetBrowserHandleTerminalCopyShortcut(keyboardEvent("c", { ctrlKey: true }), selectedTerminal))
.toBe(true)
expect(shouldLetBrowserHandleTerminalCopyShortcut(keyboardEvent("C", { metaKey: true }), selectedTerminal))
.toBe(true)
expect(shouldLetBrowserHandleTerminalCopyShortcut(keyboardEvent("c", { ctrlKey: true }), emptyTerminal))
.toBe(false)
expect(
shouldLetBrowserHandleTerminalCopyShortcut(keyboardEvent("c", { altKey: true, ctrlKey: true }), selectedTerminal)
)
.toBe(false)
expect(shouldLetBrowserHandleTerminalCopyShortcut(keyboardEvent("v", { ctrlKey: true }), selectedTerminal))
.toBe(false)
expect(
shouldLetBrowserHandleTerminalCopyShortcut(keyboardEvent("c", { ctrlKey: true, type: "keyup" }), selectedTerminal)
).toBe(false)
})
Comment on lines +67 to +86

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Отсутствует property-based тест для инварианта copy shortcut.

Согласно coding guidelines, математические инварианты должны проверяться через fast-check. Инвариант selected(t) ∧ copyShortcut(e) ⟹ browserCopy(e,t) можно выразить property-based тестом.

♻️ Пример property-based теста
import * as fc from "fast-check"

const keyArbitrary = fc.constantFrom("c", "C")
const modifierArbitrary = fc.record({
  altKey: fc.boolean(),
  ctrlKey: fc.boolean(),
  metaKey: fc.boolean()
})

it("shouldLetBrowserHandleTerminalCopyShortcut returns true iff copy shortcut with selection", () => {
  fc.assert(
    fc.property(keyArbitrary, modifierArbitrary, fc.boolean(), (key, mods, hasSelection) => {
      const event = keyboardEvent(key, { ...mods, type: "keydown" })
      const terminal = terminalWithSelection("any", hasSelection ? "text" : "")
      const isCopyShortcut = !mods.altKey && (mods.ctrlKey || mods.metaKey)
      const expected = isCopyShortcut && hasSelection
      return shouldLetBrowserHandleTerminalCopyShortcut(event, terminal) === expected
    })
  )
})

As per coding guidelines: "Property-based tests (fast-check) must verify mathematical invariants".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/terminal/tests/web/terminal-copy-interaction.test.ts` around lines
67 - 86, Add a property-based test using fast-check that encodes the invariant
"selected(t) ∧ copyShortcut(e) ⟹ browserCopy(e,t)" for
shouldLetBrowserHandleTerminalCopyShortcut: generate key (c/C), modifiers
(altKey, ctrlKey, metaKey) and a boolean hasSelection, build event with
keyboardEvent(..., type: "keydown") and terminal via
terminalWithSelection("any", hasSelection ? "text" : ""), compute isCopyShortcut
= !mods.altKey && (mods.ctrlKey || mods.metaKey), set expected = isCopyShortcut
&& hasSelection, and assert shouldLetBrowserHandleTerminalCopyShortcut(event,
terminal) === expected; import fc from fast-check and place this property in the
existing terminal-copy-interaction.test.ts near the current unit tests to
replace or supplement the example assertions.

Source: Coding guidelines


it("registers a custom key handler that bypasses xterm input for selected text copy", () => {
let keyHandler: ((event: TerminalCopyKeyboardEvent) => boolean) | null = null
const terminal: TerminalCopyInteractionTerminal = {
attachCustomKeyEventHandler: (handler) => {
keyHandler = handler
},
getSelection: () => "selected",
hasSelection: () => true,
modes: { mouseTrackingMode: "none" }
}
const host = new FakeTerminalCopyHost(null)

const disposable = attachTerminalCopyInteraction({ host, terminal })
const handleKey = requireKeyHandler(keyHandler)

expect(handleKey(keyboardEvent("c", { ctrlKey: true }))).toBe(false)
expect(handleKey(keyboardEvent("c", { ctrlKey: true, type: "keyup" }))).toBe(true)
expect(handleKey(keyboardEvent("c"))).toBe(true)

disposable.dispose()
})

it("uses Shift as the forced selection modifier on non-Mac platforms", () => {
const event = { altKey: false, shiftKey: false }

Expand Down
Loading