diff --git a/README.md b/README.md
index 3ca4220..85daa75 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,37 @@
-# canopy-web
-
-The web side of Canopy, one repository with one app per subdomain:
-
-- `apps/graphs/` — the graphs catalogue at graphs.univerlab.org: browse,
- share and install graph and node designs.
-- `apps/remote/` — Canopy remote control at remote.univerlab.org: reach your
- canopy sessions from a browser, peer to peer.
+
+
+
+
+
+
+
+
+
+
+
+
+ graphs.univerlab.org ·
+ remote.univerlab.org ·
+ Canopy ·
+ Docs
+
+
+The web side of [Canopy](https://github.com/UniverLab/harness-canopy), the graph
+engine for AI coding agents: one repository with one app per subdomain, each its
+own Cloudflare Worker with its own secrets and origin.
+
+| App | Where | What it does |
+| --- | --- | --- |
+| [Graphs](apps/graphs/README.md) | [graphs.univerlab.org](https://graphs.univerlab.org) | Browse, share and install graph and node designs, each with its plate, its README and every prompt in full. |
+| [Remote](apps/remote/README.md) | [remote.univerlab.org](https://remote.univerlab.org) | Reach your Canopy sessions from any browser, over an encrypted peer-to-peer link: drive a terminal, compose or schedule a prompt, copy its text, watch your graphs, invite a guest. |
+
+```sh
+curl -fsSL https://raw.githubusercontent.com/UniverLab/harness-canopy/main/scripts/install.sh | sh
+canopy remote login # sign in with GitHub
+canopy remote pair # a code and a QR for remote.univerlab.org
+```
-Each app is its own Cloudflare Worker with its own secrets and origin.
+---
## Graphs catalogue
diff --git a/apps/remote/README.md b/apps/remote/README.md
index fb8091e..d5b898e 100644
--- a/apps/remote/README.md
+++ b/apps/remote/README.md
@@ -6,6 +6,22 @@ or watch its graphs — over an encrypted peer-to-peer link. `web/` is the
browser app, `worker/` the blind signaling relay that introduces the two
ends (see its own README).
+## Working in a session
+
+The key bar under the terminal carries what a phone keyboard lacks. Ctrl and
+Shift are one-shot modifiers: a tap arms the key and opens the keyboard, the
+next key goes out with it, and a second tap disarms it.
+
+**Copy** opens the terminal's text in a sheet where the phone's own long-press
+selection works, with Copy selection and Copy all. With a mouse, drag over the
+terminal and use the floating Copy button or `Ctrl+Shift+C`; plain `Ctrl+C`
+always stays an interrupt for the session.
+
+The **Prompt Builder** sends now or later: Schedule picks a date and time, and
+the sheet lists the session's pending schedules, each with a Cancel. The host
+delivers a schedule even with no browser open; it needs the session still
+running at that time. Terminals take no schedules, only agent sessions do.
+
## Watching graphs
The home screen switches between **Sessions** and **Graphs**. Graphs lists
diff --git a/apps/remote/web/playwright.config.ts b/apps/remote/web/playwright.config.ts
index 36f4cc3..afaa5f4 100644
--- a/apps/remote/web/playwright.config.ts
+++ b/apps/remote/web/playwright.config.ts
@@ -11,7 +11,7 @@ const port = e2ePort(5199, import.meta.dirname, "E2E_PORT_SMOKE");
export default defineConfig({
testDir: "./test",
- testMatch: ["smoke.spec.ts", "terminal-autofit.spec.ts", "terminal-alt-screen.spec.ts", "terminal-dpr1.spec.ts", "terminal-touch-scroll.spec.ts", "graphs.spec.ts", "editor.spec.ts", "signout.spec.ts", "csp.spec.ts", "metrics.spec.ts", "guest.spec.ts", "footer.spec.ts", "fondo.spec.ts", "touch-targets.spec.ts", "account-chip.spec.ts", "upload.spec.ts", "control-size.spec.ts"],
+ testMatch: ["smoke.spec.ts", "terminal-autofit.spec.ts", "terminal-alt-screen.spec.ts", "terminal-dpr1.spec.ts", "terminal-touch-scroll.spec.ts", "graphs.spec.ts", "editor.spec.ts", "signout.spec.ts", "csp.spec.ts", "metrics.spec.ts", "guest.spec.ts", "footer.spec.ts", "fondo.spec.ts", "touch-targets.spec.ts", "account-chip.spec.ts", "upload.spec.ts", "control-size.spec.ts", "schedule.spec.ts", "latch-keyboard.spec.ts", "copy.spec.ts"],
timeout: 60_000,
use: {
baseURL: `http://127.0.0.1:${port}`,
diff --git a/apps/remote/web/src/app.ts b/apps/remote/web/src/app.ts
index d39e02c..6f2d36c 100644
--- a/apps/remote/web/src/app.ts
+++ b/apps/remote/web/src/app.ts
@@ -18,15 +18,21 @@ import { icon, mountMorphSlot, type RemoteIconName, type MorphSlot } from "./ico
import { renderKeyBar, updateKeyBarLatches } from "./keys.js";
import { MetricsRoute } from "./metrics-route.js";
import type { MetricsStore } from "./metrics-store.js";
-import { keyBytesWithModifiers, ModifierLatches, textBytesWithModifiers } from "./modifiers.js";
+import {
+ isTerminalReport,
+ keyBytesWithModifiers,
+ ModifierLatches,
+ textBytesWithModifiers,
+} from "./modifiers.js";
import { NewDialog } from "./new-dialog.js";
import { defaultDeviceLabel } from "./pairing.js";
-import type { PromptDraft, SpawnRequest } from "./protocol.js";
+import type { PromptDraft, ScheduledItem, SpawnRequest } from "./protocol.js";
import type { OnlineHost } from "./relay.js";
import type { ShelfHandle } from "./shelf-controller.js";
import { convergeOpenSessionName, type SessionStore } from "./sessions.js";
import { createXtermHandle, TerminalView } from "./terminal.js";
import type { TerminalFactory } from "./terminal.js";
+import { TermCopySheet, termCopyFloatHtml, wireCopyFloat } from "./term-copy.js";
import {
latestHidden,
termControlsHtml,
@@ -68,6 +74,9 @@ export interface ShellCallbacks {
onSpawn: (spawn: SpawnRequest, req: string) => boolean;
onCompose: (draft: PromptDraft, req: string) => boolean;
onSendDraft: (draft: PromptDraft, req: string) => boolean;
+ onSchedule: (draft: PromptDraft, fireAt: string, req: string) => boolean;
+ onScheduledList: (req: string) => boolean;
+ onScheduledCancel: (scheduleId: string, req: string) => boolean;
/** Ask the host for the graphs list (the Graphs tab shows and polls it). */
onGraphsRequest: () => void;
/** Start watching a graph: the host pushes graph_state until unwatched. */
@@ -105,6 +114,8 @@ export interface Shell {
showSessionExited: (code: number) => void;
sheetSpawned: (req: string, id: string) => void;
sheetComposed: (req: string, text: string) => void;
+ sheetScheduled: (req: string, item: ScheduledItem) => void;
+ sheetScheduledList: (req: string, items: ScheduledItem[]) => void;
sheetOk: (req: string) => void;
sheetError: (message: string, req: string | null, code: string) => void;
dispose: () => void;
@@ -160,6 +171,7 @@ export function createShell(
let sessionRows = 0;
let terminalView: TerminalView | null = null;
let latestButton: HTMLButtonElement | null = null;
+ let copyFloatDispose: (() => void) | null = null;
let termControls: TermControls | null = null;
let uploadView: UploadView | null = null;
// The two state-telling slots: the host chip's link icon and the Files
@@ -183,10 +195,15 @@ export function createShell(
const builder = new Builder(catalog, {
onCompose: (draft, req) => callbacks.onCompose(draft, req),
onSend: (draft, req) => callbacks.onSendDraft(draft, req),
+ onSchedule: (draft, fireAt, req) => callbacks.onSchedule(draft, fireAt, req),
+ onScheduledList: (req) => callbacks.onScheduledList(req),
+ onScheduledCancel: (scheduleId, req) => callbacks.onScheduledCancel(scheduleId, req),
});
+ const termCopy = new TermCopySheet();
const closeSheets = () => {
newDialog.close();
builder.close();
+ termCopy.close();
shelf?.close();
};
@@ -292,9 +309,19 @@ export function createShell(
return;
}
newDialog.close();
+ termCopy.close();
builder.open(root);
};
+ const openCopy = () => {
+ if (route !== "session") {
+ return;
+ }
+ newDialog.close();
+ builder.close();
+ termCopy.open(root, { bufferText: (maxLines) => terminalView?.bufferText(maxLines) ?? "" });
+ };
+
// The local terminal takes a new grid in place (no remount, the
// scrollback stays), and the shell remembers it so a snapshot at that
// size later does not remount either.
@@ -311,6 +338,8 @@ export function createShell(
const teardownSessionWidgets = () => {
termControls?.dispose();
termControls = null;
+ copyFloatDispose?.();
+ copyFloatDispose = null;
terminalView?.dispose();
terminalView = null;
latestButton = null;
@@ -321,6 +350,26 @@ export function createShell(
sessionRows = 0;
};
+ // Arming a latch focuses the terminal in the same tap: a phone only
+ // opens its keyboard inside the user gesture. A terminal still behind
+ // the font gate focuses once it opens, as a pane tap does; releasing a
+ // latch never touches focus. Harmless with a mouse too.
+ const focusTerminalForLatch = (): void => {
+ const view = terminalView;
+ if (!view) {
+ return;
+ }
+ if (view.fitted) {
+ view.focus();
+ return;
+ }
+ void view.ready().then(() => {
+ if (terminalView === view) {
+ view.focus();
+ }
+ });
+ };
+
const renderSession = () => {
teardownSessionWidgets();
latches.release();
@@ -344,7 +393,7 @@ export function createShell(
${termLatestHtml()}${termCopyFloatHtml()}
${termControlsHtml()}
@@ -371,6 +420,7 @@ export function createShell(
onControlSize: (cols, rows) => callbacks.onControlSize(cols, rows),
onLocalSize: (cols, rows) => resizeLocalTerminal(cols, rows),
onBuilder: () => openBuilder(),
+ onCopy: () => openCopy(),
});
latestButton = termControls.latestButton;
// In fullscreen the latest button follows the pane's own scroll (see
@@ -388,16 +438,23 @@ export function createShell(
if (bar) {
renderKeyBar(bar, (def) => {
// A latch toggles and shows pressed; a second tap releases it
- // without sending. Any other key goes out with the latch applied,
- // then the latch releases.
+ // without sending. Arming also focuses the terminal, in the same
+ // tap, so the phone keyboard opens. Any other key goes out with
+ // the latch applied, then the latch releases.
if (def.id === "ctrl") {
- latches.toggleCtrl();
+ const armed = latches.toggleCtrl();
updateKeyBarLatches(bar, latches.snapshot());
+ if (armed) {
+ focusTerminalForLatch();
+ }
return;
}
if (def.id === "shift") {
- latches.toggleShift();
+ const armed = latches.toggleShift();
updateKeyBarLatches(bar, latches.snapshot());
+ if (armed) {
+ focusTerminalForLatch();
+ }
return;
}
const bytes = keyBytesWithModifiers(def.id, latches.snapshot());
@@ -724,8 +781,16 @@ export function createShell(
terminalFactory,
{
// Phone-keyboard keys honour the latches too: Ctrl+c sends 0x03,
- // then the latch releases and the bar shows it.
+ // then the latch releases and the bar shows it. xterm's own
+ // reports (focus, cursor position, device attributes, mouse)
+ // are not typing: they go out unchanged, leaving the latch
+ // armed and the bar untouched — otherwise the focus-in report
+ // from tapping the terminal would spend the latch first.
onUserInput: (text) => {
+ if (isTerminalReport(text)) {
+ callbacks.onTerminalInput(new TextEncoder().encode(text));
+ return;
+ }
const bytes = textBytesWithModifiers(text, latches.snapshot());
if (latches.hasLatch) {
latches.release();
@@ -742,6 +807,13 @@ export function createShell(
},
);
terminalView.mount(pane, cols, rows);
+ // The selection float follows this mount: a remount rewires it.
+ copyFloatDispose?.();
+ copyFloatDispose = null;
+ const floatButton = root.querySelector("#term-copy-float");
+ if (floatButton) {
+ copyFloatDispose = wireCopyFloat(floatButton, terminalView);
+ }
// A fine pointer types straight into the session: focus once on
// attach, after xterm is open. A touch device never auto-opens its
// keyboard — the first tap on the pane focuses instead.
@@ -795,6 +867,12 @@ export function createShell(
sheetComposed(req: string, text: string): void {
builder.handleComposed(req, text);
},
+ sheetScheduled(req: string, item: ScheduledItem): void {
+ builder.handleScheduled(req, item);
+ },
+ sheetScheduledList(req: string, items: ScheduledItem[]): void {
+ builder.handleScheduledList(req, items);
+ },
sheetOk(req: string): void {
builder.handleOk(req);
},
diff --git a/apps/remote/web/src/builder.ts b/apps/remote/web/src/builder.ts
index 9fba9a8..86e77a3 100644
--- a/apps/remote/web/src/builder.ts
+++ b/apps/remote/web/src/builder.ts
@@ -32,13 +32,18 @@ import {
} from "./draft.js";
import type { DraftStorage, SectionKind } from "./draft.js";
import { newReqId } from "./protocol.js";
-import type { PromptDraft } from "./protocol.js";
+import type { PromptDraft, ScheduledItem } from "./protocol.js";
import { Sheet, esc } from "./sheet.js";
export interface BuilderCallbacks {
/** Returns false when the frame could not be sent (no connection). */
onCompose: (draft: PromptDraft, req: string) => boolean;
onSend: (draft: PromptDraft, req: string) => boolean;
+ /** Schedule the draft for fireAt (RFC 3339 UTC) on the open session. */
+ onSchedule: (draft: PromptDraft, fireAt: string, req: string) => boolean;
+ /** List the open session's pending schedules; the shell knows the id. */
+ onScheduledList: (req: string) => boolean;
+ onScheduledCancel: (scheduleId: string, req: string) => boolean;
}
interface BuilderState {
@@ -50,6 +55,13 @@ interface BuilderState {
projectError: string | null;
previewReq: string | null;
sendReq: string | null;
+ scheduleRow: boolean;
+ scheduleReq: string | null;
+ listReq: string | null;
+ cancelReq: { req: string; scheduleId: string } | null;
+ /** The session's pending schedules; null until the first list lands. */
+ scheduled: ScheduledItem[] | null;
+ scheduleStatus: string | null;
}
function freshState(): BuilderState {
@@ -62,9 +74,28 @@ function freshState(): BuilderState {
projectError: null,
previewReq: null,
sendReq: null,
+ scheduleRow: false,
+ scheduleReq: null,
+ listReq: null,
+ cancelReq: null,
+ scheduled: null,
+ scheduleStatus: null,
};
}
+// A datetime-local value in the device's own time zone: YYYY-MM-DDTHH:MM.
+function toLocalInputValue(date: Date): string {
+ const pad = (n: number): string => String(n).padStart(2, "0");
+ return (
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}`
+ );
+}
+
+function formatFireAt(fireAt: string): string {
+ return new Date(fireAt).toLocaleString();
+}
+
export class Builder {
private readonly catalog: CatalogStore;
private readonly callbacks: BuilderCallbacks;
@@ -115,12 +146,16 @@ export class Builder {
this.render();
}
});
+ this.requestScheduledList();
}
// Closing keeps the draft: memory and sessionStorage both still hold it.
close(): void {
this.state.previewReq = null;
this.state.sendReq = null;
+ this.state.scheduleReq = null;
+ this.state.listReq = null;
+ this.state.cancelReq = null;
this.unsubscribe?.();
this.unsubscribe = null;
this.body = null;
@@ -142,21 +177,66 @@ export class Builder {
}
handleOk(req: string): boolean {
- if (this.state.sendReq === null || this.state.sendReq !== req) {
+ if (this.state.sendReq !== null && this.state.sendReq === req) {
+ this.state.sendReq = null;
+ this.state.draft = emptyDraft();
+ clearDraft(this.storage);
+ this.state.preview = null;
+ this.close();
+ return true;
+ }
+ // A cancel answers the same ok: drop the row, then refresh the list.
+ if (this.state.cancelReq !== null && this.state.cancelReq.req === req) {
+ const scheduleId = this.state.cancelReq.scheduleId;
+ this.state.cancelReq = null;
+ if (this.state.scheduled !== null) {
+ this.state.scheduled = this.state.scheduled.filter((item) => item.id !== scheduleId);
+ }
+ this.render();
+ this.requestScheduledList();
+ return true;
+ }
+ return false;
+ }
+
+ // The host accepted the schedule: clear the draft like a Send, keep the
+ // sheet open with a fresh default section, and refresh the list.
+ handleScheduled(req: string, item: ScheduledItem): boolean {
+ if (this.state.scheduleReq === null || this.state.scheduleReq !== req) {
return false;
}
- this.state.sendReq = null;
- this.state.draft = emptyDraft();
+ this.state.scheduleReq = null;
+ this.state.draft = addSection(emptyDraft(), "instruction");
clearDraft(this.storage);
this.state.preview = null;
- this.close();
+ this.state.scheduleRow = false;
+ this.state.scheduleStatus = `Scheduled for ${formatFireAt(item.fireAt)}`;
+ this.render();
+ this.requestScheduledList();
+ return true;
+ }
+
+ handleScheduledList(req: string, items: ScheduledItem[]): boolean {
+ if (this.state.listReq === null || this.state.listReq !== req) {
+ return false;
+ }
+ this.state.listReq = null;
+ this.state.scheduled = [...items].sort(
+ (a, b) => Date.parse(a.fireAt) - Date.parse(b.fireAt),
+ );
+ this.render();
return true;
}
handleError(message: string, req: string | null, code: string): boolean {
const previewMatch = this.state.previewReq !== null && (req === null || this.state.previewReq === req);
const sendMatch = this.state.sendReq !== null && (req === null || this.state.sendReq === req);
- if (!previewMatch && !sendMatch) {
+ const scheduleMatch =
+ this.state.scheduleReq !== null && (req === null || this.state.scheduleReq === req);
+ const listMatch = this.state.listReq !== null && (req === null || this.state.listReq === req);
+ const cancelMatch =
+ this.state.cancelReq !== null && (req === null || this.state.cancelReq.req === req);
+ if (!previewMatch && !sendMatch && !scheduleMatch && !listMatch && !cancelMatch) {
return false;
}
if (previewMatch) {
@@ -165,6 +245,17 @@ export class Builder {
if (sendMatch) {
this.state.sendReq = null;
}
+ // A schedule, list or cancel failure keeps the draft and the rows: the
+ // message lands on the sheet's own error line, even from an old host
+ // that answers every new frame with unknown-frame.
+ if (scheduleMatch || listMatch || cancelMatch) {
+ this.state.scheduleReq = scheduleMatch ? null : this.state.scheduleReq;
+ this.state.listReq = listMatch ? null : this.state.listReq;
+ this.state.cancelReq = cancelMatch ? null : this.state.cancelReq;
+ this.state.error = message;
+ this.renderErrors();
+ return true;
+ }
const classified = classifyDraftError(code, message);
if (classified.field === "preset") {
this.state.presetError = classified.message;
@@ -200,6 +291,7 @@ export class Builder {
+ ${this.scheduledHtml()}
Preset
${this.presetOptions()}
@@ -218,8 +310,11 @@ export class Builder {
${esc(this.state.preview ?? "")}
Preview
+ Schedule
Send
+ ${this.scheduleRowHtml()}
+ ${esc(this.state.scheduleStatus ?? "")}
`;
this.wire();
this.syncSelects();
@@ -271,6 +366,47 @@ export class Builder {
.join("");
}
+ // The session's pending schedules, once the list has loaded with
+ // items. Previews are user text: always escaped, never innerHTML raw.
+ private scheduledHtml(): string {
+ const scheduled = this.state.scheduled;
+ if (scheduled === null || scheduled.length === 0) {
+ return "";
+ }
+ const rows = scheduled
+ .map(
+ (item) => `
+
+ ${esc(formatFireAt(item.fireAt))}
+ ${esc(item.preview)}
+ Cancel
+ `,
+ )
+ .join("");
+ return `
+
+ Scheduled for this session
+
+ `;
+ }
+
+ // The inline scheduling row: preset to one hour from now, never earlier
+ // than the current minute.
+ private scheduleRowHtml(): string {
+ const now = new Date();
+ const preset = toLocalInputValue(new Date(now.getTime() + 60 * 60 * 1000));
+ return `
+
+
When
+
+
+
+ Confirm schedule
+ Cancel
+
+
`;
+ }
+
private presetOptions(): string {
const presets = this.catalog.presets;
const selected = this.state.draft.preset ?? "";
@@ -430,6 +566,19 @@ export class Builder {
});
body.querySelector("#builder-preview")?.addEventListener("click", () => this.preview());
body.querySelector("#builder-send")?.addEventListener("click", () => this.send());
+ body.querySelector("#builder-schedule")?.addEventListener("click", () => {
+ this.state.scheduleRow = true;
+ this.render();
+ body.querySelector("#builder-schedule-at")?.focus();
+ });
+ body.querySelector("#builder-schedule-dismiss")?.addEventListener("click", () => {
+ this.state.scheduleRow = false;
+ this.render();
+ });
+ body.querySelector("#builder-schedule-confirm")?.addEventListener("click", () => this.confirmSchedule());
+ for (const button of body.querySelectorAll("button[data-schedule-id]")) {
+ button.addEventListener("click", () => this.cancelScheduled(button.dataset.scheduleId ?? ""));
+ }
}
// The composed text describes the draft it came from; any edit makes it
@@ -468,6 +617,58 @@ export class Builder {
}
}
+ // Confirm schedules the wire draft for the row's local time, sent as
+ // UTC. An empty, unreadable or past value is the sheet's plain error
+ // and sends nothing.
+ private confirmSchedule(): void {
+ this.clearErrors();
+ const input = this.body?.querySelector("#builder-schedule-at");
+ const value = input?.value ?? "";
+ const at = value === "" ? Number.NaN : new Date(value).getTime();
+ if (value === "") {
+ this.state.error = "Choose a date and time first.";
+ this.renderErrors();
+ return;
+ }
+ if (Number.isNaN(at) || at <= Date.now()) {
+ this.state.error = "Pick a time in the future.";
+ this.renderErrors();
+ return;
+ }
+ const req = newReqId();
+ this.state.scheduleReq = req;
+ if (!this.callbacks.onSchedule(wireDraft(this.state.draft), new Date(value).toISOString(), req)) {
+ this.state.scheduleReq = null;
+ this.state.error = "Not connected";
+ this.renderErrors();
+ }
+ }
+
+ private cancelScheduled(scheduleId: string): void {
+ if (scheduleId === "") {
+ return;
+ }
+ this.clearErrors();
+ const req = newReqId();
+ this.state.cancelReq = { req, scheduleId };
+ if (!this.callbacks.onScheduledCancel(scheduleId, req)) {
+ this.state.cancelReq = null;
+ this.state.error = "Not connected";
+ this.renderErrors();
+ }
+ }
+
+ // No polling: the list loads on open and after each schedule or cancel.
+ private requestScheduledList(): void {
+ const req = newReqId();
+ this.state.listReq = req;
+ if (!this.callbacks.onScheduledList(req)) {
+ this.state.listReq = null;
+ this.state.error = "Not connected";
+ this.renderErrors();
+ }
+ }
+
private clearErrors(): void {
this.state.error = null;
this.state.presetError = null;
diff --git a/apps/remote/web/src/fake-host.ts b/apps/remote/web/src/fake-host.ts
index 8140e7d..4c75dd7 100644
--- a/apps/remote/web/src/fake-host.ts
+++ b/apps/remote/web/src/fake-host.ts
@@ -137,6 +137,10 @@ function main(): void {
};
let nextSpawn = 0;
let promptError: { code: string; message: string } | null = null;
+ // The pending schedules per session: the preview is the composed text's
+ // first 120 chars, like the real host's.
+ const scheduled: { id: string; sessionId: string; fire_at: string; preview: string }[] = [];
+ let scheduleCounter = 0;
const sessions: SessionItem[] = [
{
@@ -402,6 +406,8 @@ function main(): void {
data?: string;
cols?: number;
rows?: number;
+ fire_at?: string;
+ schedule_id?: string;
};
sent.push(frame);
if (frame.t === "list") {
@@ -563,6 +569,41 @@ function main(): void {
} else {
deliver({ t: "ok", req: frame.req });
}
+ } else if (
+ frame.t === "prompt_schedule" &&
+ typeof frame.req === "string" &&
+ typeof frame.id === "string" &&
+ typeof frame.fire_at === "string"
+ ) {
+ scheduleCounter += 1;
+ const item = {
+ id: `ss-${scheduleCounter}`,
+ fire_at: frame.fire_at,
+ preview: composeText(frame.draft ?? { sections: [], skills: [] }).slice(0, 120),
+ };
+ scheduled.push({ ...item, sessionId: frame.id });
+ deliver({ t: "scheduled", req: frame.req, item });
+ } else if (frame.t === "scheduled_list" && typeof frame.req === "string" && typeof frame.id === "string") {
+ const items = scheduled
+ .filter((entry) => entry.sessionId === frame.id)
+ .sort((a, b) => Date.parse(a.fire_at) - Date.parse(b.fire_at))
+ .map((entry) => ({ id: entry.id, fire_at: entry.fire_at, preview: entry.preview }));
+ deliver({ t: "scheduled_list", req: frame.req, id: frame.id, items });
+ } else if (
+ frame.t === "scheduled_cancel" &&
+ typeof frame.req === "string" &&
+ typeof frame.id === "string" &&
+ typeof frame.schedule_id === "string"
+ ) {
+ const index = scheduled.findIndex(
+ (entry) => entry.sessionId === frame.id && entry.id === frame.schedule_id,
+ );
+ if (index < 0) {
+ deliver({ t: "error", req: frame.req, code: "not_found", message: "no such schedule" });
+ } else {
+ scheduled.splice(index, 1);
+ deliver({ t: "ok", req: frame.req });
+ }
}
return true;
};
@@ -681,6 +722,9 @@ function main(): void {
onSpawn: (spawn, req) => send(buildSpawn(req, spawn)),
onCompose: (draft, req) => send(JSON.stringify({ t: "compose", req, draft })),
onSendDraft: (draft, req) => controller.sendPromptDraft(draft, req),
+ onSchedule: (draft, fireAt, req) => controller.sendPromptSchedule(draft, fireAt, req),
+ onScheduledList: (req) => controller.sendScheduledList(req),
+ onScheduledCancel: (scheduleId, req) => controller.sendScheduledCancel(scheduleId, req),
onGraphsRequest: () => {
graphs.requestList();
},
diff --git a/apps/remote/web/src/guest-views.ts b/apps/remote/web/src/guest-views.ts
index 593c711..88a90c5 100644
--- a/apps/remote/web/src/guest-views.ts
+++ b/apps/remote/web/src/guest-views.ts
@@ -13,9 +13,16 @@ import { renderTopbar, wireAccount } from "./chrome.js";
import { rememberGuestReturn } from "./entry-path.js";
import { syncHeaderFondo } from "./fondo.js";
import { REMOTE_FOOTER } from "./footer.js";
+import { icon } from "./icons-dom.js";
import { renderKeyBar, updateKeyBarLatches } from "./keys.js";
-import { keyBytesWithModifiers, ModifierLatches, textBytesWithModifiers } from "./modifiers.js";
+import {
+ isTerminalReport,
+ keyBytesWithModifiers,
+ ModifierLatches,
+ textBytesWithModifiers,
+} from "./modifiers.js";
import { SessionStore, renderSessions } from "./sessions.js";
+import { TermCopySheet, termCopyFloatHtml, wireCopyFloat } from "./term-copy.js";
import { TerminalView, createXtermHandle } from "./terminal.js";
import type { TerminalFactory } from "./terminal.js";
import { pointerIsFine } from "./terminal-prefs.js";
@@ -91,6 +98,8 @@ export function createGuestView(
let sessionCols = 0;
let sessionRows = 0;
let terminalView: TerminalView | null = null;
+ let copyFloatDispose: (() => void) | null = null;
+ const termCopy = new TermCopySheet();
const latches = new ModifierLatches();
function header(): string {
@@ -123,6 +132,9 @@ export function createGuestView(
}
function teardownSessionWidgets(): void {
+ copyFloatDispose?.();
+ copyFloatDispose = null;
+ termCopy.close();
terminalView?.dispose();
terminalView = null;
sessionCols = 0;
@@ -285,6 +297,25 @@ export function createGuestView(
}
}
+ // As the owner's: arming a latch focuses the terminal in the same
+ // tap so the phone keyboard opens; a terminal still behind the font
+ // gate focuses once it opens, and releasing never touches focus.
+ function focusTerminalForLatch(): void {
+ const view = terminalView;
+ if (!view) {
+ return;
+ }
+ if (view.fitted) {
+ view.focus();
+ return;
+ }
+ void view.ready().then(() => {
+ if (terminalView === view) {
+ view.focus();
+ }
+ });
+ }
+
function paintSession(): void {
route = "session";
document.title = sessionTitle(sessionName === "" ? sessionId : sessionName);
@@ -297,26 +328,41 @@ export function createGuestView(
←
${esc(sessionName || sessionId)}
+ ${icon("copy")}
-
-
Attaching…
+
${REMOTE_FOOTER}`;
wireChrome();
root.querySelector("#session-back")?.addEventListener("click", () => callbacks.onLeaveSession());
+ // Read-only viewing copies too: the guest already sees this text.
+ root.querySelector("#term-copy")?.addEventListener("click", () => {
+ if (route === "session") {
+ termCopy.open(root, { bufferText: (maxLines) => terminalView?.bufferText(maxLines) ?? "" });
+ }
+ });
const bar = root.querySelector
("#keybar");
if (bar) {
renderKeyBar(bar, (def) => {
if (def.id === "ctrl") {
- latches.toggleCtrl();
+ const armed = latches.toggleCtrl();
updateKeyBarLatches(bar, latches.snapshot());
+ if (armed) {
+ focusTerminalForLatch();
+ }
return;
}
if (def.id === "shift") {
- latches.toggleShift();
+ const armed = latches.toggleShift();
updateKeyBarLatches(bar, latches.snapshot());
+ if (armed) {
+ focusTerminalForLatch();
+ }
return;
}
const bytes = keyBytesWithModifiers(def.id, latches.snapshot());
@@ -418,7 +464,13 @@ export function createGuestView(
}
pane.replaceChildren();
terminalView = new TerminalView(terminalFactory, {
+ // As the owner's: xterm's own reports go out unchanged, leaving
+ // the latch armed and the bar untouched — they are not typing.
onUserInput: (text) => {
+ if (isTerminalReport(text)) {
+ callbacks.onInput(new TextEncoder().encode(text));
+ return;
+ }
const bytes = textBytesWithModifiers(text, latches.snapshot());
if (latches.hasLatch) {
latches.release();
@@ -431,6 +483,13 @@ export function createGuestView(
},
});
terminalView.mount(pane, cols, rows);
+ // As the owner's float: rewired on every mount.
+ copyFloatDispose?.();
+ copyFloatDispose = null;
+ const floatButton = root.querySelector("#term-copy-float");
+ if (floatButton) {
+ copyFloatDispose = wireCopyFloat(floatButton, terminalView);
+ }
// As the owner's: a fine pointer takes focus once on attach, after
// xterm is open; a touch device waits for the first tap instead.
if (pointerIsFine()) {
diff --git a/apps/remote/web/src/guest.ts b/apps/remote/web/src/guest.ts
index 85abaf2..4a7adaf 100644
--- a/apps/remote/web/src/guest.ts
+++ b/apps/remote/web/src/guest.ts
@@ -537,6 +537,8 @@ export function createGuestSession(root: HTMLElement, deps: GuestSessionDeps): G
sessionResized: (id, cols, rows) => view.sessionResized(id, cols, rows),
sheetSpawned: () => {},
sheetComposed: () => {},
+ sheetScheduled: () => {},
+ sheetScheduledList: () => {},
sheetOk: () => {},
sheetError: () => {},
};
diff --git a/apps/remote/web/src/icons-dom.ts b/apps/remote/web/src/icons-dom.ts
index 298a85e..5d96dcc 100644
--- a/apps/remote/web/src/icons-dom.ts
+++ b/apps/remote/web/src/icons-dom.ts
@@ -32,6 +32,7 @@ import {
chevronRight,
circle,
circleDot,
+ copy,
cornerDownLeft,
deleteLeft,
folder,
@@ -68,6 +69,7 @@ const REMOTE_ICONS = {
chevronRight,
circle,
circleDot,
+ copy,
cornerDownLeft,
deleteLeft,
folder,
diff --git a/apps/remote/web/src/keys.ts b/apps/remote/web/src/keys.ts
index fb12f8e..79c1f4c 100644
--- a/apps/remote/web/src/keys.ts
+++ b/apps/remote/web/src/keys.ts
@@ -60,6 +60,11 @@ export function renderKeyBar(bar: HTMLElement, onKey: (def: KeyDef) => void): vo
bar.replaceChildren();
bar.setAttribute("role", "toolbar");
bar.setAttribute("aria-label", "Terminal keys");
+ // The bar must not steal xterm's focus: a mousedown default moves focus
+ // off the hidden textarea and closes the phone keyboard between keys.
+ // touchstart and pointerdown stay untouched — preventing those kills
+ // the tap's click.
+ bar.addEventListener("mousedown", (event) => event.preventDefault());
for (const def of KEY_DEFS) {
const button = document.createElement("button");
button.type = "button";
diff --git a/apps/remote/web/src/main.ts b/apps/remote/web/src/main.ts
index 01b4f20..6165ab5 100644
--- a/apps/remote/web/src/main.ts
+++ b/apps/remote/web/src/main.ts
@@ -128,6 +128,9 @@ async function main(): Promise {
onSpawn: (spawn, req) => manager?.send(buildSpawn(req, spawn)) ?? false,
onCompose: (draft, req) => manager?.send(buildCompose(req, draft)) ?? false,
onSendDraft: (draft, req) => controller.sendPromptDraft(draft, req),
+ onSchedule: (draft, fireAt, req) => controller.sendPromptSchedule(draft, fireAt, req),
+ onScheduledList: (req) => controller.sendScheduledList(req),
+ onScheduledCancel: (scheduleId, req) => controller.sendScheduledCancel(scheduleId, req),
onGraphsRequest: () => {
graphs.requestList();
},
diff --git a/apps/remote/web/src/modifiers.ts b/apps/remote/web/src/modifiers.ts
index 147aa8e..c594b59 100644
--- a/apps/remote/web/src/modifiers.ts
+++ b/apps/remote/web/src/modifiers.ts
@@ -86,11 +86,40 @@ export function keyBytesWithModifiers(id: string, mods: ModifierState): Uint8Arr
return new Uint8Array([]);
}
+// The bytes xterm generates by itself, through the same onData as typed
+// text: focus reports, cursor position replies, device attribute replies
+// and mouse reports. These are never the user typing, so they must never
+// apply or consume a latch. A lone ESC, arrows and other keys are input,
+// not reports.
+export function isTerminalReport(text: string): boolean {
+ if (text === "\x1b[I" || text === "\x1b[O") {
+ return true;
+ }
+ // X10 mouse: ESC[M followed by three bytes (button, x, y).
+ if (text.length === 6 && text.startsWith("\x1b[M")) {
+ return true;
+ }
+ if (!text.startsWith("\x1b[")) {
+ return false;
+ }
+ // Past the ESC[ prefix the body is plain ASCII: cursor position
+ // replies, device attribute replies and SGR mouse reports.
+ const body = text.slice(2);
+ return (
+ /^\d+;\d+R$/.test(body) ||
+ /^\?[0-9;]*c$/.test(body) ||
+ /^>[0-9;]*c$/.test(body) ||
+ /^<[0-9;]+[Mm]$/.test(body)
+ );
+}
+
// Phone-keyboard text with the latch applied. A latched Ctrl turns a
-// single letter into its control byte (a→0x01 … z→0x1a, either case) and
-// "[" into ESC; anything else goes out unchanged. Shift never alters
-// typed text (the keyboard already shifted it). The caller releases the
-// latch after any input, modified or not.
+// single letter into its control byte (a→0x01 … z→0x1a, either case,
+// so an autocapitalized letter still works) and "[" into ESC; anything
+// else goes out unchanged. With Shift latched and Ctrl not, a single
+// lowercase letter goes out uppercase — the phone keyboard knows nothing
+// of our latch. Ctrl keeps precedence. The caller releases the latch
+// after any input, modified or not.
export function textBytesWithModifiers(text: string, mods: ModifierState): Uint8Array {
if (mods.ctrl && text.length === 1) {
const ch = text[0] as string;
@@ -102,5 +131,11 @@ export function textBytesWithModifiers(text: string, mods: ModifierState): Uint8
return new Uint8Array([lower.charCodeAt(0) - 0x60]);
}
}
+ if (!mods.ctrl && mods.shift && text.length === 1) {
+ const ch = text[0] as string;
+ if (ch >= "a" && ch <= "z") {
+ return new Uint8Array([ch.charCodeAt(0) - 0x20]);
+ }
+ }
return new TextEncoder().encode(text);
}
diff --git a/apps/remote/web/src/protocol.ts b/apps/remote/web/src/protocol.ts
index ad1cee9..2b26e99 100644
--- a/apps/remote/web/src/protocol.ts
+++ b/apps/remote/web/src/protocol.ts
@@ -365,6 +365,68 @@ export function parseComposed(frame: unknown): { req: string; text: string } | n
return { req: frame.req, text: frame.text };
}
+// A prompt scheduled for later delivery to one session: the host fires it
+// at fire_at (RFC 3339 UTC) and the preview is user text, up to 120 chars.
+export interface ScheduledItem {
+ id: string;
+ fireAt: string;
+ preview: string;
+}
+
+function parseScheduledItem(value: unknown): ScheduledItem | null {
+ if (!isRecord(value)) {
+ return null;
+ }
+ if (typeof value.id !== "string" || value.id === "") {
+ return null;
+ }
+ if (typeof value.fire_at !== "string" || value.fire_at === "") {
+ return null;
+ }
+ if (typeof value.preview !== "string") {
+ return null;
+ }
+ return { id: value.id, fireAt: value.fire_at, preview: value.preview };
+}
+
+export function parseScheduled(frame: unknown): { req: string; item: ScheduledItem } | null {
+ if (frameType(frame) !== "scheduled" || !isRecord(frame)) {
+ return null;
+ }
+ if (typeof frame.req !== "string") {
+ return null;
+ }
+ const item = parseScheduledItem(frame.item);
+ if (!item) {
+ return null;
+ }
+ return { req: frame.req, item };
+}
+
+// The session's pending schedules, fire_at ascending. Malformed entries
+// are dropped like a catalog frame's, so one bad row never empties the list.
+export function parseScheduledList(
+ frame: unknown,
+): { req: string; id: string; items: ScheduledItem[] } | null {
+ if (frameType(frame) !== "scheduled_list" || !isRecord(frame)) {
+ return null;
+ }
+ if (typeof frame.req !== "string" || typeof frame.id !== "string" || frame.id === "") {
+ return null;
+ }
+ if (!Array.isArray(frame.items)) {
+ return null;
+ }
+ const items: ScheduledItem[] = [];
+ for (const value of frame.items) {
+ const item = parseScheduledItem(value);
+ if (item) {
+ items.push(item);
+ }
+ }
+ return { req: frame.req, id: frame.id, items };
+}
+
// --- The session shelf (additive to v1) -------------------------------
// The host advertises the shelf in hello.features; without "shelf" in
// that list the browser sends no shelf frame and shows no shelf UI.
@@ -546,6 +608,18 @@ export function buildPromptDraft(req: string, id: string, draft: PromptDraft): s
return JSON.stringify({ t: "prompt", req, id, draft });
}
+export function buildPromptSchedule(req: string, id: string, draft: PromptDraft, fireAt: string): string {
+ return JSON.stringify({ t: "prompt_schedule", req, id, draft, fire_at: fireAt });
+}
+
+export function buildScheduledList(req: string, id: string): string {
+ return JSON.stringify({ t: "scheduled_list", req, id });
+}
+
+export function buildScheduledCancel(req: string, id: string, scheduleId: string): string {
+ return JSON.stringify({ t: "scheduled_cancel", req, id, schedule_id: scheduleId });
+}
+
export function buildAttach(id: string): string {
return JSON.stringify({ t: "attach", id });
}
diff --git a/apps/remote/web/src/router.ts b/apps/remote/web/src/router.ts
index 3c5b2ac..a6ea303 100644
--- a/apps/remote/web/src/router.ts
+++ b/apps/remote/web/src/router.ts
@@ -25,6 +25,8 @@ import {
parseOk,
parseOut,
parseResized,
+ parseScheduled,
+ parseScheduledList,
parseSessionAdded,
parseSessionRemoved,
parseSessions,
@@ -33,6 +35,7 @@ import {
parseShelfRemoved,
parseSpawned,
} from "./protocol.js";
+import type { ScheduledItem } from "./protocol.js";
import type { SessionController } from "./session.js";
import type { ShelfFrames } from "./shelf-controller.js";
import type { SessionStore } from "./sessions.js";
@@ -53,6 +56,8 @@ export interface RouterShell {
sessionResized?: (id: string, cols: number, rows: number) => void;
sheetSpawned: (req: string, id: string) => void;
sheetComposed: (req: string, text: string) => void;
+ sheetScheduled: (req: string, item: ScheduledItem) => void;
+ sheetScheduledList: (req: string, items: ScheduledItem[]) => void;
sheetOk: (req: string) => void;
sheetError: (message: string, req: string | null, code: string) => void;
}
@@ -155,6 +160,16 @@ export function createFrameRouter(deps: FrameRouterDeps): FrameRouter {
shell.sheetComposed(composed.req, composed.text);
return;
}
+ const scheduled = parseScheduled(frame);
+ if (scheduled) {
+ shell.sheetScheduled(scheduled.req, scheduled.item);
+ return;
+ }
+ const scheduledList = parseScheduledList(frame);
+ if (scheduledList) {
+ shell.sheetScheduledList(scheduledList.req, scheduledList.items);
+ return;
+ }
// The shelf half of v1: hello gates the whole feature, the rest is
// list/push/removal/file-part. Every parser here is additive, so a
// host that sends none of them changes nothing.
diff --git a/apps/remote/web/src/session.ts b/apps/remote/web/src/session.ts
index a5a3356..ee394c9 100644
--- a/apps/remote/web/src/session.ts
+++ b/apps/remote/web/src/session.ts
@@ -12,7 +12,10 @@ import {
buildInput,
buildInputText,
buildPromptDraft,
+ buildPromptSchedule,
buildResize,
+ buildScheduledCancel,
+ buildScheduledList,
decodeSnapshotData,
} from "./protocol.js";
import type { OutFrame, PromptDraft } from "./protocol.js";
@@ -98,6 +101,28 @@ export class SessionController {
return this.callbacks.send(buildPromptDraft(req, this.openId, draft));
}
+ // The Builder's scheduling path: the same draft, delivered at fireAt.
+ sendPromptSchedule(draft: PromptDraft, fireAt: string, req: string): boolean {
+ if (this.openId === null) {
+ return false;
+ }
+ return this.callbacks.send(buildPromptSchedule(req, this.openId, draft, fireAt));
+ }
+
+ sendScheduledList(req: string): boolean {
+ if (this.openId === null) {
+ return false;
+ }
+ return this.callbacks.send(buildScheduledList(req, this.openId));
+ }
+
+ sendScheduledCancel(scheduleId: string, req: string): boolean {
+ if (this.openId === null) {
+ return false;
+ }
+ return this.callbacks.send(buildScheduledCancel(req, this.openId, scheduleId));
+ }
+
// The control-size path: the device asserts its PTY size for the open
// session. Only the toggle calls this, and only while it is on.
sendResize(cols: number, rows: number): boolean {
diff --git a/apps/remote/web/src/styles.css b/apps/remote/web/src/styles.css
index b21acf4..2f850d5 100644
--- a/apps/remote/web/src/styles.css
+++ b/apps/remote/web/src/styles.css
@@ -698,9 +698,9 @@ label > textarea {
}
/* The row wraps rather than clips as a last resort: the latest button
- * floats over the terminal (below), so with fullscreen and both attach
- * buttons up the row holds seven 44 px targets and stays one row tall on
- * a phone. */
+ * floats over the terminal (below) instead of taking a toolbar slot. On a
+ * phone the row never wraps (a second row would cost the terminal a row):
+ * it scrolls sideways instead (see the phone breakpoint below). */
.term-controls {
display: flex;
flex-wrap: wrap;
@@ -758,6 +758,36 @@ label > textarea {
display: none;
}
+/* The selection Copy button, floating over the terminal card's top-right
+ * corner the way #term-latest floats over the bottom-right: the stage is
+ * the positioning box, so it stays put while the pane scrolls beneath it.
+ * Icon plus the word Copy on a 44 px pill, slightly see-through until
+ * pointed at or focused, shown only while xterm holds a selection. */
+#term-copy-float {
+ position: absolute;
+ right: 0.5rem;
+ top: 0.5rem;
+ z-index: 1;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.35rem;
+ height: 2.75rem;
+ min-width: 2.75rem;
+ padding: 0 0.75rem;
+ border-radius: 999px;
+ opacity: 0.85;
+}
+
+#term-copy-float:hover,
+#term-copy-float:focus-visible {
+ opacity: 1;
+}
+
+#term-copy-float[hidden] {
+ display: none;
+}
+
/* The per-session size control, laid out like a settings row: the switch
* on the left, its words on the first line beside it and the explainer
* (or the takeover notice) on the second. The label spans both lines, so
@@ -902,16 +932,19 @@ label > textarea {
gap: 0.25rem;
}
- /* Seven controls share 328 px once the paperclip and the shelf button
- * are up: the tight gap keeps all seven 44 px targets on one row, and
- * the Builder word steps aside so the blocks icon names it alone. */
+ /* Eight 44 px controls no longer fit 328 px once the copy button
+ * joins the row beside the fullscreen and both attach buttons, and a
+ * wrapped second row would cost the terminal a row: the row stays one
+ * row tall and scrolls sideways instead, reaching the last button with
+ * a swipe. The tight gap keeps the swipe to a stub. */
.term-controls {
gap: 0.15rem;
+ flex-wrap: nowrap;
+ overflow-x: auto;
}
/* The labelled controls go icon-only on a phone: the aria-label (and
- * the title tooltip) names them, and seven 44 px targets stay inside
- * 360 px without a word overflowing its button. */
+ * the title tooltip) names them, so no word overflows its button. */
#term-builder span,
#term-keys span {
display: none;
@@ -1212,6 +1245,106 @@ label > textarea {
display: none;
}
+/* The builder's footer holds three buttons on a phone: let them wrap
+ * instead of pushing the sheet sideways. */
+.builder-form .sheet-actions {
+ flex-wrap: wrap;
+}
+
+/* The inline scheduling row: the datetime field spans the width and the
+ * two actions share the row below it, wrapping on the narrowest phones. */
+.builder-schedule-row {
+ margin-top: 0.75rem;
+}
+
+.builder-schedule-row[hidden] {
+ display: none;
+}
+
+.builder-schedule-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--gap);
+}
+
+.builder-schedule-actions button {
+ flex: 1 1 10rem;
+}
+
+/* The schedule confirmation: plain text, announced through role=status. */
+.builder-status {
+ margin: 0.75rem 0 0;
+ overflow-wrap: anywhere;
+}
+
+.builder-status[hidden] {
+ display: none;
+}
+
+.builder-status:empty {
+ display: none;
+}
+
+/* The session's pending schedules: one stacked card per row — the local
+ * fire time, the preview, and a full-width Cancel — so nothing squeezes
+ * at 360 px. */
+.builder-scheduled-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.scheduled-item {
+ background: var(--surface-bg-raise);
+ border: 1px solid var(--line);
+ border-radius: var(--radius-card);
+ padding: 0.6rem 0.85rem;
+ margin-bottom: var(--gap);
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.scheduled-when {
+ font-weight: 600;
+}
+
+.scheduled-preview {
+ color: var(--muted);
+ overflow-wrap: anywhere;
+}
+
+.scheduled-preview:empty::before {
+ content: "(no preview)";
+}
+
+/* The Copy sheet's text: the buffer in a native textarea, where the OS's
+ * own long-press selection works. Monospace, unwrapped (wrap="off" plus
+ * horizontal scroll, like the terminal), 16 px so iOS never zooms on
+ * focus. The status line keeps its row so "Copied" never moves the sheet. */
+#term-copy-text {
+ display: block;
+ width: 100%;
+ min-height: 40vh;
+ margin-top: 0.75rem;
+ padding: 0.75rem;
+ font-family: var(--font-mono);
+ font-size: 1rem;
+ white-space: pre;
+ overflow: auto;
+ resize: vertical;
+}
+
+.term-copy-status {
+ min-height: 1.5em;
+ margin: 0.5rem 0 0;
+ font-size: 0.85rem;
+}
+
+.term-copy-status:empty {
+ visibility: hidden;
+}
+
/* The Files button in the session bar: hidden until hello.features
* carries "shelf", its badge counting files nobody has looked at. */
#session-files {
diff --git a/apps/remote/web/src/term-controls.ts b/apps/remote/web/src/term-controls.ts
index 79e813f..6a104f4 100644
--- a/apps/remote/web/src/term-controls.ts
+++ b/apps/remote/web/src/term-controls.ts
@@ -57,7 +57,8 @@ export function termControlsHtml(): string {
aria-label="Show terminal keys"
title="Show or hide the terminal keys"
>${icon("keyboard")}Keys
- ${icon("blocks")}Builder ${fullscreen}
+ ${icon("blocks")}Builder
+ ${icon("copy")} ${fullscreen}
`;
}
@@ -128,6 +129,8 @@ export interface TermControlsDeps {
onLocalSize: (cols: number, rows: number) => void;
/** Open the Prompt Builder sheet; the controls row owns its button. */
onBuilder: () => void;
+ /** Open the Copy sheet; the controls row owns its button. */
+ onCopy: () => void;
}
export interface TermControls {
@@ -169,6 +172,7 @@ export function wireTermControls(deps: TermControlsDeps): TermControls {
root.querySelector("#term-zoom-in")?.addEventListener("click", () => deps.terminalView()?.zoomIn());
root.querySelector("#term-zoom-out")?.addEventListener("click", () => deps.terminalView()?.zoomOut());
root.querySelector("#term-builder")?.addEventListener("click", () => deps.onBuilder());
+ root.querySelector("#term-copy")?.addEventListener("click", () => deps.onCopy());
// The Keys switch hides the ten-key bar and nothing else: the zoom and
// the builder stay put. The choice is remembered per device.
const keysButton = root.querySelector("#term-keys");
diff --git a/apps/remote/web/src/term-copy.ts b/apps/remote/web/src/term-copy.ts
new file mode 100644
index 0000000..a64d49c
--- /dev/null
+++ b/apps/remote/web/src/term-copy.ts
@@ -0,0 +1,258 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copying text out of the remote terminal, on phone and desktop. A phone
+// cannot select in the terminal: one finger is the scroll gesture and
+// xterm draws through WebGL, so there is no selectable DOM text. The
+// phone gets a sheet with the terminal's text in a native textarea, where
+// the OS's own long-press selection works; a mouse drags a selection in
+// xterm itself and gets a floating Copy button plus Ctrl+Shift+C.
+
+import { icon } from "./icons-dom.js";
+import { Sheet } from "./sheet.js";
+
+/** The sheet shows the last this many logical lines of the buffer. */
+export const TERM_COPY_LINES = 2000;
+/** How long "Copied" stays up, on the sheet's status line and the float. */
+export const TERM_COPY_FLASH_MS = 2000;
+export const TERM_COPY_FAILURE = "Could not copy. Select the text and use your browser's Copy.";
+
+// Copy through the async clipboard API, falling back to a temporary
+// textarea and execCommand for browsers without clipboard permission.
+// True when either path copied, false when both failed; never throws.
+export async function copyText(text: string): Promise {
+ try {
+ const clipboard = (
+ globalThis as unknown as { navigator?: { clipboard?: { writeText?: (text: string) => Promise } } }
+ ).navigator?.clipboard;
+ if (clipboard?.writeText) {
+ try {
+ await clipboard.writeText(text);
+ return true;
+ } catch {
+ // Fall through to execCommand below.
+ }
+ }
+ } catch {
+ // A hostile navigator getter still gets the fallback.
+ }
+ try {
+ const area = document.createElement("textarea");
+ area.value = text;
+ area.setAttribute("readonly", "");
+ area.style.position = "fixed";
+ area.style.top = "0";
+ area.style.opacity = "0";
+ document.body.appendChild(area);
+ try {
+ area.select();
+ return document.execCommand("copy") === true;
+ } finally {
+ area.remove();
+ }
+ } catch {
+ return false;
+ }
+}
+
+// The copy shortcut: Ctrl+Shift+C on the keydown. Plain Ctrl+C is an
+// interrupt for the session, never a copy, even with a selection.
+export function isCopyKeydown(ev: { type: string; key: string; ctrlKey: boolean; shiftKey: boolean }): boolean {
+ return ev.type === "keydown" && (ev.key === "c" || ev.key === "C") && ev.ctrlKey && ev.shiftKey;
+}
+
+/**
+ * xterm's custom key handler for the copy shortcut: with a selection it
+ * copies and returns false (the key never reaches the session), otherwise
+ * true (xterm processes the key exactly as without the handler).
+ */
+export function handleCopyKeydown(
+ ev: { type: string; key: string; ctrlKey: boolean; shiftKey: boolean; preventDefault?: () => void },
+ getSelection: () => string,
+ copy: (text: string) => Promise = copyText,
+): boolean {
+ if (isCopyKeydown(ev)) {
+ const selection = getSelection();
+ if (selection !== "") {
+ // Chrome and Firefox bind this chord to their DevTools.
+ ev.preventDefault?.();
+ void copy(selection);
+ return false;
+ }
+ }
+ return true;
+}
+
+/** The slice of the terminal the sheet reads, once, when it opens. */
+export interface TermCopySource {
+ bufferText: (maxLines: number) => string;
+}
+
+export class TermCopySheet {
+ private readonly sheet: Sheet;
+ private readonly copy: (text: string) => Promise;
+ private body: HTMLElement | null = null;
+ private statusTimer: ReturnType | null = null;
+ private onSelectionChange: (() => void) | null = null;
+
+ constructor(copy: (text: string) => Promise = copyText) {
+ this.copy = copy;
+ this.sheet = new Sheet({ kind: "term-copy", title: "Copy from terminal" }, () => this.close());
+ }
+
+ get isOpen(): boolean {
+ return this.sheet.isOpen;
+ }
+
+ open(parent: HTMLElement, source: TermCopySource): void {
+ if (this.sheet.isOpen) {
+ return;
+ }
+ // Read once: the sheet is a snapshot, it never live-updates.
+ const text = source.bufferText(TERM_COPY_LINES);
+ const body = this.sheet.mount(parent);
+ this.body = body;
+ body.innerHTML = `
+
+
+
+ Copy all
+ Copy selection
+
`;
+ const area = body.querySelector("#term-copy-text");
+ const status = body.querySelector("#term-copy-status");
+ const copyAll = body.querySelector("#term-copy-all");
+ const copySelection = body.querySelector("#term-copy-selection");
+ if (!area || !status || !copyAll || !copySelection) {
+ return;
+ }
+ area.value = text;
+ // An empty buffer copies nothing.
+ copyAll.disabled = text === "";
+ // The tail is what is wanted: open scrolled to the bottom.
+ area.scrollTop = area.scrollHeight;
+ const syncSelection = (): void => {
+ copySelection.disabled = area.selectionStart === area.selectionEnd;
+ };
+ this.onSelectionChange = syncSelection;
+ document.addEventListener("selectionchange", syncSelection);
+ area.addEventListener("select", syncSelection);
+ const say = (message: string, flash: boolean): void => {
+ if (this.statusTimer !== null) {
+ clearTimeout(this.statusTimer);
+ this.statusTimer = null;
+ }
+ status.textContent = message;
+ if (flash) {
+ this.statusTimer = setTimeout(() => {
+ this.statusTimer = null;
+ if (status.isConnected) {
+ status.textContent = "";
+ }
+ }, TERM_COPY_FLASH_MS);
+ }
+ };
+ copyAll.addEventListener("click", () => {
+ void this.copy(area.value).then((ok) => {
+ if (status.isConnected) {
+ say(ok ? "Copied" : TERM_COPY_FAILURE, ok);
+ }
+ });
+ });
+ copySelection.addEventListener("click", () => {
+ const start = area.selectionStart ?? 0;
+ const end = area.selectionEnd ?? 0;
+ void this.copy(area.value.slice(start, end)).then((ok) => {
+ if (status.isConnected) {
+ say(ok ? "Copied" : TERM_COPY_FAILURE, ok);
+ }
+ });
+ });
+ }
+
+ close(): void {
+ if (this.statusTimer !== null) {
+ clearTimeout(this.statusTimer);
+ this.statusTimer = null;
+ }
+ if (this.onSelectionChange) {
+ document.removeEventListener("selectionchange", this.onSelectionChange);
+ this.onSelectionChange = null;
+ }
+ this.body = null;
+ this.sheet.close();
+ }
+}
+
+/**
+ * The floating selection button, in the .term-stage top-right corner the
+ * way #term-latest floats over the bottom-right: icon plus the word Copy,
+ * shown only while xterm holds a drag selection.
+ */
+export function termCopyFloatHtml(): string {
+ return `
+ ${icon("copy")}Copy `;
+}
+
+/** The slice of the terminal the float reads: selection only. */
+export interface CopyFloatView {
+ getSelection: () => string;
+ onSelectionChange: (callback: () => void) => () => void;
+}
+
+export function wireCopyFloat(
+ button: HTMLButtonElement,
+ view: CopyFloatView,
+ copy: (text: string) => Promise = copyText,
+): () => void {
+ const label = button.querySelector("span");
+ const previous = label?.textContent ?? "Copy";
+ let timer: ReturnType | null = null;
+ const sync = (): void => {
+ button.hidden = view.getSelection() === "";
+ };
+ const unsubscribe = view.onSelectionChange(sync);
+ sync();
+ const onClick = (): void => {
+ const selection = view.getSelection();
+ if (selection === "") {
+ return;
+ }
+ void copy(selection).then((ok) => {
+ // The selection stays: copying clears nothing.
+ if (ok && label && label.isConnected) {
+ label.textContent = "Copied";
+ if (timer !== null) {
+ clearTimeout(timer);
+ }
+ timer = setTimeout(() => {
+ timer = null;
+ if (label.isConnected) {
+ label.textContent = previous;
+ }
+ }, TERM_COPY_FLASH_MS);
+ }
+ });
+ };
+ button.addEventListener("click", onClick);
+ return () => {
+ unsubscribe();
+ button.removeEventListener("click", onClick);
+ if (timer !== null) {
+ clearTimeout(timer);
+ timer = null;
+ }
+ };
+}
diff --git a/apps/remote/web/src/terminal.ts b/apps/remote/web/src/terminal.ts
index 07a4fea..ec36b84 100644
--- a/apps/remote/web/src/terminal.ts
+++ b/apps/remote/web/src/terminal.ts
@@ -29,6 +29,7 @@
import "@xterm/xterm/css/xterm.css";
import { Terminal } from "@xterm/xterm";
+import { handleCopyKeydown } from "./term-copy.js";
import {
TERMINAL_FONT_FAMILY,
TERMINAL_FONT_SIZE,
@@ -56,6 +57,12 @@ export interface TerminalHandle {
onScroll: (callback: () => void) => () => void;
/** Fires once the terminal is open (and the WebGL attempt settled). */
onReady: (callback: () => void) => () => void;
+ /** The current drag selection, or "" when there is none. */
+ getSelection: () => string;
+ /** Fires when the drag selection changes. */
+ onSelectionChange: (callback: () => void) => () => void;
+ /** The last maxLines logical lines of the active buffer as plain text. */
+ bufferText: (maxLines: number) => string;
/** Resize the local terminal to cols × rows. Absent means a fixed size. */
resize?: (cols: number, rows: number) => void;
/**
@@ -258,6 +265,46 @@ export function shouldContinueFling(velocity: number): boolean {
return Math.abs(velocity) >= FLING_STOP_PX_PER_MS;
}
+// The slice of xterm's buffer bufferText reads: structural, so the real
+// IBuffer and the unit-test fakes both fit.
+export interface BufferLineLike {
+ readonly isWrapped: boolean;
+ translateToString(trimRight?: boolean): string;
+}
+
+export interface BufferLike {
+ readonly length: number;
+ getLine(y: number): BufferLineLike | undefined;
+}
+
+// The buffer as copyable text: a line whose isWrapped is true continues
+// the logical line above it (a wrapped line copies as one line), each row
+// is right-trimmed, trailing blank lines are dropped, and only the last
+// maxLines logical lines are kept. "" when maxLines is not positive.
+export function bufferLinesToText(buffer: BufferLike, maxLines: number): string {
+ if (!(maxLines > 0)) {
+ return "";
+ }
+ const logical: string[] = [];
+ for (let y = 0; y < buffer.length; y += 1) {
+ const line = buffer.getLine(y);
+ if (!line) {
+ continue;
+ }
+ const text = line.translateToString(true);
+ if (line.isWrapped && logical.length > 0) {
+ logical[logical.length - 1] += text;
+ } else {
+ logical.push(text);
+ }
+ }
+ let end = logical.length;
+ while (end > 0 && logical[end - 1] === "") {
+ end -= 1;
+ }
+ return logical.slice(0, end).slice(-maxLines).join("\n");
+}
+
export function createXtermHandle(
host: HTMLElement,
cols: number,
@@ -287,6 +334,9 @@ export function createXtermHandle(
// After open(): the addon needs the screen's canvas. A failure or a
// context loss leaves xterm's DOM renderer running.
attachWebgl(terminal);
+ // Ctrl+Shift+C with a selection copies instead of reaching the session;
+ // every other key (plain Ctrl+C included) still goes through.
+ terminal.attachCustomKeyEventHandler((ev) => handleCopyKeydown(ev, () => terminal.getSelection()));
// Test hook: the fake host's e2e reads the active buffer through it —
// buffer reads work under WebGL too, where the DOM carries no row text.
(host as unknown as { __xterm?: Terminal }).__xterm = terminal;
@@ -316,6 +366,12 @@ export function createXtermHandle(
callback();
return () => {};
},
+ getSelection: () => terminal.getSelection(),
+ onSelectionChange: (callback) => {
+ const sub = terminal.onSelectionChange(() => callback());
+ return () => sub.dispose();
+ },
+ bufferText: (maxLines) => bufferLinesToText(terminal.buffer.active, maxLines),
resize: (nextCols, nextRows) => terminal.resize(nextCols, nextRows),
baseScale: TERMINAL_FONT_SIZE / fontSize,
dispose: () => terminal.dispose(),
@@ -356,6 +412,7 @@ export class TerminalView {
private readyPromise: Promise = Promise.resolve();
private resolveReady: (() => void) | null = null;
private pendingWrites: Uint8Array[] = [];
+ private selectionListeners = new Set<() => void>();
constructor(factory: TerminalFactory, callbacks: TerminalViewCallbacks) {
this.factory = factory;
@@ -487,6 +544,27 @@ export class TerminalView {
this.handle?.focus();
}
+ /** The current drag selection, or "" while closed or unselected. */
+ getSelection(): string {
+ return this.handle?.getSelection() ?? "";
+ }
+
+ /**
+ * Fires when the drag selection changes. Subscribing before xterm opens
+ * is fine: the view forwards to the handle once it exists.
+ */
+ onSelectionChange(callback: () => void): () => void {
+ this.selectionListeners.add(callback);
+ return () => {
+ this.selectionListeners.delete(callback);
+ };
+ }
+
+ /** The last maxLines logical lines of the active buffer, or "" while closed. */
+ bufferText(maxLines: number): string {
+ return this.handle?.bufferText(maxLines) ?? "";
+ }
+
setZoom(zoom: number): void {
this.zoom = clampZoom(zoom);
this.refit();
@@ -681,6 +759,7 @@ export class TerminalView {
this.following = true;
this.alternate = false;
this.pendingWrites.length = 0;
+ this.selectionListeners.clear();
}
private openTerminal(): void {
@@ -694,6 +773,7 @@ export class TerminalView {
this.handle = handle;
handle.onUserInput((text) => this.callbacks.onUserInput(text));
handle.onScroll(() => this.refreshFollow());
+ handle.onSelectionChange(() => this.emitSelectionChange());
// Wheel and touch scrolling never fire onScroll (xterm suppresses it on
// the viewport path), so follow-bottom watches the viewport itself.
host.querySelector(".xterm-viewport")?.addEventListener("scroll", () => this.refreshFollow());
@@ -750,6 +830,12 @@ export class TerminalView {
this.callbacks.onFollowChange?.({ following });
}
+ private emitSelectionChange(): void {
+ for (const listener of [...this.selectionListeners]) {
+ listener();
+ }
+ }
+
private onTouchStart(ev: TouchEvent): void {
this.stopFling();
if (ev.touches.length === 2) {
diff --git a/apps/remote/web/test/app.test.ts b/apps/remote/web/test/app.test.ts
index 04cbadc..b97ed3e 100644
--- a/apps/remote/web/test/app.test.ts
+++ b/apps/remote/web/test/app.test.ts
@@ -66,6 +66,9 @@ class FakeHandle implements TerminalHandle {
callback();
return () => {};
};
+ getSelection = (): string => "";
+ onSelectionChange = (): (() => void) => () => {};
+ bufferText = (): string => "";
dispose = (): void => {
this.disposed = true;
};
@@ -198,6 +201,9 @@ describe("createShell", () => {
draftSends.push({ draft, req });
return sendable;
},
+ onSchedule: () => sendable,
+ onScheduledList: () => sendable,
+ onScheduledCancel: () => sendable,
onGraphsRequest: () => {},
onWatchGraph: () => {},
onUnwatchGraph: () => {},
@@ -509,6 +515,63 @@ describe("createShell", () => {
expect(shift.getAttribute("aria-pressed")).toBe("false");
});
+ it("forwards a focus report unchanged, leaving the latch armed", () => {
+ shell.showSession("s1", "build");
+ shell.ensureTerminal("s1", 80, 24);
+ const ctrl = query('#keybar .key[data-key="ctrl"]');
+ ctrl.click();
+ expect(ctrl.getAttribute("aria-pressed")).toBe("true");
+ // The focus-in report from tapping the terminal is xterm's own, not
+ // typing: it goes out as-is and spends nothing.
+ handles[0]?.emitInput("\x1b[I");
+ expect(calls.inputs.map((bytes) => [...bytes])).toEqual([[0x1b, 0x5b, 0x49]]);
+ expect(ctrl.getAttribute("aria-pressed")).toBe("true");
+ // The armed latch still applies to the next typed key.
+ handles[0]?.emitInput("c");
+ expect(calls.inputs.map((bytes) => [...bytes])).toEqual([
+ [0x1b, 0x5b, 0x49],
+ [0x03],
+ ]);
+ expect(ctrl.getAttribute("aria-pressed")).toBe("false");
+ });
+
+ it("sends typed c as 0x03 with Ctrl latched, then releases", () => {
+ shell.showSession("s1", "build");
+ shell.ensureTerminal("s1", 80, 24);
+ const ctrl = query('#keybar .key[data-key="ctrl"]');
+ ctrl.click();
+ handles[0]?.emitInput("c");
+ expect(calls.inputs.map((bytes) => [...bytes])).toEqual([[0x03]]);
+ expect(ctrl.getAttribute("aria-pressed")).toBe("false");
+ });
+
+ it("sends typed a as A with Shift latched, then releases", () => {
+ shell.showSession("s1", "build");
+ shell.ensureTerminal("s1", 80, 24);
+ const shift = query('#keybar .key[data-key="shift"]');
+ shift.click();
+ handles[0]?.emitInput("a");
+ expect(calls.inputs.map((bytes) => [...bytes])).toEqual([[0x41]]);
+ expect(shift.getAttribute("aria-pressed")).toBe("false");
+ });
+
+ it("focuses the terminal when arming a latch, never when releasing", () => {
+ shell.showSession("s1", "build");
+ shell.ensureTerminal("s1", 80, 24);
+ const ctrl = query('#keybar .key[data-key="ctrl"]');
+ const shift = query('#keybar .key[data-key="shift"]');
+ const focuses = () => handles[0]?.focuses ?? -1;
+ ctrl.click();
+ expect(focuses()).toBe(1);
+ // Releasing the latch touches nothing.
+ ctrl.click();
+ expect(focuses()).toBe(1);
+ shift.click();
+ expect(focuses()).toBe(2);
+ shift.click();
+ expect(focuses()).toBe(2);
+ });
+
it("sends Ctrl+Backspace as BS and releases the latch", () => {
shell.showSession("s1", "build");
const ctrl = query('#keybar .key[data-key="ctrl"]');
diff --git a/apps/remote/web/test/builder.test.ts b/apps/remote/web/test/builder.test.ts
index 29c7e51..6fa1192 100644
--- a/apps/remote/web/test/builder.test.ts
+++ b/apps/remote/web/test/builder.test.ts
@@ -41,6 +41,9 @@ describe("Builder", () => {
let storage: FakeStorage;
let composed: { draft: PromptDraft; req: string }[];
let sends: { draft: PromptDraft; req: string }[];
+ let schedules: { draft: PromptDraft; fireAt: string; req: string }[];
+ let lists: string[];
+ let cancels: { scheduleId: string; req: string }[];
let connected: boolean;
const query = (selector: string): T => {
@@ -107,6 +110,9 @@ describe("Builder", () => {
storage = new FakeStorage();
composed = [];
sends = [];
+ schedules = [];
+ lists = [];
+ cancels = [];
connected = true;
builder = new Builder(
catalog,
@@ -119,6 +125,18 @@ describe("Builder", () => {
sends.push({ draft, req });
return connected;
},
+ onSchedule: (draft, fireAt, req) => {
+ schedules.push({ draft, fireAt, req });
+ return connected;
+ },
+ onScheduledList: (req) => {
+ lists.push(req);
+ return connected;
+ },
+ onScheduledCancel: (scheduleId, req) => {
+ cancels.push({ scheduleId, req });
+ return connected;
+ },
},
storage,
);
@@ -440,4 +458,175 @@ describe("Builder", () => {
select("#builder-preset", "");
expect(query("#builder-preset-preview").textContent).toBe("");
});
+
+ const localInputValue = (date: Date): string => {
+ const pad = (n: number): string => String(n).padStart(2, "0");
+ return (
+ `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
+ `T${pad(date.getHours())}:${pad(date.getMinutes())}`
+ );
+ };
+
+ const openScheduleRow = (): void => {
+ query("#builder-schedule").click();
+ };
+
+ const confirmSchedule = (value: string): void => {
+ query("#builder-schedule-at").value = value;
+ query("#builder-schedule-confirm").click();
+ };
+
+ it("loads the pending schedules when the sheet opens", () => {
+ open();
+ expect(lists).toHaveLength(1);
+ expect(root.querySelector("#builder-scheduled")).toBeNull();
+ expect(builder.handleScheduledList("other", [])).toBe(false);
+ expect(
+ builder.handleScheduledList(lists[0] ?? "", [
+ { id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "review the diff" },
+ ]),
+ ).toBe(true);
+ expect(query("#builder-scheduled h3").textContent).toBe("Scheduled for this session");
+ expect(root.querySelectorAll(".scheduled-item")).toHaveLength(1);
+ });
+
+ it("reveals the schedule row preset to one hour from now", () => {
+ open();
+ expect(query("#builder-schedule-row").hidden).toBe(true);
+ openScheduleRow();
+ const input = query("#builder-schedule-at");
+ expect(query("#builder-schedule-row").hidden).toBe(false);
+ expect(new Date(input.value).getTime() - Date.now()).toBeGreaterThan(59 * 60 * 1000);
+ expect(new Date(input.value).getTime() - Date.now()).toBeLessThan(61 * 60 * 1000);
+ expect(Date.now() - new Date(input.getAttribute("min") ?? "").getTime()).toBeLessThan(120 * 1000);
+ query("#builder-schedule-dismiss").click();
+ expect(query("#builder-schedule-row").hidden).toBe(true);
+ expect(schedules).toHaveLength(0);
+ });
+
+ it("schedules the draft, clears it, and refreshes the list on success", () => {
+ open();
+ setKind(0, "goal");
+ setContent(".section-content", "water the plants");
+ toggle("tdd");
+ openScheduleRow();
+ const value = localInputValue(new Date(Date.now() + 2 * 60 * 60 * 1000));
+ confirmSchedule(value);
+ expect(schedules).toHaveLength(1);
+ expect(schedules[0]?.draft).toEqual({
+ sections: [{ title: "goal", content: "water the plants" }],
+ skills: ["tdd"],
+ });
+ expect(schedules[0]?.fireAt).toBe(new Date(value).toISOString());
+ const req = schedules[0]?.req ?? "";
+ expect(builder.handleScheduled("other", { id: "ss-1", fireAt: value, preview: "" })).toBe(false);
+ expect(
+ builder.handleScheduled(req, {
+ id: "ss-1",
+ fireAt: schedules[0]?.fireAt ?? "",
+ preview: "water the plants",
+ }),
+ ).toBe(true);
+ // The sheet stays open with a fresh default section and a status line.
+ expect(builder.isOpen).toBe(true);
+ expect(query("#builder-schedule-row").hidden).toBe(true);
+ const status = query("#builder-schedule-status");
+ expect(status.getAttribute("role")).toBe("status");
+ expect(status.textContent).toBe(
+ `Scheduled for ${new Date(schedules[0]?.fireAt ?? "").toLocaleString()}`,
+ );
+ expect(kinds()).toEqual(["instruction"]);
+ expect(contents()).toEqual([""]);
+ expect(storage.getItem(DRAFT_STORAGE_KEY)).toBeNull();
+ expect(lists).toHaveLength(2);
+ });
+
+ it("refuses an empty or past time without sending a frame", () => {
+ open();
+ setContent(".section-content", "keep me");
+ openScheduleRow();
+ confirmSchedule("");
+ expect(schedules).toHaveLength(0);
+ expect(query("#builder-error").textContent).toBe("Choose a date and time first.");
+ confirmSchedule(localInputValue(new Date(Date.now() - 60 * 60 * 1000)));
+ expect(schedules).toHaveLength(0);
+ expect(query("#builder-error").textContent).toBe("Pick a time in the future.");
+ expect(query(".section-content").value).toBe("keep me");
+ });
+
+ it("keeps the draft when scheduling fails", () => {
+ open();
+ setContent(".section-content", "keep me");
+ openScheduleRow();
+ confirmSchedule(localInputValue(new Date(Date.now() + 60 * 60 * 1000)));
+ expect(schedules).toHaveLength(1);
+ expect(builder.handleError("host is gone", schedules[0]?.req ?? "", "delivery_failed")).toBe(true);
+ expect(query("#builder-error").textContent).toBe("host is gone");
+ expect(builder.isOpen).toBe(true);
+ expect(query(".section-content").value).toBe("keep me");
+ expect(stored()?.sections).toHaveLength(1);
+ });
+
+ it("shows an old host's list error as the sheet error, never a crash", () => {
+ open();
+ expect(builder.handleError("unknown frame: scheduled_list", lists[0] ?? "", "unknown_frame")).toBe(
+ true,
+ );
+ expect(query("#builder-error").textContent).toBe("unknown frame: scheduled_list");
+ expect(root.querySelector("#builder-scheduled")).toBeNull();
+ expect(builder.handleError("late", "no-such-req", "internal")).toBe(false);
+ });
+
+ it("renders scheduled previews as text, never markup", () => {
+ open();
+ builder.handleScheduledList(lists[0] ?? "", [
+ { id: "ss-9", fireAt: "2030-06-01T12:00:00.000Z", preview: ' ' },
+ ]);
+ const item = query(".scheduled-item");
+ expect(item.querySelector("img")).toBeNull();
+ expect(item.querySelector(".scheduled-preview")?.textContent).toBe(' ');
+ expect(item.querySelector(".scheduled-when")?.textContent).toBe(
+ new Date("2030-06-01T12:00:00.000Z").toLocaleString(),
+ );
+ expect(item.querySelector("button")?.dataset.scheduleId).toBe("ss-9");
+ });
+
+ it("cancels a schedule and drops its row on the ok", () => {
+ open();
+ builder.handleScheduledList(lists[0] ?? "", [
+ { id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "first" },
+ { id: "ss-2", fireAt: "2030-01-02T09:00:00.000Z", preview: "second" },
+ ]);
+ expect(root.querySelectorAll(".scheduled-item")).toHaveLength(2);
+ query('button[data-schedule-id="ss-1"]').click();
+ expect(cancels).toEqual([{ scheduleId: "ss-1", req: cancels[0]?.req ?? "" }]);
+ expect(builder.handleOk("other")).toBe(false);
+ expect(root.querySelectorAll(".scheduled-item")).toHaveLength(2);
+ expect(builder.handleOk(cancels[0]?.req ?? "")).toBe(true);
+ expect(root.querySelectorAll(".scheduled-item")).toHaveLength(1);
+ expect(query(".scheduled-preview").textContent).toBe("second");
+ expect(lists).toHaveLength(2);
+ });
+
+ it("keeps the row when cancelling fails", () => {
+ open();
+ builder.handleScheduledList(lists[0] ?? "", [
+ { id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "first" },
+ ]);
+ query('button[data-schedule-id="ss-1"]').click();
+ expect(builder.handleError("host is gone", cancels[0]?.req ?? "", "delivery_failed")).toBe(true);
+ expect(query("#builder-error").textContent).toBe("host is gone");
+ expect(root.querySelectorAll(".scheduled-item")).toHaveLength(1);
+ });
+
+ it("says so when scheduling without a connection", () => {
+ connected = false;
+ open();
+ expect(query("#builder-error").textContent).toBe("Not connected");
+ setContent(".section-content", "words");
+ openScheduleRow();
+ confirmSchedule(localInputValue(new Date(Date.now() + 60 * 60 * 1000)));
+ expect(schedules).toHaveLength(1);
+ expect(query("#builder-error").textContent).toBe("Not connected");
+ });
});
diff --git a/apps/remote/web/test/bundle-size.test.ts b/apps/remote/web/test/bundle-size.test.ts
index 0978dc4..b7d9d80 100644
--- a/apps/remote/web/test/bundle-size.test.ts
+++ b/apps/remote/web/test/bundle-size.test.ts
@@ -132,6 +132,12 @@ const ARC_BOUNDS_BUDGET = 512;
* Merged, main is 410 B gzip over the sum of those budgets, with no new code.
*/
const MERGE_BUDGET = 512;
+/**
+ * Allowance for scheduling from the Builder, copying terminal text and the
+ * latch fixes: measured +2 657 B gzip over the allowance before them, added
+ * with the features since none of the three fit the headroom left.
+ */
+const SESSION_TOOLS_BUDGET = 3_072;
/**
* Allowance for the control-size fix (the panel-fitted size, the owner
* scale, the in-place local resize and the debounced re-assert while the
@@ -272,6 +278,7 @@ describe("the remote's bundle", () => {
SHELF_DELETE_BUDGET +
ARC_BOUNDS_BUDGET +
MERGE_BUDGET +
+ SESSION_TOOLS_BUDGET +
CONTROL_SIZE_BUDGET +
RESIZED_BUDGET +
GUEST_RETURN_BUDGET,
diff --git a/apps/remote/web/test/copy.spec.ts b/apps/remote/web/test/copy.spec.ts
new file mode 100644
index 0000000..f4d9876
--- /dev/null
+++ b/apps/remote/web/test/copy.spec.ts
@@ -0,0 +1,46 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copying terminal text out on a phone: the fake host prints known lines,
+// the Copy sheet shows the buffer in a native textarea, Copy all lands on
+// the clipboard, and the controls row still fits one row at 360x640 with
+// no sideways scroll.
+import { expect, test } from "@playwright/test";
+
+test.beforeEach(async ({ page }) => {
+ await page.setViewportSize({ width: 360, height: 640 });
+ await page.goto("/fake.html");
+ await page.locator('[data-session="smoke"]').click();
+ await expect.poll(() => page.evaluate(() => window.__fake.terminalText())).toContain("hello from the host");
+});
+
+test("copies the buffer through the sheet onto the clipboard", async ({ page, context }) => {
+ await context.grantPermissions(["clipboard-read", "clipboard-write"]);
+ await page.evaluate(() => window.__fake.pushOut("copy-me one\r\ncopy-me two\r\n"));
+ // The real xterm buffer holds the lines before the sheet snapshots it.
+ await page.waitForFunction(
+ () => (window.__fake.bufferState?.()?.rows.join("\n") ?? "").includes("copy-me two"),
+ );
+ await page.locator("#term-copy").click();
+ const text = page.locator("#term-copy-text");
+ await expect(text).toBeVisible();
+ expect(await text.inputValue()).toContain("copy-me one");
+ expect(await text.inputValue()).toContain("copy-me two");
+ await page.locator("#term-copy-all").click();
+ await expect(page.locator("#term-copy-status")).toHaveText("Copied");
+ expect(await page.evaluate(() => navigator.clipboard.readText())).toContain("copy-me two");
+ await page.locator("#term-copy-close").click();
+ await expect(text).toBeHidden();
+ // One controls row at 360x640: the Copy button did not wrap it, and the
+ // page never scrolls sideways.
+ const layout = await page.evaluate(() => {
+ const doc = document.scrollingElement ?? document.documentElement;
+ const controls = document.querySelector(".term-controls") as HTMLElement;
+ const tops = new Set(
+ [...controls.querySelectorAll("button")]
+ .filter((el) => (el as HTMLElement).offsetParent !== null)
+ .map((el) => Math.round(el.getBoundingClientRect().top)),
+ );
+ return { pageOverflow: doc.scrollWidth - doc.clientWidth, rows: tops.size };
+ });
+ expect(layout.pageOverflow).toBeLessThanOrEqual(1);
+ expect(layout.rows).toBe(1);
+});
diff --git a/apps/remote/web/test/guest-views.test.ts b/apps/remote/web/test/guest-views.test.ts
index 08b9899..c3310d0 100644
--- a/apps/remote/web/test/guest-views.test.ts
+++ b/apps/remote/web/test/guest-views.test.ts
@@ -36,6 +36,9 @@ class FakeHandle implements TerminalHandle {
callback();
return () => {};
};
+ getSelection = (): string => "";
+ onSelectionChange = (): (() => void) => () => {};
+ bufferText = (): string => "";
resizes: { cols: number; rows: number }[] = [];
resize = (cols: number, rows: number): void => {
this.resizes.push({ cols, rows });
@@ -300,6 +303,46 @@ describe("guest session view", () => {
);
});
+ it("forwards a focus report unchanged, leaving the latch armed", () => {
+ const { root, view, handles, inputs } = setup();
+ view.showSession("s1", "build");
+ view.ensureTerminal("s1", 80, 24);
+ root.querySelector('#keybar button[data-key="ctrl"]')?.click();
+ handles[0]?.emitInput("\x1b[I");
+ expect(Array.from(inputs, (bytes) => Array.from(bytes))).toEqual([[0x1b, 0x5b, 0x49]]);
+ expect(root.querySelector('#keybar button[data-key="ctrl"]')?.getAttribute("aria-pressed")).toBe(
+ "true",
+ );
+ handles[0]?.emitInput("c");
+ expect(Array.from(inputs, (bytes) => Array.from(bytes))).toEqual([
+ [0x1b, 0x5b, 0x49],
+ [0x03],
+ ]);
+ });
+
+ it("sends typed a as A with Shift latched, then releases", () => {
+ const { root, view, handles, inputs } = setup();
+ view.showSession("s1", "build");
+ view.ensureTerminal("s1", 80, 24);
+ root.querySelector('#keybar button[data-key="shift"]')?.click();
+ handles[0]?.emitInput("a");
+ expect(Array.from(inputs, (bytes) => Array.from(bytes))).toEqual([[0x41]]);
+ expect(root.querySelector('#keybar button[data-key="shift"]')?.getAttribute("aria-pressed")).toBe(
+ "false",
+ );
+ });
+
+ it("focuses the terminal when arming a latch, never when releasing", () => {
+ const { root, view, handles } = setup();
+ view.showSession("s1", "build");
+ view.ensureTerminal("s1", 80, 24);
+ const ctrl = root.querySelector('#keybar button[data-key="ctrl"]');
+ ctrl?.click();
+ expect(handles[0]?.focuses).toBe(1);
+ ctrl?.click();
+ expect(handles[0]?.focuses).toBe(1);
+ });
+
it("focuses the terminal once on attach under a fine pointer", async () => {
const { view, handles } = setup();
const realMatchMedia = globalThis.matchMedia;
diff --git a/apps/remote/web/test/guest.test.ts b/apps/remote/web/test/guest.test.ts
index fe31c5e..1967963 100644
--- a/apps/remote/web/test/guest.test.ts
+++ b/apps/remote/web/test/guest.test.ts
@@ -408,6 +408,9 @@ class FakeGuestHandle implements TerminalHandle {
callback();
return () => {};
};
+ getSelection = (): string => "";
+ onSelectionChange = (): (() => void) => () => {};
+ bufferText = (): string => "";
dispose = (): void => {};
emitInput(text: string): void {
diff --git a/apps/remote/web/test/keys.test.ts b/apps/remote/web/test/keys.test.ts
index 082e86e..e4e61c8 100644
--- a/apps/remote/web/test/keys.test.ts
+++ b/apps/remote/web/test/keys.test.ts
@@ -101,6 +101,20 @@ describe("renderKeyBar", () => {
}
});
+ it("keeps mousedown from stealing xterm's focus, without touching the click", () => {
+ const bar = document.createElement("div");
+ document.body.appendChild(bar);
+ const pressed: KeyDef[] = [];
+ renderKeyBar(bar, (def) => pressed.push(def));
+ const tab = bar.querySelector('button.key[data-key="tab"]');
+ const mousedown = new MouseEvent("mousedown", { bubbles: true, cancelable: true });
+ tab?.dispatchEvent(mousedown);
+ expect(mousedown.defaultPrevented).toBe(true);
+ // The click still lands: touchstart and pointerdown are untouched.
+ tab?.click();
+ expect(pressed.map((def) => def.id)).toEqual(["tab"]);
+ });
+
it("marks the latches unpressed until the shell updates them", () => {
const bar = document.createElement("div");
document.body.appendChild(bar);
diff --git a/apps/remote/web/test/latch-keyboard.spec.ts b/apps/remote/web/test/latch-keyboard.spec.ts
new file mode 100644
index 0000000..55a77d1
--- /dev/null
+++ b/apps/remote/web/test/latch-keyboard.spec.ts
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: Apache-2.0
+// The Ctrl/Shift latch keys on a phone, against the fake host: tapping
+// Ctrl arms the latch and focuses the terminal (the keyboard opens inside
+// the tap gesture), the focus reports from that focus never spend the
+// latch, and the next typed key goes out with the modifier applied.
+import { expect, test } from "@playwright/test";
+
+test.use({ hasTouch: true });
+
+async function terminalText(page: import("@playwright/test").Page): Promise {
+ return page.evaluate(() => window.__fake.terminalText());
+}
+
+// Every input frame the host received, decoded to bytes.
+async function inputBytes(page: import("@playwright/test").Page): Promise {
+ return page.evaluate(() =>
+ window.__fake
+ .sent()
+ .filter((frame) => (frame as { t?: string }).t === "input")
+ .map((frame) => {
+ const bin = atob((frame as { data: string }).data);
+ return Array.from(bin, (ch) => ch.charCodeAt(0));
+ }),
+ );
+}
+
+async function xtermTextareaIsFocused(page: import("@playwright/test").Page): Promise {
+ return page.evaluate(() => {
+ const active = document.activeElement;
+ return active instanceof HTMLTextAreaElement && active.classList.contains("xterm-helper-textarea");
+ });
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.setViewportSize({ width: 360, height: 640 });
+ await page.goto("/fake.html");
+ await page.locator('[data-session="smoke"]').click();
+ await expect.poll(() => terminalText(page)).toContain("hello from the host");
+ // A fine pointer hides the key bar by default; pin it visible the way a
+ // touch device shows it.
+ if (!(await page.locator("#keybar").isVisible())) {
+ await page.locator("#term-keys").click();
+ }
+});
+
+test("ctrl latch survives the focus report and applies to the next key", async ({ page }) => {
+ const ctrl = page.locator('#keybar .key[data-key="ctrl"]');
+ await ctrl.tap();
+ await expect(ctrl).toHaveAttribute("aria-pressed", "true");
+ // The tap focused xterm's hidden textarea: the phone keyboard opens.
+ await expect.poll(() => xtermTextareaIsFocused(page)).toBe(true);
+ // A TUI with focus tracking turns every blur/focus into a report
+ // through the same onData as typed text.
+ await page.evaluate(() => window.__fake.pushOut("\x1b[?1004h"));
+ await expect.poll(() => terminalText(page)).toContain("\x1b[?1004h");
+ // xterm applies the mode off its write queue; the blur below must land
+ // after that, or no report is generated at all.
+ await page.waitForTimeout(300);
+ await page.evaluate(() => window.__fake.clearSent());
+ await page.evaluate(() => {
+ const area = document.querySelector(".xterm-helper-textarea") as HTMLTextAreaElement;
+ area.blur();
+ area.focus();
+ });
+ // Both reports reached the host unchanged, and the latch stayed armed.
+ await expect.poll(() => inputBytes(page)).toEqual([
+ [0x1b, 0x5b, 0x4f],
+ [0x1b, 0x5b, 0x49],
+ ]);
+ await expect(ctrl).toHaveAttribute("aria-pressed", "true");
+ // The armed latch still applies to the next typed key.
+ await page.keyboard.type("c");
+ await expect.poll(() => inputBytes(page)).toEqual([
+ [0x1b, 0x5b, 0x4f],
+ [0x1b, 0x5b, 0x49],
+ [0x03],
+ ]);
+ await expect(ctrl).toHaveAttribute("aria-pressed", "false");
+});
diff --git a/apps/remote/web/test/modifiers.test.ts b/apps/remote/web/test/modifiers.test.ts
index 6547b9a..5884022 100644
--- a/apps/remote/web/test/modifiers.test.ts
+++ b/apps/remote/web/test/modifiers.test.ts
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: Apache-2.0
import { describe, expect, it } from "vitest";
-import { keyBytesWithModifiers, ModifierLatches, textBytesWithModifiers } from "../src/modifiers.js";
+import {
+ isTerminalReport,
+ keyBytesWithModifiers,
+ ModifierLatches,
+ textBytesWithModifiers,
+} from "../src/modifiers.js";
const CTRL = { ctrl: true, shift: false };
const SHIFT = { ctrl: false, shift: true };
@@ -103,9 +108,62 @@ describe("textBytesWithModifiers", () => {
expect([...textBytesWithModifiers("[", BOTH)]).toEqual([0x1b]);
});
+ it("uppercases a single lowercase letter with Shift latched", () => {
+ expect([...textBytesWithModifiers("a", SHIFT)]).toEqual([0x41]);
+ expect([...textBytesWithModifiers("z", SHIFT)]).toEqual([0x5a]);
+ // Already shifted, longer text and non-letters go out unchanged.
+ expect([...textBytesWithModifiers("A", SHIFT)]).toEqual([0x41]);
+ expect([...textBytesWithModifiers("hi", SHIFT)]).toEqual([0x68, 0x69]);
+ expect([...textBytesWithModifiers("5", SHIFT)]).toEqual([0x35]);
+ });
+
+ it("keeps Ctrl's precedence over Shift for typed text", () => {
+ expect([...textBytesWithModifiers("c", BOTH)]).toEqual([0x03]);
+ expect([...textBytesWithModifiers("[", SHIFT)]).toEqual([0x5b]);
+ });
+
it("leaves other latched input unchanged for the caller to release", () => {
expect([...textBytesWithModifiers("5", CTRL)]).toEqual([0x35]);
- expect([...textBytesWithModifiers("c", SHIFT)]).toEqual([0x63]);
expect([...textBytesWithModifiers("hi", CTRL)]).toEqual([0x68, 0x69]);
});
});
+
+describe("isTerminalReport", () => {
+ it("matches the focus reports", () => {
+ expect(isTerminalReport("\x1b[I")).toBe(true);
+ expect(isTerminalReport("\x1b[O")).toBe(true);
+ });
+
+ it("matches cursor position replies", () => {
+ expect(isTerminalReport("\x1b[24;80R")).toBe(true);
+ expect(isTerminalReport("\x1b[1;1R")).toBe(true);
+ });
+
+ it("matches device attribute replies", () => {
+ expect(isTerminalReport("\x1b[?1;2c")).toBe(true);
+ expect(isTerminalReport("\x1b[?62c")).toBe(true);
+ expect(isTerminalReport("\x1b[>0;95;0c")).toBe(true);
+ });
+
+ it("matches SGR mouse reports, press and release", () => {
+ expect(isTerminalReport("\x1b[<0;10;20M")).toBe(true);
+ expect(isTerminalReport("\x1b[<0;10;20m")).toBe(true);
+ expect(isTerminalReport("\x1b[<64;5;7M")).toBe(true);
+ });
+
+ it("matches X10 mouse reports, three bytes after ESC[M", () => {
+ expect(isTerminalReport("\x1b[M !\"")).toBe(true);
+ expect(isTerminalReport("\x1b[M#(+")).toBe(true);
+ });
+
+ it("rejects typed input: a lone ESC, arrows and plain text", () => {
+ expect(isTerminalReport("\x1b")).toBe(false);
+ expect(isTerminalReport("\x1b[A")).toBe(false);
+ expect(isTerminalReport("\x1b[1;5A")).toBe(false);
+ expect(isTerminalReport("\x1b[Z")).toBe(false);
+ expect(isTerminalReport("c")).toBe(false);
+ expect(isTerminalReport("hello")).toBe(false);
+ expect(isTerminalReport("")).toBe(false);
+ expect(isTerminalReport("\x1b[M !")).toBe(false);
+ });
+});
diff --git a/apps/remote/web/test/primary-buttons.test.ts b/apps/remote/web/test/primary-buttons.test.ts
index 6bfe2db..f825a46 100644
--- a/apps/remote/web/test/primary-buttons.test.ts
+++ b/apps/remote/web/test/primary-buttons.test.ts
@@ -80,7 +80,13 @@ describe("primary buttons", () => {
builderCatalog.replace(CATALOG);
const builder = new Builder(
builderCatalog,
- { onCompose: () => true, onSend: () => true },
+ {
+ onCompose: () => true,
+ onSend: () => true,
+ onSchedule: () => true,
+ onScheduledList: () => true,
+ onScheduledCancel: () => true,
+ },
new FakeStorage(),
);
builder.open(builderRoot);
diff --git a/apps/remote/web/test/protocol.test.ts b/apps/remote/web/test/protocol.test.ts
index 037d707..03b4e23 100644
--- a/apps/remote/web/test/protocol.test.ts
+++ b/apps/remote/web/test/protocol.test.ts
@@ -13,7 +13,10 @@ import {
buildInputText,
buildList,
buildPromptDraft,
+ buildPromptSchedule,
buildResize,
+ buildScheduledCancel,
+ buildScheduledList,
buildShelfDelete,
buildShelfGet,
buildShelfList,
@@ -34,6 +37,8 @@ import {
parseOk,
parseOut,
parseResized,
+ parseScheduled,
+ parseScheduledList,
parseSessionAdded,
parseSessionItem,
parseSessionRemoved,
@@ -115,6 +120,24 @@ describe("builders", () => {
id: "s1",
draft,
});
+ expect(JSON.parse(buildPromptSchedule("r10", "s1", draft, "2030-01-01T09:00:00.000Z"))).toEqual({
+ t: "prompt_schedule",
+ req: "r10",
+ id: "s1",
+ draft,
+ fire_at: "2030-01-01T09:00:00.000Z",
+ });
+ expect(JSON.parse(buildScheduledList("r11", "s1"))).toEqual({
+ t: "scheduled_list",
+ req: "r11",
+ id: "s1",
+ });
+ expect(JSON.parse(buildScheduledCancel("r12", "s1", "ss-3"))).toEqual({
+ t: "scheduled_cancel",
+ req: "r12",
+ id: "s1",
+ schedule_id: "ss-3",
+ });
});
it("base64-encodes input bytes", () => {
@@ -296,6 +319,37 @@ describe("frame parsers", () => {
expect(parseComposed({ t: "ok", req: "r2" })).toBeNull();
});
+ it("parses scheduled and scheduled_list", () => {
+ const item = { id: "ss-1", fire_at: "2030-01-01T09:00:00.000Z", preview: "review it" };
+ expect(parseScheduled({ t: "scheduled", req: "r1", item })).toEqual({
+ req: "r1",
+ item: { id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "review it" },
+ });
+ expect(parseScheduled({ t: "scheduled", req: "r1" })).toBeNull();
+ expect(parseScheduled({ t: "scheduled", req: "r1", item: { id: "", fire_at: "", preview: 7 } })).toBeNull();
+ expect(parseScheduled({ t: "ok", req: "r1" })).toBeNull();
+ expect(
+ parseScheduledList({ t: "scheduled_list", req: "r2", id: "s1", items: [item] }),
+ ).toEqual({
+ req: "r2",
+ id: "s1",
+ items: [{ id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "review it" }],
+ });
+ // Malformed entries are dropped, like a catalog frame's.
+ expect(
+ parseScheduledList({
+ t: "scheduled_list",
+ req: "r2",
+ id: "s1",
+ items: [item, { id: "", fire_at: "x", preview: "bad" }, null],
+ })?.items,
+ ).toHaveLength(1);
+ expect(parseScheduledList({ t: "scheduled_list", req: "r2", id: "s1" })).toBeNull();
+ expect(parseScheduledList({ t: "scheduled_list", req: "r2", id: "", items: [] })).toBeNull();
+ expect(parseScheduledList({ t: "scheduled_list", req: "r2", id: "s1", items: {} })).toBeNull();
+ expect(parseScheduledList({ t: "shelf", req: "r2", items: [] })).toBeNull();
+ });
+
it("reads the harness yolo flag defensively", () => {
expect(
parseCatalog({
diff --git a/apps/remote/web/test/router.test.ts b/apps/remote/web/test/router.test.ts
index 9f05ba7..0308555 100644
--- a/apps/remote/web/test/router.test.ts
+++ b/apps/remote/web/test/router.test.ts
@@ -28,6 +28,8 @@ interface Harness {
exits: number[];
spawned: { req: string; id: string }[];
composed: { req: string; text: string }[];
+ scheduled: { req: string; item: unknown }[];
+ scheduledLists: { req: string; items: unknown[] }[];
sheetOks: string[];
sheetErrors: { message: string; req: string | null; code: string }[];
hostSend: (frame: unknown) => void;
@@ -43,6 +45,8 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames):
const exits: number[] = [];
const spawned: Harness["spawned"] = [];
const composed: Harness["composed"] = [];
+ const scheduled: Harness["scheduled"] = [];
+ const scheduledLists: Harness["scheduledLists"] = [];
const sheetOks: string[] = [];
const sheetErrors: Harness["sheetErrors"] = [];
// Only the methods the router calls; main.ts wires the real shell.
@@ -62,6 +66,12 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames):
sheetComposed: (req: string, text: string) => {
composed.push({ req, text });
},
+ sheetScheduled: (req: string, item: unknown) => {
+ scheduled.push({ req, item });
+ },
+ sheetScheduledList: (req: string, items: unknown[]) => {
+ scheduledLists.push({ req, items });
+ },
sheetOk: (req: string) => {
sheetOks.push(req);
},
@@ -101,6 +111,8 @@ function setup(onForbidden?: (id: string | null) => void, shelf?: ShelfFrames):
exits,
spawned,
composed,
+ scheduled,
+ scheduledLists,
sheetOks,
sheetErrors,
hostSend: (frame: unknown) => channel.deliver(JSON.stringify(frame)),
@@ -240,6 +252,30 @@ describe("createFrameRouter", () => {
expect(h.composed).toEqual([{ req: "r3", text: "# Goal" }]);
});
+ it("routes scheduled and scheduled_list to the shell", () => {
+ const h = setup();
+ const item = { id: "ss-1", fire_at: "2030-01-01T09:00:00.000Z", preview: "review it" };
+ h.hostSend({ t: "scheduled", req: "r4", item });
+ expect(h.scheduled).toEqual([
+ {
+ req: "r4",
+ item: { id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "review it" },
+ },
+ ]);
+ h.hostSend({ t: "scheduled_list", req: "r5", id: "s1", items: [item] });
+ expect(h.scheduledLists).toEqual([
+ {
+ req: "r5",
+ items: [{ id: "ss-1", fireAt: "2030-01-01T09:00:00.000Z", preview: "review it" }],
+ },
+ ]);
+ // Malformed schedule frames land nowhere, like any unknown frame.
+ h.hostSend({ t: "scheduled", req: "r6" });
+ h.hostSend({ t: "scheduled_list", req: "r7", id: "s1", items: {} });
+ expect(h.scheduled).toHaveLength(1);
+ expect(h.scheduledLists).toHaveLength(1);
+ });
+
it("routes hello harnesses to the catalog's installed set", () => {
const h = setup();
h.hostSend({
@@ -286,6 +322,8 @@ describe("createFrameRouter", () => {
ensureTerminal: () => {},
sheetSpawned: () => {},
sheetComposed: () => {},
+ sheetScheduled: () => {},
+ sheetScheduledList: () => {},
sheetOk: () => {},
sheetError: () => {},
},
diff --git a/apps/remote/web/test/schedule.spec.ts b/apps/remote/web/test/schedule.spec.ts
new file mode 100644
index 0000000..31aa789
--- /dev/null
+++ b/apps/remote/web/test/schedule.spec.ts
@@ -0,0 +1,67 @@
+// SPDX-License-Identifier: Apache-2.0
+// Scheduling a Prompt Builder draft against the fake host: open the
+// builder, schedule the draft for later, see it listed under "Scheduled
+// for this session", cancel it, see it gone — at a 360x640 phone viewport.
+import { expect, test } from "@playwright/test";
+
+function futureLocalValue(): string {
+ const at = new Date(Date.now() + 2 * 60 * 60 * 1000);
+ const pad = (n: number): string => String(n).padStart(2, "0");
+ return (
+ `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}` +
+ `T${pad(at.getHours())}:${pad(at.getMinutes())}`
+ );
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.setViewportSize({ width: 360, height: 640 });
+ await page.goto("/fake.html");
+ await page.locator('[data-session="smoke"]').click();
+ await expect.poll(() => page.evaluate(() => window.__fake.terminalText())).toContain("hello from the host");
+});
+
+test("schedule a draft, see it listed, cancel it", async ({ page }) => {
+ await page.locator("#term-builder").click();
+ await expect(page.locator("#builder-form")).toBeVisible();
+ await page.locator(".section-content").fill("water the plants");
+ // A fresh host holds no schedules, so the block is absent, not empty.
+ await expect(page.locator("#builder-scheduled")).toHaveCount(0);
+
+ await page.locator("#builder-schedule").click();
+ await expect(page.locator("#builder-schedule-row")).toBeVisible();
+ // Every new target clears 44 px.
+ for (const selector of ["#builder-schedule", "#builder-schedule-confirm", "#builder-schedule-dismiss"]) {
+ const box = await page.locator(selector).boundingBox();
+ expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
+ }
+ await page.locator("#builder-schedule-at").fill(futureLocalValue());
+ await page.evaluate(() => window.__fake.clearSent());
+ await page.locator("#builder-schedule-confirm").click();
+
+ // The sheet stays open with a status line and a cleared draft.
+ await expect(page.locator("#builder-schedule-status")).toContainText("Scheduled for");
+ await expect(page.locator("#builder-form")).toBeVisible();
+ await expect(page.locator(".section-content")).toHaveValue("");
+ const sent = await page.evaluate(() => window.__fake.sent());
+ const scheduled = (sent as Record[]).find((frame) => frame.t === "prompt_schedule");
+ expect(scheduled).toMatchObject({ t: "prompt_schedule", id: "smoke" });
+ expect(typeof scheduled?.fire_at).toBe("string");
+ expect(Number.isNaN(Date.parse(scheduled?.fire_at as string))).toBe(false);
+
+ // The new schedule lists with the local fire time and its preview.
+ const row = page.locator(".scheduled-item");
+ await expect(row).toHaveCount(1);
+ await expect(row).toContainText("water the plants");
+ const cancelBox = await row.locator("button[data-schedule-id]").boundingBox();
+ expect(cancelBox?.height ?? 0).toBeGreaterThanOrEqual(44);
+ const overflow = await page.evaluate(() => {
+ const el = document.scrollingElement ?? document.documentElement;
+ return el.scrollWidth - el.clientWidth;
+ });
+ expect(overflow).toBeLessThanOrEqual(1);
+
+ // Cancelling drops the row once the host's ok lands.
+ await row.locator("button[data-schedule-id]").click();
+ await expect(page.locator(".scheduled-item")).toHaveCount(0);
+ await expect(page.locator("#builder-scheduled")).toHaveCount(0);
+});
diff --git a/apps/remote/web/test/send-draft.test.ts b/apps/remote/web/test/send-draft.test.ts
index d3df2a9..0f07e52 100644
--- a/apps/remote/web/test/send-draft.test.ts
+++ b/apps/remote/web/test/send-draft.test.ts
@@ -65,6 +65,47 @@ describe("SessionController.sendPromptDraft", () => {
});
});
+describe("SessionController scheduled prompts", () => {
+ const open = (): { controller: SessionController; sent: string[] } => {
+ const sent: string[] = [];
+ const controller = new SessionController({
+ send: (data) => {
+ sent.push(data);
+ return true;
+ },
+ terminal: () => null,
+ });
+ controller.open("s1");
+ sent.length = 0;
+ return { controller, sent };
+ };
+
+ it("refuses without an open session", () => {
+ const controller = new SessionController({ send: () => true, terminal: () => null });
+ expect(controller.sendPromptSchedule(DRAFT, "2030-01-01T09:00:00.000Z", "r1")).toBe(false);
+ expect(controller.sendScheduledList("r2")).toBe(false);
+ expect(controller.sendScheduledCancel("ss-1", "r3")).toBe(false);
+ });
+
+ it("sends the exact schedule frames for the open session", () => {
+ const { controller, sent } = open();
+ expect(controller.sendPromptSchedule(DRAFT, "2030-01-01T09:00:00.000Z", "r4")).toBe(true);
+ expect(controller.sendScheduledList("r5")).toBe(true);
+ expect(controller.sendScheduledCancel("ss-1", "r6")).toBe(true);
+ expect(sent.map((raw) => JSON.parse(raw))).toEqual([
+ {
+ t: "prompt_schedule",
+ req: "r4",
+ id: "s1",
+ draft: DRAFT,
+ fire_at: "2030-01-01T09:00:00.000Z",
+ },
+ { t: "scheduled_list", req: "r5", id: "s1" },
+ { t: "scheduled_cancel", req: "r6", id: "s1", schedule_id: "ss-1" },
+ ]);
+ });
+});
+
describe("Prompt Builder send through the shell", () => {
let root: HTMLDivElement | null = null;
let shell: Shell | null = null;
@@ -110,6 +151,9 @@ describe("Prompt Builder send through the shell", () => {
onSpawn: () => true,
onCompose: () => true,
onSendDraft: (draft, req) => controller?.sendPromptDraft(draft, req) ?? false,
+ onSchedule: (draft, fireAt, req) => controller?.sendPromptSchedule(draft, fireAt, req) ?? false,
+ onScheduledList: (req) => controller?.sendScheduledList(req) ?? false,
+ onScheduledCancel: (scheduleId, req) => controller?.sendScheduledCancel(scheduleId, req) ?? false,
onGraphsRequest: () => {},
onWatchGraph: () => {},
onUnwatchGraph: () => {},
@@ -136,9 +180,15 @@ describe("Prompt Builder send through the shell", () => {
shell.showSession("s1", "build");
controller.open("s1");
- channel.sent.length = 0;
shell.showBuilder();
+ // Opening the sheet lists the session's schedules; Send is what this
+ // test counts, so drop the list frame before clicking it.
+ expect(channel.sent.map((raw) => (JSON.parse(raw) as { t: string }).t)).toEqual([
+ "attach",
+ "scheduled_list",
+ ]);
+ channel.sent.length = 0;
const kind = root.querySelector(".section-kind");
if (!kind) {
throw new Error("missing section kind");
diff --git a/apps/remote/web/test/shelf-pick.test.ts b/apps/remote/web/test/shelf-pick.test.ts
index d709139..ec126e2 100644
--- a/apps/remote/web/test/shelf-pick.test.ts
+++ b/apps/remote/web/test/shelf-pick.test.ts
@@ -53,6 +53,9 @@ const CALLBACKS: ShellCallbacks = {
onSpawn: () => true,
onCompose: () => true,
onSendDraft: () => true,
+ onSchedule: () => true,
+ onScheduledList: () => true,
+ onScheduledCancel: () => true,
onGraphsRequest: () => {},
onWatchGraph: () => {},
onUnwatchGraph: () => {},
diff --git a/apps/remote/web/test/shelf.test.ts b/apps/remote/web/test/shelf.test.ts
index 06d359b..99a6e9c 100644
--- a/apps/remote/web/test/shelf.test.ts
+++ b/apps/remote/web/test/shelf.test.ts
@@ -29,6 +29,9 @@ const CALLBACKS: ShellCallbacks = {
onSpawn: () => true,
onCompose: () => true,
onSendDraft: () => true,
+ onSchedule: () => true,
+ onScheduledList: () => true,
+ onScheduledCancel: () => true,
onGraphsRequest: () => {},
onWatchGraph: () => {},
onUnwatchGraph: () => {},
diff --git a/apps/remote/web/test/signout.test.ts b/apps/remote/web/test/signout.test.ts
index b81ea38..514f314 100644
--- a/apps/remote/web/test/signout.test.ts
+++ b/apps/remote/web/test/signout.test.ts
@@ -26,6 +26,9 @@ class StubTerminal implements TerminalHandle {
callback();
return () => {};
};
+ getSelection = (): string => "";
+ onSelectionChange = (): (() => void) => () => {};
+ bufferText = (): string => "";
dispose = (): void => {};
}
@@ -73,6 +76,9 @@ describe("sign out", () => {
onSpawn: () => true,
onCompose: () => true,
onSendDraft: () => true,
+ onSchedule: () => true,
+ onScheduledList: () => true,
+ onScheduledCancel: () => true,
onGraphsRequest: () => {},
onWatchGraph: () => {},
onUnwatchGraph: () => {},
diff --git a/apps/remote/web/test/term-controls.test.ts b/apps/remote/web/test/term-controls.test.ts
index d7906f4..082ba41 100644
--- a/apps/remote/web/test/term-controls.test.ts
+++ b/apps/remote/web/test/term-controls.test.ts
@@ -35,6 +35,7 @@ function deps(root: HTMLElement): TermControlsDeps {
onControlSize: () => {},
onLocalSize: () => {},
onBuilder: () => {},
+ onCopy: () => {},
};
}
diff --git a/apps/remote/web/test/term-copy.test.ts b/apps/remote/web/test/term-copy.test.ts
new file mode 100644
index 0000000..0b1951b
--- /dev/null
+++ b/apps/remote/web/test/term-copy.test.ts
@@ -0,0 +1,424 @@
+// SPDX-License-Identifier: Apache-2.0
+// Copying terminal text out: the buffer as plain text (wrapped rows join,
+// trailing blanks go, only the last maxLines stay), copyText's clipboard
+// then execCommand fallback, the sheet's Copy all / Copy selection, the
+// floating selection button, and the Ctrl+Shift+C key handler.
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ TERM_COPY_FAILURE,
+ copyText,
+ handleCopyKeydown,
+ isCopyKeydown,
+ termCopyFloatHtml,
+ wireCopyFloat,
+ TermCopySheet,
+} from "../src/term-copy.js";
+import type { CopyFloatView } from "../src/term-copy.js";
+import { bufferLinesToText, TerminalView } from "../src/terminal.js";
+import type { BufferLike } from "../src/terminal.js";
+
+function fakeBuffer(rows: ({ text: string; wrapped: boolean } | null)[]): BufferLike {
+ return {
+ length: rows.length,
+ getLine: (y: number) => {
+ const row = rows[y];
+ if (!row) {
+ return undefined;
+ }
+ return {
+ isWrapped: row.wrapped,
+ translateToString: () => row.text,
+ };
+ },
+ };
+}
+
+describe("bufferLinesToText", () => {
+ it("joins a wrapped line into one line", () => {
+ const buffer = fakeBuffer([
+ { text: "hel", wrapped: false },
+ { text: "lo", wrapped: true },
+ { text: "next", wrapped: false },
+ ]);
+ expect(bufferLinesToText(buffer, 2000)).toBe("hello\nnext");
+ });
+
+ it("right-trims every row", () => {
+ const seen: (boolean | undefined)[] = [];
+ const buffer: BufferLike = {
+ length: 1,
+ getLine: () => ({ isWrapped: false, translateToString: (trim) => (seen.push(trim), "ab") }),
+ };
+ expect(bufferLinesToText(buffer, 2000)).toBe("ab");
+ expect(seen).toEqual([true]);
+ });
+
+ it("drops trailing blank lines but keeps interior ones", () => {
+ const buffer = fakeBuffer([
+ { text: "a", wrapped: false },
+ { text: "", wrapped: false },
+ { text: "b", wrapped: false },
+ { text: "", wrapped: false },
+ { text: "", wrapped: false },
+ ]);
+ expect(bufferLinesToText(buffer, 2000)).toBe("a\n\nb");
+ });
+
+ it("keeps the last maxLines lines", () => {
+ const buffer = fakeBuffer([
+ { text: "one", wrapped: false },
+ { text: "two", wrapped: false },
+ { text: "three", wrapped: false },
+ ]);
+ expect(bufferLinesToText(buffer, 2)).toBe("two\nthree");
+ expect(bufferLinesToText(buffer, 99)).toBe("one\ntwo\nthree");
+ });
+
+ it("skips missing rows and refuses a non-positive maxLines", () => {
+ const buffer = fakeBuffer([{ text: "a", wrapped: false }, null, { text: "b", wrapped: false }]);
+ expect(bufferLinesToText(buffer, 2000)).toBe("a\nb");
+ expect(bufferLinesToText(buffer, 0)).toBe("");
+ expect(bufferLinesToText(buffer, -3)).toBe("");
+ });
+});
+
+describe("copyText", () => {
+ let hadClipboard: boolean;
+ let prevClipboard: unknown;
+ let prevExecCommand: typeof document.execCommand | undefined;
+
+ beforeEach(() => {
+ hadClipboard = "clipboard" in navigator;
+ prevClipboard = (navigator as unknown as Record)["clipboard"];
+ prevExecCommand = document.execCommand;
+ });
+
+ afterEach(() => {
+ if (hadClipboard) {
+ Object.defineProperty(navigator, "clipboard", {
+ value: prevClipboard,
+ configurable: true,
+ writable: true,
+ });
+ } else {
+ delete (navigator as unknown as Record)["clipboard"];
+ }
+ if (prevExecCommand === undefined) {
+ delete (document as unknown as Record)["execCommand"];
+ } else {
+ document.execCommand = prevExecCommand;
+ }
+ document.body.innerHTML = "";
+ });
+
+ const stubClipboard = (clipboard: unknown): void => {
+ Object.defineProperty(navigator, "clipboard", { value: clipboard, configurable: true, writable: true });
+ };
+
+ it("copies through the async clipboard when it is there", async () => {
+ const writeText = vi.fn(async (): Promise => {});
+ stubClipboard({ writeText });
+ await expect(copyText("hello")).resolves.toBe(true);
+ expect(writeText).toHaveBeenCalledWith("hello");
+ });
+
+ it("falls back to a temporary textarea and execCommand", async () => {
+ stubClipboard(undefined);
+ const execCommand = vi.fn((): boolean => true);
+ document.execCommand = execCommand as typeof document.execCommand;
+ await expect(copyText("hello")).resolves.toBe(true);
+ expect(execCommand).toHaveBeenCalledWith("copy");
+ // The temporary textarea is gone again.
+ expect(document.body.querySelector("textarea")).toBeNull();
+ });
+
+ it("falls back when the clipboard write rejects", async () => {
+ stubClipboard({
+ writeText: vi.fn(async (): Promise => {
+ throw new Error("denied");
+ }),
+ });
+ document.execCommand = (() => true) as typeof document.execCommand;
+ await expect(copyText("hello")).resolves.toBe(true);
+ });
+
+ it("is false without throwing when both paths fail", async () => {
+ stubClipboard({
+ writeText: vi.fn(async (): Promise => {
+ throw new Error("denied");
+ }),
+ });
+ document.execCommand = (() => false) as typeof document.execCommand;
+ await expect(copyText("hello")).resolves.toBe(false);
+ document.execCommand = (() => {
+ throw new Error("no copy");
+ }) as typeof document.execCommand;
+ await expect(copyText("hello")).resolves.toBe(false);
+ });
+});
+
+describe("TermCopySheet", () => {
+ let parent: HTMLDivElement;
+ let sheet: TermCopySheet;
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ parent = document.createElement("div");
+ document.body.appendChild(parent);
+ });
+
+ afterEach(() => {
+ sheet?.close();
+ vi.useRealTimers();
+ document.body.innerHTML = "";
+ });
+
+ const area = (): HTMLTextAreaElement => {
+ const el = parent.querySelector("#term-copy-text");
+ if (!el) {
+ throw new Error("missing #term-copy-text");
+ }
+ return el;
+ };
+ const status = (): HTMLElement => {
+ const el = parent.querySelector("#term-copy-status");
+ if (!el) {
+ throw new Error("missing #term-copy-status");
+ }
+ return el;
+ };
+
+ it("shows the buffer once, scrolled to the bottom", () => {
+ const reads: number[] = [];
+ sheet = new TermCopySheet(async () => true);
+ sheet.open(parent, {
+ bufferText: (maxLines) => {
+ reads.push(maxLines);
+ return "one\ntwo";
+ },
+ });
+ expect(sheet.isOpen).toBe(true);
+ expect(parent.querySelector(".sheet")?.getAttribute("aria-label")).toBe("Copy from terminal");
+ expect(area().value).toBe("one\ntwo");
+ expect(area().readOnly).toBe(true);
+ expect(area().getAttribute("wrap")).toBe("off");
+ expect(area().getAttribute("spellcheck")).toBe("false");
+ expect(area().scrollTop).toBe(area().scrollHeight);
+ // Read once on open: no live update.
+ expect(reads).toEqual([2000]);
+ });
+
+ it("copies everything on Copy all and flashes Copied", async () => {
+ const copied: string[] = [];
+ sheet = new TermCopySheet(async (text) => {
+ copied.push(text);
+ return true;
+ });
+ sheet.open(parent, { bufferText: () => "one\ntwo" });
+ parent.querySelector("#term-copy-all")?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(copied).toEqual(["one\ntwo"]);
+ expect(status().textContent).toBe("Copied");
+ expect(status().getAttribute("role")).toBe("status");
+ vi.advanceTimersByTime(2000);
+ expect(status().textContent).toBe("");
+ });
+
+ it("enables Copy selection through selectionchange and copies only it", async () => {
+ const copied: string[] = [];
+ sheet = new TermCopySheet(async (text) => {
+ copied.push(text);
+ return true;
+ });
+ sheet.open(parent, { bufferText: () => "one\ntwo" });
+ const button = parent.querySelector("#term-copy-selection");
+ expect(button?.disabled).toBe(true);
+ area().selectionStart = 0;
+ area().selectionEnd = 3;
+ document.dispatchEvent(new Event("selectionchange"));
+ expect(button?.disabled).toBe(false);
+ button?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(copied).toEqual(["one"]);
+ expect(status().textContent).toBe("Copied");
+ });
+
+ it("says how to copy by hand when the copy fails", async () => {
+ sheet = new TermCopySheet(async () => false);
+ sheet.open(parent, { bufferText: () => "one" });
+ parent.querySelector("#term-copy-all")?.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(status().textContent).toBe(TERM_COPY_FAILURE);
+ expect(TERM_COPY_FAILURE).toBe("Could not copy. Select the text and use your browser's Copy.");
+ });
+
+ it("disables Copy all on an empty buffer", () => {
+ sheet = new TermCopySheet(async () => true);
+ sheet.open(parent, { bufferText: () => "" });
+ expect(area().value).toBe("");
+ expect(parent.querySelector("#term-copy-all")?.disabled).toBe(true);
+ expect(parent.querySelector("#term-copy-selection")?.disabled).toBe(true);
+ });
+});
+
+describe("the floating selection button", () => {
+ let button: HTMLButtonElement;
+ let selection = "";
+ let listeners: (() => void)[] = [];
+ let copied: string[] = [];
+ let dispose: (() => void) | null = null;
+
+ const view: CopyFloatView = {
+ getSelection: () => selection,
+ onSelectionChange: (callback: () => void) => {
+ listeners.push(callback);
+ return () => {};
+ },
+ };
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ selection = "";
+ listeners = [];
+ copied = [];
+ const host = document.createElement("div");
+ host.innerHTML = termCopyFloatHtml();
+ document.body.appendChild(host);
+ button = document.querySelector("#term-copy-float") as HTMLButtonElement;
+ dispose = wireCopyFloat(button, view, async (text) => {
+ copied.push(text);
+ return true;
+ });
+ });
+
+ afterEach(() => {
+ dispose?.();
+ dispose = null;
+ vi.useRealTimers();
+ document.body.innerHTML = "";
+ });
+
+ const emit = (next: string): void => {
+ selection = next;
+ for (const listener of listeners) {
+ listener();
+ }
+ };
+
+ it("shows only while a selection exists", () => {
+ expect(button.hidden).toBe(true);
+ expect(button.getAttribute("aria-label")).toBe("Copy selection");
+ emit("picked");
+ expect(button.hidden).toBe(false);
+ emit("");
+ expect(button.hidden).toBe(true);
+ });
+
+ it("copies the selection, flashes Copied and clears nothing", async () => {
+ emit("picked");
+ button.click();
+ await Promise.resolve();
+ await Promise.resolve();
+ expect(copied).toEqual(["picked"]);
+ expect(button.querySelector("span")?.textContent).toBe("Copied");
+ expect(view.getSelection()).toBe("picked");
+ vi.advanceTimersByTime(2000);
+ expect(button.querySelector("span")?.textContent).toBe("Copy");
+ });
+});
+
+describe("the copy shortcut", () => {
+ const key = (overrides: Partial<{ type: string; key: string; ctrlKey: boolean; shiftKey: boolean }>) => ({
+ type: "keydown",
+ key: "c",
+ ctrlKey: true,
+ shiftKey: true,
+ ...overrides,
+ });
+
+ it("names Ctrl+Shift+C on the keydown", () => {
+ expect(isCopyKeydown(key({}))).toBe(true);
+ expect(isCopyKeydown(key({ key: "C" }))).toBe(true);
+ expect(isCopyKeydown(key({ shiftKey: false }))).toBe(false);
+ expect(isCopyKeydown(key({ type: "keyup" }))).toBe(false);
+ expect(isCopyKeydown(key({ key: "x" }))).toBe(false);
+ });
+
+ it("copies and is not forwarded with a selection", async () => {
+ const copied: string[] = [];
+ const copy = async (text: string): Promise => {
+ copied.push(text);
+ return true;
+ };
+ expect(handleCopyKeydown(key({}), () => "picked", copy)).toBe(false);
+ await Promise.resolve();
+ expect(copied).toEqual(["picked"]);
+ });
+
+ it("leaves plain Ctrl+C an interrupt even with a selection", () => {
+ const copy = vi.fn(async (): Promise => true);
+ expect(handleCopyKeydown(key({ shiftKey: false }), () => "picked", copy)).toBe(true);
+ expect(handleCopyKeydown(key({}), () => "", copy)).toBe(true);
+ expect(handleCopyKeydown(key({ key: "x" }), () => "picked", copy)).toBe(true);
+ expect(copy).not.toHaveBeenCalled();
+ });
+});
+
+describe("TerminalView selection and buffer", () => {
+ afterEach(() => {
+ document.body.innerHTML = "";
+ });
+
+ it("forwards getSelection, onSelectionChange and bufferText", () => {
+ const parent = document.createElement("div");
+ document.body.appendChild(parent);
+ let selection = "";
+ const listeners: (() => void)[] = [];
+ const view = new TerminalView(
+ () => ({
+ write: () => {},
+ clear: () => {},
+ focus: () => {},
+ scrollToBottom: () => {},
+ isAtBottom: () => true,
+ onUserInput: () => () => {},
+ onScroll: () => () => {},
+ onReady: (callback: () => void) => {
+ callback();
+ return () => {};
+ },
+ getSelection: () => selection,
+ onSelectionChange: (callback: () => void) => {
+ listeners.push(callback);
+ return () => {};
+ },
+ bufferText: (maxLines: number) => `last ${maxLines}`,
+ dispose: () => {},
+ }),
+ { onUserInput: () => {} },
+ );
+ // Subscribing before the mount still hears the handle.
+ const heard: string[] = [];
+ view.mount(parent, 80, 24);
+ const stop = view.onSelectionChange(() => heard.push(view.getSelection()));
+ selection = "picked";
+ for (const listener of listeners) {
+ listener();
+ }
+ expect(heard).toEqual(["picked"]);
+ expect(view.getSelection()).toBe("picked");
+ expect(view.bufferText(2000)).toBe("last 2000");
+ stop();
+ selection = "again";
+ for (const listener of listeners) {
+ listener();
+ }
+ expect(heard).toEqual(["picked"]);
+ view.dispose();
+ expect(view.getSelection()).toBe("");
+ expect(view.bufferText(2000)).toBe("");
+ });
+});
diff --git a/apps/remote/web/test/terminal-view.test.ts b/apps/remote/web/test/terminal-view.test.ts
index 370d650..04613cd 100644
--- a/apps/remote/web/test/terminal-view.test.ts
+++ b/apps/remote/web/test/terminal-view.test.ts
@@ -39,6 +39,7 @@ class FakeHandle implements TerminalHandle {
private inputListeners: ((text: string) => void)[] = [];
private scrollListeners: (() => void)[] = [];
private bufferListeners: (() => void)[] = [];
+ private selectionListeners: (() => void)[] = [];
constructor(cols: number, rows: number) {
this.cols = cols;
@@ -76,6 +77,14 @@ class FakeHandle implements TerminalHandle {
callback();
return () => {};
};
+ selection = "";
+ bufferLines = "";
+ getSelection = (): string => this.selection;
+ onSelectionChange = (callback: () => void): (() => void) => {
+ this.selectionListeners.push(callback);
+ return () => {};
+ };
+ bufferText = (): string => this.bufferLines;
resize = (cols: number, rows: number): void => {
this.cols = cols;
this.rows = rows;
@@ -91,6 +100,13 @@ class FakeHandle implements TerminalHandle {
}
}
+ emitSelectionChange(selection: string): void {
+ this.selection = selection;
+ for (const listener of this.selectionListeners) {
+ listener();
+ }
+ }
+
emitScroll(atBottom: boolean): void {
this.atBottom = atBottom;
for (const listener of this.scrollListeners) {
diff --git a/apps/remote/web/test/titles.test.ts b/apps/remote/web/test/titles.test.ts
index 4024eb9..f88e874 100644
--- a/apps/remote/web/test/titles.test.ts
+++ b/apps/remote/web/test/titles.test.ts
@@ -85,6 +85,9 @@ describe("shell titles", () => {
onSpawn: () => true,
onCompose: () => true,
onSendDraft: () => true,
+ onSchedule: () => true,
+ onScheduledList: () => true,
+ onScheduledCancel: () => true,
onGraphsRequest: () => {},
onWatchGraph: () => {},
onUnwatchGraph: () => {},
diff --git a/apps/remote/web/test/touch-targets.spec.ts b/apps/remote/web/test/touch-targets.spec.ts
index eabc72a..38edf64 100644
--- a/apps/remote/web/test/touch-targets.spec.ts
+++ b/apps/remote/web/test/touch-targets.spec.ts
@@ -108,10 +108,10 @@ test("touch_targets_meet_44px", async ({ page }) => {
}),
);
process.stdout.write(`session bar boxes at 390x844: ${JSON.stringify(barBoxes, null, 2)}\n`);
- // Zoom out, zoom in, Keys, Builder, Attach, Attach from shelf,
+ // Zoom out, zoom in, Keys, Builder, Copy, Attach, Attach from shelf,
// fullscreen, and the ten keys; latest stays hidden until the user
// scrolls up.
- expect(barBoxes).toHaveLength(17);
+ expect(barBoxes).toHaveLength(18);
for (const control of barBoxes) {
expect(control.width, `${control.label} width`).toBeGreaterThanOrEqual(44);
expect(control.height, `${control.label} height`).toBeGreaterThanOrEqual(44);
@@ -121,8 +121,9 @@ test("touch_targets_meet_44px", async ({ page }) => {
// Audit (2026-11-04): the terminal toolbar at phone widths. Keys goes
// icon-only under 640 px (aria-label and title name it), so no label runs
// past its button; the latest button floats over the terminal card's
-// bottom-right corner instead of wrapping onto a second toolbar row (which
-// cost the terminal a row), so the toolbar stays one 44 px row; and the
+// bottom-right corner instead of taking a toolbar slot; the toolbar
+// itself never wraps onto a second row (which cost the terminal a row)
+// but scrolls sideways instead, so it stays one 44 px row; and the
// size switch's label is a 44 px target.
for (const viewport of [
{ width: 360, height: 640 },
@@ -162,9 +163,13 @@ for (const viewport of [
const latest = latestEl?.getBoundingClientRect();
const pane = document.querySelector("#term-pane")?.getBoundingClientRect();
const rowBox = controls.getBoundingClientRect();
+ const rowStyle = getComputedStyle(controls);
return {
pageOverflow: doc.scrollWidth - doc.clientWidth,
- rowOverflow: controls.scrollWidth - controls.clientWidth,
+ rowLeft: rowBox.left,
+ rowScrollWidth: controls.scrollWidth,
+ rowWrap: rowStyle.flexWrap,
+ rowScroll: rowStyle.overflowX,
rowHeight: rowBox.height,
buttons,
toggle: toggle ? { width: toggle.width, height: toggle.height } : null,
@@ -179,8 +184,10 @@ for (const viewport of [
process.stdout.write(`toolbar at ${viewport.width}px: ${JSON.stringify(report)}\n`);
await page.screenshot({ path: testInfo.outputPath(`toolbar-${viewport.width}.png`) });
expect(report.pageOverflow).toBeLessThanOrEqual(1);
- expect(report.rowOverflow).toBeLessThanOrEqual(1);
- // One toolbar row: every control at the 44 px floor, none wrapped.
+ // One toolbar row: it never wraps (nowrap), it scrolls sideways
+ // instead, so every control stays at the 44 px floor on one row.
+ expect(report.rowWrap).toBe("nowrap");
+ expect(report.rowScroll).toBe("auto");
expect(report.rowHeight).toBeLessThan(2 * 44);
expect(new Set(report.buttons.map((button) => Math.round(button.top))).size).toBe(1);
// latest floats over the card's bottom-right corner: a 44 px named
@@ -206,7 +213,12 @@ for (const viewport of [
expect(button.width, `${button.id} width`).toBeGreaterThanOrEqual(44);
expect(button.height, `${button.id} height`).toBeGreaterThanOrEqual(44);
expect(button.left, `${button.id} left`).toBeGreaterThanOrEqual(0);
- expect(button.right, `${button.id} right`).toBeLessThanOrEqual(viewport.width);
+ // Past the viewport edge a button still sits inside the scrollable
+ // row: one swipe reaches it.
+ expect(
+ button.right - report.rowLeft,
+ `${button.id} inside the scrollable row`,
+ ).toBeLessThanOrEqual(report.rowScrollWidth + 1);
}
expect(report.toggle?.width ?? 0).toBeGreaterThanOrEqual(44);
expect(report.toggle?.height ?? 0).toBeGreaterThanOrEqual(44);