From ad33ce788b2531bfa3e6a76ad985a3aa5a2fcf6c Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Fri, 4 Sep 2026 22:50:46 -0700 Subject: [PATCH 01/16] fix(terminal): preserve fullscreen selection through native drags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep native selection through output redraws and Shift release, including the final pointer position. Track drag ownership separately from the selected range and stop autoscroll on release, cancellation, capture loss and deactivation. Retire completed selections when typing, pasting or other committed input resumes, so a later Ctrl+C can interrupt. Preserve copy gestures, modifiers and terminal protocol replies. Prevent older copy completions from clearing a newer selection. Verified with focused tests using the real Ghostty core and a desktop check on the MacBook Neo. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- .../src/components/ThreadTerminalDrawer.tsx | 20 +- apps/web/src/terminal/ghostty/surface.test.ts | 298 +++++++++++++++++- apps/web/src/terminal/ghostty/surface.ts | 159 +++++++--- 3 files changed, 409 insertions(+), 68 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9ddf9225bd..37e16795244 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -729,19 +729,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 +749,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 +757,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; } @@ -944,7 +931,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/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index ee3240b41b0..af8c64e3189 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -67,6 +67,7 @@ describe("GhosttyTerminalSurface visibility", () => { private readonly captures = new Set(); setAttribute() {} + select() {} append(...children: TerminalTestElement[]) { for (const child of children) child.parentElement = this; } @@ -117,6 +118,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,17 +170,22 @@ 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) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, - clientY: 5, + clientY, pointerId: 1, button: 0, buttons, + shiftKey, }), ); }, + loseCapture() { + canvas.releasePointerCapture(1); + canvas.dispatchEvent(Object.assign(new Event("lostpointercapture"), { pointerId: 1 })); + }, async create(options: Partial = {}) { const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, { theme: { @@ -210,6 +217,208 @@ 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.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 +489,91 @@ 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("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("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 29aaac6f6ab..e0ade559479 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -37,6 +37,16 @@ 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", +]); const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; const TERMINAL_FONT_LOAD_VARIANTS = [ "normal 400", @@ -572,19 +582,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; + mode: "cell" | "word" | "line"; + moved: boolean; + lastCell: { x: number; y: number }; + pointer: { x: number; y: number }; + // The original word/line range stays anchored through viewport scrolling. + base: { + 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,7 +605,6 @@ 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 resizeNotified = false; @@ -730,7 +742,7 @@ export class GhosttyTerminalSurface { this.scrollbarDirty = true; if (!visible) { this.cancelRender(); - this.setSelectionAutoscroll(0); + this.endSelectionDrag(); return; } this.fit(); @@ -749,6 +761,7 @@ export class GhosttyTerminalSurface { resetAndWrite(data: string): void { if (this.disposed) return; + this.clearSelection(); this.lastMouseMotionData = ""; this.core.resetAndWrite(data); this.synchronizeMouseTrackingState(); @@ -900,6 +913,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 +939,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 +975,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 +1002,12 @@ export class GhosttyTerminalSurface { dispose(): void { if (this.disposed) return; this.disposed = true; + this.endSelectionDrag(); 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; @@ -1092,7 +1111,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 +1132,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 +1159,7 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; + this.endSelectionDrag(); this.linkModifierActive = false; this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for @@ -1150,6 +1171,10 @@ export class GhosttyTerminalSurface { this.requestRender(); }; + private readonly onWindowBlur = () => { + this.endSelectionDrag(); + }; + private readonly onDevicePixelRatioChange = () => { this.watchDevicePixelRatio(); this.fit(); @@ -1202,7 +1227,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 +1239,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 +1258,7 @@ export class GhosttyTerminalSurface { return; } this.clearCompositionInputSuppression(); - if (data.length > 0) this.options.onData(data); + this.sendUserInput(data); this.input.value = ""; }; @@ -1246,12 +1271,14 @@ export class GhosttyTerminalSurface { } private readonly onPointerDown = (event: PointerEvent) => { + if (this.selectionDrag && this.selectionDrag.pointerId !== event.pointerId) return; this.focus(); if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { const button = ghosttyMouseButton(event.button); if (button === null) return; event.preventDefault(); event.stopPropagation(); + this.clearSelection(); this.clearHoveredLink("default"); this.mouseReportingPointerId = event.pointerId; this.mouseReportingButton = button; @@ -1268,30 +1295,33 @@ export class GhosttyTerminalSurface { return; } this.clearHoveredLink(); + 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" : "cell"; 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, + mode: range ? mode : "cell", + moved: false, + lastCell: cell, + pointer: { x: event.clientX, y: event.clientY }, + base: 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; @@ -1313,7 +1343,9 @@ export class GhosttyTerminalSurface { 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 +1358,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 range = - this.selectionMode === "line" + drag.mode === "line" ? this.core.selectLine(cell.x, cell.y) - : this.selectionMode === "word" + : drag.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 base = drag.base; const beforeBase = base !== null && (cellScreen.y < base.start.y || @@ -1385,11 +1418,29 @@ 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.selectionDrag?.pointerId === event.pointerId && + !this.canvas.hasPointerCapture(event.pointerId) + ) { + this.endSelectionDrag(); + } + }; + private updateHoverCursor(event: PointerEvent): void { this.hoverPointer = { x: event.clientX, y: event.clientY }; this.linkModifierActive = isTerminalLinkPointerGesture(event); @@ -1436,7 +1487,6 @@ export class GhosttyTerminalSurface { } private readonly onPointerUp = (event: PointerEvent) => { - this.setSelectionAutoscroll(0); if (this.linkActivationPointerId === event.pointerId) { event.preventDefault(); event.stopPropagation(); @@ -1468,11 +1518,18 @@ export class GhosttyTerminalSurface { } return; } - if (this.canvas.hasPointerCapture(event.pointerId)) { - this.canvas.releasePointerCapture(event.pointerId); + const drag = this.selectionDrag; + if (drag?.pointerId !== event.pointerId) 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 (event.button !== 0) return; - if (!this.selectionMoved && this.selectionMode === "cell") { + if (!drag.moved && drag.mode === "cell") { this.clearSelection(); } this.options.onSelectionChange(); @@ -1584,6 +1641,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 +1656,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 +1668,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 +1683,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); From 9d256b000af146e8ff402ca6d06a7965e51ef4a4 Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Fri, 4 Sep 2026 22:51:13 -0700 Subject: [PATCH 02/16] fix(terminal): support focused OSC 52 clipboard writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observe bounded OSC 52 writes in the shared desktop/web terminal. Keep stream framing separate from payload retention and copy eligibility, including chunked escapes, wrapped payloads and control-string exits. Never replay historical copies; revoke pending copies on focus or visibility loss while retaining parser framing. Use the Clipboard API without stealing focus or inserting denial errors into a fullscreen application. Clipboard queries remain unsupported. Verified 128 focused tests, web typecheck and the macOS clipboard in the isolated desktop client on the MacBook Neo. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- .../src/components/ThreadTerminalDrawer.tsx | 2 + .../src/terminal/ghostty/clipboard.test.ts | 177 ++++++++++++++++++ apps/web/src/terminal/ghostty/clipboard.ts | 132 +++++++++++++ apps/web/src/terminal/ghostty/surface.test.ts | 71 +++++++ apps/web/src/terminal/ghostty/surface.ts | 12 ++ 5 files changed, 394 insertions(+) create mode 100644 apps/web/src/terminal/ghostty/clipboard.test.ts create mode 100644 apps/web/src/terminal/ghostty/clipboard.ts diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 37e16795244..c76ff220071 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -84,6 +84,7 @@ import { terminalEnvironment } from "../state/terminal"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; +import { writeTerminalClipboard } from "../terminal/ghostty/clipboard"; import { resolveTerminalFontPreference, resolveTerminalFontSizePreference, @@ -499,6 +500,7 @@ export function TerminalViewport({ onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), + onClipboardWrite: (text) => void writeTerminalClipboard(text), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), // The surface listens from construction, so a right-click can land 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 00000000000..4747a83b6c1 --- /dev/null +++ b/apps/web/src/terminal/ghostty/clipboard.test.ts @@ -0,0 +1,177 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { TerminalClipboardParser, writeTerminalClipboard } from "./clipboard"; + +function osc(text: string, target = "c", terminator = "\x07") { + return `\x1b]52;${target};${Buffer.from(text).toString("base64")}${terminator}`; +} + +describe("terminal OSC 52 clipboard writes", () => { + 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;\x07", + "\x1b]52;c;bad!\x07", + "\x1b]52;c;/w==\x07", + osc("primary", "p"), + "\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 = Buffer.from(text) + .toString("base64") + .replace(/(.{76})/g, `$1${separator}`); + parser.write(`\x1b]52;c;${encoded}\x07`, true); + expect(copy.mock.calls).toEqual([[text]]); + }); + + it.each(["c", "cp", "pc", "s0c", "7c"])("writes 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(["\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"]]); + } + }); +}); + +describe("application clipboard writes", () => { + afterEach(() => vi.unstubAllGlobals()); + + 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.toBeUndefined(); + 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 00000000000..06bb7097273 --- /dev/null +++ b/apps/web/src/terminal/ghostty/clipboard.ts @@ -0,0 +1,132 @@ +// Bound retained OSC text independently of terminal scrollback. +const MAX_OSC_LENGTH = 1024 * 1024; +const decoder = new TextDecoder("utf-8", { fatal: true }); +// oxlint-disable-next-line no-control-regex -- C0 bytes are handled by the outer VT parser. +const oscControl = /[\x00-\x1f]/g; + +/** Application output cannot use the focus-stealing, user-gesture copy fallback. */ +export async function writeTerminalClipboard(text: string): Promise { + try { + await navigator.clipboard?.writeText(text); + } catch { + // Browser-denied application copies must not write errors into the TUI. + } +} + +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 !== "" && (!/^[cps0-7]+$/.test(target) || !target.includes("c"))) return null; + const encoded = osc.slice(separator + 1); + if (encoded === "" || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return null; + try { + return decoder.decode(Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0))) || null; + } catch { + return null; + } +} + +/** Observes 7-bit OSC framing alongside Ghostty. Its C ABI exposes neither a + * clipboard callback nor clipboard data from ghostty_osc_command_data. */ +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; + + constructor(private readonly onWrite: (text: string) => void) {} + + 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 += data.slice(index, index + length); + if (!"52;".startsWith(this.payload.slice(0, 3))) this.payload = null; + } else { + 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); + } +} diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index af8c64e3189..49e96cfe430 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -511,6 +511,65 @@ describe("GhosttyTerminalSurface visibility", () => { harness.pointer("pointerup", 45, 0); }); + 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.write(data); + expect(copy).not.toHaveBeenCalled(); + surface.focus(); + surface.resetAndWrite(data); + surface.resetAndWrite("\x1b]52;c;"); + surface.write("aGVsbG8=\x07"); + expect(copy).not.toHaveBeenCalled(); + surface.write(data); + expect(copy.mock.calls).toEqual([["hello"]]); + copy.mockClear(); + surface.setVisible(false); + surface.write(data); + surface.setVisible(true); + surface.input.dispatchEvent(new Event("blur")); + surface.write(data); + surface.focus(); + vi.spyOn(document, "hasFocus").mockReturnValue(false); + surface.write(data); + surface.dispose(); + surface.write(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.write(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.write(data.slice(-1)); + expect(copy).not.toHaveBeenCalled(); + surface.write(data); + expect(copy.mock.calls).toEqual([["hello"]]); + }, + ); + it("uses the release position when a trackpad drag has no intermediate motion", async () => { const harness = createHarness(); const surface = await harness.create(); @@ -543,6 +602,18 @@ describe("GhosttyTerminalSurface visibility", () => { }, ); + 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(); + surface.write("\x1bPtmux;\x1b\x1b]52;c;aGVsbG8=\x07visible\x1b\\"); + harness.flushFrame(); + expect(copy.mock.calls).toEqual([["hello"]]); + 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(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index e0ade559479..0395c9dda65 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -16,6 +16,7 @@ import { } from "./renderer"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; import { isMonospaceFamily } from "../../appearanceFonts"; +import { TerminalClipboardParser } from "./clipboard"; export const DEFAULT_TERMINAL_FONT_SIZE = 12; const MIN_TERMINAL_FONT_SIZE = 6; @@ -535,6 +536,7 @@ export interface GhosttyTerminalSurfaceOptions { readonly onData: (data: string) => void; readonly onResize: (cols: number, rows: number) => void; readonly onSelectionChange: () => void; + readonly onClipboardWrite?: (text: string) => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; /** @@ -607,6 +609,9 @@ export class GhosttyTerminalSurface { private selectionClickSequence: TerminalSelectionClickSequence | null = null; private composing = false; private focused = false; + private readonly clipboardParser = new TerminalClipboardParser((text) => { + this.options.onClipboardWrite?.(text); + }); private resizeNotified = false; private canvasConfigured = false; private theme: GhosttyTheme; @@ -743,6 +748,7 @@ export class GhosttyTerminalSurface { if (!visible) { this.cancelRender(); this.endSelectionDrag(); + this.clipboardParser.invalidatePendingCopy(); return; } this.fit(); @@ -751,6 +757,7 @@ export class GhosttyTerminalSurface { write(data: string): void { if (this.disposed) return; this.core.write(data); + this.clipboardParser.write(data, this.visible && this.focused && document.hasFocus()); 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. @@ -764,6 +771,8 @@ export class GhosttyTerminalSurface { this.clearSelection(); this.lastMouseMotionData = ""; this.core.resetAndWrite(data); + this.clipboardParser.reset(); + this.clipboardParser.write(data, false); this.synchronizeMouseTrackingState(); // A replayed session starts from the visible phase like any other write: // reattaching mid-blink must not open on an invisible cursor. @@ -1003,6 +1012,7 @@ export class GhosttyTerminalSurface { if (this.disposed) return; this.disposed = true; this.endSelectionDrag(); + this.clipboardParser.reset(); this.resizeObserver.disconnect(); document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); @@ -1160,6 +1170,7 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; this.endSelectionDrag(); + this.clipboardParser.invalidatePendingCopy(); this.linkModifierActive = false; this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for @@ -1173,6 +1184,7 @@ export class GhosttyTerminalSurface { private readonly onWindowBlur = () => { this.endSelectionDrag(); + this.clipboardParser.invalidatePendingCopy(); }; private readonly onDevicePixelRatioChange = () => { From b2f5e95c82e30025b7686ec06a5a9a42edc56907 Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Fri, 4 Sep 2026 23:11:49 -0700 Subject: [PATCH 03/16] fix(terminal): preserve OSC clipboard text and write order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Preserve a leading U+FEFF when decoding clipboard text, including a payload containing only that character. Serialize OSC clipboard writes across terminal instances and retain only the newest pending copy. Recheck its terminal generation and focus before writing, so a delayed request cannot survive blur, hiding, reset or disposal. Consume clipboard denial without blocking subsequent copies or writing errors to the TUI. Verified regression failures against the previous writer, 138 focused tests, web typecheck and targeted lint (existing React warnings only). 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- .../src/components/ThreadTerminalDrawer.tsx | 2 +- .../src/terminal/ghostty/clipboard.test.ts | 36 ++++++++++++ apps/web/src/terminal/ghostty/clipboard.ts | 39 +++++++++++-- apps/web/src/terminal/ghostty/surface.test.ts | 55 ++++++++++++++++++- apps/web/src/terminal/ghostty/surface.ts | 27 +++++++-- 5 files changed, 144 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index c76ff220071..38970eb3abc 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -500,7 +500,7 @@ export function TerminalViewport({ onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), - onClipboardWrite: (text) => void writeTerminalClipboard(text), + onClipboardWrite: (text, canWrite) => void writeTerminalClipboard(text, canWrite), beforeKey: (event) => handleBeforeKey(event), onLinkActivate: (text, event) => handleLinkActivate(text, event), // The surface listens from construction, so a right-click can land diff --git a/apps/web/src/terminal/ghostty/clipboard.test.ts b/apps/web/src/terminal/ghostty/clipboard.test.ts index 4747a83b6c1..685b567b0e5 100644 --- a/apps/web/src/terminal/ghostty/clipboard.test.ts +++ b/apps/web/src/terminal/ghostty/clipboard.test.ts @@ -6,6 +6,12 @@ function osc(text: string, target = "c", terminator = "\x07") { } describe("terminal OSC 52 clipboard writes", () => { + it.each(["\uFEFFtext", "\uFEFF"])("preserves leading U+FEFF in %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) => { @@ -158,6 +164,36 @@ describe("terminal OSC 52 clipboard writes", () => { describe("application clipboard writes", () => { afterEach(() => vi.unstubAllGlobals()); + 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(); + await Promise.all(writes); + 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) => { diff --git a/apps/web/src/terminal/ghostty/clipboard.ts b/apps/web/src/terminal/ghostty/clipboard.ts index 06bb7097273..a4c3dfff57b 100644 --- a/apps/web/src/terminal/ghostty/clipboard.ts +++ b/apps/web/src/terminal/ghostty/clipboard.ts @@ -1,16 +1,43 @@ // Bound retained OSC text independently of terminal scrollback. const MAX_OSC_LENGTH = 1024 * 1024; -const decoder = new TextDecoder("utf-8", { fatal: true }); +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; +// OSC writers share one system clipboard. Retain only the newest pending copy +// while a browser write is in flight, so slow permission checks cannot build a backlog. +let writingClipboard = false; +let pendingClipboardWrite: { + text: string; + canWrite: () => boolean; + resolve: () => void; +} | null = null; + /** Application output cannot use the focus-stealing, user-gesture copy fallback. */ -export async function writeTerminalClipboard(text: string): Promise { - try { - await navigator.clipboard?.writeText(text); - } catch { - // Browser-denied application copies must not write errors into the TUI. +export function writeTerminalClipboard( + text: string, + canWrite: () => boolean = () => true, +): Promise { + return new Promise((resolve) => { + pendingClipboardWrite?.resolve(); + pendingClipboardWrite = { text, canWrite, resolve }; + if (!writingClipboard) void drainClipboardWrites(); + }); +} + +async function drainClipboardWrites(): Promise { + writingClipboard = true; + while (pendingClipboardWrite) { + const request = pendingClipboardWrite; + pendingClipboardWrite = null; + try { + if (request.canWrite()) await navigator.clipboard?.writeText(request.text); + } catch { + // Browser-denied application copies must not write errors into the TUI. + } + request.resolve(); } + writingClipboard = false; } function decodeClipboardPayload(osc: string): string | null { diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 49e96cfe430..889cdb37146 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { writeTerminalClipboard } from "./clipboard"; import { GhosttyTerminalCore, type GhosttyCell, type GhosttyRow } from "./core"; import { DEFAULT_TERMINAL_FONT_FAMILY, @@ -524,7 +525,7 @@ describe("GhosttyTerminalSurface visibility", () => { surface.write("aGVsbG8=\x07"); expect(copy).not.toHaveBeenCalled(); surface.write(data); - expect(copy.mock.calls).toEqual([["hello"]]); + expect(copy.mock.calls).toEqual([["hello", expect.any(Function)]]); copy.mockClear(); surface.setVisible(false); surface.write(data); @@ -566,7 +567,55 @@ describe("GhosttyTerminalSurface visibility", () => { surface.write(data.slice(-1)); expect(copy).not.toHaveBeenCalled(); surface.write(data); - expect(copy.mock.calls).toEqual([["hello"]]); + 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.write("\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.resetAndWrite(""); + 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.write("\x1b]52;c;bmV3\x07"); + await Promise.all(copy.mock.results.map((result) => result.value)); + expect(writeText.mock.calls).toEqual([["first"], ["new"]]); + } }, ); @@ -609,7 +658,7 @@ describe("GhosttyTerminalSurface visibility", () => { surface.focus(); surface.write("\x1bPtmux;\x1b\x1b]52;c;aGVsbG8=\x07visible\x1b\\"); harness.flushFrame(); - expect(copy.mock.calls).toEqual([["hello"]]); + 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"); }); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 0395c9dda65..8bbb3883a4e 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -536,7 +536,7 @@ export interface GhosttyTerminalSurfaceOptions { readonly onData: (data: string) => void; readonly onResize: (cols: number, rows: number) => void; readonly onSelectionChange: () => void; - readonly onClipboardWrite?: (text: string) => void; + readonly onClipboardWrite?: (text: string, canWrite: () => boolean) => void; readonly beforeKey: (event: KeyboardEvent) => boolean; readonly onLinkActivate: (text: string, event: MouseEvent) => void; /** @@ -609,8 +609,18 @@ export class GhosttyTerminalSurface { private selectionClickSequence: TerminalSelectionClickSequence | null = null; private composing = false; private focused = false; + private clipboardCopyGeneration = 0; private readonly clipboardParser = new TerminalClipboardParser((text) => { - this.options.onClipboardWrite?.(text); + const generation = this.clipboardCopyGeneration; + this.options.onClipboardWrite?.( + text, + () => + generation === this.clipboardCopyGeneration && + !this.disposed && + this.visible && + this.focused && + document.hasFocus(), + ); }); private resizeNotified = false; private canvasConfigured = false; @@ -748,7 +758,7 @@ export class GhosttyTerminalSurface { if (!visible) { this.cancelRender(); this.endSelectionDrag(); - this.clipboardParser.invalidatePendingCopy(); + this.invalidateClipboardCopies(); return; } this.fit(); @@ -771,6 +781,7 @@ export class GhosttyTerminalSurface { this.clearSelection(); this.lastMouseMotionData = ""; this.core.resetAndWrite(data); + this.invalidateClipboardCopies(); this.clipboardParser.reset(); this.clipboardParser.write(data, false); this.synchronizeMouseTrackingState(); @@ -1012,6 +1023,7 @@ export class GhosttyTerminalSurface { if (this.disposed) return; this.disposed = true; this.endSelectionDrag(); + this.invalidateClipboardCopies(); this.clipboardParser.reset(); this.resizeObserver.disconnect(); document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); @@ -1170,7 +1182,7 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; this.endSelectionDrag(); - this.clipboardParser.invalidatePendingCopy(); + this.invalidateClipboardCopies(); this.linkModifierActive = false; this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for @@ -1182,9 +1194,14 @@ export class GhosttyTerminalSurface { this.requestRender(); }; + private invalidateClipboardCopies(): void { + this.clipboardCopyGeneration += 1; + this.clipboardParser.invalidatePendingCopy(); + } + private readonly onWindowBlur = () => { this.endSelectionDrag(); - this.clipboardParser.invalidatePendingCopy(); + this.invalidateClipboardCopies(); }; private readonly onDevicePixelRatioChange = () => { From 4124f32106da7a2d428cb4e4e06c46f5bc581097 Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Fri, 4 Sep 2026 23:17:35 -0700 Subject: [PATCH 04/16] fix(terminal): clear completed selections on application scroll MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Retire completed native selections before forwarding wheel mouse reports or alternate-screen arrow keys. Preserve active drags, local scrollback selection and fractional wheel movements that do not send input. Both wheel regressions failed before the fix. Verified 143 focused tests, web typecheck and targeted lint. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- apps/web/src/terminal/ghostty/surface.test.ts | 50 +++++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 3 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 889cdb37146..d4f107f5259 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -187,6 +187,17 @@ describe("GhosttyTerminalSurface visibility", () => { canvas.releasePointerCapture(1); canvas.dispatchEvent(Object.assign(new Event("lostpointercapture"), { pointerId: 1 })); }, + wheel(deltaY = 1, deltaMode = 1) { + canvas.dispatchEvent( + Object.assign(new Event("wheel", { cancelable: true }), { + deltaY, + deltaMode, + clientX: 5, + clientY: 5, + shiftKey: false, + }), + ); + }, async create(options: Partial = {}) { const surface = await GhosttyTerminalSurface.create(mount as unknown as HTMLElement, { theme: { @@ -619,6 +630,45 @@ describe("GhosttyTerminalSurface visibility", () => { }, ); + 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(); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 8bbb3883a4e..9a96749319f 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -1577,6 +1577,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); @@ -1586,7 +1587,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); From f8e58e25364758141a7bc42e6cf0ffd5f5843f4e Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Fri, 4 Sep 2026 23:37:06 -0700 Subject: [PATCH 05/16] fix(mobile): copy live OSC 52 output on iOS and Android MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share the existing OSC parser and bounded clipboard writer through client-runtime. Observe live terminal subscription chunks on mobile and write through Expo Clipboard. This supports both native renderers without parsing their replayed initialBuffer. Only the focused terminal route in the active app may copy. Ignore initial history, reconnect/reset replays and background output; invalidate incomplete and queued copies on navigation, app deactivation and terminal changes. Clipboard reads remain disabled. Verified 171 focused tests, mobile/web/client-runtime typechecks and targeted lint. Six installed provider startup captures passed Ghostty selection/copy/input smoke tests. Simulator verification is pending installation of Xcode on the test host. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- .../terminal/ThreadTerminalRouteScreen.tsx | 2 + .../terminal/terminalClipboard.test.ts | 104 +++++++++++ .../features/terminal/terminalClipboard.ts | 39 ++++ .../features/terminal/useTerminalClipboard.ts | 47 +++++ .../src/terminal/ghostty/clipboard.test.ts | 3 +- apps/web/src/terminal/ghostty/clipboard.ts | 161 +---------------- apps/web/src/terminal/ghostty/surface.ts | 2 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/terminalClipboard.ts | 167 ++++++++++++++++++ 9 files changed, 370 insertions(+), 159 deletions(-) create mode 100644 apps/mobile/src/features/terminal/terminalClipboard.test.ts create mode 100644 apps/mobile/src/features/terminal/terminalClipboard.ts create mode 100644 apps/mobile/src/features/terminal/useTerminalClipboard.ts create mode 100644 packages/client-runtime/src/terminalClipboard.ts diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 351082580d6..8800197a404 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(terminalKey, terminal.output); const bufferReplayKey = useMemo( () => getTerminalBufferReplayKey({ terminalKey, fontSize }), [fontSize, terminalKey], diff --git a/apps/mobile/src/features/terminal/terminalClipboard.test.ts b/apps/mobile/src/features/terminal/terminalClipboard.test.ts new file mode 100644 index 00000000000..5c025113adb --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalClipboard.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard"; +import { + applyTerminalAttachStreamEvent, + EMPTY_TERMINAL_BUFFER_STATE, +} from "@t3tools/client-runtime/state/terminal"; +import { ThreadId } from "@t3tools/contracts"; +import { createTerminalClipboardSession } from "./terminalClipboard"; + +const osc = (text: string) => `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`; + +function harness(writeText = vi.fn(async (_text: string) => {})) { + const write = createTerminalClipboardWriter(writeText); + const pending: Promise[] = []; + const session = createTerminalClipboardSession((text, canWrite) => { + const result = write(text, canWrite); + pending.push(result); + return result; + }); + let state = EMPTY_TERMINAL_BUFFER_STATE; + return { + session, + writeText, + flush: () => Promise.all(pending), + update: () => session.update(state.output), + append(data: string) { + state = applyTerminalAttachStreamEvent(state, { + type: "output", + threadId: ThreadId.make("thread"), + terminalId: "terminal", + data, + }); + session.update(state.output); + }, + reset() { + state = { + ...state, + output: { ...state.output, resetVersion: state.output.resetVersion + 1 }, + }; + session.update(state.output); + }, + }; +} + +describe("mobile terminal clipboard session", () => { + it("copies live Unicode output once while ignoring initial history and replay", async () => { + const h = harness(); + h.session.setActive(true); + h.append(osc("history")); + const text = "\uFEFFClaude: café 界🙂"; + const data = osc(text); + h.append(data.slice(0, -1)); + h.append(data.slice(-1)); + h.update(); + 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("does not finish a split copy across leaving and returning to the terminal", async () => { + const h = harness(); + h.update(); + h.session.setActive(true); + h.append(osc("old").slice(0, -1)); + h.session.setActive(false); + h.session.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 = harness(); + h.update(); + h.append(osc("background").slice(0, -1)); + h.session.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 = harness(vi.fn(() => first)); + h.update(); + h.session.setActive(true); + h.append(osc("first") + osc("queued")); + if (action === "reset") h.reset(); + else h.session.setActive(false); + h.session.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/apps/mobile/src/features/terminal/terminalClipboard.ts b/apps/mobile/src/features/terminal/terminalClipboard.ts new file mode 100644 index 00000000000..04f2d98b1c4 --- /dev/null +++ b/apps/mobile/src/features/terminal/terminalClipboard.ts @@ -0,0 +1,39 @@ +import { TerminalClipboardParser } from "@t3tools/client-runtime/terminal-clipboard"; +import { + INITIAL_TERMINAL_OUTPUT_CURSOR, + readTerminalOutputUpdate, + type TerminalOutputState, +} from "@t3tools/client-runtime/state/terminal"; + +/** Observe the subscription's live chunks, never a native renderer's history replay. */ +export function createTerminalClipboardSession( + write: (text: string, canWrite: () => boolean) => Promise, +) { + let active = false; + let generation = 0; + let cursor = INITIAL_TERMINAL_OUTPUT_CURSOR; + const invalidate = () => { + generation += 1; + parser.invalidatePendingCopy(); + }; + const parser = new TerminalClipboardParser((text) => { + const requestedGeneration = generation; + void write(text, () => active && generation === requestedGeneration); + }); + return { + setActive(next: boolean) { + active = next; + if (!active) invalidate(); + }, + update(output: TerminalOutputState) { + const update = readTerminalOutputUpdate(output, cursor); + cursor = update.cursor; + if (update.type === "none") return; + if (update.type === "reset") { + invalidate(); + parser.reset(); + } + parser.write(update.data, active && update.type === "append"); + }, + }; +} diff --git a/apps/mobile/src/features/terminal/useTerminalClipboard.ts b/apps/mobile/src/features/terminal/useTerminalClipboard.ts new file mode 100644 index 00000000000..801161eb9cc --- /dev/null +++ b/apps/mobile/src/features/terminal/useTerminalClipboard.ts @@ -0,0 +1,47 @@ +import { useFocusEffect } from "@react-navigation/native"; +import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard"; +import type { TerminalOutputState } from "@t3tools/client-runtime/state/terminal"; +import * as Clipboard from "expo-clipboard"; +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { AppState } from "react-native"; +import { createTerminalClipboardSession } from "./terminalClipboard"; + +const writeClipboard = createTerminalClipboardWriter((text) => Clipboard.setStringAsync(text)); + +/** Route focus, not keyboard visibility, owns touch-driven TUI copies on iOS and Android. */ +export function useTerminalClipboard(terminalKey: string, output: TerminalOutputState) { + const current = useRef<{ + terminalKey: string; + session: ReturnType; + } | null>(null); + const latestOutput = useRef(output); + useLayoutEffect(() => { + latestOutput.current = output; + }, [output]); + + useFocusEffect( + useCallback(() => { + const session = createTerminalClipboardSession(writeClipboard); + current.current = { terminalKey, session }; + const activate = () => { + session.setActive(false); + session.update(latestOutput.current); + session.setActive(AppState.currentState === "active"); + }; + activate(); + const change = AppState.addEventListener("change", activate); + const blur = AppState.addEventListener("blur", () => session.setActive(false)); + const focus = AppState.addEventListener("focus", activate); + return () => { + session.setActive(false); + current.current = null; + change.remove(); + blur.remove(); + focus.remove(); + }; + }, [terminalKey]), + ); + useEffect(() => { + if (current.current?.terminalKey === terminalKey) current.current.session.update(output); + }, [output, terminalKey]); +} diff --git a/apps/web/src/terminal/ghostty/clipboard.test.ts b/apps/web/src/terminal/ghostty/clipboard.test.ts index 685b567b0e5..d1a50b79a0d 100644 --- a/apps/web/src/terminal/ghostty/clipboard.test.ts +++ b/apps/web/src/terminal/ghostty/clipboard.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { TerminalClipboardParser, writeTerminalClipboard } from "./clipboard"; +import { TerminalClipboardParser } from "@t3tools/client-runtime/terminal-clipboard"; +import { writeTerminalClipboard } from "./clipboard"; function osc(text: string, target = "c", terminator = "\x07") { return `\x1b]52;${target};${Buffer.from(text).toString("base64")}${terminator}`; diff --git a/apps/web/src/terminal/ghostty/clipboard.ts b/apps/web/src/terminal/ghostty/clipboard.ts index a4c3dfff57b..8ae2aa0ca75 100644 --- a/apps/web/src/terminal/ghostty/clipboard.ts +++ b/apps/web/src/terminal/ghostty/clipboard.ts @@ -1,159 +1,6 @@ -// 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; - -// OSC writers share one system clipboard. Retain only the newest pending copy -// while a browser write is in flight, so slow permission checks cannot build a backlog. -let writingClipboard = false; -let pendingClipboardWrite: { - text: string; - canWrite: () => boolean; - resolve: () => void; -} | null = null; +import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard"; /** Application output cannot use the focus-stealing, user-gesture copy fallback. */ -export function writeTerminalClipboard( - text: string, - canWrite: () => boolean = () => true, -): Promise { - return new Promise((resolve) => { - pendingClipboardWrite?.resolve(); - pendingClipboardWrite = { text, canWrite, resolve }; - if (!writingClipboard) void drainClipboardWrites(); - }); -} - -async function drainClipboardWrites(): Promise { - writingClipboard = true; - while (pendingClipboardWrite) { - const request = pendingClipboardWrite; - pendingClipboardWrite = null; - try { - if (request.canWrite()) await navigator.clipboard?.writeText(request.text); - } catch { - // Browser-denied application copies must not write errors into the TUI. - } - request.resolve(); - } - writingClipboard = false; -} - -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 !== "" && (!/^[cps0-7]+$/.test(target) || !target.includes("c"))) return null; - const encoded = osc.slice(separator + 1); - if (encoded === "" || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return null; - try { - return decoder.decode(Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0))) || null; - } catch { - return null; - } -} - -/** Observes 7-bit OSC framing alongside Ghostty. Its C ABI exposes neither a - * clipboard callback nor clipboard data from ghostty_osc_command_data. */ -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; - - constructor(private readonly onWrite: (text: string) => void) {} - - 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 += data.slice(index, index + length); - if (!"52;".startsWith(this.payload.slice(0, 3))) this.payload = null; - } else { - 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); - } -} +export const writeTerminalClipboard = createTerminalClipboardWriter((text) => + navigator.clipboard?.writeText(text), +); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 9a96749319f..9f1b123cf67 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -16,7 +16,7 @@ import { } from "./renderer"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; import { isMonospaceFamily } from "../../appearanceFonts"; -import { TerminalClipboardParser } from "./clipboard"; +import { TerminalClipboardParser } from "@t3tools/client-runtime/terminal-clipboard"; export const DEFAULT_TERMINAL_FONT_SIZE = 12; const MIN_TERMINAL_FONT_SIZE = 6; diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 68f5bad5147..a8595646f25 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/terminalClipboard.ts b/packages/client-runtime/src/terminalClipboard.ts new file mode 100644 index 00000000000..476a56475e4 --- /dev/null +++ b/packages/client-runtime/src/terminalClipboard.ts @@ -0,0 +1,167 @@ +// 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; + +/** 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: () => void; + } | null = null; + + function writeTerminalClipboard( + text: string, + canWrite: () => boolean = () => true, + ): Promise { + return new Promise((resolve) => { + pendingClipboardWrite?.resolve(); + pendingClipboardWrite = { text, canWrite, resolve }; + if (!writingClipboard) void drainClipboardWrites(); + }); + } + + async function drainClipboardWrites(): Promise { + writingClipboard = true; + while (pendingClipboardWrite) { + const request = pendingClipboardWrite; + pendingClipboardWrite = null; + try { + if (request.canWrite()) await writeText(request.text); + } catch { + // Clipboard failures must not write errors into the TUI or block later copies. + } + request.resolve(); + } + 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 !== "" && (!/^[cps0-7]+$/.test(target) || !target.includes("c"))) return null; + const encoded = osc.slice(separator + 1); + if (encoded === "" || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return null; + try { + return decoder.decode(Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0))) || null; + } catch { + return null; + } +} + +/** Observes 7-bit OSC framing in live output independently of native renderer replays. */ +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 += data.slice(index, index + length); + if (!"52;".startsWith(this.payload.slice(0, 3))) this.payload = null; + } else { + 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); + } +} From 947f0aeacf47ba1cf80017fd3cf998394c23e975 Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Fri, 4 Sep 2026 23:37:22 -0700 Subject: [PATCH 06/16] fix(terminal): honor empty OSC 52 clipboard writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward an empty decoded payload so applications can clear the clipboard. Keep malformed payloads and clipboard queries distinct from valid empty text. The shared parser applies the behavior to web, desktop and mobile. Verified the focused clipboard tests, including exact empty and BOM payloads. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- apps/web/src/terminal/ghostty/clipboard.test.ts | 3 +-- packages/client-runtime/src/terminalClipboard.ts | 5 +++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/terminal/ghostty/clipboard.test.ts b/apps/web/src/terminal/ghostty/clipboard.test.ts index d1a50b79a0d..6b41b253e87 100644 --- a/apps/web/src/terminal/ghostty/clipboard.test.ts +++ b/apps/web/src/terminal/ghostty/clipboard.test.ts @@ -7,7 +7,7 @@ function osc(text: string, target = "c", terminator = "\x07") { } describe("terminal OSC 52 clipboard writes", () => { - it.each(["\uFEFFtext", "\uFEFF"])("preserves leading U+FEFF in %j", (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]]); @@ -37,7 +37,6 @@ describe("terminal OSC 52 clipboard writes", () => { it.each([ "\x1b]52;c;?\x07", - "\x1b]52;c;\x07", "\x1b]52;c;bad!\x07", "\x1b]52;c;/w==\x07", osc("primary", "p"), diff --git a/packages/client-runtime/src/terminalClipboard.ts b/packages/client-runtime/src/terminalClipboard.ts index 476a56475e4..f4a110a873e 100644 --- a/packages/client-runtime/src/terminalClipboard.ts +++ b/packages/client-runtime/src/terminalClipboard.ts @@ -54,9 +54,10 @@ function decodeClipboardPayload(osc: string): string | null { // client's clipboard to a PTY; application selection buffers stay local. if (target !== "" && (!/^[cps0-7]+$/.test(target) || !target.includes("c"))) return null; const encoded = osc.slice(separator + 1); - if (encoded === "" || !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return null; + if (encoded === "") return ""; + if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return null; try { - return decoder.decode(Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0))) || null; + return decoder.decode(Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0))); } catch { return null; } From c07362cbbc722c18c7a08d90479e0449aefd89ec Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Sat, 5 Sep 2026 00:01:27 -0700 Subject: [PATCH 07/16] fix(web): offer terminal copy on plain HTTP connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Offer a Copy or Clear action when the browser has no async clipboard API. The click supplies the user gesture required for a clipboard copy event, without moving terminal focus or changing the clipboard before confirmation. Reuse one prompt per terminal and dismiss it when that terminal is disposed. Verified 138 focused web tests and the web typecheck. A real Chrome client on the MacBook Neo copied text and cleared the macOS clipboard over LAN HTTP; its clipboard remained unchanged until each action was clicked. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- .../src/components/ThreadTerminalDrawer.tsx | 33 +++++++++++++++++-- .../src/terminal/ghostty/clipboard.test.ts | 31 ++++++++++++++++- apps/web/src/terminal/ghostty/clipboard.ts | 21 ++++++++++++ 3 files changed, 82 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 38970eb3abc..70fbe3898d7 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -84,7 +84,11 @@ import { terminalEnvironment } from "../state/terminal"; import { openTerminalLinkInPreview } from "./preview/openTerminalLinkInPreview"; import { useAtomCommand } from "../state/use-atom-command"; import { preventTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; -import { writeTerminalClipboard } from "../terminal/ghostty/clipboard"; +import { + copyTerminalClipboardFromGesture, + writeTerminalClipboard, +} from "../terminal/ghostty/clipboard"; +import { toastManager } from "./ui/toast"; import { resolveTerminalFontPreference, resolveTerminalFontSizePreference, @@ -491,6 +495,8 @@ export function TerminalViewport({ const setup = async (): Promise<(() => void) | null> => { const setupFont = terminalFontRef.current; + const clipboardToastId = `terminal-copy:${environmentId}:${threadId}:${terminalId}`; + setupCleanups.push(() => toastManager.close(clipboardToastId)); const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), font: terminalFontOptions(setupFont.family, setupFont.size), @@ -500,7 +506,30 @@ export function TerminalViewport({ onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), - onClipboardWrite: (text, canWrite) => void writeTerminalClipboard(text, canWrite), + onClipboardWrite: (text, canWrite) => { + if (typeof navigator.clipboard?.writeText === "function") { + void writeTerminalClipboard(text, canWrite); + } else if (canWrite()) { + 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 diff --git a/apps/web/src/terminal/ghostty/clipboard.test.ts b/apps/web/src/terminal/ghostty/clipboard.test.ts index 6b41b253e87..4fefb51635a 100644 --- a/apps/web/src/terminal/ghostty/clipboard.test.ts +++ b/apps/web/src/terminal/ghostty/clipboard.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { TerminalClipboardParser } from "@t3tools/client-runtime/terminal-clipboard"; -import { writeTerminalClipboard } from "./clipboard"; +import { copyTerminalClipboardFromGesture, writeTerminalClipboard } from "./clipboard"; function osc(text: string, target = "c", terminator = "\x07") { return `\x1b]52;${target};${Buffer.from(text).toString("base64")}${terminator}`; @@ -164,6 +164,35 @@ describe("terminal OSC 52 clipboard writes", () => { 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) => { diff --git a/apps/web/src/terminal/ghostty/clipboard.ts b/apps/web/src/terminal/ghostty/clipboard.ts index 8ae2aa0ca75..8ba71438d7b 100644 --- a/apps/web/src/terminal/ghostty/clipboard.ts +++ b/apps/web/src/terminal/ghostty/clipboard.ts @@ -4,3 +4,24 @@ import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal- 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 }); + } +} From d36bab5b5fc8f65176338056b9bfa4cef94303f9 Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Sat, 5 Sep 2026 00:10:00 -0700 Subject: [PATCH 08/16] fix(terminal): accept combined secondary clipboard selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accept q when a selector list also includes the system clipboard c. Secondary-only requests remain local. The three combined-selector tests failed before the change; all 50 clipboard tests now pass. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- apps/web/src/terminal/ghostty/clipboard.test.ts | 7 +++++++ packages/client-runtime/src/terminalClipboard.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/web/src/terminal/ghostty/clipboard.test.ts b/apps/web/src/terminal/ghostty/clipboard.test.ts index 4fefb51635a..61e2ae31df8 100644 --- a/apps/web/src/terminal/ghostty/clipboard.test.ts +++ b/apps/web/src/terminal/ghostty/clipboard.test.ts @@ -7,6 +7,12 @@ function osc(text: string, target = "c", terminator = "\x07") { } describe("terminal OSC 52 clipboard writes", () => { + it.each(["qc", "cq", "cpqs01234567"])("copies combined clipboard target %s", (target) => { + const copy = vi.fn(); + new TerminalClipboardParser(copy).write(osc("combined", target), true); + expect(copy.mock.calls).toEqual([["combined"]]); + }); + it.each(["", "\uFEFFtext", "\uFEFF"])("preserves exact clipboard text %j", (text) => { const copy = vi.fn(); new TerminalClipboardParser(copy).write(osc(text), true); @@ -40,6 +46,7 @@ describe("terminal OSC 52 clipboard writes", () => { "\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(); diff --git a/packages/client-runtime/src/terminalClipboard.ts b/packages/client-runtime/src/terminalClipboard.ts index f4a110a873e..1ba98217f88 100644 --- a/packages/client-runtime/src/terminalClipboard.ts +++ b/packages/client-runtime/src/terminalClipboard.ts @@ -52,7 +52,7 @@ function decodeClipboardPayload(osc: string): string | 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 !== "" && (!/^[cps0-7]+$/.test(target) || !target.includes("c"))) return null; + 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; From e19814aae5e672d8f98d2dc7f4571ef17e398f1d Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Sat, 5 Sep 2026 00:12:24 -0700 Subject: [PATCH 09/16] fix(web): offer copy after async clipboard rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Return written, failed, or skipped from the serialized clipboard writer. Offer the existing Copy/Clear prompt after an eligible failure, including browsers that expose the API but require a fresh user gesture. Ignore results from superseded requests and requests that lost terminal focus. Verified 142 web tests, five mobile clipboard tests, web/client-runtime typechecks, and targeted lint. A real Chrome client with async rejection forced copied text and cleared the macOS clipboard through the prompt. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- .../terminal/terminalClipboard.test.ts | 2 +- .../features/terminal/terminalClipboard.ts | 2 +- .../src/components/ThreadTerminalDrawer.tsx | 11 ++++++---- .../src/terminal/ghostty/clipboard.test.ts | 11 ++++++++-- .../client-runtime/src/terminalClipboard.ts | 22 ++++++++++++++----- 5 files changed, 34 insertions(+), 14 deletions(-) diff --git a/apps/mobile/src/features/terminal/terminalClipboard.test.ts b/apps/mobile/src/features/terminal/terminalClipboard.test.ts index 5c025113adb..c161e0df1f8 100644 --- a/apps/mobile/src/features/terminal/terminalClipboard.test.ts +++ b/apps/mobile/src/features/terminal/terminalClipboard.test.ts @@ -11,7 +11,7 @@ const osc = (text: string) => `\x1b]52;c;${Buffer.from(text).toString("base64")} function harness(writeText = vi.fn(async (_text: string) => {})) { const write = createTerminalClipboardWriter(writeText); - const pending: Promise[] = []; + const pending: Promise[] = []; const session = createTerminalClipboardSession((text, canWrite) => { const result = write(text, canWrite); pending.push(result); diff --git a/apps/mobile/src/features/terminal/terminalClipboard.ts b/apps/mobile/src/features/terminal/terminalClipboard.ts index 04f2d98b1c4..d7d0bcb9124 100644 --- a/apps/mobile/src/features/terminal/terminalClipboard.ts +++ b/apps/mobile/src/features/terminal/terminalClipboard.ts @@ -7,7 +7,7 @@ import { /** Observe the subscription's live chunks, never a native renderer's history replay. */ export function createTerminalClipboardSession( - write: (text: string, canWrite: () => boolean) => Promise, + write: (text: string, canWrite: () => boolean) => Promise, ) { let active = false; let generation = 0; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 70fbe3898d7..d1a8d9f2e44 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -497,6 +497,7 @@ export function TerminalViewport({ const setupFont = terminalFontRef.current; const clipboardToastId = `terminal-copy:${environmentId}:${threadId}:${terminalId}`; setupCleanups.push(() => toastManager.close(clipboardToastId)); + let clipboardRequest = 0; const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), font: terminalFontOptions(setupFont.family, setupFont.size), @@ -506,10 +507,12 @@ export function TerminalViewport({ onData: (data) => handleData(data), onResize: (cols, rows) => void resizeTerminal(cols, rows), onSelectionChange: () => handleSelectionChange(), - onClipboardWrite: (text, canWrite) => { - if (typeof navigator.clipboard?.writeText === "function") { - void writeTerminalClipboard(text, canWrite); - } else if (canWrite()) { + 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", diff --git a/apps/web/src/terminal/ghostty/clipboard.test.ts b/apps/web/src/terminal/ghostty/clipboard.test.ts index 61e2ae31df8..d02d851d491 100644 --- a/apps/web/src/terminal/ghostty/clipboard.test.ts +++ b/apps/web/src/terminal/ghostty/clipboard.test.ts @@ -223,7 +223,11 @@ describe("application clipboard writes", () => { ]; const callsWhilePending = [...writeText.mock.calls]; finishFirst(); - await Promise.all(writes); + 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"); @@ -240,7 +244,10 @@ describe("application clipboard writes", () => { const execCommand = vi.fn(); vi.stubGlobal("navigator", result === "unavailable" ? {} : { clipboard: { writeText } }); vi.stubGlobal("document", { createElement, execCommand }); - await expect(writeTerminalClipboard("application text")).resolves.toBeUndefined(); + 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/packages/client-runtime/src/terminalClipboard.ts b/packages/client-runtime/src/terminalClipboard.ts index 1ba98217f88..d0daa48f966 100644 --- a/packages/client-runtime/src/terminalClipboard.ts +++ b/packages/client-runtime/src/terminalClipboard.ts @@ -4,6 +4,8 @@ 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, @@ -14,15 +16,15 @@ export function createTerminalClipboardWriter( let pendingClipboardWrite: { text: string; canWrite: () => boolean; - resolve: () => void; + resolve: (result: ClipboardWriteResult) => void; } | null = null; function writeTerminalClipboard( text: string, canWrite: () => boolean = () => true, - ): Promise { + ): Promise { return new Promise((resolve) => { - pendingClipboardWrite?.resolve(); + pendingClipboardWrite?.resolve("skipped"); pendingClipboardWrite = { text, canWrite, resolve }; if (!writingClipboard) void drainClipboardWrites(); }); @@ -33,12 +35,20 @@ export function createTerminalClipboardWriter( while (pendingClipboardWrite) { const request = pendingClipboardWrite; pendingClipboardWrite = null; + let result: ClipboardWriteResult = "skipped"; try { - if (request.canWrite()) await writeText(request.text); + if (request.canWrite()) { + const write = writeText(request.text); + if (write === undefined) result = "failed"; + else { + await write; + result = "written"; + } + } } catch { - // Clipboard failures must not write errors into the TUI or block later copies. + result = "failed"; } - request.resolve(); + request.resolve(result); } writingClipboard = false; } From ea6fd303a1d2a89259b3a63d3e5758c896914429 Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Sat, 5 Sep 2026 00:20:06 -0700 Subject: [PATCH 10/16] fix(mobile): parse clipboard requests before output retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observe raw events from the existing terminal attachment before the 512 KiB output window and React batching can discard them. Scope observers to the full environment and attach request, and remove them when the route loses focus. The mobile clipboard session still ignores history and background output. The large single-write regression failed on the previous implementation. All 29 focused mobile/state tests pass, including a real subscription test for complete delivery, retained-buffer limits, isolation and unsubscription. Mobile, web and shared typechecks pass. The web clipboard fallback also passes through the updated attach path in a real Chrome client on the MacBook Neo. Native simulator verification remains pending Xcode installation. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- .../terminal/ThreadTerminalRouteScreen.tsx | 2 +- .../terminal/terminalClipboard.test.ts | 61 ++++--- .../features/terminal/terminalClipboard.ts | 33 ++-- .../features/terminal/useTerminalClipboard.ts | 35 ++-- .../client-runtime/src/state/terminal.test.ts | 167 ++++++++++++++++++ packages/client-runtime/src/state/terminal.ts | 54 ++++-- 6 files changed, 281 insertions(+), 71 deletions(-) create mode 100644 packages/client-runtime/src/state/terminal.test.ts diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 8800197a404..02732a4e141 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -336,7 +336,7 @@ export function ThreadTerminalRouteScreen(props: ThreadTerminalRouteScreenProps) const terminalKey = selectedThread ? `${selectedThread.environmentId}:${selectedThread.id}:${terminalId}` : terminalId; - useTerminalClipboard(terminalKey, terminal.output); + useTerminalClipboard(selectedThread?.environmentId ?? null, terminalAttachInput); const bufferReplayKey = useMemo( () => getTerminalBufferReplayKey({ terminalKey, fontSize }), [fontSize, terminalKey], diff --git a/apps/mobile/src/features/terminal/terminalClipboard.test.ts b/apps/mobile/src/features/terminal/terminalClipboard.test.ts index c161e0df1f8..a16eddc627a 100644 --- a/apps/mobile/src/features/terminal/terminalClipboard.test.ts +++ b/apps/mobile/src/features/terminal/terminalClipboard.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard"; -import { - applyTerminalAttachStreamEvent, - EMPTY_TERMINAL_BUFFER_STATE, -} from "@t3tools/client-runtime/state/terminal"; import { ThreadId } from "@t3tools/contracts"; import { createTerminalClipboardSession } from "./terminalClipboard"; @@ -17,27 +13,33 @@ function harness(writeText = vi.fn(async (_text: string) => {})) { pending.push(result); return result; }); - let state = EMPTY_TERMINAL_BUFFER_STATE; + const target = { threadId: ThreadId.make("thread"), terminalId: "terminal" }; return { session, writeText, flush: () => Promise.all(pending), - update: () => session.update(state.output), - append(data: string) { - state = applyTerminalAttachStreamEvent(state, { - type: "output", - threadId: ThreadId.make("thread"), - terminalId: "terminal", - data, + 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", + }, }); - session.update(state.output); + }, + append(data: string) { + session.update({ type: "output", ...target, data }); }, reset() { - state = { - ...state, - output: { ...state.output, resetVersion: state.output.resetVersion + 1 }, - }; - session.update(state.output); + session.update({ type: "cleared", ...target }); }, }; } @@ -46,12 +48,11 @@ describe("mobile terminal clipboard session", () => { it("copies live Unicode output once while ignoring initial history and replay", async () => { const h = harness(); h.session.setActive(true); - h.append(osc("history")); + h.history(osc("history")); const text = "\uFEFFClaude: café 界🙂"; const data = osc(text); h.append(data.slice(0, -1)); h.append(data.slice(-1)); - h.update(); await h.flush(); expect(h.writeText.mock.calls).toEqual([[text]]); h.reset(); @@ -60,9 +61,25 @@ describe("mobile terminal clipboard session", () => { 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 = harness(); + h.session.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 = harness(); - h.update(); h.session.setActive(true); h.append(osc("old").slice(0, -1)); h.session.setActive(false); @@ -74,7 +91,6 @@ describe("mobile terminal clipboard session", () => { it("ignores background output without losing stream framing", async () => { const h = harness(); - h.update(); h.append(osc("background").slice(0, -1)); h.session.setActive(true); h.append("\x07" + osc("live")); @@ -88,7 +104,6 @@ describe("mobile terminal clipboard session", () => { finish = resolve; }); const h = harness(vi.fn(() => first)); - h.update(); h.session.setActive(true); h.append(osc("first") + osc("queued")); if (action === "reset") h.reset(); diff --git a/apps/mobile/src/features/terminal/terminalClipboard.ts b/apps/mobile/src/features/terminal/terminalClipboard.ts index d7d0bcb9124..3bcfc41d017 100644 --- a/apps/mobile/src/features/terminal/terminalClipboard.ts +++ b/apps/mobile/src/features/terminal/terminalClipboard.ts @@ -1,9 +1,5 @@ import { TerminalClipboardParser } from "@t3tools/client-runtime/terminal-clipboard"; -import { - INITIAL_TERMINAL_OUTPUT_CURSOR, - readTerminalOutputUpdate, - type TerminalOutputState, -} from "@t3tools/client-runtime/state/terminal"; +import type { TerminalAttachStreamEvent } from "@t3tools/contracts"; /** Observe the subscription's live chunks, never a native renderer's history replay. */ export function createTerminalClipboardSession( @@ -11,7 +7,6 @@ export function createTerminalClipboardSession( ) { let active = false; let generation = 0; - let cursor = INITIAL_TERMINAL_OUTPUT_CURSOR; const invalidate = () => { generation += 1; parser.invalidatePendingCopy(); @@ -25,15 +20,25 @@ export function createTerminalClipboardSession( active = next; if (!active) invalidate(); }, - update(output: TerminalOutputState) { - const update = readTerminalOutputUpdate(output, cursor); - cursor = update.cursor; - if (update.type === "none") return; - if (update.type === "reset") { - invalidate(); - parser.reset(); + update(event: TerminalAttachStreamEvent) { + switch (event.type) { + case "output": + parser.write(event.data, active); + break; + case "snapshot": + case "restarted": + invalidate(); + parser.reset(); + parser.write(event.snapshot.history, false); + break; + case "cleared": + case "closed": + case "exited": + case "error": + invalidate(); + parser.reset(); + break; } - parser.write(update.data, active && update.type === "append"); }, }; } diff --git a/apps/mobile/src/features/terminal/useTerminalClipboard.ts b/apps/mobile/src/features/terminal/useTerminalClipboard.ts index 801161eb9cc..7081032e5ae 100644 --- a/apps/mobile/src/features/terminal/useTerminalClipboard.ts +++ b/apps/mobile/src/features/terminal/useTerminalClipboard.ts @@ -1,47 +1,36 @@ import { useFocusEffect } from "@react-navigation/native"; import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard"; -import type { TerminalOutputState } from "@t3tools/client-runtime/state/terminal"; +import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts"; import * as Clipboard from "expo-clipboard"; -import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { useCallback } from "react"; import { AppState } from "react-native"; +import { terminalEnvironment } from "../../state/terminal"; import { createTerminalClipboardSession } from "./terminalClipboard"; const writeClipboard = createTerminalClipboardWriter((text) => Clipboard.setStringAsync(text)); /** Route focus, not keyboard visibility, owns touch-driven TUI copies on iOS and Android. */ -export function useTerminalClipboard(terminalKey: string, output: TerminalOutputState) { - const current = useRef<{ - terminalKey: string; - session: ReturnType; - } | null>(null); - const latestOutput = useRef(output); - useLayoutEffect(() => { - latestOutput.current = output; - }, [output]); - +export function useTerminalClipboard( + environmentId: EnvironmentId | null, + input: TerminalAttachInput | null, +) { useFocusEffect( useCallback(() => { + if (environmentId === null || input === null) return; const session = createTerminalClipboardSession(writeClipboard); - current.current = { terminalKey, session }; - const activate = () => { - session.setActive(false); - session.update(latestOutput.current); - session.setActive(AppState.currentState === "active"); - }; + const stop = terminalEnvironment.observeAttach({ environmentId, input }, session.update); + const activate = () => session.setActive(AppState.currentState === "active"); activate(); const change = AppState.addEventListener("change", activate); const blur = AppState.addEventListener("blur", () => session.setActive(false)); const focus = AppState.addEventListener("focus", activate); return () => { session.setActive(false); - current.current = null; + stop(); change.remove(); blur.remove(); focus.remove(); }; - }, [terminalKey]), + }, [environmentId, input]), ); - useEffect(() => { - if (current.current?.terminalKey === terminalKey) current.current.session.update(output); - }, [output, terminalKey]); } 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 00000000000..ad07932a70f --- /dev/null +++ b/packages/client-runtime/src/state/terminal.test.ts @@ -0,0 +1,167 @@ +import { + EnvironmentId, + 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 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 runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + ); + 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", + cols: 80, + rows: 24, + }, + }; + const observed: TerminalAttachStreamEvent[] = []; + const unrelated: TerminalAttachStreamEvent[] = []; + 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), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + stop(); + stopOtherEnvironment(); + stopOtherAttach(); + }), + ); + 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); + }), + ), +); diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index 3bc2bca78f0..8c51c344cd8 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,8 +12,10 @@ import { createEnvironmentRpcCommand, createEnvironmentRpcSubscriptionAtomFamily, createEnvironmentSubscriptionAtomFamily, + environmentRpcKey, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; import { applyTerminalAttachStreamEvent, @@ -36,16 +43,43 @@ 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( + const attachObservers = new Map void>>(); + const attach = createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:terminal:attach", + subscribe: (input: EnvironmentRpcInput) => + Stream.unwrap( + Effect.gen(function* () { + const supervisor = yield* EnvironmentSupervisor; + const key = environmentRpcKey({ environmentId: supervisor.target.environmentId, input }); + return subscribe(WS_METHODS.terminalAttach, input).pipe( + Stream.map((event) => { + const observers = attachObservers.get(key); + if (observers) for (const observe of observers) observe(event); + return event; + }), 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, From fa8ab059ae0097cfedfe1d2a0282c3648e5542f7 Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Sat, 5 Sep 2026 00:38:55 -0700 Subject: [PATCH 11/16] fix(terminal): observe web clipboard writes before retention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Feed OSC 52 from the existing raw terminal attachment on web and desktop. Keep display writes/resynchronization separate from clipboard input so a 512 KiB retention reset neither loses a large copy nor replays one. Session resets still invalidate incomplete and queued clipboard requests. Verified 143 focused web tests and the web typecheck. A real Chrome client on the MacBook Neo copied 600 KiB to the macOS clipboard and then cleared it. All six captured provider startup selection/copy/input smoke tests pass again. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- .../src/components/ThreadTerminalDrawer.tsx | 32 ++++++++++++ apps/web/src/terminal/ghostty/surface.test.ts | 50 +++++++++++++------ apps/web/src/terminal/ghostty/surface.ts | 18 +++++-- 3 files changed, 80 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d1a8d9f2e44..8eb70c38c71 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -582,6 +582,38 @@ export function TerminalViewport({ terminal.focus(); } + setupCleanups.push( + terminalEnvironment.observeAttach( + { + environmentId, + input: { + threadId, + terminalId, + cwd, + ...(worktreePath !== undefined ? { worktreePath } : {}), + ...(runtimeEnv ? { env: runtimeEnv } : {}), + }, + }, + (event) => { + switch (event.type) { + case "output": + terminal.writeClipboard(event.data); + break; + case "snapshot": + case "restarted": + terminal.resetClipboard(event.snapshot.history); + break; + case "cleared": + case "closed": + case "exited": + case "error": + terminal.resetClipboard(); + break; + } + }, + ), + ); + const dismissSelectionAction = (supersede = false) => { const ownsMenu = openSelectionMenuRequestIdRef.current === selectionActionRequestIdRef.current; diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index d4f107f5259..63bda45019e 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -523,31 +523,47 @@ describe("GhosttyTerminalSurface visibility", () => { 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.writeClipboard(frame.slice(0, -1)); + surface.resetAndWrite(frame.slice(-100)); + surface.writeClipboard(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.write(data); + surface.writeClipboard(data); expect(copy).not.toHaveBeenCalled(); surface.focus(); - surface.resetAndWrite(data); - surface.resetAndWrite("\x1b]52;c;"); - surface.write("aGVsbG8=\x07"); + surface.resetClipboard(data); + surface.resetClipboard("\x1b]52;c;"); + surface.writeClipboard("aGVsbG8=\x07"); expect(copy).not.toHaveBeenCalled(); - surface.write(data); + surface.writeClipboard(data); expect(copy.mock.calls).toEqual([["hello", expect.any(Function)]]); copy.mockClear(); surface.setVisible(false); - surface.write(data); + surface.writeClipboard(data); surface.setVisible(true); surface.input.dispatchEvent(new Event("blur")); - surface.write(data); + surface.writeClipboard(data); surface.focus(); vi.spyOn(document, "hasFocus").mockReturnValue(false); - surface.write(data); + surface.writeClipboard(data); surface.dispose(); - surface.write(data); + surface.writeClipboard(data); expect(copy).not.toHaveBeenCalled(); expect(harness.onData).not.toHaveBeenCalled(); }); @@ -560,7 +576,7 @@ describe("GhosttyTerminalSurface visibility", () => { const surface = await harness.create({ onClipboardWrite: copy }); const data = "\x1b]52;c;aGVsbG8=\x07"; surface.focus(); - surface.write(data.slice(0, -1)); + surface.writeClipboard(data.slice(0, -1)); switch (change) { case "hide": surface.setVisible(false); @@ -575,9 +591,9 @@ describe("GhosttyTerminalSurface visibility", () => { window.dispatchEvent(new Event("focus")); break; } - surface.write(data.slice(-1)); + surface.writeClipboard(data.slice(-1)); expect(copy).not.toHaveBeenCalled(); - surface.write(data); + surface.writeClipboard(data); expect(copy.mock.calls).toEqual([["hello", expect.any(Function)]]); }, ); @@ -595,7 +611,7 @@ describe("GhosttyTerminalSurface visibility", () => { const copy = vi.fn(writeTerminalClipboard); const surface = await harness.create({ onClipboardWrite: copy }); surface.focus(); - surface.write("\x1b]52;c;Zmlyc3Q=\x07\x1b]52;c;b2xk\x07"); + surface.writeClipboard("\x1b]52;c;Zmlyc3Q=\x07\x1b]52;c;b2xk\x07"); switch (change) { case "hide": surface.setVisible(false); @@ -613,7 +629,7 @@ describe("GhosttyTerminalSurface visibility", () => { vi.spyOn(document, "hasFocus").mockReturnValue(false); break; case "reset": - surface.resetAndWrite(""); + surface.resetClipboard(""); break; case "dispose": surface.dispose(); @@ -623,7 +639,7 @@ describe("GhosttyTerminalSurface visibility", () => { await Promise.all(copy.mock.results.map((result) => result.value)); expect(writeText.mock.calls).toEqual([["first"]]); if (change !== "dispose" && change !== "inactive window") { - surface.write("\x1b]52;c;bmV3\x07"); + surface.writeClipboard("\x1b]52;c;bmV3\x07"); await Promise.all(copy.mock.results.map((result) => result.value)); expect(writeText.mock.calls).toEqual([["first"], ["new"]]); } @@ -706,7 +722,9 @@ describe("GhosttyTerminalSurface visibility", () => { const copy = vi.fn(); const surface = await harness.create({ onClipboardWrite: copy }); surface.focus(); - surface.write("\x1bPtmux;\x1b\x1b]52;c;aGVsbG8=\x07visible\x1b\\"); + const data = "\x1bPtmux;\x1b\x1b]52;c;aGVsbG8=\x07visible\x1b\\"; + surface.writeClipboard(data); + surface.write(data); harness.flushFrame(); expect(copy.mock.calls).toEqual([["hello", expect.any(Function)]]); expect(harness.renderedSnapshot.rowData[0]?.text).toContain("visible"); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 9f1b123cf67..c39f102d2fc 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -764,10 +764,23 @@ export class GhosttyTerminalSurface { this.fit(); } + /** Feed live attachment output before renderer retention and batching. */ + writeClipboard(data: string): void { + if (this.disposed) return; + this.clipboardParser.write(data, this.visible && this.focused && document.hasFocus()); + } + + /** Only session history/lifecycle resets revoke copies; display resynchronization does not. */ + resetClipboard(history = ""): void { + if (this.disposed) return; + this.invalidateClipboardCopies(); + this.clipboardParser.reset(); + this.clipboardParser.write(history, false); + } + write(data: string): void { if (this.disposed) return; this.core.write(data); - this.clipboardParser.write(data, this.visible && this.focused && document.hasFocus()); 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. @@ -781,9 +794,6 @@ export class GhosttyTerminalSurface { this.clearSelection(); this.lastMouseMotionData = ""; this.core.resetAndWrite(data); - this.invalidateClipboardCopies(); - this.clipboardParser.reset(); - this.clipboardParser.write(data, false); this.synchronizeMouseTrackingState(); // A replayed session starts from the visible phase like any other write: // reattaching mid-blink must not open on an invisible cursor. From e94635eeaac5b4281f6b25aa8dc606ffbd60543f Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Sat, 5 Sep 2026 00:38:55 -0700 Subject: [PATCH 12/16] fix(mobile): preserve clipboard parsing across equivalent inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the stable attachment atom as the focus-effect dependency. Keep the latest target in a layout-updated ref, so equivalent input objects preserve an ongoing copy while an actual attachment change starts a new session. A React re-render probe failed on the previous code and passes with this change. It covers a split OSC across equivalent input objects and rejection of a partial copy after changing attachments. Mobile typecheck and lint pass. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- .../features/terminal/useTerminalClipboard.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/terminal/useTerminalClipboard.ts b/apps/mobile/src/features/terminal/useTerminalClipboard.ts index 7081032e5ae..9652412da4e 100644 --- a/apps/mobile/src/features/terminal/useTerminalClipboard.ts +++ b/apps/mobile/src/features/terminal/useTerminalClipboard.ts @@ -2,7 +2,7 @@ import { useFocusEffect } from "@react-navigation/native"; import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard"; import type { EnvironmentId, TerminalAttachInput } from "@t3tools/contracts"; import * as Clipboard from "expo-clipboard"; -import { useCallback } from "react"; +import { useCallback, useLayoutEffect, useRef } from "react"; import { AppState } from "react-native"; import { terminalEnvironment } from "../../state/terminal"; import { createTerminalClipboardSession } from "./terminalClipboard"; @@ -14,11 +14,20 @@ export function useTerminalClipboard( environmentId: EnvironmentId | null, input: TerminalAttachInput | null, ) { + const target = environmentId !== null && input !== null ? { environmentId, input } : null; + // Atom identity follows the attach values; recreating an input object must not reset a copy. + const attachment = target === null ? null : terminalEnvironment.attach(target); + const latestTarget = useRef(target); + useLayoutEffect(() => { + latestTarget.current = + environmentId !== null && input !== null ? { environmentId, input } : null; + }, [environmentId, input]); + useFocusEffect( useCallback(() => { - if (environmentId === null || input === null) return; + if (attachment === null || latestTarget.current === null) return; const session = createTerminalClipboardSession(writeClipboard); - const stop = terminalEnvironment.observeAttach({ environmentId, input }, session.update); + const stop = terminalEnvironment.observeAttach(latestTarget.current, session.update); const activate = () => session.setActive(AppState.currentState === "active"); activate(); const change = AppState.addEventListener("change", activate); @@ -31,6 +40,6 @@ export function useTerminalClipboard( blur.remove(); focus.remove(); }; - }, [environmentId, input]), + }, [attachment]), ); } From 113a441fc5d10900fe53dd32d3b211e8baf3e6c6 Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Sat, 5 Sep 2026 07:14:54 -0700 Subject: [PATCH 13/16] fix(terminal): match clipboard observers to provider attachments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Include the provider instance in clipboard observer registration and refresh it when the provider changes. Remove the duplicate toast import introduced by merging main, and verify observer isolation between providers. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- apps/web/src/components/ThreadTerminalDrawer.tsx | 4 ++-- packages/client-runtime/src/state/terminal.test.ts | 10 ++++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 8eb70c38c71..5ebf822649b 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -88,7 +88,6 @@ import { copyTerminalClipboardFromGesture, writeTerminalClipboard, } from "../terminal/ghostty/clipboard"; -import { toastManager } from "./ui/toast"; import { resolveTerminalFontPreference, resolveTerminalFontSizePreference, @@ -592,6 +591,7 @@ export function TerminalViewport({ cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { env: runtimeEnv } : {}), + ...(providerInstanceId ? { providerInstanceId } : {}), }, }, (event) => { @@ -973,7 +973,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; diff --git a/packages/client-runtime/src/state/terminal.test.ts b/packages/client-runtime/src/state/terminal.test.ts index ad07932a70f..5fc73bd818f 100644 --- a/packages/client-runtime/src/state/terminal.test.ts +++ b/packages/client-runtime/src/state/terminal.test.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + ProviderInstanceId, ThreadId, WS_METHODS, type TerminalAttachStreamEvent, @@ -109,6 +110,7 @@ it.effect("observes live attach output before retention without another RPC subs threadId: ThreadId.make("thread"), terminalId: "term", cwd: "/tmp", + providerInstanceId: ProviderInstanceId.make("provider-1"), cols: 80, rows: 24, }, @@ -124,11 +126,19 @@ it.effect("observes live attach output before retention without another RPC subs { ...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(); stopOtherEnvironment(); stopOtherAttach(); + stopOtherProvider(); }), ); const atom = atoms.attach(target); From cbb028238d84115fd51700c99f047759a1bc967e Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Sat, 5 Sep 2026 07:30:44 -0700 Subject: [PATCH 14/16] fix(terminal): retire invalid selection and pointer state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Check selection endpoints against the core after output so scrollback eviction cannot reuse stale row coordinates. Finish native drags when the left button releases, clear cancelled single-cell selections, and release stale application and link ownership on capture loss. Preserve selection for all modifier key names and avoid repainting the whole grid when there is no selection to clear. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- apps/web/src/terminal/ghostty/core.ts | 20 ++++ apps/web/src/terminal/ghostty/surface.test.ts | 106 +++++++++++++++++- apps/web/src/terminal/ghostty/surface.ts | 41 +++++-- 3 files changed, 157 insertions(+), 10 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.ts b/apps/web/src/terminal/ghostty/core.ts index d01e20529d4..3bf261a5272 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); } diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 63bda45019e..6ef0c13d791 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -171,13 +171,20 @@ describe("GhosttyTerminalSurface visibility", () => { resize() { for (const callback of resizeCallbacks) callback(); }, - pointer(type: string, clientX: number, buttons: number, shiftKey = false, clientY = 5) { + pointer( + type: string, + clientX: number, + buttons: number, + shiftKey = false, + clientY = 5, + button = 0, + ) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, clientY, pointerId: 1, - button: 0, + button, buttons, shiftKey, }), @@ -377,6 +384,101 @@ describe("GhosttyTerminalSurface visibility", () => { 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, -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(); + 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("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, 2); + harness.pointer("pointermove", 37, 2, false, 5, 0); + harness.pointer("pointermove", 85, 2); + harness.pointer("pointerup", 85, 0, false, 5, 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", diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index c39f102d2fc..f618e93b0b1 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -47,6 +47,12 @@ const MODIFIER_KEYS = new Set([ "CapsLock", "NumLock", "ScrollLock", + "Fn", + "FnLock", + "Hyper", + "Super", + "Symbol", + "SymbolLock", ]); const TERMINAL_FONT_LOAD_TEXT = "iMW0@# ."; const TERMINAL_FONT_LOAD_VARIANTS = [ @@ -781,6 +787,18 @@ export class GhosttyTerminalSurface { 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. @@ -1317,7 +1335,7 @@ export class GhosttyTerminalSurface { if (button === null) return; event.preventDefault(); event.stopPropagation(); - this.clearSelection(); + if (this.selectionAnchorScreen || this.selectionDrag) this.clearSelection(); this.clearHoveredLink("default"); this.mouseReportingPointerId = event.pointerId; this.mouseReportingButton = button; @@ -1334,7 +1352,7 @@ export class GhosttyTerminalSurface { return; } this.clearHoveredLink(); - this.clearSelection(); + if (this.selectionAnchorScreen || this.selectionDrag) this.clearSelection(); const cell = this.cellAt(event.clientX, event.clientY); this.selectionClickSequence = advanceTerminalSelectionClickSequence( this.selectionClickSequence, @@ -1376,6 +1394,11 @@ export class GhosttyTerminalSurface { }; private readonly onPointerMove = (event: PointerEvent) => { + // 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. @@ -1472,12 +1495,14 @@ export class GhosttyTerminalSurface { } private readonly onLostPointerCapture = (event: PointerEvent) => { - if ( - this.selectionDrag?.pointerId === event.pointerId && - !this.canvas.hasPointerCapture(event.pointerId) - ) { - this.endSelectionDrag(); + if (this.canvas.hasPointerCapture(event.pointerId)) return; + if (this.selectionDrag?.pointerId === event.pointerId) this.endSelectionDrag(); + if (this.mouseReportingPointerId === event.pointerId) { + this.mouseReportingPointerId = null; + this.mouseReportingButton = null; + this.lastMouseMotionData = ""; } + if (this.linkActivationPointerId === event.pointerId) this.linkActivationPointerId = null; }; private updateHoverCursor(event: PointerEvent): void { @@ -1559,6 +1584,7 @@ export class GhosttyTerminalSurface { } 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); @@ -1567,7 +1593,6 @@ export class GhosttyTerminalSurface { } } this.endSelectionDrag(); - if (event.button !== 0) return; if (!drag.moved && drag.mode === "cell") { this.clearSelection(); } From 8c458f650522411c3f5bbacba10683830e400f23 Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Sat, 5 Sep 2026 12:05:19 -0700 Subject: [PATCH 15/16] fix(terminal): free selection coordinates when conversion fails MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Release both WASM allocations if converting a tracked selection point throws. A fault-injection test checks cleanup through the real runtime. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: GPT-6 Astra --- apps/web/src/terminal/ghostty/core.test.ts | 17 +++++++++ apps/web/src/terminal/ghostty/core.ts | 43 ++++++++++++---------- 2 files changed, 41 insertions(+), 19 deletions(-) diff --git a/apps/web/src/terminal/ghostty/core.test.ts b/apps/web/src/terminal/ghostty/core.test.ts index 48cc4256de6..08e7570a379 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 3bf261a5272..2d7b9ad9329 100644 --- a/apps/web/src/terminal/ghostty/core.ts +++ b/apps/web/src/terminal/ghostty/core.ts @@ -871,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 { @@ -1133,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 { From 8339d2ea117a2e15592239e967b73d1fb3854eaf Mon Sep 17 00:00:00 2001 From: Morgan Allen Date: Sat, 5 Sep 2026 12:05:30 -0700 Subject: [PATCH 16/16] fix(terminal): share clipboard lifecycle across clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidate web and mobile clipboard eligibility and replay handling, and move parser and session tests alongside the shared implementation. Reuse the attach family key and a single web attach input so clipboard observation stays on the same stream. Isolate observer failures without dropping terminal output. Preserve focus acquired during asynchronous setup, keep the first pointer's ownership, and send a matching application mouse release on capture loss. Register AppState focus events only on Android; iOS does not support them. Verified with 180 focused tests, six provider startup captures, scoped typechecks, and native iOS clipboard text, clear, background, and reattachment checks. 🤖 Generated with [T3 Code](https://t3.codes) Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6 Astra --- .../terminal/terminalClipboard.test.ts | 119 ------- .../features/terminal/terminalClipboard.ts | 44 --- .../features/terminal/useTerminalClipboard.ts | 60 ++-- .../src/components/ThreadTerminalDrawer.tsx | 61 ++-- .../src/terminal/ghostty/clipboard.test.ts | 167 ---------- apps/web/src/terminal/ghostty/surface.test.ts | 118 +++++-- apps/web/src/terminal/ghostty/surface.ts | 87 ++---- packages/client-runtime/src/state/runtime.ts | 5 +- .../client-runtime/src/state/terminal.test.ts | 13 +- packages/client-runtime/src/state/terminal.ts | 29 +- .../src/terminalClipboard.test.ts | 292 ++++++++++++++++++ .../client-runtime/src/terminalClipboard.ts | 73 ++++- 12 files changed, 573 insertions(+), 495 deletions(-) delete mode 100644 apps/mobile/src/features/terminal/terminalClipboard.test.ts delete mode 100644 apps/mobile/src/features/terminal/terminalClipboard.ts create mode 100644 packages/client-runtime/src/terminalClipboard.test.ts diff --git a/apps/mobile/src/features/terminal/terminalClipboard.test.ts b/apps/mobile/src/features/terminal/terminalClipboard.test.ts deleted file mode 100644 index a16eddc627a..00000000000 --- a/apps/mobile/src/features/terminal/terminalClipboard.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard"; -import { ThreadId } from "@t3tools/contracts"; -import { createTerminalClipboardSession } from "./terminalClipboard"; - -const osc = (text: string) => `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`; - -function harness(writeText = vi.fn(async (_text: string) => {})) { - const write = createTerminalClipboardWriter(writeText); - const pending: Promise[] = []; - const session = createTerminalClipboardSession((text, canWrite) => { - const result = write(text, canWrite); - pending.push(result); - return result; - }); - const target = { threadId: ThreadId.make("thread"), terminalId: "terminal" }; - return { - session, - writeText, - 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("mobile terminal clipboard session", () => { - it("copies live Unicode output once while ignoring initial history and replay", async () => { - const h = harness(); - h.session.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 = harness(); - h.session.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 = harness(); - h.session.setActive(true); - h.append(osc("old").slice(0, -1)); - h.session.setActive(false); - h.session.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 = harness(); - h.append(osc("background").slice(0, -1)); - h.session.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 = harness(vi.fn(() => first)); - h.session.setActive(true); - h.append(osc("first") + osc("queued")); - if (action === "reset") h.reset(); - else h.session.setActive(false); - h.session.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/apps/mobile/src/features/terminal/terminalClipboard.ts b/apps/mobile/src/features/terminal/terminalClipboard.ts deleted file mode 100644 index 3bcfc41d017..00000000000 --- a/apps/mobile/src/features/terminal/terminalClipboard.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { TerminalClipboardParser } from "@t3tools/client-runtime/terminal-clipboard"; -import type { TerminalAttachStreamEvent } from "@t3tools/contracts"; - -/** Observe the subscription's live chunks, never a native renderer's history replay. */ -export function createTerminalClipboardSession( - write: (text: string, canWrite: () => boolean) => Promise, -) { - let active = false; - let generation = 0; - const invalidate = () => { - generation += 1; - parser.invalidatePendingCopy(); - }; - const parser = new TerminalClipboardParser((text) => { - const requestedGeneration = generation; - void write(text, () => active && generation === requestedGeneration); - }); - return { - setActive(next: boolean) { - active = next; - if (!active) invalidate(); - }, - update(event: TerminalAttachStreamEvent) { - switch (event.type) { - case "output": - parser.write(event.data, active); - break; - case "snapshot": - case "restarted": - invalidate(); - parser.reset(); - parser.write(event.snapshot.history, false); - break; - case "cleared": - case "closed": - case "exited": - case "error": - invalidate(); - parser.reset(); - break; - } - }, - }; -} diff --git a/apps/mobile/src/features/terminal/useTerminalClipboard.ts b/apps/mobile/src/features/terminal/useTerminalClipboard.ts index 9652412da4e..59766c6e2df 100644 --- a/apps/mobile/src/features/terminal/useTerminalClipboard.ts +++ b/apps/mobile/src/features/terminal/useTerminalClipboard.ts @@ -1,11 +1,14 @@ import { useFocusEffect } from "@react-navigation/native"; -import { createTerminalClipboardWriter } from "@t3tools/client-runtime/terminal-clipboard"; +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, useRef } from "react"; -import { AppState } from "react-native"; +import { useCallback, useLayoutEffect, useMemo, useRef } from "react"; +import { AppState, Platform } from "react-native"; import { terminalEnvironment } from "../../state/terminal"; -import { createTerminalClipboardSession } from "./terminalClipboard"; const writeClipboard = createTerminalClipboardWriter((text) => Clipboard.setStringAsync(text)); @@ -14,32 +17,49 @@ export function useTerminalClipboard( environmentId: EnvironmentId | null, input: TerminalAttachInput | null, ) { - const target = environmentId !== null && input !== null ? { environmentId, input } : null; - // Atom identity follows the attach values; recreating an input object must not reset a copy. - const attachment = target === null ? null : terminalEnvironment.attach(target); + 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 = - environmentId !== null && input !== null ? { environmentId, input } : null; - }, [environmentId, input]); + latestTarget.current = target; + }, [target]); useFocusEffect( useCallback(() => { - if (attachment === null || latestTarget.current === null) return; - const session = createTerminalClipboardSession(writeClipboard); - const stop = terminalEnvironment.observeAttach(latestTarget.current, session.update); - const activate = () => session.setActive(AppState.currentState === "active"); + // 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); - const blur = AppState.addEventListener("blur", () => session.setActive(false)); - const focus = AppState.addEventListener("focus", 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 () => { - session.setActive(false); + setActive(false); stop(); change.remove(); - blur.remove(); - focus.remove(); + blur?.remove(); + focus?.remove(); }; - }, [attachment]), + }, [targetKey]), ); } diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 5ebf822649b..fa3dc195686 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -145,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)), @@ -381,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(); }); @@ -405,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({ @@ -495,7 +499,6 @@ export function TerminalViewport({ const setup = async (): Promise<(() => void) | null> => { const setupFont = terminalFontRef.current; const clipboardToastId = `terminal-copy:${environmentId}:${threadId}:${terminalId}`; - setupCleanups.push(() => toastManager.close(clipboardToastId)); let clipboardRequest = 0; const terminalOptions: GhosttyTerminalSurfaceOptions = { theme: terminalThemeFromApp(mount), @@ -546,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. @@ -582,35 +586,8 @@ export function TerminalViewport({ } setupCleanups.push( - terminalEnvironment.observeAttach( - { - environmentId, - input: { - threadId, - terminalId, - cwd, - ...(worktreePath !== undefined ? { worktreePath } : {}), - ...(runtimeEnv ? { env: runtimeEnv } : {}), - ...(providerInstanceId ? { providerInstanceId } : {}), - }, - }, - (event) => { - switch (event.type) { - case "output": - terminal.writeClipboard(event.data); - break; - case "snapshot": - case "restarted": - terminal.resetClipboard(event.snapshot.history); - break; - case "cleared": - case "closed": - case "exited": - case "error": - terminal.resetClipboard(); - break; - } - }, + terminalEnvironment.observeAttach({ environmentId, input: terminalAttachInput }, (event) => + terminal.observeClipboard(event), ), ); diff --git a/apps/web/src/terminal/ghostty/clipboard.test.ts b/apps/web/src/terminal/ghostty/clipboard.test.ts index d02d851d491..eaff49afc8f 100644 --- a/apps/web/src/terminal/ghostty/clipboard.test.ts +++ b/apps/web/src/terminal/ghostty/clipboard.test.ts @@ -1,173 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { TerminalClipboardParser } from "@t3tools/client-runtime/terminal-clipboard"; import { copyTerminalClipboardFromGesture, writeTerminalClipboard } from "./clipboard"; -function osc(text: string, target = "c", terminator = "\x07") { - return `\x1b]52;${target};${Buffer.from(text).toString("base64")}${terminator}`; -} - -describe("terminal OSC 52 clipboard writes", () => { - it.each(["qc", "cq", "cpqs01234567"])("copies combined clipboard target %s", (target) => { - const copy = vi.fn(); - new TerminalClipboardParser(copy).write(osc("combined", target), true); - expect(copy.mock.calls).toEqual([["combined"]]); - }); - - 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 = Buffer.from(text) - .toString("base64") - .replace(/(.{76})/g, `$1${separator}`); - parser.write(`\x1b]52;c;${encoded}\x07`, true); - expect(copy.mock.calls).toEqual([[text]]); - }); - - it.each(["c", "cp", "pc", "s0c", "7c"])("writes 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(["\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"]]); - } - }); -}); - describe("application clipboard writes", () => { afterEach(() => vi.unstubAllGlobals()); diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index 6ef0c13d791..75e3f7898aa 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -1,4 +1,5 @@ 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"; @@ -41,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(); @@ -177,22 +200,30 @@ describe("GhosttyTerminalSurface visibility", () => { buttons: number, shiftKey = false, clientY = 5, - button = 0, + options: Partial = {}, ) { canvas.dispatchEvent( Object.assign(new Event(type, { cancelable: true }), { clientX, clientY, pointerId: 1, - button, + button: 0, buttons, shiftKey, + ...options, }), ); }, - loseCapture() { + loseCapture(clientX = 5, clientY = 5) { canvas.releasePointerCapture(1); - canvas.dispatchEvent(Object.assign(new Event("lostpointercapture"), { pointerId: 1 })); + canvas.dispatchEvent( + Object.assign(new Event("lostpointercapture"), { + pointerId: 1, + clientX, + clientY, + buttons: 0, + }), + ); }, wheel(deltaY = 1, deltaMode = 1) { canvas.dispatchEvent( @@ -390,7 +421,7 @@ describe("GhosttyTerminalSurface visibility", () => { surface.write("hello world"); harness.flushFrame(); harness.pointer("pointerdown", 5, 1); - harness.pointer("pointercancel", 5, 0, false, 5, -1); + harness.pointer("pointercancel", 5, 0, false, 5, { button: -1 }); expect(surface.getSelection()).toBe(""); expect(surface.getSelectionPosition()).toBeNull(); }); @@ -401,7 +432,10 @@ describe("GhosttyTerminalSurface visibility", () => { surface.write("\x1b[?1002h\x1b[?1006hhello world"); harness.flushFrame(); harness.pointer("pointerdown", 5, 1); - harness.loseCapture(); + 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); @@ -410,6 +444,36 @@ describe("GhosttyTerminalSurface visibility", () => { 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(); @@ -434,10 +498,10 @@ describe("GhosttyTerminalSurface visibility", () => { 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, 2); - harness.pointer("pointermove", 37, 2, false, 5, 0); + 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, 2); + harness.pointer("pointerup", 85, 0, false, 5, { button: 2 }); expect(surface.getSelection()).toBe("hello"); }); @@ -632,9 +696,9 @@ describe("GhosttyTerminalSurface visibility", () => { surface.focus(); const text = "x".repeat(600 * 1024); const frame = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`; - surface.writeClipboard(frame.slice(0, -1)); + surface.observeClipboard(clipboardOutput(frame.slice(0, -1))); surface.resetAndWrite(frame.slice(-100)); - surface.writeClipboard(frame.slice(-1)); + surface.observeClipboard(clipboardOutput(frame.slice(-1))); surface.write(frame); surface.resetAndWrite(frame); expect(copy.mock.calls).toEqual([[text, expect.any(Function)]]); @@ -646,26 +710,26 @@ describe("GhosttyTerminalSurface visibility", () => { const copy = vi.fn(); const surface = await harness.create({ onClipboardWrite: copy }); const data = "\x1b]52;c;aGVsbG8=\x07"; - surface.writeClipboard(data); + surface.observeClipboard(clipboardOutput(data)); expect(copy).not.toHaveBeenCalled(); surface.focus(); - surface.resetClipboard(data); - surface.resetClipboard("\x1b]52;c;"); - surface.writeClipboard("aGVsbG8=\x07"); + surface.observeClipboard(clipboardSnapshot(data)); + surface.observeClipboard(clipboardSnapshot("\x1b]52;c;")); + surface.observeClipboard(clipboardOutput("aGVsbG8=\x07")); expect(copy).not.toHaveBeenCalled(); - surface.writeClipboard(data); + surface.observeClipboard(clipboardOutput(data)); expect(copy.mock.calls).toEqual([["hello", expect.any(Function)]]); copy.mockClear(); surface.setVisible(false); - surface.writeClipboard(data); + surface.observeClipboard(clipboardOutput(data)); surface.setVisible(true); surface.input.dispatchEvent(new Event("blur")); - surface.writeClipboard(data); + surface.observeClipboard(clipboardOutput(data)); surface.focus(); vi.spyOn(document, "hasFocus").mockReturnValue(false); - surface.writeClipboard(data); + surface.observeClipboard(clipboardOutput(data)); surface.dispose(); - surface.writeClipboard(data); + surface.observeClipboard(clipboardOutput(data)); expect(copy).not.toHaveBeenCalled(); expect(harness.onData).not.toHaveBeenCalled(); }); @@ -678,7 +742,7 @@ describe("GhosttyTerminalSurface visibility", () => { const surface = await harness.create({ onClipboardWrite: copy }); const data = "\x1b]52;c;aGVsbG8=\x07"; surface.focus(); - surface.writeClipboard(data.slice(0, -1)); + surface.observeClipboard(clipboardOutput(data.slice(0, -1))); switch (change) { case "hide": surface.setVisible(false); @@ -693,9 +757,9 @@ describe("GhosttyTerminalSurface visibility", () => { window.dispatchEvent(new Event("focus")); break; } - surface.writeClipboard(data.slice(-1)); + surface.observeClipboard(clipboardOutput(data.slice(-1))); expect(copy).not.toHaveBeenCalled(); - surface.writeClipboard(data); + surface.observeClipboard(clipboardOutput(data)); expect(copy.mock.calls).toEqual([["hello", expect.any(Function)]]); }, ); @@ -713,7 +777,7 @@ describe("GhosttyTerminalSurface visibility", () => { const copy = vi.fn(writeTerminalClipboard); const surface = await harness.create({ onClipboardWrite: copy }); surface.focus(); - surface.writeClipboard("\x1b]52;c;Zmlyc3Q=\x07\x1b]52;c;b2xk\x07"); + surface.observeClipboard(clipboardOutput("\x1b]52;c;Zmlyc3Q=\x07\x1b]52;c;b2xk\x07")); switch (change) { case "hide": surface.setVisible(false); @@ -731,7 +795,7 @@ describe("GhosttyTerminalSurface visibility", () => { vi.spyOn(document, "hasFocus").mockReturnValue(false); break; case "reset": - surface.resetClipboard(""); + surface.observeClipboard(clipboardSnapshot("")); break; case "dispose": surface.dispose(); @@ -741,7 +805,7 @@ describe("GhosttyTerminalSurface visibility", () => { await Promise.all(copy.mock.results.map((result) => result.value)); expect(writeText.mock.calls).toEqual([["first"]]); if (change !== "dispose" && change !== "inactive window") { - surface.writeClipboard("\x1b]52;c;bmV3\x07"); + 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"]]); } @@ -825,7 +889,7 @@ describe("GhosttyTerminalSurface visibility", () => { const surface = await harness.create({ onClipboardWrite: copy }); surface.focus(); const data = "\x1bPtmux;\x1b\x1b]52;c;aGVsbG8=\x07visible\x1b\\"; - surface.writeClipboard(data); + surface.observeClipboard(clipboardOutput(data)); surface.write(data); harness.flushFrame(); expect(copy.mock.calls).toEqual([["hello", expect.any(Function)]]); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index f618e93b0b1..cdbd465d6fd 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -16,7 +16,8 @@ import { } from "./renderer"; import symbolsFontUrl from "./fonts/SymbolsNerdFontMono-Regular.woff2?url"; import { isMonospaceFamily } from "../../appearanceFonts"; -import { TerminalClipboardParser } from "@t3tools/client-runtime/terminal-clipboard"; +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; @@ -594,12 +595,12 @@ export class GhosttyTerminalSurface { private selectionEndScreen: { x: number; y: number } | null = null; private selectionDrag: { pointerId: number; - mode: "cell" | "word" | "line"; moved: boolean; lastCell: { x: number; y: number }; pointer: { x: number; y: number }; - // The original word/line range stays anchored through viewport scrolling. + // 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; @@ -615,18 +616,9 @@ export class GhosttyTerminalSurface { private selectionClickSequence: TerminalSelectionClickSequence | null = null; private composing = false; private focused = false; - private clipboardCopyGeneration = 0; - private readonly clipboardParser = new TerminalClipboardParser((text) => { - const generation = this.clipboardCopyGeneration; - this.options.onClipboardWrite?.( - text, - () => - generation === this.clipboardCopyGeneration && - !this.disposed && - this.visible && - this.focused && - document.hasFocus(), - ); + 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; @@ -661,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; @@ -764,24 +758,16 @@ export class GhosttyTerminalSurface { if (!visible) { this.cancelRender(); this.endSelectionDrag(); - this.invalidateClipboardCopies(); + this.clipboard.invalidate(); return; } this.fit(); } - /** Feed live attachment output before renderer retention and batching. */ - writeClipboard(data: string): void { + /** Feed live attachment events before renderer retention and batching. */ + observeClipboard(event: TerminalAttachStreamEvent): void { if (this.disposed) return; - this.clipboardParser.write(data, this.visible && this.focused && document.hasFocus()); - } - - /** Only session history/lifecycle resets revoke copies; display resynchronization does not. */ - resetClipboard(history = ""): void { - if (this.disposed) return; - this.invalidateClipboardCopies(); - this.clipboardParser.reset(); - this.clipboardParser.write(history, false); + this.clipboard.update(event); } write(data: string): void { @@ -1051,8 +1037,7 @@ export class GhosttyTerminalSurface { if (this.disposed) return; this.disposed = true; this.endSelectionDrag(); - this.invalidateClipboardCopies(); - this.clipboardParser.reset(); + this.clipboard.invalidate(); this.resizeObserver.disconnect(); document.fonts.removeEventListener("loadingdone", this.onFontsLoaded); this.dprMedia?.removeEventListener("change", this.onDevicePixelRatioChange); @@ -1126,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 @@ -1210,7 +1192,7 @@ export class GhosttyTerminalSurface { private readonly onBlur = () => { this.focused = false; this.endSelectionDrag(); - this.invalidateClipboardCopies(); + this.clipboard.invalidate(); this.linkModifierActive = false; this.refreshHoveredLink(); // Suppressions survive blur deliberately: a shortcut that moves focus (for @@ -1222,14 +1204,9 @@ export class GhosttyTerminalSurface { this.requestRender(); }; - private invalidateClipboardCopies(): void { - this.clipboardCopyGeneration += 1; - this.clipboardParser.invalidatePendingCopy(); - } - private readonly onWindowBlur = () => { this.endSelectionDrag(); - this.invalidateClipboardCopies(); + this.clipboard.invalidate(); }; private readonly onDevicePixelRatioChange = () => { @@ -1267,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(); } }; @@ -1327,8 +1301,14 @@ 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.selectionDrag && this.selectionDrag.pointerId !== event.pointerId) return; + if (this.activePointerId !== null && this.activePointerId !== event.pointerId) return; this.focus(); if (shouldReportTerminalMouse(this.core.isMouseTracking(), event)) { const button = ghosttyMouseButton(event.button); @@ -1359,7 +1339,7 @@ export class GhosttyTerminalSurface { event, ); const clickCount = this.selectionClickSequence.count; - const mode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : "cell"; + const mode = clickCount >= 3 ? "line" : clickCount === 2 ? "word" : null; const range = mode === "line" ? this.core.selectLine(cell.x, cell.y) @@ -1368,11 +1348,10 @@ export class GhosttyTerminalSurface { : null; this.selectionDrag = { pointerId: event.pointerId, - mode: range ? mode : "cell", moved: false, lastCell: cell, pointer: { x: event.clientX, y: event.clientY }, - base: range?.screen ?? null, + base: mode !== null && range !== null ? { mode, ...range.screen } : null, }; if (range) { this.selectionAnchorScreen = range.screen.start; @@ -1394,6 +1373,7 @@ 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); @@ -1442,15 +1422,15 @@ export class GhosttyTerminalSurface { if (!drag || anchorScreen === null) return; drag.moved = true; drag.lastCell = cell; + const base = drag.base; const range = - drag.mode === "line" + base?.mode === "line" ? this.core.selectLine(cell.x, cell.y) - : drag.mode === "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 = drag.base; const beforeBase = base !== null && (cellScreen.y < base.start.y || @@ -1498,6 +1478,7 @@ export class GhosttyTerminalSurface { 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 = ""; @@ -1593,9 +1574,7 @@ export class GhosttyTerminalSurface { } } this.endSelectionDrag(); - if (!drag.moved && drag.mode === "cell") { - this.clearSelection(); - } + if (!drag.moved && drag.base === null) this.clearSelection(); this.options.onSelectionChange(); }; diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 56489a4ba66..4c1528c7a1b 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 index 5fc73bd818f..960443782a0 100644 --- a/packages/client-runtime/src/state/terminal.test.ts +++ b/packages/client-runtime/src/state/terminal.test.ts @@ -9,6 +9,7 @@ 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"; @@ -97,8 +98,12 @@ it.effect("observes live attach output before retention without another RPC subs Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor), } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const logs: unknown[] = []; const runtime = Atom.runtime( - Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environmentRegistry), + 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) => @@ -117,6 +122,10 @@ it.effect("observes live attach output before retention without another RPC subs }; 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") }, @@ -136,6 +145,7 @@ it.effect("observes live attach output before retention without another RPC subs yield* Effect.addFinalizer(() => Effect.sync(() => { stop(); + stopBrokenObserver(); stopOtherEnvironment(); stopOtherAttach(); stopOtherProvider(); @@ -172,6 +182,7 @@ it.effect("observes live attach output before retention without another RPC subs 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 8c51c344cd8..2fda2b6c0a6 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -15,7 +15,6 @@ import { environmentRpcKey, } from "./runtime.ts"; import type { EnvironmentRegistry } from "../connection/registry.ts"; -import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { subscribe, type EnvironmentRpcInput } from "../rpc/client.ts"; import { applyTerminalAttachStreamEvent, @@ -46,20 +45,24 @@ export function createTerminalEnvironmentAtoms( const attachObservers = new Map void>>(); const attach = createEnvironmentSubscriptionAtomFamily(runtime, { label: "environment-data:terminal:attach", - subscribe: (input: EnvironmentRpcInput) => - Stream.unwrap( - Effect.gen(function* () { - const supervisor = yield* EnvironmentSupervisor; - const key = environmentRpcKey({ environmentId: supervisor.target.environmentId, input }); - return subscribe(WS_METHODS.terminalAttach, input).pipe( - Stream.map((event) => { + 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) for (const observe of observers) observe(event); - return event; + 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), - ); - }), + ), + Stream.scan(nextTerminalAttachSeedState(), applyTerminalAttachStreamEvent), + ), ), }); return { diff --git a/packages/client-runtime/src/terminalClipboard.test.ts b/packages/client-runtime/src/terminalClipboard.test.ts new file mode 100644 index 00000000000..b59080456b0 --- /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 index d0daa48f966..caca5de24aa 100644 --- a/packages/client-runtime/src/terminalClipboard.ts +++ b/packages/client-runtime/src/terminalClipboard.ts @@ -1,3 +1,5 @@ +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 }); @@ -67,13 +69,20 @@ function decodeClipboardPayload(osc: string): string | null { if (encoded === "") return ""; if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)) return null; try { - return decoder.decode(Uint8Array.from(atob(encoded), (char) => char.charCodeAt(0))); + 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. */ +/** + * 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. @@ -146,11 +155,14 @@ export class TerminalClipboardParser { 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 += data.slice(index, index + length); - if (!"52;".startsWith(this.payload.slice(0, 3))) this.payload = null; - } else { + 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; @@ -176,3 +188,52 @@ export class TerminalClipboardParser { 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; + } + }, + }; +}