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
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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*"]

Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentonweb/extension",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
16 changes: 8 additions & 8 deletions apps/safari/AgentOnWeb/AgentOnWeb.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -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,
Expand All @@ -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;
Expand All @@ -553,7 +553,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.4.0;
MARKETING_VERSION = 0.4.1;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
Expand All @@ -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;
Expand All @@ -594,7 +594,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 0.4.0;
MARKETING_VERSION = 0.4.1;
OTHER_LDFLAGS = (
"-framework",
SafariServices,
Expand Down
6 changes: 3 additions & 3 deletions apps/safari/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion apps/terminal-surface/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@agentonweb/terminal-surface",
"version": "0.4.0",
"version": "0.4.1",
"private": true,
"type": "module",
"scripts": {
Expand Down
19 changes: 19 additions & 0 deletions apps/terminal-surface/src/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const terminalMock = vi.hoisted(() => ({
vi.mock("@xterm/xterm", () => ({
Terminal: class {
options: any = {};
modes = { mouseTrackingMode: "none" };
parser = { registerCsiHandler() {}, registerOscHandler() {} };
loadAddon() {}
open() {}
Expand Down Expand Up @@ -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]);
Expand Down
6 changes: 6 additions & 0 deletions apps/terminal-surface/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<HTMLElement>("#terminal")!;
container.title = "Drag to select text · Right-click to copy";
const status = document.querySelector<HTMLElement>("#status")!;
const notice = document.querySelector<HTMLElement>("#connection-notice")!;

Expand All @@ -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<HTMLElement>("#sessions")!;
const newButton = document.querySelector<HTMLButtonElement>("#new")!;
Expand Down Expand Up @@ -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(
Expand Down
72 changes: 72 additions & 0 deletions apps/terminal-surface/src/mouse-selection.test.ts
Original file line number Diff line number Diff line change
@@ -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('<main><div class="xterm-screen"><span>Terminal</span></div><div class="xterm-viewport"></div></main>');
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]]);
});
65 changes: 65 additions & 0 deletions apps/terminal-surface/src/mouse-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { isMacPlatform } from "@agentonweb/connector-contract";
import type { Terminal } from "@xterm/xterm";

type SelectionTerminal = Pick<Terminal, "focus"> & { readonly modes: Pick<Terminal["modes"], "mouseTrackingMode"> };

/** 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);
}
Loading
Loading