From 4f087544f3506ecfe866de45ea6a855e3ece90bb Mon Sep 17 00:00:00 2001 From: HeftyKoo Date: Tue, 20 Oct 2026 19:47:29 +0800 Subject: [PATCH] fix(terminal): enable direct text selection and prepare patch releases --- .github/workflows/release.yml | 2 +- CHANGELOG.md | 12 ++ apps/extension/package.json | 2 +- .../AgentOnWeb.xcodeproj/project.pbxproj | 16 +-- apps/safari/README.md | 6 +- apps/terminal-surface/package.json | 2 +- apps/terminal-surface/src/main.test.ts | 19 +++ apps/terminal-surface/src/main.ts | 6 + .../src/mouse-selection.test.ts | 72 +++++++++++ apps/terminal-surface/src/mouse-selection.ts | 65 ++++++++++ docs/releasing.md | 18 +-- docs/verification/release-0.4.2.md | 20 +++ docs/verification/terminal-copy-2026-10-20.md | 49 ++++++++ package.json | 2 +- packages/terminal-host/README.md | 1 + packages/terminal-host/package.json | 2 +- release-contract.json | 12 +- scripts/check-terminal-copy-browser.mjs | 116 ++++++++++++++++++ scripts/install.sh | 2 +- scripts/install.test.mjs | 2 +- 20 files changed, 393 insertions(+), 33 deletions(-) create mode 100644 apps/terminal-surface/src/mouse-selection.test.ts create mode 100644 apps/terminal-surface/src/mouse-selection.ts create mode 100644 docs/verification/release-0.4.2.md create mode 100644 docs/verification/terminal-copy-2026-10-20.md create mode 100644 scripts/check-terminal-copy-browser.mjs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f92836a..2dd3da3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,7 +7,7 @@ on: description: Existing release tag to publish or retry required: true type: choice - options: [terminal-v0.4.1, dsh-v0.4.0, terminal-v0.4.0, ext-v0.4.0, dsh-v0.3.0, terminal-v0.3.0, ext-v0.3.0, dsh-v0.2.0, terminal-v0.2.0, terminal-v0.2.1, ext-v0.2.0, ext-v0.2.1] + options: [terminal-v0.4.2, ext-v0.4.1, terminal-v0.4.1, dsh-v0.4.0, terminal-v0.4.0, ext-v0.4.0, dsh-v0.3.0, terminal-v0.3.0, ext-v0.3.0, dsh-v0.2.0, terminal-v0.2.0, terminal-v0.2.1, ext-v0.2.0, ext-v0.2.1] push: tags: ["ext-v*", "dsh-v*", "terminal-v*"] diff --git a/CHANGELOG.md b/CHANGELOG.md index 56c9364..17e828d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Terminal host 0.4.2 / Extension 0.4.1 — 2026-10-20 + +- Select terminal text with an ordinary mouse or trackpad drag, including inside TUIs with mouse reporting. Right-click to copy, or use the existing copy shortcut. Preserve app clicks, scrolling, native word/line selection and scrollbar interaction. +- Keep selection gestures from triggering the double-Option website interaction shortcut. +- Include keyboard-driven checkbox agent setup and readable Matrix-style CLI output in terminal host 0.4.2. +- Apply acknowledged mode-button clicks immediately and ignore stale replies in extension 0.4.1. +- Safari uses version 0.4.1, build 10. DSH remains 0.4.0; connector protocol remains 1. + +### Upgrade + +The terminal copy fix requires terminal host 0.4.2. Finish active shell work, then run `aow update` from a separate macOS Terminal. Update browser extensions when each store approves 0.4.1; store submission and public availability are separate states. + ## Terminal host 0.4.1 — 2026-10-13 - Add terminal-host version reporting, daily asynchronous npm update checks with caching/backoff, persistent opt-out, and reminders in the private management page and service log. diff --git a/apps/extension/package.json b/apps/extension/package.json index 338f09f..ab38c63 100644 --- a/apps/extension/package.json +++ b/apps/extension/package.json @@ -1,6 +1,6 @@ { "name": "@agentonweb/extension", - "version": "0.4.0", + "version": "0.4.1", "private": true, "license": "MIT", "type": "module", diff --git a/apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj/project.pbxproj b/apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj/project.pbxproj index edfe352..ac4ced7 100644 --- a/apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj/project.pbxproj +++ b/apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj/project.pbxproj @@ -467,7 +467,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -481,7 +481,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.4; - MARKETING_VERSION = 0.4.0; + MARKETING_VERSION = 0.4.1; OTHER_LDFLAGS = ( "-framework", SafariServices, @@ -501,7 +501,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_USER_SELECTED_FILES = readonly; @@ -515,7 +515,7 @@ "@executable_path/../../../../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 15.4; - MARKETING_VERSION = 0.4.0; + MARKETING_VERSION = 0.4.1; OTHER_LDFLAGS = ( "-framework", SafariServices, @@ -538,7 +538,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; @@ -553,7 +553,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.4.0; + MARKETING_VERSION = 0.4.1; OTHER_LDFLAGS = ( "-framework", SafariServices, @@ -579,7 +579,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - CURRENT_PROJECT_VERSION = 9; + CURRENT_PROJECT_VERSION = 10; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; @@ -594,7 +594,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 0.4.0; + MARKETING_VERSION = 0.4.1; OTHER_LDFLAGS = ( "-framework", SafariServices, diff --git a/apps/safari/README.md b/apps/safari/README.md index 824b60f..fc06fa4 100644 --- a/apps/safari/README.md +++ b/apps/safari/README.md @@ -5,7 +5,7 @@ The containing app is a macOS-only Safari Web Extension, built with Xcode. It gi - App Store Connect app ID: recorded in the gitignored `docs/verification/local/` notes and intentionally not published - App bundle ID: `dev.agentonweb.extension` - Extension bundle ID: `dev.agentonweb.extension.safari` -- Version: `0.4.0`, build `9` +- Version: `0.4.1`, build `10` - Minimum: macOS 15.4 and Safari 18.4 - Architectures: Apple Silicon and Intel @@ -36,11 +36,11 @@ To archive and export: DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild \ -project apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj \ -scheme AgentOnWeb -configuration Release \ - -archivePath release/safari/AgentOnWeb-0.4.0-9.xcarchive \ + -archivePath release/safari/AgentOnWeb-0.4.1-10.xcarchive \ -derivedDataPath output/safari-derived -allowProvisioningUpdates archive DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer xcodebuild \ - -exportArchive -archivePath release/safari/AgentOnWeb-0.4.0-9.xcarchive \ + -exportArchive -archivePath release/safari/AgentOnWeb-0.4.1-10.xcarchive \ -exportPath release/safari/app-store \ -exportOptionsPlist apps/safari/config/ExportOptions.plist \ -allowProvisioningUpdates diff --git a/apps/terminal-surface/package.json b/apps/terminal-surface/package.json index b7d57af..712bc4e 100644 --- a/apps/terminal-surface/package.json +++ b/apps/terminal-surface/package.json @@ -1,6 +1,6 @@ { "name": "@agentonweb/terminal-surface", - "version": "0.4.0", + "version": "0.4.1", "private": true, "type": "module", "scripts": { diff --git a/apps/terminal-surface/src/main.test.ts b/apps/terminal-surface/src/main.test.ts index 60e7aeb..520a860 100644 --- a/apps/terminal-surface/src/main.test.ts +++ b/apps/terminal-surface/src/main.test.ts @@ -10,6 +10,7 @@ const terminalMock = vi.hoisted(() => ({ vi.mock("@xterm/xterm", () => ({ Terminal: class { options: any = {}; + modes = { mouseTrackingMode: "none" }; parser = { registerCsiHandler() {}, registerOscHandler() {} }; loadAddon() {} open() {} @@ -572,6 +573,24 @@ it.each(["MacIntel", "Win32"])("forwards enabled mode keys from terminal input o expect(terminalMock.focus).toHaveBeenCalledOnce(); }); +it("does not treat Option-drag selection as an Option tap, while preserving bare Option taps", async () => { + dom = new JSDOM(await readFile(new URL("./index.html", import.meta.url), "utf8"), { url: "http://localhost:1234/#agentonweb=nonce" }); + for (const key of ["window", "document", "location", "sessionStorage"]) vi.stubGlobal(key, (dom.window as any)[key]); + const parent = { postMessage: vi.fn() }; + vi.stubGlobal("parent", parent); + vi.stubGlobal("ResizeObserver", class { observe() {} }); + await import("./main.js"); + const alt = (type: string) => dom.window.dispatchEvent(new dom.window.KeyboardEvent(type, { key: "Alt", altKey: type === "keydown" })); + const taps = () => parent.postMessage.mock.calls.filter(([message]) => message.type === "site-pass.option-tap"); + alt("keydown"); + dom.window.document.querySelector("#terminal")!.dispatchEvent(new dom.window.MouseEvent("pointerdown", { bubbles: true, altKey: true })); + alt("keyup"); + expect(taps()).toHaveLength(0); + alt("keydown"); + alt("keyup"); + expect(taps()).toHaveLength(1); +}); + it("activates an authenticated target before the initial list resolves and rejects missing terminal targets", async () => { dom = new JSDOM(await readFile(new URL("./index.html", import.meta.url), "utf8"), { url: "http://localhost:1234/#agentonweb=nonce" }); for (const key of ["window", "document", "location", "sessionStorage"]) vi.stubGlobal(key, (dom.window as any)[key]); diff --git a/apps/terminal-surface/src/main.ts b/apps/terminal-surface/src/main.ts index 4bbd3a8..7f301d0 100644 --- a/apps/terminal-surface/src/main.ts +++ b/apps/terminal-surface/src/main.ts @@ -3,6 +3,7 @@ import { OptionTapTracker } from "@agentonweb/connector-contract"; import { Terminal } from "@xterm/xterm"; import { splitInput } from "./input.js"; import { installCellBackgroundOpacity } from "./cell-backgrounds.js"; +import { installMouseSelection } from "./mouse-selection.js"; import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import "./style.css"; @@ -14,11 +15,14 @@ const terminal = new Terminal({ fontFamily: "Menlo, Consolas, monospace", fontSize: 14, scrollback: 3000, + // Keep text selectable while native TUIs enable mouse reporting. + macOptionClickForcesSelection: true, theme: { background: "#15171cff", foreground: "#f1f3f5" }, }); const fit = new FitAddon(); terminal.loadAddon(fit); const container = document.querySelector("#terminal")!; +container.title = "Drag to select text · Right-click to copy"; const status = document.querySelector("#status")!; const notice = document.querySelector("#connection-notice")!; @@ -28,6 +32,7 @@ function showStatus(message: string) { notice.hidden = message === "Connected"; } terminal.open(container); +installMouseSelection(terminal, container); const setCellBackgroundOpacity = installCellBackgroundOpacity(terminal, container); const sessionPicker = document.querySelector("#sessions")!; const newButton = document.querySelector("#new")!; @@ -660,6 +665,7 @@ window.addEventListener("focus", () => { parent.postMessage({ source: "agentonweb-surface", type: "surface.focused", nonce }, "*"); }); window.addEventListener("pointerdown", () => { + optionTap.reset(); if (nonce && parent !== window && authorizedParent) parent.postMessage({ source: "agentonweb-surface", type: "surface.pointerdown", nonce }, "*"); }, true); window.addEventListener( diff --git a/apps/terminal-surface/src/mouse-selection.test.ts b/apps/terminal-surface/src/mouse-selection.test.ts new file mode 100644 index 0000000..a28e13a --- /dev/null +++ b/apps/terminal-surface/src/mouse-selection.test.ts @@ -0,0 +1,72 @@ +import { JSDOM } from "jsdom"; +import { afterEach, expect, it, vi } from "vitest"; +import { installMouseSelection } from "./mouse-selection.js"; + +let dom: JSDOM; +afterEach(() => dom.window.close()); +function fixture(mac = true) { + dom = new JSDOM('
Terminal
'); + const container = dom.window.document.querySelector("main")!; + const text = container.querySelector("span")!; + const terminal = { modes: { mouseTrackingMode: "drag" as "drag" | "none" }, focus: vi.fn() }; + installMouseSelection(terminal, container, mac); + const received: MouseEvent[] = []; + for (const type of ["mousedown", "mouseup"]) text.addEventListener(type, event => received.push(event as MouseEvent)); + const mouse = (type: string, options: MouseEventInit = {}, target: EventTarget = text) => { + const event = new dom.window.MouseEvent(type, { bubbles: true, cancelable: true, clientX: 10, clientY: 20, button: 0, buttons: type === "mouseup" ? 0 : 1, ...options }); + target.dispatchEvent(event); + return event; + }; + return { terminal, container, received, mouse }; +} + +it.each([true, false])("lets xterm select a plain drag, using its platform selection modifier (mac=%s)", mac => { + const { mouse, received } = fixture(mac); + mouse("mousedown"); + expect(received).toHaveLength(0); + mouse("mousemove", { clientX: 30 }); + expect(received).toHaveLength(1); + expect(received[0]).toMatchObject({ type: "mousedown", clientX: 10, clientY: 20, altKey: mac, shiftKey: !mac }); + mouse("mousemove", { clientX: 40 }); + mouse("mouseup", { clientX: 40 }); + expect(received.map(event => event.type)).toEqual(["mousedown", "mouseup"]); +}); + +it("delivers an ordinary click and small trackpad jitter to the TUI as a matching press and release", () => { + const { mouse, received } = fixture(); + mouse("mousedown"); + mouse("mousemove", { clientX: 11 }); + expect(received).toHaveLength(0); + mouse("mouseup", { clientX: 11 }); + expect(received.map(event => [event.type, event.altKey, event.shiftKey, event.buttons])).toEqual([ + ["mousedown", false, false, 1], ["mouseup", false, false, 0], + ]); +}); + +it("cancels an unfinished click on blur without sending a stale press", () => { + const { mouse, received } = fixture(); + mouse("mousedown"); + dom.window.dispatchEvent(new dom.window.Event("blur")); + mouse("mousemove", { clientX: 40 }); + expect(received).toHaveLength(0); + mouse("mousedown"); + mouse("mouseup"); + expect(received.map(event => event.type)).toEqual(["mousedown", "mouseup"]); +}); + +it("preserves native shell selection, modified gestures, context clicks and scrollbar interaction", () => { + const { mouse, received, terminal, container } = fixture(); + terminal.modes.mouseTrackingMode = "none"; + expect(mouse("mousedown").defaultPrevented).toBe(false); + terminal.modes.mouseTrackingMode = "drag"; + for (const options of [{ shiftKey: true }, { altKey: true }, { ctrlKey: true }, { metaKey: true }, { button: 2 }]) + expect(mouse("mousedown", options).defaultPrevented).toBe(false); + expect(mouse("mousedown", {}, container.querySelector(".xterm-viewport")!).defaultPrevented).toBe(false); + expect(received).toHaveLength(6); +}); + +it("lets xterm handle word and line selection immediately on double and triple clicks", () => { + const { mouse, received } = fixture(); + for (const detail of [2, 3]) mouse("mousedown", { detail }); + expect(received.map(event => [event.detail, event.altKey])).toEqual([[2, true], [3, true]]); +}); diff --git a/apps/terminal-surface/src/mouse-selection.ts b/apps/terminal-surface/src/mouse-selection.ts new file mode 100644 index 0000000..b96e8e7 --- /dev/null +++ b/apps/terminal-surface/src/mouse-selection.ts @@ -0,0 +1,65 @@ +import { isMacPlatform } from "@agentonweb/connector-contract"; +import type { Terminal } from "@xterm/xterm"; + +type SelectionTerminal = Pick & { readonly modes: Pick }; + +/** Distinguish text drags from TUI clicks before forwarding a mouse press. */ +export function installMouseSelection(terminal: SelectionTerminal, container: HTMLElement, mac = isMacPlatform()) { + const document = container.ownerDocument; + const window = document.defaultView!; + let forwarding = false; + let cancelGesture: (() => void) | undefined; + function forward(target: EventTarget, event: MouseEvent, select = false) { + forwarding = true; + try { + target.dispatchEvent(new window.MouseEvent(event.type, { + bubbles: true, cancelable: true, view: window, detail: event.detail, + clientX: event.clientX, clientY: event.clientY, screenX: event.screenX, screenY: event.screenY, + button: event.button, buttons: event.buttons, + ctrlKey: event.ctrlKey, metaKey: event.metaKey, + altKey: event.altKey || (select && mac), shiftKey: event.shiftKey || (select && !mac), + })); + } finally { + forwarding = false; + } + } + container.addEventListener("mousedown", event => { + if (forwarding || event.button !== 0 || event.altKey || event.shiftKey || event.ctrlKey || event.metaKey + || terminal.modes.mouseTrackingMode === "none" || !(event.target instanceof window.Element) + || !event.target.closest(".xterm-screen")) return; + cancelGesture?.(); + event.preventDefault(); + event.stopImmediatePropagation(); + terminal.focus(); + const target = event.target!; + // Let xterm perform its own word/line selection for double/triple clicks. + if (event.detail >= 2) { forward(target, event, true); return; } + let selecting = false; + const cleanup = () => { + document.removeEventListener("mousemove", move, true); + document.removeEventListener("mouseup", up, true); + window.removeEventListener("blur", cleanup); + cancelGesture = undefined; + }; + const move = (next: MouseEvent) => { + if (selecting || Math.hypot(next.clientX - event.clientX, next.clientY - event.clientY) < 3) return; + selecting = true; + // Use xterm's native selection, including wide characters and scrolling. + forward(target, event, true); + }; + const up = (next: MouseEvent) => { + if (next.button !== 0) return; + cleanup(); + if (selecting) return; + next.preventDefault(); + next.stopImmediatePropagation(); + // A click still reaches the app, with its original coordinates/modifiers. + forward(target, event); + forward(next.target ?? target, next); + }; + document.addEventListener("mousemove", move, true); + document.addEventListener("mouseup", up, true); + window.addEventListener("blur", cleanup); + cancelGesture = cleanup; + }, true); +} diff --git a/docs/releasing.md b/docs/releasing.md index f90c2ad..d669b51 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,16 +1,16 @@ # Release AgentOnWeb -## Terminal host 0.4.1 scope +## Terminal copy patch release -Terminal host 0.4.1 adds asynchronous daily npm version checks, private management-page reminders, opt-out, explicit `aow update`, and guarded `aow service restart` with running-version verification. This is a terminal-host-only publication; publish `terminal-v0.4.1`. See [CHANGELOG](../CHANGELOG.md). +Terminal host **0.4.2** enables direct mouse/trackpad text selection while TUIs capture the mouse, preserves app clicks and scrolling, and includes the checkbox setup and CLI output improvements merged since 0.4.1. Extension **0.4.1** includes acknowledged mode-click updates and the matching Safari package. Safari uses marketing version **0.4.1**, build **10**. DSH remains **0.4.0** and connector protocol remains **1**. -The terminal host uses **0.4.1**; the extension and DSH plugin remain **0.4.0**. Safari targets remain marketing version **0.4.0**, build **9**. `release-contract.json` records each artifact's independent version and filename. Connector protocol remains **1**. Terminal host 0.4.1 works with the 0.4.0 extension; no browser-store submission or DSH publication is needed for this update. The DSH compatibility baseline remains **0.1.7-rc.2**; the `dsh-latest` CI job checks the currently published upstream version. +The copy fix is bundled in the terminal host. Updating the browser extension alone does not install it. After finishing live shells, update the host with `aow update` from a separate macOS Terminal. The DSH compatibility baseline remains **0.1.7-rc.2**; `dsh-latest` checks the currently published upstream version. | Artifact | Version field | Delivery | | --- | --- | --- | -| Chrome/Firefox extension ZIPs and source | `extensionVersion` | `ext-v0.4.0`, then store submission | -| DSH plugin TGZ | `pluginVersion` | `dsh-v0.4.0`, npm + GitHub release | -| Terminal host TGZ + installer | `terminalHostVersion` | `terminal-v0.4.1`, npm + GitHub release | +| Chrome/Firefox extension ZIPs and source | `extensionVersion` | `ext-v0.4.1`, then store submission | +| DSH plugin TGZ | `pluginVersion` | Remains 0.4.0; no publication | +| Terminal host TGZ + installer | `terminalHostVersion` | `terminal-v0.4.2`, npm + GitHub release | A merged branch or GitHub release does not mean a browser store has approved the update. Live terminal acceptance currently covers macOS/Chrome. Firefox/Safari builds and Linux CI do not establish live terminal support on those platforms. Automatic setup targets macOS + Chrome only. @@ -52,19 +52,19 @@ The `dsh-latest` CI job installs `@deepseek-ai/dsh@latest`, builds the actual pl After the relevant release is public, users can run: ```sh -npm install -g @agentonweb/terminal-host@0.4.1 +npm install -g @agentonweb/terminal-host@0.4.2 # Repair setup or run it explicitly when lifecycle scripts were blocked: aow setup ``` -The release installer at `https://github.com/HeftyKoo/AgentOnWeb/releases/download/terminal-v0.4.1/install.sh` pins the package to 0.4.1 and explicitly calls setup. The user-facing command downloads `https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh`. Both that script and its pinned npm package must be live before announcing the installation flow. The installer uses the user-owned `~/.local` npm prefix, configures zsh/bash/fish startup paths, and links `aow` beside Node when that directory is writable and already on PATH. Existing AgentOnWeb npm links are updated; unrelated commands are preserved. The versioned release URL is a publication target, not proof that the asset exists. Download and inspect it before running it; do not advertise it until the release and clean-install test succeed. +The release installer at `https://github.com/HeftyKoo/AgentOnWeb/releases/download/terminal-v0.4.2/install.sh` pins the package to 0.4.2 and explicitly calls setup. The user-facing command downloads `https://raw.githubusercontent.com/HeftyKoo/AgentOnWeb/main/scripts/install.sh`. Both that script and its pinned npm package must be live before announcing the installation flow. The installer uses the user-owned `~/.local` npm prefix, configures zsh/bash/fish startup paths, and links `aow` beside Node when that directory is writable and already on PATH. Existing AgentOnWeb npm links are updated; unrelated commands are preserved. The versioned release URL is a publication target, not proof that the asset exists. Download and inspect it before running it; do not advertise it until the release and clean-install test succeed. ## Local installation and upgrades Before publishing, use the audited archive with the source-built extension: ```sh -AOW_SKIP_SETUP=1 npm install -g ./release/agentonweb-terminal-host-0.4.1.tgz +AOW_SKIP_SETUP=1 npm install -g ./release/agentonweb-terminal-host-0.4.2.tgz aow setup --extension-id YOUR_UNPACKED_CHROME_EXTENSION_ID ``` diff --git a/docs/verification/release-0.4.2.md b/docs/verification/release-0.4.2.md new file mode 100644 index 0000000..b2b8ab4 --- /dev/null +++ b/docs/verification/release-0.4.2.md @@ -0,0 +1,20 @@ +# Terminal host 0.4.2 and extension 0.4.1 + +Prepared October 20, 2026. Safari uses 0.4.1, build 10; DSH remains 0.4.0 +and connector protocol remains 1. See the changelog for changes since the +previous independent artifact releases. + +`pnpm release:audit` passed: 393 tests passed, one existing optional test skipped +across 57 test files. Privacy, architecture, TypeScript, Chrome/Firefox/Safari +builds, Safari native validation, reproducible archives and isolated terminal +package installation with real shell input/output passed. + +Direct text dragging, reverse Chinese selection, right-click copy preparation, +clipboard contents, TUI clicks/scrolling and word selection passed in real +Chrome. The detailed scope and reproducible runner are recorded in +[terminal copy verification](terminal-copy-2026-10-20.md). + +The copy fix requires terminal host 0.4.2. Updating only the browser extension +does not install the terminal frontend. Finish active shells before `aow update`. +Publication and each store's processed/review/public state must be verified +separately after merging; an audit or package upload alone is not publication. diff --git a/docs/verification/terminal-copy-2026-10-20.md b/docs/verification/terminal-copy-2026-10-20.md new file mode 100644 index 0000000..b14e9bf --- /dev/null +++ b/docs/verification/terminal-copy-2026-10-20.md @@ -0,0 +1,49 @@ +# Terminal mouse selection — 2026-10-20 + +## Behavior + +Mouse and trackpad drags select text without modifier keys, including when a +native TUI enables mouse reporting. Right-click prepares the selected text for +the browser's Copy menu item; existing copy shortcuts remain available. +Double/triple clicks use xterm's word/line selection. Single clicks and scrolling +continue to reach the TUI. Modified gestures retain xterm's existing behavior. + +Previously, xterm disabled ordinary selection during mouse reporting. On macOS, +its Option selection override was also disabled. The surface now delays a plain +left-button press until it can distinguish a click from a drag. Drags enter +xterm's own selection path; clicks forward the original press and release. +Scrollbar gestures bypass this handling. Pointer presses also cancel a pending +Option tap so selection cannot accidentally trigger the double-Option shortcut. + +## Validation + +- 67 focused tests passed across terminal input, selection, presentation, + extension content and native-surface wrappers. Both platform selection + branches, small trackpad movement, canceled gestures, modified gestures, + scrollbar interaction and Option-tap behavior are covered. +- TypeScript, the terminal-host build, architecture checks and `git diff --check` + passed. +- Real visible Chrome passed direct dragging, reverse Chinese selection, + right-click copy preparation and system clipboard contents with mouse + reporting off and modes 1000, 1002 and 1003 enabled. TUI click/release and wheel + reports, plus double-click word selection, also passed. The browser Copy + command is invoked with `document.execCommand("copy")` after right-click; + the OS context-menu item itself is not automated. +- The browser runner uses the actual built terminal UI and xterm, replacing + only HTTP/WebSocket host transport with a fixture. It uses an isolated browser + profile, restores the previous text clipboard and closes its browser/server. + +Reproduce after building the terminal surface: + +```sh +node scripts/check-terminal-copy-browser.mjs \ + --deps /path/to/prefix-containing-node_modules-playwright-core \ + --chromium '/path/to/Google Chrome for Testing' +``` + +## Delivery boundary + +This record covers the local fix and validation. The installed service and +extension were not replaced or restarted. Publication is tracked separately in +[the patch release record](release-0.4.2.md). Live Firefox/Safari/Windows/Linux +acceptance is not included. diff --git a/package.json b/package.json index 30b8786..327d2b2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "agentonweb", "description": "Your local terminal and DSH workspace on the website you are using.", - "version": "0.4.0", + "version": "0.4.1", "private": true, "license": "MIT", "type": "module", diff --git a/packages/terminal-host/README.md b/packages/terminal-host/README.md index 71e76fe..61caa65 100644 --- a/packages/terminal-host/README.md +++ b/packages/terminal-host/README.md @@ -32,6 +32,7 @@ For Pi 1.1+, run `pi` in the same terminal. Setup adds an observational extensio - **+** opens another independent local shell (up to eight). The selector switches between shells without stopping their processes. - **×** appears on tab hover or keyboard focus and closes that shell and its running process after an in-page confirmation. Cancel or Escape leaves it running. Typing `exit` ends that shell normally; **+** opens a new one. - Every connected view of the same terminal can type and paste immediately; input reaches the same shell and output is synchronized across views. Only the focused view controls terminal dimensions. Background views cannot resize the shell, and no manual takeover is needed. +- Drag with the mouse or trackpad to select terminal text, even while a TUI captures the mouse; right-click the selection and choose **Copy**. Double-click selects a word. You can also copy with **Command+C** on macOS or **Ctrl+Shift+C** on Windows/Linux. Single clicks and scrolling keep working in the TUI. - Tab dots show connected (green), connecting (amber), disconnected (red), or background/exited (gray). **Reconnect** appears only when disconnected; connecting shows a disabled spinning indicator. Reconnect and page refresh restore the same host-owned terminal. Shell directory, history, draft input and running commands remain in the process. Uncertain input is never resent. - Tabs follow the shell’s current directory until manually renamed. Long names are truncated with an ellipsis; hover to see the full current path. - Right-click a tab and choose **Rename**, double-click its name, or focus it and press **F2** to rename it. Names are shared across views through the existing WebSocket and retained for the life of that terminal session. Directory titles update after shell output; idle surfaces do not poll the terminal list. diff --git a/packages/terminal-host/package.json b/packages/terminal-host/package.json index b88e2ae..0abd2e5 100644 --- a/packages/terminal-host/package.json +++ b/packages/terminal-host/package.json @@ -1,6 +1,6 @@ { "name": "@agentonweb/terminal-host", - "version": "0.4.1", + "version": "0.4.2", "type": "module", "license": "MIT", "description": "Your local interactive shell in AgentOnWeb", diff --git a/release-contract.json b/release-contract.json index 6e34015..9ae778e 100644 --- a/release-contract.json +++ b/release-contract.json @@ -1,14 +1,14 @@ { - "extensionVersion": "0.4.0", + "extensionVersion": "0.4.1", "pluginVersion": "0.4.0", - "terminalHostVersion": "0.4.1", + "terminalHostVersion": "0.4.2", "dsh": "0.1.7-rc.2", "connectorProtocol": 1, "artifacts": { - "extension": "agentonweb-extension-0.4.0.zip", - "firefoxExtension": "agentonweb-extension-firefox-0.4.0.zip", - "extensionSource": "agentonweb-extension-source-0.4.0.zip", + "extension": "agentonweb-extension-0.4.1.zip", + "firefoxExtension": "agentonweb-extension-firefox-0.4.1.zip", + "extensionSource": "agentonweb-extension-source-0.4.1.zip", "dshPlugin": "agentonweb-dsh-surface-0.4.0.tgz", - "terminalHost": "agentonweb-terminal-host-0.4.1.tgz" + "terminalHost": "agentonweb-terminal-host-0.4.2.tgz" } } diff --git a/scripts/check-terminal-copy-browser.mjs b/scripts/check-terminal-copy-browser.mjs new file mode 100644 index 0000000..419bfb4 --- /dev/null +++ b/scripts/check-terminal-copy-browser.mjs @@ -0,0 +1,116 @@ +// Real xterm and browser clipboard, with only the host transport replaced. +// Build the terminal surface first. The browser/profile are isolated. +import assert from "node:assert/strict"; +import { createServer } from "node:http"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parseArgs } from "node:util"; + +const { values } = parseArgs({ options: { deps: { type: "string" }, chromium: { type: "string" } } }); +assert.ok(values.deps && values.chromium, "Required: --deps --chromium "); +const { chromium } = await import(pathToFileURL(resolve(values.deps, "node_modules/playwright-core/index.mjs"))); +const root = new URL("../apps/terminal-surface/dist/", import.meta.url); +const server = createServer(async (request, response) => { + const path = new URL(request.url, "http://localhost").pathname; + if (path === "/api/terminals" || path === "/ticket") { + response.setHeader("content-type", "application/json"); + response.end(JSON.stringify(path === "/api/terminals" ? [{ id: "one", name: "Copy fixture" }] : { token: "fixture" })); + return; + } + const file = { "/": "index.html", "/main.js": "main.js", "/main.css": "main.css" }[path]; + if (!file) { response.statusCode = 404; response.end(); return; } + response.setHeader("content-type", file.endsWith(".js") ? "text/javascript" : file.endsWith(".css") ? "text/css" : "text/html"); + response.end(await readFile(new URL(file, root))); +}); +await new Promise(done => server.listen(0, "127.0.0.1", done)); +let browser; +let originalClipboard; +let page; +const copyText = text => { + const area = document.createElement("textarea"); + area.value = text; + document.body.append(area); + area.select(); + const copied = document.execCommand("copy"); + area.remove(); + return copied; +}; +try { + browser = await chromium.launch({ executablePath: values.chromium, headless: false }); + const context = await browser.newContext({ permissions: ["clipboard-read"], viewport: { width: 1000, height: 600 } }); + page = await context.newPage(); + await page.addInitScript(() => { + window.copyInputs = []; + window.WebSocket = class { + static OPEN = 1; + readyState = 1; + constructor() { + window.copySocket = this; + setTimeout(() => { + this.emit({ type: "snapshot", epoch: "one", cols: 100, rows: 30, data: "COPY_ME_123\r\n中文测试\r\n" }); + setTimeout(() => this.emit({ type: "control", epoch: "one", active: true, lease: 1, resizeOwner: true }), 50); + }, 50); + } + emit(message) { this.onmessage?.({ data: JSON.stringify(message) }); } + send(data) { + const message = JSON.parse(data); + if (message.type === "input") window.copyInputs.push(message.data); + } + close() { this.readyState = 3; } + }; + }); + await page.goto(`http://127.0.0.1:${server.address().port}/`); + await page.getByText("COPY_ME_123", { exact: true }).waitFor(); + originalClipboard = await page.evaluate(() => navigator.clipboard.readText()); + const screen = await page.locator(".xterm-screen").boundingBox(); + const row = await page.locator(".xterm-rows > div").first().boundingBox(); + const cellWidth = screen.width / 100; + const point = (column, line = 0) => ({ x: screen.x + cellWidth * column, y: row.y + row.height * (line + 0.5) }); + const inputs = () => page.evaluate(() => window.copyInputs); + const clearInputs = () => page.evaluate(() => { window.copyInputs = []; }); + const output = data => page.evaluate(data => window.copySocket.emit({ type: "output", epoch: "one", sequence: 1, data }), data); + async function drag(start, end) { + await page.mouse.move(start.x, start.y); + await page.mouse.down(); + await page.mouse.move(end.x, end.y, { steps: 10 }); + await page.mouse.up(); + } + async function copySelection(expected, position = point(2)) { + await page.mouse.click(position.x, position.y, { button: "right" }); + const prepared = await page.locator(".xterm-helper-textarea").evaluate(area => area.value.slice(area.selectionStart, area.selectionEnd)); + assert.equal(prepared, expected, "right-click prepares the selection for the browser Copy menu item"); + // The browser's Copy menu command dispatches the same native copy event. + assert.equal(await page.evaluate(() => document.execCommand("copy")), true); + assert.equal(await page.evaluate(() => navigator.clipboard.readText()), expected); + await page.keyboard.press("Escape"); + } + for (const mode of [0, 1000, 1002, 1003]) { + await output("\x1b[?1000l\x1b[?1002l\x1b[?1003l" + (mode ? `\x1b[?${mode}h\x1b[?1006h` : "")); + await page.waitForFunction(mode => document.querySelector(".xterm").classList.contains("enable-mouse-events") === Boolean(mode), mode); + await clearInputs(); + await drag(point(0.2), point(11.3)); + // Any-event tracking legitimately sends hover reports before the drag. + assert.equal((await inputs()).some(data => /\x1b\[<0;/u.test(data)), false, "text drags send no left-button press to the app"); + await copySelection("COPY_ME_123"); + await drag(point(8.3, 1), point(0.2, 1)); + await copySelection("中文测试", point(2, 1)); + if (mode) { + await clearInputs(); + await page.mouse.click(point(30).x, point(30).y); + assert.ok((await inputs()).some(data => data.startsWith("\x1b[<0;")), "a single click still reaches the app"); + assert.ok((await inputs()).some(data => data.endsWith("m")), "the app receives the matching release"); + await clearInputs(); + await page.mouse.wheel(0, 100); + await page.waitForFunction(() => window.copyInputs.some(data => data.startsWith("\x1b[<65;"))); + } + console.log(`PASS: mouse mode ${mode || "off"}: direct drag, reverse Unicode selection, right-click copy${mode ? ", app click and scroll" : ""}`); + } + await page.mouse.dblclick(point(2).x, point(2).y); + await copySelection("COPY_ME_123"); + console.log("PASS: double-click selects a word while mouse reporting is active"); +} finally { + if (originalClipboard !== undefined) await page?.evaluate(copyText, originalClipboard).catch(() => {}); + await browser?.close(); + await new Promise(done => server.close(done)); +} diff --git a/scripts/install.sh b/scripts/install.sh index 9b93883..2f9a8ba 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -23,7 +23,7 @@ install_agentonweb() { aow_node_bin="$(dirname "$(command -v node)")" export PATH="$aow_prefix/bin:$PATH" echo 'Installing AgentOnWeb terminal…' - AOW_SKIP_SETUP=1 npm install --global --prefix "$aow_prefix" --foreground-scripts --prefer-online --no-audit --no-fund '@agentonweb/terminal-host@0.4.1'