diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 351082580d63..02732a4e1410 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -75,6 +75,7 @@ import { type PendingModifier, } from "./terminalInput"; import { createTerminalPasteSession } from "./terminalPaste"; +import { useTerminalClipboard } from "./useTerminalClipboard"; import { cacheTerminalGridSize, getCachedTerminalGridSize } from "./terminalUiState"; const DEFAULT_TERMINAL_COLS = 80; @@ -335,6 +336,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const terminalKey = selectedThread ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` : terminalId; + useTerminalClipboard(selectedThread?.environmentId ?? null, terminalAttachInput); const bufferReplayKey = useMemo( () => getTerminalBufferReplayKey({ terminalKey, fontSize }), [fontSize, terminalKey], diff --git a/apps/mobile/src/features/terminal/useTerminalClipboard.ts b/apps/mobile/src/features/terminal/useTerminalClipboard.ts new file mode 100644 index 000000000000..59766c6e2df7 --- /dev/null +++ b/apps/mobile/src/features/terminal/useTerminalClipboard.ts @@ -0,0 +1,65 @@ +import { useFocusEffect } from "@react-navigation/native"; +import { environmentRpcKey } from "@t3tools/client-runtime/state/runtime"; +import { + createTerminalClipboardSession, + createTerminalClipboardWriter, +} from "@t3tools/client-runtime/terminal-clipboard"; +import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts"; +import * as Clipboard from "expo-clipboard"; +import { useCallback, useLayoutEffect, useMemo, useRef } from "react"; +import { AppState, Platform } from "react-native"; +import { terminalEnvironment } from "../../state/terminal"; + +const writeClipboard = createTerminalClipboardWriter((text) => Clipboard.setStringAsync(text)); + +/** Route focus, not keyboard visibility, owns touch-driven TUI copies on iOS and Android. */ +export function useTerminalClipboard( + environmentId: EnvironmentId | null, + input: TerminalAttachInput | null, +) { + const target = useMemo( + () => (environmentId !== null && input !== null ? { environmentId, input } : null), + [environmentId, input], + ); + const targetKey = useMemo(() => (target === null ? null : environmentRpcKey(target)), [target]); + const latestTarget = useRef(target); + useLayoutEffect(() => { + latestTarget.current = target; + }, [target]); + + useFocusEffect( + useCallback(() => { + // The key, not the object, restarts the session: equal attach values + // recreated by a render must not drop a copy that is mid-sequence. + const target = targetKey === null ? null : latestTarget.current; + if (target === null) return; + let active = false; + const session = createTerminalClipboardSession({ + isEligible: () => active, + onCopy: (text, canWrite) => void writeClipboard(text, canWrite), + }); + const setActive = (next: boolean) => { + active = next; + if (!next) session.invalidate(); + }; + const stop = terminalEnvironment.observeAttach(target, session.update); + const activate = () => setActive(AppState.currentState === "active"); + activate(); + const change = AppState.addEventListener("change", activate); + // Android can lose interaction focus without changing AppState (e.g. its notification drawer). + const blur = + Platform.OS === "android" + ? AppState.addEventListener("blur", () => setActive(false)) + : undefined; + const focus = + Platform.OS === "android" ? AppState.addEventListener("focus", activate) : undefined; + return () => { + setActive(false); + stop(); + change.remove(); + blur?.remove(); + focus?.remove(); + }; + }, [targetKey]), + ); +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9ddf9225bdf..fa3dc195686e 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -84,6 +84,10 @@ import { terminalEnvironment } from "../state/terminal"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; +import { + copyTerminalClipboardFromGesture, + writeTerminalClipboard, +} from "../terminal/ghostty/clipboard"; import { resolveTerminalFontPreference, resolveTerminalFontSizePreference, @@ -141,9 +145,9 @@ function parseTerminalColor(value: string, fallback: GhosttyColor): GhosttyColor }; } -function runtimeEnvSignature(runtimeEnv: Record | undefined): string { - if (!runtimeEnv) return ""; - return JSON.stringify( +function normalizeRuntimeEnv(runtimeEnv: Record | undefined) { + if (!runtimeEnv) return undefined; + return Object.fromEntries( Object.entries(runtimeEnv) .filter(([key, value]) => key.length > 0 && typeof value === "string") .sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)), @@ -377,7 +381,10 @@ export function TerminalViewport({ // cannot be mistaken for the active flow. const openSelectionMenuRequestIdRef = useRef(null); const keybindingsRef = useRef(keybindings); - const runtimeEnvKey = useMemo(() => runtimeEnvSignature(runtimeEnv), [runtimeEnv]); + const [terminalEnv, runtimeEnvKey] = useMemo(() => { + const env = normalizeRuntimeEnv(runtimeEnv); + return [env, env ? JSON.stringify(env) : ""] as const; + }, [runtimeEnv]); const handleSessionExited = useEffectEvent(() => { onSessionExited(); }); @@ -401,16 +408,17 @@ export function TerminalViewport({ }), ); const terminalFontRef = useRef({ family: terminalFontFamily, size: terminalFontSize }); + const terminalAttachInput = { + threadId, + terminalId, + cwd, + ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(terminalEnv ? { env: terminalEnv } : {}), + ...(providerInstanceId ? { providerInstanceId } : {}), + }; const terminalSession = useAttachedTerminalSession({ environmentId, - terminal: { - threadId, - terminalId, - cwd, - ...(worktreePath !== undefined ? { worktreePath } : {}), - ...(runtimeEnv ? { env: runtimeEnv } : {}), - ...(providerInstanceId ? { providerInstanceId } : {}), - }, + terminal: terminalAttachInput, }); const writeTerminal = useEffectEvent((data: string) => runTerminalWrite({ @@ -490,6 +498,8 @@ export function TerminalViewport({ const setup = async (): Promise<(() => void) | null> => { const setupFont = terminalFontRef.current; + const clipboardToastId = `terminal-copy:${environmentId}:${threadId}:${terminalId}`; + let clipboardRequest = 0; const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), font: terminalFontOptions(setupFont.family, setupFont.size), @@ -499,6 +509,32 @@ export function TerminalViewport({ onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), + onClipboardWrite: async (text, canWrite) => { + const request = ++clipboardRequest; + const result = await writeTerminalClipboard(text, canWrite); + if (request !== clipboardRequest || !canWrite()) return; + if (result === "written") toastManager.close(clipboardToastId); + else if (result === "failed") { + toastManager.add({ + id: clipboardToastId, + type: "info", + title: text === "" ? "Clear clipboard?" : "Terminal text ready to copy", + description: "Your browser needs a click to allow this clipboard write.", + timeout: 10_000, + actionProps: { + children: text === "" ? "Clear" : "Copy", + onClick: () => { + if (copyTerminalClipboardFromGesture(text)) toastManager.close(clipboardToastId); + else + toastManager.update(clipboardToastId, { + type: "error", + title: "Clipboard write was blocked", + }); + }, + }, + }); + } + }, beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), // The surface listens from construction, so a right-click can land @@ -513,6 +549,7 @@ export function TerminalViewport({ terminal.dispose(); return null; } + setupCleanups.push(() => toastManager.close(clipboardToastId)); terminal.setVisible(visibleRef.current); // The theme observer is not installed yet, so re-read the theme in case // the app toggled light/dark while the WASM surface was loading. @@ -548,6 +585,12 @@ export function TerminalViewport({ terminal.focus(); } + setupCleanups.push( + terminalEnvironment.observeAttach({ environmentId, input: terminalAttachInput }, (event) => + terminal.observeClipboard(event), + ), + ); + const dismissSelectionAction = (supersede = false) => { const ownsMenu = openSelectionMenuRequestIdRef.current === selectionActionRequestIdRef.current; @@ -729,19 +772,6 @@ export function TerminalViewport({ } }; - const sendTerminalInput = async (data: string, fallbackError: string) => { - const activeTerminal = terminalRef.current; - if (!activeTerminal) return; - const result = await writeTerminal(data); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - writeSystemMessage( - activeTerminal, - error instanceof Error ? error.message : fallbackError, - ); - } - }; - function handleBeforeKey(event: KeyboardEvent): boolean { const currentKeybindings = keybindingsRef.current; const options = { context: { terminalFocus: true, terminalOpen: true } }; @@ -762,7 +792,7 @@ export function TerminalViewport({ if (navigationData !== null) { event.preventDefault(); event.stopPropagation(); - void sendTerminalInput(navigationData, "Failed to move cursor"); + terminalRef.current?.sendUserInput(navigationData); return false; } @@ -770,14 +800,14 @@ export function TerminalViewport({ if (deleteData !== null) { event.preventDefault(); event.stopPropagation(); - void sendTerminalInput(deleteData, "Failed to delete terminal input"); + terminalRef.current?.sendUserInput(deleteData); return false; } if (!isTerminalClearShortcut(event)) return true; event.preventDefault(); event.stopPropagation(); - void sendTerminalInput("\u000c", "Failed to clear terminal"); + terminalRef.current?.sendUserInput("\u000c"); return false; } @@ -920,7 +950,7 @@ export function TerminalViewport({ teardown?.(); if (hadFocus && mount.isConnected) mount.focus({ preventScroll: true }); }; - }, [cwd, environmentId, runtimeEnvKey, terminalId, threadId, worktreePath]); + }, [cwd, environmentId, providerInstanceId, runtimeEnvKey, terminalId, threadId, worktreePath]); useEffect(() => { const terminal = terminalRef.current; @@ -944,7 +974,6 @@ export function TerminalViewport({ const outputUpdate = readTerminalOutputUpdate(current.output, outputCursorRef.current); writeTerminalOutputUpdate(terminal, outputUpdate); outputCursorRef.current = outputUpdate.cursor; - terminal.clearSelection(); if (current.error !== null && current.error !== previous.error) { writeSystemMessage(terminal, current.error); diff --git a/apps/web/src/terminal/ghostty/clipboard.test.ts b/apps/web/src/terminal/ghostty/clipboard.test.ts new file mode 100644 index 000000000000..eaff49afc8f0 --- /dev/null +++ b/apps/web/src/terminal/ghostty/clipboard.test.ts @@ -0,0 +1,89 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { copyTerminalClipboardFromGesture, writeTerminalClipboard } from "./clipboard"; + +describe("application clipboard writes", () => { + afterEach(() => vi.unstubAllGlobals()); + + it.each(["text", ""])("copies %j during a gesture and removes its copy handler", (text) => { + const setData = vi.fn(); + const document = Object.assign(new EventTarget(), { + execCommand: () => + document.dispatchEvent( + Object.assign(new Event("copy", { cancelable: true }), { clipboardData: { setData } }), + ), + }); + vi.stubGlobal("document", document); + expect(setData).not.toHaveBeenCalled(); + expect(copyTerminalClipboardFromGesture(text)).toBe(true); + expect(setData.mock.calls).toEqual([["text/plain", text]]); + document.execCommand(); + expect(setData).toHaveBeenCalledTimes(1); + }); + + it("reports a blocked gesture without leaving a copy handler installed", () => { + const document = Object.assign(new EventTarget(), { + execCommand: () => { + throw new Error("denied"); + }, + }); + vi.stubGlobal("document", document); + expect(copyTerminalClipboardFromGesture("blocked")).toBe(false); + const setData = vi.fn(); + document.dispatchEvent(Object.assign(new Event("copy"), { clipboardData: { setData } })); + expect(setData).not.toHaveBeenCalled(); + }); + + it.each(["success", "denied"])( + "serializes writes and keeps only the newest pending text after %s", + async (result) => { + let finishFirst!: () => void; + const first = new Promise((resolve) => { + finishFirst = resolve; + }); + let clipboard = ""; + const writeText = vi.fn(async (text: string) => { + if (text === "first") { + await first; + if (result === "denied") throw new Error("NotAllowedError"); + } + clipboard = text; + }); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + const writes = [ + writeTerminalClipboard("first"), + writeTerminalClipboard("superseded"), + writeTerminalClipboard("last"), + ]; + const callsWhilePending = [...writeText.mock.calls]; + finishFirst(); + expect(await Promise.all(writes)).toEqual([ + result === "denied" ? "failed" : "written", + "skipped", + "written", + ]); + expect(callsWhilePending).toEqual([["first"]]); + expect(writeText.mock.calls).toEqual([["first"], ["last"]]); + expect(clipboard).toBe("last"); + }, + ); + + it.each(["success", "denied", "unavailable"])( + "keeps browser focus and consumes failures when clipboard access is %s", + async (result) => { + const writeText = vi.fn(() => + result === "denied" ? Promise.reject(new Error("NotAllowedError")) : Promise.resolve(), + ); + const createElement = vi.fn(); + const execCommand = vi.fn(); + vi.stubGlobal("navigator", result === "unavailable" ? {} : { clipboard: { writeText } }); + vi.stubGlobal("document", { createElement, execCommand }); + await expect(writeTerminalClipboard("application text")).resolves.toBe( + result === "success" ? "written" : "failed", + ); + await expect(writeTerminalClipboard("inactive", () => false)).resolves.toBe("skipped"); + expect(writeText.mock.calls).toEqual(result === "unavailable" ? [] : [["application text"]]); + expect(createElement).not.toHaveBeenCalled(); + expect(execCommand).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/apps/web/src/terminal/ghostty/clipboard.ts b/apps/web/src/terminal/ghostty/clipboard.ts new file mode 100644 index 000000000000..8ba71438d7bf --- /dev/null +++ b/apps/web/src/terminal/ghostty/clipboard.ts @@ -0,0 +1,27 @@ +import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard"; + +/** Application output cannot use the focus-stealing, user-gesture copy fallback. */ +export const writeTerminalClipboard = createTerminalClipboardWriter((text) => + navigator.clipboard?.writeText(text), +); + +/** A click can authorize copying on HTTP pages, including clearing with empty text. */ +export function copyTerminalClipboardFromGesture(text: string): boolean { + let copied = false; + const onCopy = (event: ClipboardEvent) => { + if (!event.clipboardData) return; + event.clipboardData.setData("text/plain", text); + event.preventDefault(); + event.stopPropagation(); + copied = true; + }; + document.addEventListener("copy", onCopy, { capture: true }); + try { + document.execCommand("copy"); + return copied; + } catch { + return false; + } finally { + document.removeEventListener("copy", onCopy, { capture: true }); + } +} diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 48cc4256de61..08e7570a379f 100644 --- a/apps/web/src/terminal/ghostty/core.test.ts +++ b/apps/web/src/terminal/ghostty/core.test.ts @@ -58,6 +58,23 @@ describe("ghosttyCellText", () => { }); describe("GhosttyTerminalCore snapshots", () => { + it("releases point conversion allocations when the native call throws", async () => { + const core = await createCore(); + const runtime = await loadGhosttyRuntime(); + const call = runtime.call.bind(runtime); + vi.spyOn(runtime, "call").mockImplementation((name, ...args) => { + if (name === "ghostty_terminal_point_from_grid_ref") throw new Error("conversion failed"); + return call(name, ...args); + }); + const allocate = vi.spyOn(runtime, "alloc"); + const free = vi.spyOn(runtime, "free"); + expect(() => core.viewportPointToScreen(0, 0)).toThrow("conversion failed"); + for (const result of allocate.mock.results) { + if (result.type === "return") + expect(free.mock.calls.some(([pointer]) => pointer === result.value)).toBe(true); + } + }); + const cores = new Set(); async function createCore(onData: (data: string) => void = () => {}) { diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index d01e20529d45..2d7b9ad9329c 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -839,6 +839,26 @@ export class GhosttyTerminalCore { return this.convertPoint(col, row, 1, 2); } + /** Resolve terminal-owned selection endpoints before their borrowed refs can go stale. */ + selectionPosition(): GhosttySelectionRange["screen"] | null { + this.ensureActive(); + const layout = this.runtime.layout("GhosttySelection"); + const selection = this.runtime.alloc(layout.size); + try { + this.runtime.setField(selection, "GhosttySelection", "size", layout.size); + if ( + this.runtime.call("ghostty_terminal_get", this.terminal, 31, selection) !== GHOSTTY_SUCCESS + ) { + return null; + } + const start = this.pointFromGridRef(selection + layout.fields.start!.offset, 2); + const end = this.pointFromGridRef(selection + layout.fields.end!.offset, 2); + return start && end ? { start, end } : null; + } finally { + this.runtime.free(selection, layout.size); + } + } + screenPointToViewport(col: number, row: number): { x: number; y: number } | null { return this.convertPoint(col, row, 2, 1); } @@ -851,9 +871,11 @@ export class GhosttyTerminalCore { ): { x: number; y: number } | null { this.ensureActive(); const ref = this.gridRef(col, row, fromTag); - const point = this.pointFromGridRef(ref, toTag); - this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); - return point; + try { + return this.pointFromGridRef(ref, toTag); + } finally { + this.runtime.free(ref, this.runtime.layout("GhosttyGridRef").size); + } } dispose(): void { @@ -1113,22 +1135,25 @@ export class GhosttyTerminalCore { private pointFromGridRef(ref: number, tag: 1 | 2): { x: number; y: number } | null { const coordinateLayout = this.runtime.layout("GhosttyPointCoordinate"); const coordinate = this.runtime.alloc(coordinateLayout.size); - const result = this.runtime.call( - "ghostty_terminal_point_from_grid_ref", - this.terminal, - ref, - tag, - coordinate, - ); - const point = - result === GHOSTTY_SUCCESS - ? { - x: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "x"), - y: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "y"), - } - : null; - this.runtime.free(coordinate, coordinateLayout.size); - return point; + try { + const result = this.runtime.call( + "ghostty_terminal_point_from_grid_ref", + this.terminal, + ref, + tag, + coordinate, + ); + const point = + result === GHOSTTY_SUCCESS + ? { + x: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "x"), + y: this.runtime.readField(coordinate, "GhosttyPointCoordinate", "y"), + } + : null; + return point; + } finally { + this.runtime.free(coordinate, coordinateLayout.size); + } } private getU16(data: number): number { diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index ee3240b41b08..75e3f7898aa3 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { ThreadId, type TerminalAttachStreamEvent } from "@t3tools/contracts"; +import { writeTerminalClipboard } from "./clipboard"; import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core"; import { DEFAULT_TERMINAL_FONT_FAMILY, @@ -40,6 +42,28 @@ vi.mock("./vendor/ghostty-write-pty.wasm?url&no-inline", async () => ({ default: (await import("./vendor/ghostty-write-pty.wasm?inline")).default, })); +const clipboardTarget = { threadId: ThreadId.make("thread"), terminalId: "terminal" }; +const clipboardOutput = (data: string): TerminalAttachStreamEvent => ({ + type: "output", + ...clipboardTarget, + data, +}); +const clipboardSnapshot = (history: string): TerminalAttachStreamEvent => ({ + type: "snapshot", + snapshot: { + ...clipboardTarget, + cwd: "/tmp", + worktreePath: null, + status: "running", + pid: 1, + history, + exitCode: null, + exitSignal: null, + label: "Terminal 1", + updatedAt: "2026-09-05T00:00:00Z", + }, +}); + describe("GhosttyTerminalSurface visibility", () => { const surfaces = new Set(); @@ -67,6 +91,7 @@ describe("GhosttyTerminalSurface visibility", () => { private readonly captures = new Set(); setAttribute() {} + select() {} append(...children: TerminalTestElement[]) { for (const child of children) child.parentElement = this; } @@ -117,6 +142,7 @@ describe("GhosttyTerminalSurface visibility", () => { }), }; vi.stubGlobal("document", { + hasFocus: () => true, createElement: (tag: string) => (tag === "canvas" ? canvas : new TerminalTestElement()), fonts: Object.assign(new EventTarget(), { load: async () => [], add() {} }), }); @@ -168,14 +194,45 @@ describe("GhosttyTerminalSurface visibility", () => { resize() { for (const callback of resizeCallbacks) callback(); }, - pointer(type: string, clientX: number, buttons: number) { + pointer( + type: string, + clientX: number, + buttons: number, + shiftKey = false, + clientY = 5, + options: Partial = {}, + ) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, - clientY: 5, + clientY, pointerId: 1, button: 0, buttons, + shiftKey, + ...options, + }), + ); + }, + loseCapture(clientX = 5, clientY = 5) { + canvas.releasePointerCapture(1); + canvas.dispatchEvent( + Object.assign(new Event("lostpointercapture"), { + pointerId: 1, + clientX, + clientY, + buttons: 0, + }), + ); + }, + wheel(deltaY = 1, deltaMode = 1) { + canvas.dispatchEvent( + Object.assign(new Event("wheel", { cancelable: true }), { + deltaY, + deltaMode, + clientX: 5, + clientY: 5, + shiftKey: false, }), ); }, @@ -210,6 +267,336 @@ describe("GhosttyTerminalSurface visibility", () => { vi.restoreAllMocks(); }); + function key( + surface: GhosttyTerminalSurface, + value: string, + code: string, + modifiers: Partial> = {}, + type = "keydown", + ) { + surface.input.dispatchEvent( + Object.assign(new Event(type, { cancelable: true }), { + key: value, + code, + repeat: false, + isComposing: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + getModifierState: () => false, + ...modifiers, + }), + ); + } + + it.each(["Linux x86_64", "Win32"])( + "lets Ctrl+C interrupt after typing resumes on %s", + async (platform) => { + const harness = createHarness(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { platform, clipboard: { writeText } }); + const surface = await harness.create({ beforeKey: () => true }); + surface.write("hello world\r\nprompt: "); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 37, 0); + expect(surface.getSelection()).toBe("hello"); + key(surface, "x", "KeyX"); + surface.write("x"); + key(surface, "c", "KeyC", { ctrlKey: true }); + expect(surface.getSelection()).toBe(""); + expect(harness.onData.mock.calls).toEqual([["x"], ["\x03"]]); + expect(writeText).not.toHaveBeenCalled(); + }, + ); + + it.each([ + "input", + "composition", + "native paste", + "menu paste", + "shortcut paste", + "forwarded shortcut", + ])("retires a completed selection on %s", async (path) => { + const harness = createHarness(); + vi.stubGlobal("navigator", { + platform: "MacIntel", + clipboard: { readText: async () => "text" }, + }); + const surface = await harness.create({ beforeKey: () => true }); + surface.write("\x1b[?2004hhello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 37, 0); + const paste = path.includes("paste"); + switch (path) { + case "input": + surface.input.value = "text"; + surface.input.dispatchEvent(new Event("input")); + break; + case "composition": + surface.input.dispatchEvent(new Event("compositionstart")); + surface.input.value = "text"; + surface.input.dispatchEvent(Object.assign(new Event("compositionend"), { data: "text" })); + surface.input.dispatchEvent( + Object.assign(new Event("input"), { data: "text", inputType: "insertFromComposition" }), + ); + break; + case "native paste": + surface.input.dispatchEvent( + Object.assign(new Event("paste", { cancelable: true }), { + clipboardData: { getData: () => "text" }, + }), + ); + break; + case "menu paste": + await surface.pasteFromClipboard(async () => "text"); + break; + case "shortcut paste": + key(surface, "v", "KeyV", { metaKey: true }); + await Promise.resolve(); + break; + case "forwarded shortcut": + surface.sendUserInput("\x01"); + break; + } + expect(surface.getSelection()).toBe(""); + expect(harness.onData.mock.calls).toEqual([ + [paste ? "\x1b[200~text\x1b[201~" : path === "forwarded shortcut" ? "\x01" : "text"], + ]); + }); + + it("preserves selection for protocol replies, modifier events and native copy", async () => { + const harness = createHarness(); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal("navigator", { platform: "MacIntel", clipboard: { writeText } }); + const surface = await harness.create({ beforeKey: () => true }); + surface.write("hello world\x1b[>11u"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 37, 0); + surface.write("\x1b[5n"); + expect(harness.onData).toHaveBeenCalledWith("\x1b[0n"); + harness.onData.mockClear(); + key(surface, "Shift", "ShiftLeft", { shiftKey: true }); + key(surface, "Shift", "ShiftLeft", {}, "keyup"); + expect(harness.onData).toHaveBeenCalledTimes(2); + expect(surface.getSelection()).toBe("hello"); + key(surface, "c", "KeyC", { metaKey: true }); + await Promise.resolve(); + await Promise.resolve(); + expect(writeText).toHaveBeenCalledWith("hello"); + expect(surface.getSelection()).toBe("hello"); + }); + + it("does not let an older copy completion clear a newer selection", async () => { + const harness = createHarness(); + let finishCopy!: () => void; + const copied = new Promise((resolve) => { + finishCopy = resolve; + }); + vi.stubGlobal("navigator", { + platform: "Linux x86_64", + clipboard: { writeText: () => copied }, + }); + const surface = await harness.create({ beforeKey: () => true }); + surface.write("hello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 37, 0); + key(surface, "c", "KeyC", { ctrlKey: true }); + await Promise.resolve(); + harness.pointer("pointerdown", 53, 1); + harness.pointer("pointerup", 85, 0); + expect(surface.getSelection()).toBe("world"); + finishCopy(); + await copied; + expect(surface.getSelection()).toBe("world"); + }); + + it("clears an unmoved selection when its pointer is cancelled", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("hello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointercancel", 5, 0, false, 5, { button: -1 }); + expect(surface.getSelection()).toBe(""); + expect(surface.getSelectionPosition()).toBeNull(); + }); + + it("allows a native selection after an application drag loses capture", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("\x1b[?1002h\x1b[?1006hhello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.loseCapture(37); + expect(harness.onData.mock.calls).toEqual([["\x1b[<0;1;1M"], ["\x1b[<0;5;1m"]]); + harness.pointer("pointerup", 37, 0); + expect(harness.onData).toHaveBeenCalledTimes(2); + harness.onData.mockClear(); + harness.pointer("pointerdown", 5, 1, true); + harness.pointer("pointermove", 37, 1, true); + harness.pointer("pointerup", 37, 0, true); + expect(surface.getSelection()).toBe("hello"); + expect(harness.onData).not.toHaveBeenCalled(); + }); + + it("recognizes input focus acquired before asynchronous surface setup finishes", async () => { + const harness = createHarness(); + const createElement = document.createElement.bind(document); + vi.spyOn(document, "createElement").mockImplementation((tag) => { + const element = createElement(tag); + if (tag === "textarea") + Object.defineProperty(document, "activeElement", { value: element, configurable: true }); + return element; + }); + const copy = vi.fn(); + const surface = await harness.create({ onClipboardWrite: copy }); + surface.observeClipboard(clipboardOutput("\x1b]52;c;aGVsbG8=\x07")); + expect(copy).toHaveBeenCalledWith("hello", expect.any(Function)); + }); + + it.each([1002, 1003])("keeps one application pointer owner in mouse mode %s", async (mode) => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write(`\x1b[?${mode}h\x1b[?1006hhello world`); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.onData.mockClear(); + harness.pointer("pointerdown", 37, 1, false, 5, { pointerId: 2 }); + harness.pointer("pointermove", 45, 1, false, 5, { pointerId: 2 }); + harness.pointer("pointerup", 45, 0, false, 5, { pointerId: 2 }); + expect(harness.onData).not.toHaveBeenCalled(); + harness.pointer("pointerup", 5, 0); + expect(harness.onData.mock.calls).toEqual([["\x1b[<0;1;1m"]]); + }); + + it("only repaints the cursor row for an application mouse press without selection", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("\x1b[?1002h\x1b[?1006hhello world\r\ncursor row"); + harness.flushFrame(); + harness.paint.mockClear(); + harness.pointer("pointerdown", 5, 1); + harness.flushFrame(); + expect(harness.onData).toHaveBeenCalled(); + expect( + harness.paint.mock.calls + .filter(([operation]) => operation === "fillText") + .map(([, args]) => args[0]), + ).toEqual(["cursor row"]); + }); + + it("ends selection on left release while another mouse button remains held", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("hello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1); + // Chorded button changes arrive as pointermove until the final release. + harness.pointer("pointermove", 37, 3, false, 5, { button: 2 }); + harness.pointer("pointermove", 37, 2, false, 5, { button: 0 }); + harness.pointer("pointermove", 85, 2); + harness.pointer("pointerup", 85, 0, false, 5, { button: 2 }); + expect(surface.getSelection()).toBe("hello"); + }); + + it.each([false, true])( + "retires stale selection coordinates after scrollback eviction (dragging: %s)", + async (dragging) => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("hello world\r\n".repeat(11_000)); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1); + if (!dragging) harness.pointer("pointerup", 37, 0); + expect(surface.getSelection()).toBe("hello"); + surface.write("later output\r\n".repeat(500)); + expect(surface.getSelectionPosition()).toBeNull(); + expect(surface.getSelection()).toBe(""); + harness.pointer("pointerup", 85, 0); + expect(surface.getSelection()).toBe(""); + expect(surface.getSelectionEndClientRect()).toBeNull(); + }, + ); + + it.each([ + ["Fn", "Fn"], + ["FnLock", "FnLock"], + ["Super", "MetaLeft"], + ["Hyper", "MetaRight"], + ])("preserves a selection for the %s modifier", async (value, code) => { + const harness = createHarness(); + vi.stubGlobal("navigator", { platform: "MacIntel" }); + const surface = await harness.create({ beforeKey: () => true }); + surface.write("hello world\x1b[>11u"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointerup", 37, 0); + key(surface, value, code); + key(surface, value, code, {}, "keyup"); + expect(surface.getSelection()).toBe("hello"); + }); + + it.each([ + "release", + "cancel", + "capture loss", + "hide", + "input blur", + "window blur", + "reset", + "dispose", + ])("stops selection autoscroll on %s", async (end) => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("hello world\r\n".repeat(20)); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1, false, 120); + const selected = surface.getSelection(); + switch (end) { + case "release": + harness.pointer("pointerup", 37, 0, false, 120); + break; + case "cancel": + harness.pointer("pointercancel", 37, 0, false, 120); + break; + case "capture loss": + harness.loseCapture(); + break; + case "hide": + surface.setVisible(false); + surface.setVisible(true); + break; + case "input blur": + surface.input.dispatchEvent(new Event("blur")); + break; + case "window blur": + window.dispatchEvent(new Event("blur")); + break; + case "reset": + surface.resetAndWrite("new session"); + break; + case "dispose": + surface.dispose(); + break; + } + harness.flushFrame(); + harness.paint.mockClear(); + vi.advanceTimersByTime(240); + harness.flushFrame(); + expect(harness.paint).not.toHaveBeenCalled(); + if (end !== "dispose") { + expect(surface.getSelection()).toBe(end === "reset" ? "" : selected); + } + }); + it("stops hidden snapshots and paint while preserving live VT replies and the next cursor", async () => { const harness = createHarness(); const surface = await harness.create(); @@ -280,6 +667,267 @@ describe("GhosttyTerminalSurface visibility", () => { expect(harness.renderedSnapshot.rowData[0]?.cells.some((cell) => cell.selected)).toBe(false); }); + it("finishes a native fullscreen drag after Shift is released", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("\x1b[?1049h\x1b[?1003h\x1b[?1006hhello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1, true); + harness.pointer("pointermove", 21, 1, true); + harness.pointer("pointermove", 37, 1); + harness.pointer("pointerup", 37, 0); + expect(surface.getSelection()).toBe("hello"); + expect(harness.onData).not.toHaveBeenCalled(); + + // After release, normal hover reporting resumes. + harness.pointer("pointermove", 45, 0); + expect(harness.onData).toHaveBeenCalledTimes(1); + // Switching back to the application's selection must not leave a native + // selection that would intercept the next Cmd+C. + harness.pointer("pointerdown", 45, 1); + expect(surface.getSelection()).toBe(""); + harness.pointer("pointerup", 45, 0); + }); + + it("keeps a large live clipboard frame across display resynchronization without replaying it", async () => { + const harness = createHarness(); + const copy = vi.fn(); + const surface = await harness.create({ onClipboardWrite: copy }); + surface.focus(); + const text = "x".repeat(600 * 1024); + const frame = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`; + surface.observeClipboard(clipboardOutput(frame.slice(0, -1))); + surface.resetAndWrite(frame.slice(-100)); + surface.observeClipboard(clipboardOutput(frame.slice(-1))); + surface.write(frame); + surface.resetAndWrite(frame); + expect(copy.mock.calls).toEqual([[text, expect.any(Function)]]); + expect(copy.mock.calls[0]![1]()).toBe(true); + }); + + it("copies live OSC 52 only from the focused, visible terminal in the active window", async () => { + const harness = createHarness(); + const copy = vi.fn(); + const surface = await harness.create({ onClipboardWrite: copy }); + const data = "\x1b]52;c;aGVsbG8=\x07"; + surface.observeClipboard(clipboardOutput(data)); + expect(copy).not.toHaveBeenCalled(); + surface.focus(); + surface.observeClipboard(clipboardSnapshot(data)); + surface.observeClipboard(clipboardSnapshot("\x1b]52;c;")); + surface.observeClipboard(clipboardOutput("aGVsbG8=\x07")); + expect(copy).not.toHaveBeenCalled(); + surface.observeClipboard(clipboardOutput(data)); + expect(copy.mock.calls).toEqual([["hello", expect.any(Function)]]); + copy.mockClear(); + surface.setVisible(false); + surface.observeClipboard(clipboardOutput(data)); + surface.setVisible(true); + surface.input.dispatchEvent(new Event("blur")); + surface.observeClipboard(clipboardOutput(data)); + surface.focus(); + vi.spyOn(document, "hasFocus").mockReturnValue(false); + surface.observeClipboard(clipboardOutput(data)); + surface.dispose(); + surface.observeClipboard(clipboardOutput(data)); + expect(copy).not.toHaveBeenCalled(); + expect(harness.onData).not.toHaveBeenCalled(); + }); + + it.each(["hide", "input blur", "window blur"])( + "invalidates pending clipboard writes on %s even without intervening output", + async (change) => { + const harness = createHarness(); + const copy = vi.fn(); + const surface = await harness.create({ onClipboardWrite: copy }); + const data = "\x1b]52;c;aGVsbG8=\x07"; + surface.focus(); + surface.observeClipboard(clipboardOutput(data.slice(0, -1))); + switch (change) { + case "hide": + surface.setVisible(false); + surface.setVisible(true); + break; + case "input blur": + surface.input.dispatchEvent(new Event("blur")); + surface.focus(); + break; + case "window blur": + window.dispatchEvent(new Event("blur")); + window.dispatchEvent(new Event("focus")); + break; + } + surface.observeClipboard(clipboardOutput(data.slice(-1))); + expect(copy).not.toHaveBeenCalled(); + surface.observeClipboard(clipboardOutput(data)); + expect(copy.mock.calls).toEqual([["hello", expect.any(Function)]]); + }, + ); + + it.each(["hide", "input blur", "window blur", "inactive window", "reset", "dispose"])( + "drops a completed OSC copy queued before %s", + async (change) => { + const harness = createHarness(); + let finishFirst!: () => void; + const first = new Promise((resolve) => { + finishFirst = resolve; + }); + const writeText = vi.fn(() => first); + vi.stubGlobal("navigator", { clipboard: { writeText } }); + const copy = vi.fn(writeTerminalClipboard); + const surface = await harness.create({ onClipboardWrite: copy }); + surface.focus(); + surface.observeClipboard(clipboardOutput("\x1b]52;c;Zmlyc3Q=\x07\x1b]52;c;b2xk\x07")); + switch (change) { + case "hide": + surface.setVisible(false); + surface.setVisible(true); + break; + case "input blur": + surface.input.dispatchEvent(new Event("blur")); + surface.focus(); + break; + case "window blur": + window.dispatchEvent(new Event("blur")); + window.dispatchEvent(new Event("focus")); + break; + case "inactive window": + vi.spyOn(document, "hasFocus").mockReturnValue(false); + break; + case "reset": + surface.observeClipboard(clipboardSnapshot("")); + break; + case "dispose": + surface.dispose(); + break; + } + finishFirst(); + await Promise.all(copy.mock.results.map((result) => result.value)); + expect(writeText.mock.calls).toEqual([["first"]]); + if (change !== "dispose" && change !== "inactive window") { + surface.observeClipboard(clipboardOutput("\x1b]52;c;bmV3\x07")); + await Promise.all(copy.mock.results.map((result) => result.value)); + expect(writeText.mock.calls).toEqual([["first"], ["new"]]); + } + }, + ); + + it.each([ + ["mouse reports", "\x1b[?1049h\x1b[?1000h\x1b[?1006h", "\x1b[<65;1;1M"], + ["arrow keys", "\x1b[?1049h", "\x1b[B"], + ])("retires completed selection before forwarding wheel %s", async (_mode, setup, input) => { + const harness = createHarness(); + vi.stubGlobal("navigator", { platform: "Linux x86_64" }); + const surface = await harness.create({ beforeKey: () => true }); + surface.write(setup + "hello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1, true); + harness.pointer("pointerup", 37, 0, true); + expect(surface.getSelection()).toBe("hello"); + const selectedDuringInput: string[] = []; + harness.onData.mockImplementation(() => { + selectedDuringInput.push(surface.getSelection()); + }); + harness.wheel(2); + expect(harness.onData.mock.calls.flat().join("")).toBe(input!.repeat(2)); + expect(selectedDuringInput.every((text) => text === "")).toBe(true); + key(surface, "c", "KeyC", { ctrlKey: true }); + expect(harness.onData).toHaveBeenLastCalledWith("\x03"); + }); + + it.each(["active drag", "local scrollback", "fractional wheel"])( + "preserves selection during %s", + async (mode) => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write((mode === "local scrollback" ? "" : "\x1b[?1049h") + "hello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1, true); + harness.pointer("pointermove", 37, 1, true); + if (mode !== "active drag") harness.pointer("pointerup", 37, 0, true); + harness.wheel(mode === "fractional wheel" ? 0.1 : 1, mode === "fractional wheel" ? 0 : 1); + expect(surface.getSelection()).toBe("hello"); + if (mode !== "active drag") expect(harness.onData).not.toHaveBeenCalled(); + }, + ); + + it("uses the release position when a trackpad drag has no intermediate motion", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("\x1b[?1049h\x1b[?1003h\x1b[?1006hhello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1, true); + harness.pointer("pointerup", 37, 0); + expect(surface.getSelection()).toBe("hello"); + expect(harness.onData).not.toHaveBeenCalled(); + }); + + it.each([2, 3])( + "keeps a %s-click selection when output scrolls before release", + async (clicks) => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("hello world\r\nsecond line"); + harness.flushFrame(); + for (let click = 1; click < clicks; click += 1) { + harness.pointer("pointerdown", 13, 1); + harness.pointer("pointerup", 13, 0); + } + harness.pointer("pointerdown", 13, 1); + const selected = surface.getSelection(); + expect(selected).toBe(clicks === 2 ? "hello" : "hello world"); + surface.write("\x1b[6;1H\r\n"); + harness.flushFrame(); + harness.pointer("pointerup", 13, 0); + expect(surface.getSelection()).toBe(selected); + }, + ); + + it("agrees with Ghostty about leaving a control string before an OSC request", async () => { + const harness = createHarness(); + const copy = vi.fn(); + const surface = await harness.create({ onClipboardWrite: copy }); + surface.focus(); + const data = "\x1bPtmux;\x1b\x1b]52;c;aGVsbG8=\x07visible\x1b\\"; + surface.observeClipboard(clipboardOutput(data)); + surface.write(data); + harness.flushFrame(); + expect(copy.mock.calls).toEqual([["hello", expect.any(Function)]]); + expect(harness.renderedSnapshot.rowData[0]?.text).toContain("visible"); + expect(harness.renderedSnapshot.rowData[0]?.text).not.toContain("aGVsbG8"); + }); + + it("continues a native drag through fullscreen status redraws and clears it on reset", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("\x1b[?1049h\x1b[?1003h\x1b[?1006hhello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1, true); + harness.pointer("pointermove", 21, 1, true); + surface.write("\x1b[2;1Hworking\x1b[1;1H"); + harness.flushFrame(); + harness.pointer("pointermove", 37, 1, true); + harness.pointer("pointerup", 37, 0, true); + expect(surface.getSelection()).toBe("hello"); + expect(harness.onData).not.toHaveBeenCalled(); + + surface.resetAndWrite("restored session"); + expect(surface.getSelection()).toBe(""); + expect(surface.getSelectionPosition()).toBeNull(); + }); + + it("keeps an application drag in the application when Shift is pressed midway", async () => { + const harness = createHarness(); + const surface = await harness.create(); + surface.write("\x1b[?1049h\x1b[?1003h\x1b[?1006hhello world"); + harness.flushFrame(); + harness.pointer("pointerdown", 5, 1); + harness.pointer("pointermove", 37, 1, true); + harness.pointer("pointerup", 37, 0, true); + expect(surface.getSelection()).toBe(""); + expect(harness.onData).toHaveBeenCalledTimes(3); + }); + it("stops zero-size mounts and repaints when the same size returns", async () => { const harness = createHarness(); const surface = await harness.create(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 29aaac6f6abd..cdbd465d6fdf 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -16,6 +16,8 @@ import { } from "./renderer"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; import { isMonospaceFamily } from "../../appearanceFonts"; +import { createTerminalClipboardSession } from "@t3tools/client-runtime/terminal-clipboard"; +import type { TerminalAttachStreamEvent } from "@t3tools/contracts"; export const DEFAULT_TERMINAL_FONT_SIZE = 12; const MIN_TERMINAL_FONT_SIZE = 6; @@ -37,6 +39,22 @@ const CONTENT_PADDING = 4; const MIN_SCROLLBAR_THUMB_HEIGHT = 18; /** Half a blink cycle: the visible and hidden phases are equally long. */ const CURSOR_BLINK_INTERVAL_MS = 500; +const MODIFIER_KEYS = new Set([ + "Shift", + "Control", + "Alt", + "Meta", + "AltGraph", + "CapsLock", + "NumLock", + "ScrollLock", + "Fn", + "FnLock", + "Hyper", + "Super", + "Symbol", + "SymbolLock", +]); const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; const TERMINAL_FONT_LOAD_VARIANTS = [ "normal 400", @@ -525,6 +543,7 @@ export interface GhosttyTerminalSurfaceOptions { readonly onData: (data: string) => void; readonly onResize: (cols: number, rows: number) => void; readonly onSelectionChange: () => void; + readonly onClipboardWrite?: (text: string, canWrite: () => boolean) => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; /** @@ -572,19 +591,22 @@ export class GhosttyTerminalSurface { private resizeNotifyTimer: number | null = null; private originY = CONTENT_PADDING; private mountHeight = 0; - private selectionEnd: { x: number; y: number } | null = null; private selectionAnchorScreen: { x: number; y: number } | null = null; private selectionEndScreen: { x: number; y: number } | null = null; - private selectionMode: "cell" | "word" | "line" = "cell"; - // Word/line selection base in screen coordinates so streaming output cannot - // shift the origin of a drag selection. - private selectionBase: { - start: { x: number; y: number }; - end: { x: number; y: number }; + private selectionDrag: { + pointerId: number; + moved: boolean; + lastCell: { x: number; y: number }; + pointer: { x: number; y: number }; + // A word/line gesture keeps its original range anchored through viewport scrolling. + base: { + mode: "word" | "line"; + start: { x: number; y: number }; + end: { x: number; y: number }; + } | null; } | null = null; private selectionScrollTimer: number | null = null; private selectionScrollDelta = 0; - private selectionPointer: { x: number; y: number } | null = null; private mouseReportingPointerId: number | null = null; private mouseReportingButton: number | null = null; private linkActivationPointerId: number | null = null; @@ -592,9 +614,12 @@ export class GhosttyTerminalSurface { private hoverPointer: { x: number; y: number } | null = null; private linkModifierActive = false; private selectionClickSequence: TerminalSelectionClickSequence | null = null; - private selectionMoved = false; private composing = false; private focused = false; + private readonly clipboard = createTerminalClipboardSession({ + isEligible: () => !this.disposed && this.visible && this.focused && document.hasFocus(), + onCopy: (text, canWrite) => this.options.onClipboardWrite?.(text, canWrite), + }); private resizeNotified = false; private canvasConfigured = false; private theme: GhosttyTheme; @@ -628,6 +653,8 @@ export class GhosttyTerminalSurface { this.mount = mount; this.canvas = canvas; this.input = input; + // The textarea can receive focus while fonts and WASM are still loading. + this.focused = document.activeElement === input; this.scrollbar = scrollbar; this.scrollbarThumb = scrollbarThumb; this.context = context; @@ -730,15 +757,34 @@ export class GhosttyTerminalSurface { this.scrollbarDirty = true; if (!visible) { this.cancelRender(); - this.setSelectionAutoscroll(0); + this.endSelectionDrag(); + this.clipboard.invalidate(); return; } this.fit(); } + /** Feed live attachment events before renderer retention and batching. */ + observeClipboard(event: TerminalAttachStreamEvent): void { + if (this.disposed) return; + this.clipboard.update(event); + } + write(data: string): void { if (this.disposed) return; this.core.write(data); + if (this.selectionAnchorScreen) { + const selection = this.core.selectionPosition(); + // Scrollback eviction can move the core's tracked endpoints. Retire the + // gesture and its cached word/line anchors before they select different rows. + if ( + selection?.start.x !== this.selectionAnchorScreen.x || + selection?.start.y !== this.selectionAnchorScreen.y || + selection?.end.x !== this.selectionEndScreen?.x || + selection?.end.y !== this.selectionEndScreen?.y + ) + this.clearSelection(); + } this.synchronizeMouseTrackingState(); // Restart the blink cycle from the visible phase so the cursor never sits // invisible through a stream of output or a burst of typing echo. @@ -749,6 +795,7 @@ export class GhosttyTerminalSurface { resetAndWrite(data: string): void { if (this.disposed) return; + this.clearSelection(); this.lastMouseMotionData = ""; this.core.resetAndWrite(data); this.synchronizeMouseTrackingState(); @@ -900,6 +947,13 @@ export class GhosttyTerminalSurface { this.input.focus({ preventScroll: true }); } + /** Sends committed user input; terminal-generated replies bypass selection cleanup. */ + sendUserInput(data: string): void { + if (this.disposed || data.length === 0) return; + if (!this.selectionDrag && this.selectionAnchorScreen) this.clearSelection(); + this.options.onData(data); + } + /** * Pastes clipboard text read by the host (context menu) with the same * bracketed-paste encoding as a native paste event. The read joins the same @@ -919,7 +973,7 @@ export class GhosttyTerminalSurface { this.pasteShortcutToken += 1; if (text.length === 0) return; const encoded = this.core.encodePaste(text); - if (encoded.length > 0) this.options.onData(encoded); + this.sendUserInput(encoded); } hasSelection(): boolean { @@ -955,14 +1009,13 @@ export class GhosttyTerminalSurface { } clearSelection(): void { + this.endSelectionDrag(); + this.copyShortcutToken += 1; + this.clearSelectionAfterCopy = false; this.clearPrimedCopy(); this.core.clearSelection(); - this.selectionEnd = null; this.selectionAnchorScreen = null; this.selectionEndScreen = null; - this.selectionMode = "cell"; - this.selectionBase = null; - this.setSelectionAutoscroll(0); this.options.onSelectionChange(); // Selection highlights span rows Ghostty may not mark dirty for this change. this.forceFullRender = true; @@ -983,12 +1036,13 @@ export class GhosttyTerminalSurface { dispose(): void { if (this.disposed) return; this.disposed = true; + this.endSelectionDrag(); + this.clipboard.invalidate(); this.resizeObserver.disconnect(); document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); this.dprMedia = null; this.reducedMotionMedia?.removeEventListener("change", this.onReducedMotionChange); - if (this.selectionScrollTimer !== null) window.clearInterval(this.selectionScrollTimer); if (this.resizeNotifyTimer !== null) { window.clearTimeout(this.resizeNotifyTimer); this.resizeNotifyTimer = null; @@ -1057,10 +1111,7 @@ export class GhosttyTerminalSurface { // The write may have been superseded while in flight; only // touch the selection if this gesture still owns the token. if (this.disposed || this.copyShortcutToken !== token) return; - if (this.clearSelectionAfterCopy) { - this.clearSelectionAfterCopy = false; - this.clearSelection(); - } + if (this.clearSelectionAfterCopy) this.clearSelection(); }, () => { // The write failed and the native event has already had its @@ -1092,7 +1143,7 @@ export class GhosttyTerminalSurface { (text) => { if (this.disposed || this.pasteShortcutToken !== token) return; this.pasteShortcutToken += 1; - if (text.length > 0) this.options.onData(this.core.encodePaste(text)); + if (text.length > 0) this.sendUserInput(this.core.encodePaste(text)); }, () => { // Clipboard read denied; the native paste event remains the path. @@ -1113,7 +1164,8 @@ export class GhosttyTerminalSurface { this.suppressedKeyCodes.delete(event.code); event.preventDefault(); event.stopPropagation(); - this.options.onData(data); + if (MODIFIER_KEYS.has(event.key)) this.options.onData(data); + else this.sendUserInput(data); }; private readonly onKeyUp = (event: KeyboardEvent) => { @@ -1139,6 +1191,8 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; + this.endSelectionDrag(); + this.clipboard.invalidate(); this.linkModifierActive = false; this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for @@ -1150,6 +1204,11 @@ export class GhosttyTerminalSurface { this.requestRender(); }; + private readonly onWindowBlur = () => { + this.endSelectionDrag(); + this.clipboard.invalidate(); + }; + private readonly onDevicePixelRatioChange = () => { this.watchDevicePixelRatio(); this.fit(); @@ -1185,10 +1244,7 @@ export class GhosttyTerminalSurface { // The native event actually wrote the selection; drop the in-flight // writeText so a late resolution cannot clobber a later user copy. this.copyShortcutToken += 1; - if (this.clearSelectionAfterCopy) { - this.clearSelectionAfterCopy = false; - this.clearSelection(); - } + if (this.clearSelectionAfterCopy) this.clearSelection(); } }; @@ -1202,7 +1258,7 @@ export class GhosttyTerminalSurface { // The native paste won the race with actual text; a pending clipboard read // must not double. An empty native paste leaves the read as the only path. this.pasteShortcutToken += 1; - this.options.onData(this.core.encodePaste(data)); + this.sendUserInput(this.core.encodePaste(data)); }; private readonly onCompositionStart = () => { @@ -1214,7 +1270,7 @@ export class GhosttyTerminalSurface { private readonly onCompositionEnd = (event: CompositionEvent) => { this.composing = false; const data = this.input.value || event.data; - if (data.length > 0) this.options.onData(data); + this.sendUserInput(data); this.input.value = ""; this.compositionInputToSuppress = data; this.compositionSuppressionTimer = window.setTimeout(() => { @@ -1233,7 +1289,7 @@ export class GhosttyTerminalSurface { return; } this.clearCompositionInputSuppression(); - if (data.length > 0) this.options.onData(data); + this.sendUserInput(data); this.input.value = ""; }; @@ -1245,13 +1301,21 @@ export class GhosttyTerminalSurface { this.compositionInputToSuppress = null; } + private get activePointerId(): number | null { + return ( + this.selectionDrag?.pointerId ?? this.mouseReportingPointerId ?? this.linkActivationPointerId + ); + } + private readonly onPointerDown = (event: PointerEvent) => { + if (this.activePointerId !== null && this.activePointerId !== event.pointerId) return; this.focus(); if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { const button = ghosttyMouseButton(event.button); if (button === null) return; event.preventDefault(); event.stopPropagation(); + if (this.selectionAnchorScreen || this.selectionDrag) this.clearSelection(); this.clearHoveredLink("default"); this.mouseReportingPointerId = event.pointerId; this.mouseReportingButton = button; @@ -1268,30 +1332,32 @@ export class GhosttyTerminalSurface { return; } this.clearHoveredLink(); + if (this.selectionAnchorScreen || this.selectionDrag) this.clearSelection(); const cell = this.cellAt(event.clientX, event.clientY); - this.selectionMoved = false; this.selectionClickSequence = advanceTerminalSelectionClickSequence( this.selectionClickSequence, event, ); const clickCount = this.selectionClickSequence.count; - this.selectionMode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; + const mode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : null; const range = - this.selectionMode === "line" + mode === "line" ? this.core.selectLine(cell.x, cell.y) - : this.selectionMode === "word" + : mode === "word" ? this.core.selectWord(cell.x, cell.y) : null; + this.selectionDrag = { + pointerId: event.pointerId, + moved: false, + lastCell: cell, + pointer: { x: event.clientX, y: event.clientY }, + base: mode !== null && range !== null ? { mode, ...range.screen } : null, + }; if (range) { - this.selectionBase = range.screen; - this.selectionEnd = range.viewport.end; this.selectionAnchorScreen = range.screen.start; this.selectionEndScreen = range.screen.end; this.options.onSelectionChange(); } else { - this.selectionMode = "cell"; - this.selectionBase = null; - this.selectionEnd = cell; const screen = this.core.viewportPointToScreen(cell.x, cell.y); this.selectionAnchorScreen = screen; this.selectionEndScreen = screen; @@ -1307,13 +1373,21 @@ export class GhosttyTerminalSurface { }; private readonly onPointerMove = (event: PointerEvent) => { + if (this.activePointerId !== null && this.activePointerId !== event.pointerId) return; + // Releasing left while another button is held produces pointermove, not pointerup. + if (this.selectionDrag?.pointerId === event.pointerId && (event.buttons & 1) === 0) { + this.onPointerUp(event); + return; + } if (this.linkActivationPointerId === event.pointerId) return; // Hover motion is only reportable in any-event tracking (DEC 1003); normal and // button-event tracking never report motion without a captured pressed button. const anyEventTracking = this.synchronizeMouseTrackingState(); if ( this.mouseReportingPointerId === event.pointerId || - shouldReportTerminalMouse(anyEventTracking, event) + // A native drag owns its pointer until release, even if Shift is let go. + (this.selectionDrag?.pointerId !== event.pointerId && + shouldReportTerminalMouse(anyEventTracking, event)) ) { event.preventDefault(); this.hoverPointer = { x: event.clientX, y: event.clientY }; @@ -1326,36 +1400,37 @@ export class GhosttyTerminalSurface { return; } this.lastMouseMotionData = ""; - if (!this.selectionAnchorScreen || !this.canvas.hasPointerCapture(event.pointerId)) { + const drag = this.selectionDrag; + if (!this.selectionAnchorScreen || drag?.pointerId !== event.pointerId) { this.updateHoverCursor(event); return; } this.clearHoveredLink(); - this.selectionPointer = { x: event.clientX, y: event.clientY }; + drag.pointer = { x: event.clientX, y: event.clientY }; const bounds = this.canvas.getBoundingClientRect(); this.setSelectionAutoscroll( event.clientY < bounds.top ? -1 : event.clientY > bounds.bottom ? 1 : 0, ); const cell = this.cellAt(event.clientX, event.clientY); - if (cell.x === this.selectionEnd?.x && cell.y === this.selectionEnd.y) return; - this.extendSelectionTo(event.clientX, event.clientY); + if (cell.x === drag.lastCell.x && cell.y === drag.lastCell.y) return; + this.extendSelectionTo(cell); }; - private extendSelectionTo(clientX: number, clientY: number): void { + private extendSelectionTo(cell: { x: number; y: number }): void { + const drag = this.selectionDrag; const anchorScreen = this.selectionAnchorScreen; - if (anchorScreen === null) return; - const cell = this.cellAt(clientX, clientY); - this.selectionMoved = true; - this.selectionEnd = cell; + if (!drag || anchorScreen === null) return; + drag.moved = true; + drag.lastCell = cell; + const base = drag.base; const range = - this.selectionMode === "line" + base?.mode === "line" ? this.core.selectLine(cell.x, cell.y) - : this.selectionMode === "word" + : base?.mode === "word" ? this.core.selectWord(cell.x, cell.y) : null; const cellScreen = this.core.viewportPointToScreen(cell.x, cell.y); if (cellScreen === null) return; - const base = this.selectionBase; const beforeBase = base !== null && (cellScreen.y < base.start.y || @@ -1385,11 +1460,32 @@ export class GhosttyTerminalSurface { this.selectionScrollTimer = window.setInterval(() => { if (this.disposed || this.selectionScrollDelta === 0) return; this.scrollViewport(this.selectionScrollDelta); - const pointer = this.selectionPointer; - if (pointer) this.extendSelectionTo(pointer.x, pointer.y); + const pointer = this.selectionDrag?.pointer; + if (pointer) this.extendSelectionTo(this.cellAt(pointer.x, pointer.y)); }, 80); } + private endSelectionDrag(): void { + const drag = this.selectionDrag; + this.selectionDrag = null; + this.setSelectionAutoscroll(0); + if (drag && this.canvas.hasPointerCapture(drag.pointerId)) { + this.canvas.releasePointerCapture(drag.pointerId); + } + } + + private readonly onLostPointerCapture = (event: PointerEvent) => { + if (this.canvas.hasPointerCapture(event.pointerId)) return; + if (this.selectionDrag?.pointerId === event.pointerId) this.endSelectionDrag(); + if (this.mouseReportingPointerId === event.pointerId) { + this.sendMouse("release", this.mouseReportingButton, event); + this.mouseReportingPointerId = null; + this.mouseReportingButton = null; + this.lastMouseMotionData = ""; + } + if (this.linkActivationPointerId === event.pointerId) this.linkActivationPointerId = null; + }; + private updateHoverCursor(event: PointerEvent): void { this.hoverPointer = { x: event.clientX, y: event.clientY }; this.linkModifierActive = isTerminalLinkPointerGesture(event); @@ -1436,7 +1532,6 @@ export class GhosttyTerminalSurface { } private readonly onPointerUp = (event: PointerEvent) => { - this.setSelectionAutoscroll(0); if (this.linkActivationPointerId === event.pointerId) { event.preventDefault(); event.stopPropagation(); @@ -1468,13 +1563,18 @@ export class GhosttyTerminalSurface { } return; } - if (this.canvas.hasPointerCapture(event.pointerId)) { - this.canvas.releasePointerCapture(event.pointerId); - } - if (event.button !== 0) return; - if (!this.selectionMoved && this.selectionMode === "cell") { - this.clearSelection(); + const drag = this.selectionDrag; + if (drag?.pointerId !== event.pointerId) return; + if (event.type !== "pointercancel" && (event.buttons & 1) !== 0) return; + if (event.type !== "pointercancel" && this.selectionAnchorScreen) { + // The release can be in a new cell without an intervening pointermove. + const cell = this.cellAt(event.clientX, event.clientY); + if (cell.x !== drag.lastCell.x || cell.y !== drag.lastCell.y) { + this.extendSelectionTo(cell); + } } + this.endSelectionDrag(); + if (!drag.moved && drag.base === null) this.clearSelection(); this.options.onSelectionChange(); }; @@ -1491,6 +1591,7 @@ export class GhosttyTerminalSurface { if (delta.rows === 0) return; const magnitude = Math.abs(delta.rows); if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { + if (!this.selectionDrag && this.selectionAnchorScreen) this.clearSelection(); const button = delta.rows < 0 ? 4 : 5; for (let index = 0; index < magnitude; index += 1) { this.sendMouse("press", button, event); @@ -1500,7 +1601,7 @@ export class GhosttyTerminalSurface { if (this.core.isAlternateScreen()) { // The alternate screen has no scrollback: translate wheel motion into // arrow keys so full-screen apps like vim and less scroll, matching xterm. - this.options.onData(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys())); + this.sendUserInput(terminalWheelArrowData(delta.rows, this.core.isApplicationCursorKeys())); return; } this.scrollViewport(delta.rows); @@ -1584,6 +1685,7 @@ export class GhosttyTerminalSurface { }; private installEvents(): void { + window.addEventListener("blur", this.onWindowBlur); this.input.addEventListener("keydown", this.onKeyDown); this.input.addEventListener("keyup", this.onKeyUp); this.input.addEventListener("focus", this.onFocus); @@ -1598,6 +1700,7 @@ export class GhosttyTerminalSurface { this.canvas.addEventListener("pointerleave", this.onPointerLeave); this.canvas.addEventListener("pointerup", this.onPointerUp); this.canvas.addEventListener("pointercancel", this.onPointerUp); + this.canvas.addEventListener("lostpointercapture", this.onLostPointerCapture); this.canvas.addEventListener("wheel", this.onWheel, { passive: false }); this.canvas.addEventListener("mousedown", this.onMouseDown); this.canvas.addEventListener("contextmenu", this.onContextMenu); @@ -1609,6 +1712,7 @@ export class GhosttyTerminalSurface { } private removeEvents(): void { + window.removeEventListener("blur", this.onWindowBlur); this.input.removeEventListener("keydown", this.onKeyDown); this.input.removeEventListener("keyup", this.onKeyUp); this.input.removeEventListener("focus", this.onFocus); @@ -1623,6 +1727,7 @@ export class GhosttyTerminalSurface { this.canvas.removeEventListener("pointerleave", this.onPointerLeave); this.canvas.removeEventListener("pointerup", this.onPointerUp); this.canvas.removeEventListener("pointercancel", this.onPointerUp); + this.canvas.removeEventListener("lostpointercapture", this.onLostPointerCapture); this.canvas.removeEventListener("wheel", this.onWheel); this.canvas.removeEventListener("mousedown", this.onMouseDown); this.canvas.removeEventListener("contextmenu", this.onContextMenu); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 68f5bad51474..a8595646f25c 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./terminal-clipboard": { + "types": "./src/terminalClipboard.ts", + "default": "./src/terminalClipboard.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 56489a4ba668..4c1528c7a1b7 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -59,7 +59,8 @@ interface EnvironmentQueryAtomOptions extends EnvironmentAtomOpt interface EnvironmentSubscriptionAtomOptions { readonly label: string; - readonly subscribe: (input: Input) => Stream.Stream; + /** `key` is this target's family key, the string `environmentRpcKey` returns for it. */ + readonly subscribe: (input: Input, key: string) => Stream.Stream; readonly idleTtlMs?: number; } @@ -590,7 +591,7 @@ export function createEnvironmentSubscriptionAtomFamily( const family = Atom.family((key: string) => { const target = parseEnvironmentRpcKey(key); return runtime - .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input))) + .atom(followStreamInEnvironment(target.environmentId, options.subscribe(target.input, key))) .pipe( Atom.setIdleTTL(options.idleTtlMs ?? 5 * 60_000), Atom.withLabel(`${options.label}:${key}`), diff --git a/packages/client-runtime/src/state/terminal.test.ts b/packages/client-runtime/src/state/terminal.test.ts new file mode 100644 index 000000000000..960443782a0d --- /dev/null +++ b/packages/client-runtime/src/state/terminal.test.ts @@ -0,0 +1,188 @@ +import { + EnvironmentId, + ProviderInstanceId, + ThreadId, + WS_METHODS, + type TerminalAttachStreamEvent, +} from "@t3tools/contracts"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; +import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; + +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, + type SupervisorConnectionState, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { + createTerminalEnvironmentAtoms, + DEFAULT_MAX_TERMINAL_BUFFER_BYTES, + terminalOutputText, +} from "./terminal.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("environment-1"), + label: "Test environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +function session(client: WsRpcProtocolClient): RpcSession { + return { + client, + initialConfig: Effect.never, + subscribeServerConfig: (input) => client.subscribeServerConfig(input), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; +} + +it.effect("observes live attach output before retention without another RPC subscription", () => + Effect.scoped( + Effect.gen(function* () { + const events = yield* Queue.unbounded(); + const started = Latch.makeUnsafe(); + let subscriptions = 0; + const client = { + [WS_METHODS.terminalAttach]: () => + Stream.suspend(() => { + subscriptions += 1; + started.openUnsafe(); + return Stream.concat( + Stream.succeed({ + type: "output", + threadId: ThreadId.make("thread"), + terminalId: "term", + data: "", + } as const), + Stream.fromQueue(events), + ); + }), + } as unknown as WsRpcProtocolClient; + const connectionState: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, + }; + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(connectionState), + session: yield* SubscriptionRef.make(Option.some(session(client))), + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const environmentRegistry = EnvironmentRegistry.EnvironmentRegistry.of({ + run: (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + runStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + followStream: (_environmentId, stream) => + Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + + const logs: unknown[] = []; + const runtime = Atom.runtime( + Layer.mergeAll( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + Logger.layer([Logger.make(({ message }) => logs.push(message))]), + ), + ); + const atoms = createTerminalEnvironmentAtoms(runtime); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const target = { + environmentId: TARGET.environmentId, + input: { + threadId: ThreadId.make("thread"), + terminalId: "term", + cwd: "/tmp", + providerInstanceId: ProviderInstanceId.make("provider-1"), + cols: 80, + rows: 24, + }, + }; + const observed: TerminalAttachStreamEvent[] = []; + const unrelated: TerminalAttachStreamEvent[] = []; + const observerFailure = new Error("observer failed"); + const stopBrokenObserver = atoms.observeAttach(target, () => { + throw observerFailure; + }); + const stop = atoms.observeAttach(target, (event) => observed.push(event)); + const stopOtherEnvironment = atoms.observeAttach( + { ...target, environmentId: EnvironmentId.make("other") }, + (event) => unrelated.push(event), + ); + const stopOtherAttach = atoms.observeAttach( + { ...target, input: { ...target.input, cols: 90 } }, + (event) => unrelated.push(event), + ); + const stopOtherProvider = atoms.observeAttach( + { + ...target, + input: { ...target.input, providerInstanceId: ProviderInstanceId.make("provider-2") }, + }, + (event) => unrelated.push(event), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + stop(); + stopBrokenObserver(); + stopOtherEnvironment(); + stopOtherAttach(); + stopOtherProvider(); + }), + ); + const atom = atoms.attach(target); + const text = "x".repeat(600 * 1024); + const firstUpdate = Latch.makeUnsafe(); + const secondUpdate = Latch.makeUnsafe(); + const unmount = registry.subscribe(atom, (result) => { + if (!AsyncResult.isSuccess(result)) return; + if (result.value.output.nextOffset === text.length) firstUpdate.openUnsafe(); + if (result.value.output.nextOffset === text.length + 4) secondUpdate.openUnsafe(); + }); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + yield* AtomRegistry.getResult(registry, atom); + observed.length = 0; + yield* started.await; + const event = { + type: "output", + threadId: target.input.threadId, + terminalId: "term", + data: text, + } as const; + yield* Queue.offer(events, event); + yield* firstUpdate.await; + expect(observed).toEqual([event]); + const state = yield* AtomRegistry.getResult(registry, atom); + expect(state.output.retainedBytes).toBe(DEFAULT_MAX_TERMINAL_BUFFER_BYTES); + expect(terminalOutputText(state.output)).toHaveLength(DEFAULT_MAX_TERMINAL_BUFFER_BYTES); + stop(); + yield* Queue.offer(events, { ...event, data: "tail" }); + yield* secondUpdate.await; + expect(observed).toEqual([event]); + expect(unrelated).toEqual([]); + expect(subscriptions).toBe(1); + expect(logs).toContainEqual(["Terminal attach observer failed", observerFailure]); + }), + ), +); diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index 3bc2bca78f0f..2fda2b6c0a61 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -1,4 +1,9 @@ -import { type TerminalSummary, WS_METHODS } from "@t3tools/contracts"; +import { + type TerminalAttachStreamEvent, + type TerminalSummary, + WS_METHODS, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; import * as Stream from "effect/Stream"; import { Atom } from "effect/unstable/reactivity"; @@ -7,6 +12,7 @@ import { createEnvironmentRpcCommand, createEnvironmentRpcSubscriptionAtomFamily, createEnvironmentSubscriptionAtomFamily, + environmentRpcKey, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; @@ -36,16 +42,47 @@ export function createTerminalEnvironmentAtoms( readonly input: { readonly threadId: string; readonly terminalId?: string | undefined }; }) => JSON.stringify([environmentId, input.threadId, input.terminalId ?? null]); const lifecycleConcurrency = { mode: "serial" as const, key: terminalThreadKey }; - return { - attach: createEnvironmentSubscriptionAtomFamily(runtime, { - label: "environment-data:terminal:attach", - subscribe: (input: EnvironmentRpcInput) => - Stream.suspend(() => - subscribe(WS_METHODS.terminalAttach, input).pipe( - Stream.scan(nextTerminalAttachSeedState(), applyTerminalAttachStreamEvent), + const attachObservers = new Map void>>(); + const attach = createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:attach", + subscribe: (input: EnvironmentRpcInput, key) => + Stream.suspend(() => + subscribe(WS_METHODS.terminalAttach, input).pipe( + Stream.tap((event) => + Effect.gen(function* () { + const observers = attachObservers.get(key); + if (!observers) return; + for (const observe of observers) { + try { + observe(event); + } catch (error) { + yield* Effect.logError("Terminal attach observer failed", error); + } + } + }), ), + Stream.scan(nextTerminalAttachSeedState(), applyTerminalAttachStreamEvent), ), - }), + ), + }); + return { + attach, + /** Observe each live event before retention and React batching, without a second RPC stream. */ + observeAttach( + target: Parameters[0], + observe: (event: TerminalAttachStreamEvent) => void, + ) { + const key = environmentRpcKey(target); + const observers = + attachObservers.get(key) ?? new Set<(event: TerminalAttachStreamEvent) => void>(); + attachObservers.set(key, observers); + observers.add(observe); + return () => { + observers.delete(observe); + if (observers.size === 0 && attachObservers.get(key) === observers) + attachObservers.delete(key); + }; + }, events: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:terminal:events", tag: WS_METHODS.subscribeTerminalEvents, diff --git a/packages/client-runtime/src/terminalClipboard.test.ts b/packages/client-runtime/src/terminalClipboard.test.ts new file mode 100644 index 000000000000..b59080456b01 --- /dev/null +++ b/packages/client-runtime/src/terminalClipboard.test.ts @@ -0,0 +1,292 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { ThreadId } from "@t3tools/contracts"; +import { + TerminalClipboardParser, + createTerminalClipboardSession, + createTerminalClipboardWriter, +} from "./terminalClipboard.ts"; + +function base64(text: string) { + let binary = ""; + for (const byte of new TextEncoder().encode(text)) binary += String.fromCharCode(byte); + return btoa(binary); +} + +function osc(text: string, target = "c", terminator = "\x07") { + return `\x1b]52;${target};${base64(text)}${terminator}`; +} + +describe("terminal OSC 52 clipboard writes", () => { + it.each(["c", "qc", "cq", "cp", "pc", "s0c", "7c", "cpqs01234567"])( + "accepts clipboard target list %j", + (target) => { + const copy = vi.fn(); + new TerminalClipboardParser(copy).write(osc("text", target), true); + expect(copy.mock.calls).toEqual([["text"]]); + }, + ); + + it.each(["", "\uFEFFtext", "\uFEFF"])("preserves exact clipboard text %j", (text) => { + const copy = vi.fn(); + new TerminalClipboardParser(copy).write(osc(text), true); + expect(copy.mock.calls).toEqual([[text]]); + }); + + it.each(["\x07", "\x1b\\"])( + "decodes Unicode with terminator %j at every chunk boundary", + (end) => { + const text = "Claude: café 界🙂\nsecond line"; + const data = `prompt${osc(text, "c", end)}tail`; + for (let split = 0; split <= data.length; split += 1) { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write(data.slice(0, split), true); + parser.write(data.slice(split), true); + expect(copy.mock.calls).toEqual([[text]]); + } + }, + ); + + it("handles single-character chunks and multiple writes", () => { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + for (const char of osc("one") + osc("two", "", "\x1b\\")) parser.write(char, true); + expect(copy.mock.calls).toEqual([["one"], ["two"]]); + }); + + it.each([ + "\x1b]52;c;?\x07", + "\x1b]52;c;bad!\x07", + "\x1b]52;c;/w==\x07", + osc("primary", "p"), + osc("secondary", "q"), + "\x1b]0;title\x07", + ])("ignores unsupported or malformed request %j and recovers", (data) => { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write(data + osc("valid"), true); + expect(copy.mock.calls).toEqual([["valid"]]); + }); + + it("does not complete an ineligible request after focus returns", () => { + const data = osc("historical"); + for (let split = 1; split < data.length; split += 1) { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write(data.slice(0, split), false); + parser.write(data.slice(split) + osc("live"), true); + expect(copy.mock.calls).toEqual([["live"]]); + } + }); + + it("drops requests that become ineligible before completion", () => { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write("\x1b]52;c;", true); + parser.write("YQ==", false); + parser.write("\x07", true); + expect(copy).not.toHaveBeenCalled(); + }); + + it.each(["P", "_", "^", "X"])("exits %s strings on ESC before parsing OSC", (type) => { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write(`\x1b${type}${osc("embedded")}\x1b\\${osc("live")}`, true); + expect(copy.mock.calls).toEqual([["embedded"], ["live"]]); + }); + + it.each(["\x1b]0;unfinished", "\x1b]52;c;YQ==\u009c", "\x1bPtmux;\x1b"])( + "recovers the first complete request after %j at every chunk boundary", + (prefix) => { + const data = prefix + osc("next") + "\x1b\\" + osc("last"); + for (let split = 0; split <= data.length; split += 1) { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write(data.slice(0, split), true); + parser.write(data.slice(split), true); + expect(copy.mock.calls).toEqual([["next"], ["last"]]); + } + }, + ); + + it.each(["\x1b]0;unfinished", "\x1bPq", "\x1b_ignored"])( + "does not replay an OSC escape split across focus changes after %j", + (prefix) => { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write(prefix + "\x1b", false); + parser.write(osc("historical").slice(1) + osc("live"), true); + expect(copy.mock.calls).toEqual([["live"]]); + copy.mockClear(); + parser.reset(); + parser.write(prefix, false); + parser.write(osc("live"), true); + expect(copy.mock.calls).toEqual([["live"]]); + }, + ); + + it.each(["\n", "\r\n", "\t"])("ignores C0 controls %j in wrapped payloads", (separator) => { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + const text = "long clipboard text ".repeat(20); + const encoded = base64(text).replace(/(.{76})/g, `$1${separator}`); + parser.write(`\x1b]52;c;${encoded}\x07`, true); + expect(copy.mock.calls).toEqual([[text]]); + }); + + it.each(["\x18", "\x1a", "\x1b[0m"])("recovers from aborted OSC with %j", (cancel) => { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write(`\x1b]52;c;YQ==${cancel}\x07${osc("live")}`, true); + expect(copy.mock.calls).toEqual([["live"]]); + }); + + it("bounds unfinished clipboard data and recovers after the terminator", () => { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write("\x1b]52;c;", true); + const chunk = "YWFh".repeat(16_384); + for (let index = 0; index < 17; index += 1) parser.write(chunk, true); + parser.write("\x07" + osc("live"), true); + expect(copy.mock.calls).toEqual([["live"]]); + }); + + it("drops incomplete requests on reset", () => { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write("\x1b]52;c;YQ==", true); + parser.reset(); + parser.write("\x07" + osc("live"), true); + expect(copy.mock.calls).toEqual([["live"]]); + }); + + it.each(["\x07", "\x1b\\"])("invalidates a pending copy without losing framing for %j", (end) => { + const data = osc("old", "c", end); + for (let split = 1; split < data.length; split += 1) { + const copy = vi.fn(); + const parser = new TerminalClipboardParser(copy); + parser.write(data.slice(0, split), true); + parser.invalidatePendingCopy(); + parser.write(data.slice(split) + osc("live"), true); + expect(copy.mock.calls).toEqual([["live"]]); + } + }); +}); + +function sessionHarness(writeText = vi.fn(async (_text: string) => {})) { + const write = createTerminalClipboardWriter(writeText); + const pending: Promise[] = []; + let active = false; + const session = createTerminalClipboardSession({ + isEligible: () => active, + onCopy: (text, canWrite) => { + pending.push(write(text, canWrite)); + }, + }); + const target = { threadId: ThreadId.make("thread"), terminalId: "terminal" }; + return { + writeText, + setActive(next: boolean) { + active = next; + if (!next) session.invalidate(); + }, + flush: () => Promise.all(pending), + history(history: string) { + session.update({ + type: "snapshot", + snapshot: { + ...target, + cwd: "/tmp", + worktreePath: null, + status: "running", + pid: 1, + history, + exitCode: null, + exitSignal: null, + label: "Terminal 1", + updatedAt: "2026-09-05T00:00:00Z", + }, + }); + }, + append(data: string) { + session.update({ type: "output", ...target, data }); + }, + reset() { + session.update({ type: "cleared", ...target }); + }, + }; +} + +describe("terminal clipboard session", () => { + it("copies live Unicode output once while ignoring initial history and replay", async () => { + const h = sessionHarness(); + h.setActive(true); + h.history(osc("history")); + const text = "\uFEFFClaude: café 界🙂"; + const data = osc(text); + h.append(data.slice(0, -1)); + h.append(data.slice(-1)); + await h.flush(); + expect(h.writeText.mock.calls).toEqual([[text]]); + h.reset(); + h.append(osc("new")); + await h.flush(); + expect(h.writeText.mock.calls).toEqual([[text], ["new"]]); + }); + + it.each(["single", "chunks"])( + "copies a payload larger than retained output in %s writes", + async (mode) => { + const h = sessionHarness(); + h.setActive(true); + const text = "x".repeat(600 * 1024); + const data = osc(text); + if (mode === "single") h.append(data); + else + for (let index = 0; index < data.length; index += 16 * 1024) + h.append(data.slice(index, index + 16 * 1024)); + h.append("later output".repeat(100_000)); + await h.flush(); + expect(h.writeText.mock.calls).toEqual([[text]]); + }, + ); + + it("does not finish a split copy across leaving and returning to the terminal", async () => { + const h = sessionHarness(); + h.setActive(true); + h.append(osc("old").slice(0, -1)); + h.setActive(false); + h.setActive(true); + h.append("\x07" + osc("live")); + await h.flush(); + expect(h.writeText.mock.calls).toEqual([["live"]]); + }); + + it("ignores background output without losing stream framing", async () => { + const h = sessionHarness(); + h.append(osc("background").slice(0, -1)); + h.setActive(true); + h.append("\x07" + osc("live")); + await h.flush(); + expect(h.writeText.mock.calls).toEqual([["live"]]); + }); + + it.each(["deactivate", "reset"])("revokes queued native writes on %s", async (action) => { + let finish!: () => void; + const first = new Promise((resolve) => { + finish = resolve; + }); + const h = sessionHarness(vi.fn(() => first)); + h.setActive(true); + h.append(osc("first") + osc("queued")); + if (action === "reset") h.reset(); + else h.setActive(false); + h.setActive(true); + finish(); + await h.flush(); + expect(h.writeText.mock.calls).toEqual([["first"]]); + h.append(osc("current")); + await h.flush(); + expect(h.writeText.mock.calls).toEqual([["first"], ["current"]]); + }); +}); diff --git a/packages/client-runtime/src/terminalClipboard.ts b/packages/client-runtime/src/terminalClipboard.ts new file mode 100644 index 000000000000..caca5de24aaf --- /dev/null +++ b/packages/client-runtime/src/terminalClipboard.ts @@ -0,0 +1,239 @@ +import type { TerminalAttachStreamEvent } from "@t3tools/contracts"; + +// Bound retained OSC text independently of terminal scrollback. +const MAX_OSC_LENGTH = 1024 * 1024; +const decoder = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }); +// oxlint-disable-next-line no-control-regex -- C0 bytes are handled by the outer VT parser. +const oscControl = /[\x00-\x1f]/g; + +type ClipboardWriteResult = "written" | "failed" | "skipped"; + +/** Shares one serialized writer per client, with at most one pending clipboard payload. */ +export function createTerminalClipboardWriter( + writeText: (text: string) => Promise | undefined, +) { + // OSC writers share one system clipboard. Retain only the newest pending copy + // while a clipboard write is in flight, so slow permission checks cannot build a backlog. + let writingClipboard = false; + let pendingClipboardWrite: { + text: string; + canWrite: () => boolean; + resolve: (result: ClipboardWriteResult) => void; + } | null = null; + + function writeTerminalClipboard( + text: string, + canWrite: () => boolean = () => true, + ): Promise { + return new Promise((resolve) => { + pendingClipboardWrite?.resolve("skipped"); + pendingClipboardWrite = { text, canWrite, resolve }; + if (!writingClipboard) void drainClipboardWrites(); + }); + } + + async function drainClipboardWrites(): Promise { + writingClipboard = true; + while (pendingClipboardWrite) { + const request = pendingClipboardWrite; + pendingClipboardWrite = null; + let result: ClipboardWriteResult = "skipped"; + try { + if (request.canWrite()) { + const write = writeText(request.text); + if (write === undefined) result = "failed"; + else { + await write; + result = "written"; + } + } + } catch { + result = "failed"; + } + request.resolve(result); + } + writingClipboard = false; + } + return writeTerminalClipboard; +} + +function decodeClipboardPayload(osc: string): string | null { + if (!osc.startsWith("52;")) return null; + const separator = osc.indexOf(";", 3); + if (separator === -1) return null; + const target = osc.slice(3, separator); + // Empty targets use the system clipboard. Queries never read or send the + // client's clipboard to a PTY; application selection buffers stay local. + if (target !== "" && (!/^[cpqs0-7]+$/.test(target) || !target.includes("c"))) return null; + const encoded = osc.slice(separator + 1); + if (encoded === "") return ""; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return null; + try { + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return decoder.decode(bytes); + } catch { + return null; + } +} + +/** + * Observes 7-bit OSC framing in live output independently of native renderer replays. + * libghostty parses OSC payloads only after the caller has framed them, so the + * framing lives here for both clients. + */ +export class TerminalClipboardParser { + private state: "ground" | "escape" | "osc" | "oscEscape" = "ground"; + // Null means this sequence has no clipboard payload worth retaining. + private payload: string | null = null; + private eligible = false; + private escapeEligible = false; + + private readonly onWrite: (text: string) => void; + + constructor(onWrite: (text: string) => void) { + this.onWrite = onWrite; + } + + reset(): void { + this.state = "ground"; + this.invalidatePendingCopy(); + } + + /** Revokes a pending copy on focus/visibility loss without losing VT framing. */ + invalidatePendingCopy(): void { + this.payload = null; + this.eligible = false; + this.escapeEligible = false; + } + + private beginEscape(eligible: boolean): void { + this.state = "escape"; + this.payload = null; + this.eligible = eligible; + } + + /** Ineligible chunks still advance parsing, so replay cannot become a live copy. */ + write(data: string, eligible: boolean): void { + if (!eligible) this.invalidatePendingCopy(); + for (let index = 0; index < data.length; index += 1) { + const char = data[index]!; + if (char === "\x18" || char === "\x1a") { + this.reset(); + continue; + } + switch (this.state) { + case "ground": { + // ESC also exits DCS/APC/PM/SOS, so their payloads need no separate state. + const escape = data.indexOf("\x1b", index); + if (escape === -1) return; + index = escape; + this.beginEscape(eligible); + break; + } + case "escape": + if (char === "]") { + this.state = "osc"; + this.payload = this.eligible ? "" : null; + } else if (char === "\x1b") { + this.beginEscape(eligible); + } else if (char >= " " && char !== "\x7f") { + this.state = "ground"; + } + break; + case "osc": + if (char === "\x07") { + this.finish(); + } else if (char === "\x1b") { + this.state = "oscEscape"; + this.escapeEligible = eligible; + } else if (char < " ") { + // Ghostty ignores other C0 bytes inside OSC, including wrapped lines. + } else { + oscControl.lastIndex = index; + const control = oscControl.exec(data); + const length = (control?.index ?? data.length) - index; + if (this.payload !== null) { + if (this.payload.length + length > MAX_OSC_LENGTH) { + this.payload = null; + } else { + // Only the first three characters can disqualify the request. Checking + // later would flatten the whole accumulated payload on every append. + const checkPrefix = this.payload.length < 3; + this.payload += data.slice(index, index + length); + if (checkPrefix && !"52;".startsWith(this.payload.slice(0, 3))) this.payload = null; + } + } + index += length - 1; + } + break; + case "oscEscape": + if (char === "\\") { + this.finish(); + } else { + // An ESC other than ST aborts the OSC and starts a new escape. + this.beginEscape(this.escapeEligible); + index -= 1; + } + break; + } + } + } + + private finish(): void { + const text = + this.eligible && this.payload !== null ? decodeClipboardPayload(this.payload) : null; + this.reset(); + if (text !== null) this.onWrite(text); + } +} + +/** + * One client surface's live-copy policy over the parser: output copies only while + * the surface is eligible, history never does, and copies parsed or queued before + * the surface lost eligibility are revoked. + */ +export function createTerminalClipboardSession(options: { + /** Read when output arrives and again before a queued write reaches the clipboard. */ + readonly isEligible: () => boolean; + readonly onCopy: (text: string, canWrite: () => boolean) => void; +}) { + let generation = 0; + const parser = new TerminalClipboardParser((text) => { + const requested = generation; + options.onCopy(text, () => generation === requested && options.isEligible()); + }); + const reset = (history = "") => { + generation += 1; + parser.reset(); + parser.write(history, false); + }; + return { + /** Revokes copies on focus or visibility loss without losing VT framing. */ + invalidate(): void { + generation += 1; + parser.invalidatePendingCopy(); + }, + /** Session history and lifecycle events revoke copies; display resynchronization does not. */ + update(event: TerminalAttachStreamEvent): void { + switch (event.type) { + case "output": + parser.write(event.data, options.isEligible()); + break; + case "snapshot": + case "restarted": + reset(event.snapshot.history); + break; + case "cleared": + case "closed": + case "exited": + case "error": + reset(); + break; + case "activity": + break; + } + }, + }; +}