diff --git a/docs/UI_CONTRACT.md b/docs/UI_CONTRACT.md
index 0c125898..e2780c2e 100644
--- a/docs/UI_CONTRACT.md
+++ b/docs/UI_CONTRACT.md
@@ -46,6 +46,7 @@ This contract preserves the approved MVP concept and prevents feature ownership
- In semantic summary mode, clicking a card still selects it with a visible outline and camera focus follows only the configured Off/Single click/Double click mode. Renderer, xterm, and plugin cards follow the same wheel focus matrix as normal scale: only a focused input-bearing card can keep wheel in Off or released-Key mode; On, an active Key binding, or the held full navigation override gives it to the canvas. A Browser summary or other non-native placeholder is always canvas-owned for wheel/pinch.
- Every terminal edge and corner is a resize target. The minimum card size is `420 × 260`; resizing updates the xterm viewport and preserves the opposite edge.
- Live terminal selection follows the visible pointer position at every canvas zoom. Copy and paste use the selected preset's editable terminal bindings: Linux defaults to `Ctrl+C`/`Ctrl+Shift+V`, Windows to `Ctrl+Shift+C`/`Ctrl+Shift+V`, and macOS to `Cmd+C`/`Cmd+V`. Plain `Ctrl+C` without a selection retains the PTY interrupt. Presets use Enter to submit and Shift+Enter for a newline in the optional Codex editor; other CLIs retain their editor policies.
+- Controls includes an off-by-default **Copy on selection** setting, persisted and applied immediately to every agent and terminal, including fullscreen cards. Completing a non-empty mouse selection copies its text to the system clipboard without clearing the highlight; search results and cleared selections do not copy. With the setting enabled, Shift forces text selection in mouse-reporting TUIs on all desktop platforms; on macOS the terminal mouse adapter translates it to xterm's Option selection without taking the canvas's Option navigation binding.
- Canvas plugin apps use the same movable card grammar, `54px` header, resize/snap behavior, and semantic summary below `0.5×`. A `window` contribution opens a separate CanvasTTY-owned sandboxed window; arbitrary native window embedding is not part of the contract.
- The built-in Browser is one movable, resizable core canvas card, not a plugin contribution. It uses the same `54px` outer window header as other canvas cards, with its identity and hide-card action isolated from the internal tab strip below. Its trusted DOM chrome owns compact tabs/favicons, address/search, back/forward/reload, downloads, per-tab provider badges, site dialogs, and explicit Close tab/Close all. Hiding the card keeps its tabs and shared authenticated Chromium profile alive. Below `0.5×`, during Edit HOME, behind trusted dialogs/popovers, and while overlapped by a higher renderer-owned canvas window, the native page is hidden and replaced with a stable semantic surface. During card or camera motion it remains live and follows frame-coalesced viewport geometry.
- An authenticated connection or heartbeat alone never shows agent presence. A branded badge appears only after that agent actually issues a browser command, and its cursor appears only after a real pointer position exists. Claude uses `#D97757`, Codex `#10A37F`, Qwen `#6D44E8`, Kimi `#7C5CFC`, OpenCode `#5A5858`, Hermes `#D6A700`, and an unknown provider `#7A8291`. Browser Settings separately controls indicator visibility without revoking browser access; the agent-access kill switch revokes the connection itself.
diff --git a/docs/UI_CONTRACT.ru.md b/docs/UI_CONTRACT.ru.md
index c34a3daa..e1a9f86c 100644
--- a/docs/UI_CONTRACT.ru.md
+++ b/docs/UI_CONTRACT.ru.md
@@ -40,6 +40,7 @@
- В semantic summary нажатие по карточке по-прежнему выбирает её с видимой рамкой, а camera focus подчиняется только Off/Single click/Double click. Renderer-, xterm- и plugin-карточки используют ту же wheel focus-матрицу, что и при нормальном масштабе: только focused input-карточка может оставить wheel себе в Off или при отпущенном Key; On, активный Key binding или полный navigation override передаёт его канвасу. Browser summary и другие non-native placeholder-поверхности всегда передают wheel/pinch канвасу.
- Любая грань/угол terminal — resize target. Минимальный размер `420 × 260`; resize обновляет viewport xterm и сохраняет противоположную грань.
- Selection живого терминала следует за видимой позицией указателя при любом canvas zoom. При непустом выделении `Ctrl+C`/`Ctrl+Shift+C` или `Cmd+C` копирует; `Ctrl+Shift+V`/`Cmd+V` и `Shift+Insert` вставляют из system clipboard. Обычный `Ctrl+C` без выделения остаётся PTY interrupt. `Shift+Enter` отправляет line-break sequence (`ESC [ 13 ; 2 u`) в PTY вместо отправки строки.
+- В «Управлении» есть выключенная по умолчанию настройка **Копировать при выделении**. Она сохраняется и сразу применяется ко всем агентам и терминалам, включая полноэкранные карточки. После завершения непустого выделения мышью текст копируется в системный буфер, подсветка сохраняется. Поиск и снятие выделения не запускают копирование. При включённой настройке Shift позволяет выделять текст в TUI, перехватывающих мышь, на всех desktop-платформах; на macOS адаптер мыши терминала переводит Shift в Option для xterm, сохраняя навигацию канвасом через Option.
- Canvas plugin apps используют ту же movable card grammar, header `54px`, resize/snap и semantic summary ниже `0.5×`. Contribution `window` открывает отдельное sandboxed окно под управлением CanvasTTY; произвольные native windows не встраиваются.
- Built-in Browser — одна movable/resizable core canvas card, а не plugin contribution. Она использует тот же внешний header `54px`, что и другие canvas cards: identity и hide-card action отделены от внутренней полосы вкладок ниже. Trusted DOM chrome владеет tabs/favicons, address/search, back/forward/reload, downloads, per-tab provider badges, site dialogs и явными Close tab/Close all. Скрытие карточки сохраняет вкладки и общий аутентифицированный Chromium profile. Ниже `0.5×`, в Edit HOME и за trusted dialogs/popovers native page скрывается и заменяется стабильной semantic surface. Во время движения карточки или камеры он остаётся live и следует за frame-coalesced viewport geometry.
- Само authenticated connection или heartbeat не показывает agent presence. Branded badge появляется только после реальной browser-команды агента, а cursor — только после появления настоящей pointer position. Claude использует `#D97757`, Codex — `#10A37F`, Qwen — `#6D44E8`, Kimi — `#7C5CFC`, OpenCode — `#5A5858`, Hermes — `#D6A700`, unknown provider — `#7A8291`. Browser Settings отдельно управляет видимостью индикаторов, не отзывая доступ; kill switch доступа агентов отзывает само подключение.
diff --git a/docs/UI_CONTRACT.zh-CN.md b/docs/UI_CONTRACT.zh-CN.md
index 2a5a6503..701c3849 100644
--- a/docs/UI_CONTRACT.zh-CN.md
+++ b/docs/UI_CONTRACT.zh-CN.md
@@ -40,6 +40,7 @@
- Semantic summary 模式中,点击卡片仍会选中并显示 outline,camera focus 只遵循 Off/Single click/Double click。Renderer、xterm 与 plugin 卡片使用和正常 scale 相同的 wheel focus 矩阵:只有 focused 且具备 input 的卡片能在 Off 或 Key 未按下时保留 wheel;On、激活的 Key binding 或完整 navigation override 会把它交给 canvas。Browser summary 与其他 non-native placeholder surface 的 wheel/pinch 始终归 canvas。
- Terminal 任意 edge/corner 都是 resize target。最小 card size 为 `420 × 260`;resize 更新 xterm viewport 并保持对边不动。
- 在任意 canvas zoom 下,实时终端 selection 都跟随可见指针位置。有文字选择时,`Ctrl+C`/`Ctrl+Shift+C` 或 `Cmd+C` 复制;`Ctrl+Shift+V`/`Cmd+V` 与 `Shift+Insert` 从系统剪贴板粘贴。没有选择时,普通 `Ctrl+C` 仍是 PTY interrupt。`Shift+Enter` 向 PTY 发送换行 sequence(`ESC [ 13 ; 2 u`),而不是提交当前行。
+- Controls 提供默认关闭的 **Copy on selection** 设置,保存后立即应用于所有智能体和终端,包括全屏卡片。鼠标完成非空文本选择后复制到系统剪贴板,保留高亮;搜索结果和清除选择不会复制。启用后,所有桌面平台都可按住 Shift 在接管鼠标的 TUI 中选择文本;macOS 的终端鼠标适配器将 Shift 转为 xterm 的 Option 选择,同时保留 canvas 的 Option 导航。
- Canvas plugin app 使用同一 movable card grammar、`54px` header、resize/snap behavior,以及 `0.5×` 以下的 semantic summary。`window` contribution 打开 CanvasTTY 管理的独立 sandbox window;不支持嵌入任意原生窗口。
- 内置 Browser 是唯一的可移动、可调整尺寸 core canvas card,而不是 plugin contribution。它使用与其他 canvas card 相同的 `54px` 外层窗口 header,将 identity 与 hide-card action 和下方内部 tab strip 分开。可信 DOM chrome 负责 tab/favicon、address/search、back/forward/reload、download、per-tab provider badge、site dialog 以及明确的 Close tab/Close all。隐藏卡片会保留 tab 与共享的已认证 Chromium profile。低于 `0.5×`、Edit HOME 期间及可信 dialog/popover 后方,native page 会由稳定 semantic surface 替代;卡片或 camera 移动时它保持实时渲染,并跟随按帧合并的 viewport geometry。
- 仅有 authenticated connection 或 heartbeat 不会显示智能体 presence。智能体实际发出 browser command 后才显示品牌 badge,获得真实 pointer position 后才显示 cursor。Claude 使用 `#D97757`,Codex 使用 `#10A37F`,Qwen 使用 `#6D44E8`,Kimi 使用 `#7C5CFC`,OpenCode 使用 `#5A5858`,Hermes 使用 `#D6A700`,未知 provider 使用 `#7A8291`。Browser Settings 可单独控制 indicator visibility 而不撤销访问;智能体访问 kill switch 会撤销连接本身。
diff --git a/src/main/services/SettingsStore.ts b/src/main/services/SettingsStore.ts
index 8f2aafc7..5ba49188 100644
--- a/src/main/services/SettingsStore.ts
+++ b/src/main/services/SettingsStore.ts
@@ -373,6 +373,7 @@ function createDefaults(systemLocale: string, platform: string): AppSettings {
terminalSkinAnimationEnabled: true,
appSkin: "classic",
snapToGrid: true,
+ copyOnSelect: false,
invertTerminalWheel: true,
invertCanvasWheel: false,
edgePan: false,
@@ -605,6 +606,7 @@ export function normalizeSettings(
? source.appSkin as AppSkinId
: fallback.appSkin,
snapToGrid: typeof source.snapToGrid === "boolean" ? source.snapToGrid : fallback.snapToGrid,
+ copyOnSelect: typeof source.copyOnSelect === "boolean" ? source.copyOnSelect : fallback.copyOnSelect,
invertTerminalWheel: typeof source.invertTerminalWheel === "boolean"
? source.invertTerminalWheel
: fallback.invertTerminalWheel,
diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx
index 301ec078..6d36491a 100644
--- a/src/renderer/src/App.tsx
+++ b/src/renderer/src/App.tsx
@@ -120,6 +120,7 @@ const FALLBACK_SETTINGS: AppSettings = {
terminalSkinAnimationEnabled: true,
appSkin: "classic",
snapToGrid: true,
+ copyOnSelect: false,
invertTerminalWheel: true,
invertCanvasWheel: false,
edgePan: false,
diff --git a/src/renderer/src/features/settings/SettingsPanel.tsx b/src/renderer/src/features/settings/SettingsPanel.tsx
index 33ac100c..f03f630a 100644
--- a/src/renderer/src/features/settings/SettingsPanel.tsx
+++ b/src/renderer/src/features/settings/SettingsPanel.tsx
@@ -1136,6 +1136,13 @@ export function SettingsPanel({
onChange={(value) => void onChange({ useScrollWheelToZoom: value === "on" })}
/>
+
+ void onChange({ copyOnSelect: value === "on" })}
+ />
+
Promise>(async () => undefined);
const invertTerminalWheelRef = useRef(invertTerminalWheel);
invertTerminalWheelRef.current = invertTerminalWheel;
+ const copyOnSelectRef = useRef(copyOnSelect);
+ copyOnSelectRef.current = copyOnSelect;
const captureCanvasWheelRef = useRef(captureCanvasWheelOverWidgets);
captureCanvasWheelRef.current = captureCanvasWheelOverWidgets;
const dragState = useRef(null);
@@ -314,6 +319,8 @@ function TerminalCardView({
// into the pinned row below it.
scrollOnUserInput: false,
allowTransparency: true,
+ macOptionClickForcesSelection: copyOnSelectRef.current,
+ altClickMovesCursor: !(copyOnSelectRef.current && window.canvasTTY.window.isMacOS),
// Search decorations (highlighting every match and reporting the match
// count) are proposed API in xterm; without this flag findNext throws and
// the counter never leaves 0/0. The flag only unlocks that surface.
@@ -537,7 +544,20 @@ function TerminalCardView({
? attachTerminalMouseCoordinateAdapter(
screen,
() => invertTerminalWheelRef.current ? -1 : 1,
- () => captureCanvasWheelRef.current
+ () => captureCanvasWheelRef.current,
+ () => copyOnSelectRef.current && window.canvasTTY.window.isMacOS
+ )
+ : () => undefined;
+ const detachCopyOnSelect = screen
+ ? attachTerminalCopyOnSelect(
+ screen,
+ () => terminal.getSelection(),
+ () => copyOnSelectRef.current,
+ (text) => window.canvasTTY.clipboard.writeText(text),
+ (listener) => {
+ const disposable = terminal.onSelectionChange(listener);
+ return () => disposable.dispose();
+ }
)
: () => undefined;
terminalRef.current = terminal;
@@ -585,6 +605,7 @@ function TerminalCardView({
unregisterWebgl();
cancelAnimationFrame(frame);
detachMouseCoordinateAdapter();
+ detachCopyOnSelect();
detachScrollbarCoordinateAdapter();
unsubscribe();
resizeObserver.disconnect();
@@ -614,6 +635,13 @@ function TerminalCardView({
if (terminal) terminal.options.theme = terminalTheme(palette, pixelSkinTheme);
}, [palette, pixelSkinTheme]);
+ useEffect(() => {
+ const terminal = terminalRef.current;
+ if (!terminal) return;
+ terminal.options.macOptionClickForcesSelection = copyOnSelect;
+ terminal.options.altClickMovesCursor = !(copyOnSelect && window.canvasTTY.window.isMacOS);
+ }, [copyOnSelect]);
+
const enableWebgl = (terminal: Terminal): boolean => {
if (webglAddonRef.current) return true;
// WebglAddon takes no transparency argument in 0.19.0: it reads the stored
diff --git a/src/renderer/src/features/terminal/terminalCopyOnSelect.ts b/src/renderer/src/features/terminal/terminalCopyOnSelect.ts
new file mode 100644
index 00000000..2226c4f2
--- /dev/null
+++ b/src/renderer/src/features/terminal/terminalCopyOnSelect.ts
@@ -0,0 +1,61 @@
+/** Copy only completed mouse gestures that xterm confirmed as selection changes. */
+export function attachTerminalCopyOnSelect(
+ screen: HTMLElement,
+ getSelection: () => string,
+ isEnabled: () => boolean,
+ copy: (text: string) => void,
+ subscribeSelectionChange: (listener: () => void) => () => void = () => () => undefined
+): () => void {
+ const ownerDocument = screen.ownerDocument;
+ const ownerWindow = ownerDocument.defaultView;
+ if (!ownerWindow) return () => undefined;
+ let selecting = false;
+ let selectionChanged = false;
+ let pendingCopy: number | undefined;
+
+ const onSelectionChange = (): void => {
+ if (selecting || pendingCopy !== undefined) selectionChanged = true;
+ };
+ const detachSelectionChange = subscribeSelectionChange(onSelectionChange);
+
+ const cancel = (): void => {
+ selecting = false;
+ selectionChanged = false;
+ if (pendingCopy !== undefined) ownerWindow.clearTimeout(pendingCopy);
+ pendingCopy = undefined;
+ };
+ const start = (event: MouseEvent): void => {
+ if (event.button !== 0) return;
+ cancel();
+ selecting = true;
+ };
+ const finish = (event: MouseEvent): void => {
+ if (event.button !== 0 || !selecting) return;
+ selecting = false;
+ // xterm finalizes selection in its document mouseup listener. Read after it,
+ // including when the coordinate adapter redispatches a scaled mouse event.
+ pendingCopy = ownerWindow.setTimeout(() => {
+ pendingCopy = undefined;
+ if (!isEnabled() || !selectionChanged) {
+ selectionChanged = false;
+ return;
+ }
+ selectionChanged = false;
+ const text = getSelection();
+ if (text.length > 0) copy(text);
+ }, 0);
+ };
+
+ screen.addEventListener("mousedown", start, true);
+ ownerDocument.addEventListener("mouseup", finish, true);
+ ownerDocument.addEventListener("pointercancel", cancel, true);
+ ownerWindow.addEventListener("blur", cancel);
+ return () => {
+ cancel();
+ screen.removeEventListener("mousedown", start, true);
+ ownerDocument.removeEventListener("mouseup", finish, true);
+ ownerDocument.removeEventListener("pointercancel", cancel, true);
+ ownerWindow.removeEventListener("blur", cancel);
+ detachSelectionChange();
+ };
+}
diff --git a/src/renderer/src/features/terminal/terminalMouseCoordinates.ts b/src/renderer/src/features/terminal/terminalMouseCoordinates.ts
index 3e34cb80..d93601f4 100644
--- a/src/renderer/src/features/terminal/terminalMouseCoordinates.ts
+++ b/src/renderer/src/features/terminal/terminalMouseCoordinates.ts
@@ -64,7 +64,8 @@ export function remapTerminalMouseCoordinates(
export function attachTerminalMouseCoordinateAdapter(
screen: HTMLElement,
getWheelMultiplier: () => 1 | -1 = () => 1,
- shouldRouteWheelToCanvas: () => boolean = () => false
+ shouldRouteWheelToCanvas: () => boolean = () => false,
+ shouldForceSelectionWithShift: () => boolean = () => false
): () => void {
const ownerDocument = screen.ownerDocument;
const syntheticEvents = new WeakSet();
@@ -88,14 +89,17 @@ export function attachTerminalMouseCoordinateAdapter(
);
const needsRemap = Math.abs(adjusted.x - event.clientX) > 0.01
|| Math.abs(adjusted.y - event.clientY) > 0.01;
- if (!needsRemap) {
+ // On macOS xterm forces selection with Option, which is the canvas pan key.
+ // Translate Shift only for xterm mouse events; pointer navigation stays intact.
+ const forceSelection = shouldForceSelectionWithShift() && event.shiftKey && event.button === 0;
+ if (!needsRemap && !forceSelection) {
if (event.type === "mouseup") endDrag();
return;
}
event.preventDefault();
event.stopImmediatePropagation();
- const remapped = cloneMouseEvent(event, adjusted);
+ const remapped = cloneMouseEvent(event, adjusted, forceSelection);
syntheticEvents.add(remapped);
target.dispatchEvent(remapped);
if (event.type === "mouseup") endDrag();
@@ -164,7 +168,7 @@ export function attachTerminalMouseCoordinateAdapter(
};
}
-function cloneMouseEvent(event: MouseEvent, point: ClientPoint): MouseEvent {
+function cloneMouseEvent(event: MouseEvent, point: ClientPoint, forceSelection = false): MouseEvent {
return new MouseEvent(event.type, {
bubbles: true,
cancelable: true,
@@ -177,7 +181,7 @@ function cloneMouseEvent(event: MouseEvent, point: ClientPoint): MouseEvent {
clientY: point.y,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
- altKey: event.altKey,
+ altKey: event.altKey || forceSelection,
metaKey: event.metaKey,
button: event.button,
buttons: event.buttons,
diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx
index 4b4e4bf6..54d2da7e 100644
--- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx
+++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx
@@ -1005,6 +1005,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
snapEnabled={settings.snapToGrid}
focusActivation={settings.focusActivation}
invertTerminalWheel={settings.invertTerminalWheel}
+ copyOnSelect={settings.copyOnSelect}
captureCanvasWheelOverWidgets={routeWidgetWheelToCanvas || widgetFocus.id !== terminalCanvasWidgetId(session.id)}
focused={widgetFocus.id === terminalCanvasWidgetId(session.id)}
focusChangeSource={widgetFocus.source}
@@ -1138,6 +1139,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
snapEnabled={false}
focusActivation={settings.focusActivation}
invertTerminalWheel={settings.invertTerminalWheel}
+ copyOnSelect={settings.copyOnSelect}
captureCanvasWheelOverWidgets={false}
focused={widgetFocus.id === terminalCanvasWidgetId(session.id)}
focusChangeSource={widgetFocus.source}
diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts
index 021952ee..8428c2fc 100644
--- a/src/renderer/src/lib/i18n.ts
+++ b/src/renderer/src/lib/i18n.ts
@@ -471,6 +471,8 @@ const ru = {
canvasOverrideBindingsMatch: "Комбинации совпадают: полный перехват имеет приоритет и также забирает drag.",
disabled: "Отключено",
terminalWheelDirection: "Направление колеса терминала",
+ copyOnSelect: "Копировать при выделении",
+ copyOnSelectDescription: "Копирует текст в буфер обмена после выделения мышью во всех агентах и терминале. Если приложение перехватывает мышь, удерживайте Shift при выделении.",
canvasWheelDirection: "Направление колеса канваса",
wheelNormal: "Обычное",
wheelInverted: "Обратное",
@@ -1296,6 +1298,8 @@ const en: Record = {
canvasOverrideBindingsMatch: "The shortcuts match: full navigation takes priority and also captures drag.",
disabled: "Disabled",
terminalWheelDirection: "Terminal wheel direction",
+ copyOnSelect: "Copy on selection",
+ copyOnSelectDescription: "Copies text to the clipboard after mouse selection in every agent and terminal. Hold Shift while selecting if the application captures the mouse.",
canvasWheelDirection: "Canvas wheel direction",
wheelNormal: "Normal",
renameWindow: "Rename window",
diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts
index 39164fc5..63367199 100644
--- a/src/shared/contracts.ts
+++ b/src/shared/contracts.ts
@@ -289,6 +289,7 @@ export interface AppSettings {
terminalSkinAnimationEnabled: boolean;
appSkin: AppSkinId;
snapToGrid: boolean;
+ copyOnSelect: boolean;
invertTerminalWheel: boolean;
invertCanvasWheel: boolean;
edgePan: boolean;
diff --git a/tests/terminal-copy-on-select.test.mjs b/tests/terminal-copy-on-select.test.mjs
new file mode 100644
index 00000000..2c12f30d
--- /dev/null
+++ b/tests/terminal-copy-on-select.test.mjs
@@ -0,0 +1,76 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+import { attachTerminalCopyOnSelect } from "../src/renderer/src/features/terminal/terminalCopyOnSelect.ts";
+
+class FakeTarget {
+ constructor() {
+ this.listeners = new Map();
+ }
+
+ addEventListener(type, listener) {
+ if (!this.listeners.has(type)) this.listeners.set(type, new Set());
+ this.listeners.get(type).add(listener);
+ }
+
+ removeEventListener(type, listener) {
+ this.listeners.get(type)?.delete(listener);
+ }
+
+ fire(type, event = {}) {
+ for (const listener of [...(this.listeners.get(type) ?? [])]) listener(event);
+ }
+}
+
+function setup() {
+ const document = new FakeTarget();
+ const window = new FakeTarget();
+ window.setTimeout = globalThis.setTimeout.bind(globalThis);
+ window.clearTimeout = globalThis.clearTimeout.bind(globalThis);
+ const screen = new FakeTarget();
+ screen.ownerDocument = document;
+ document.defaultView = window;
+ let selection = "retained text";
+ const selectionListeners = new Set();
+ const copied = [];
+ const detach = attachTerminalCopyOnSelect(
+ screen,
+ () => selection,
+ () => true,
+ (text) => copied.push(text),
+ (listener) => {
+ selectionListeners.add(listener);
+ return () => selectionListeners.delete(listener);
+ }
+ );
+ return {
+ document,
+ screen,
+ copied,
+ setSelection(text) { selection = text; },
+ notifySelectionChange() {
+ for (const listener of [...selectionListeners]) listener();
+ },
+ detach
+ };
+}
+
+const leftButton = { button: 0 };
+
+test("an ordinary TUI click does not copy a retained selection", async () => {
+ const state = setup();
+ state.screen.fire("mousedown", leftButton);
+ state.document.fire("mouseup", leftButton);
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.deepEqual(state.copied, []);
+ state.detach();
+});
+
+test("a genuine selection change copies even when the selected text is unchanged", async () => {
+ const state = setup();
+ state.screen.fire("mousedown", { button: 0, shiftKey: true });
+ state.notifySelectionChange();
+ state.document.fire("mouseup", { button: 0, shiftKey: true });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ assert.deepEqual(state.copied, ["retained text"]);
+ state.detach();
+});