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(); +});