From beab6886f45bf42906d0bd01aefe5dfe9e66a867 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:11:20 -0400 Subject: [PATCH 001/125] fix(web): import dependency-heavy Open VSX themes (#7642) Co-authored-by: t3-code[bot] <269035359+t3-code[bot]@users.noreply.github.com> Co-authored-by: maria <254055478+maria-rcks@users.noreply.github.com> --- apps/web/src/openVsxThemes.test.ts | 5 +++++ apps/web/src/openVsxThemes.ts | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/web/src/openVsxThemes.test.ts b/apps/web/src/openVsxThemes.test.ts index 4775a0188a08..b4794f61986a 100644 --- a/apps/web/src/openVsxThemes.test.ts +++ b/apps/web/src/openVsxThemes.test.ts @@ -293,6 +293,11 @@ describe("Open VSX themes", () => { it("downloads a verified VSIX, reads JSONC includes, and pairs contributed variants", async () => { const zip = new JSZip(); zip.file("extension/.gitkeep", ""); + // Theme extensions sometimes publish their development dependencies too. + // Those unused files should not prevent importing the small theme payload. + for (let index = 0; index < 3_000; index += 1) { + zip.file(`extension/node_modules/package-${index}.js`, ""); + } zip.file( "extension/themes/base.jsonc", `{ diff --git a/apps/web/src/openVsxThemes.ts b/apps/web/src/openVsxThemes.ts index 90d0d62ceade..8b03d84869b5 100644 --- a/apps/web/src/openVsxThemes.ts +++ b/apps/web/src/openVsxThemes.ts @@ -17,8 +17,8 @@ const MAX_DETAIL_BYTES = 256 * 1024; const MAX_MANIFEST_BYTES = 256 * 1024; const SEARCH_REQUEST_TIMEOUT_MS = 10_000; const MAX_THEME_BYTES = 256 * 1024; -const MAX_ZIP_ENTRIES = 2_000; -const MAX_UNCOMPRESSED_BYTES = 50 * 1024 * 1024; +const MAX_ZIP_ENTRIES = 5_000; +const MAX_UNCOMPRESSED_BYTES = 100 * 1024 * 1024; const MAX_COMPRESSION_RATIO = 200; const MAX_THEMES_PER_EXTENSION = 40; const MAX_INCLUDE_DEPTH = 8; From 8824f8f24f8b81fd6aed6f228a57a1d4013d01d9 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Thu, 20 Aug 2026 11:46:28 -0700 Subject: [PATCH 002/125] fix(web): retry failed thread bootstraps with a fresh id (#7664) --- apps/server/src/server.test.ts | 83 +++++++++++++++++++ apps/server/src/ws.ts | 26 +++++- apps/web/src/components/ChatView.tsx | 15 ++++ apps/web/src/composerDraftStore.test.ts | 37 +++++++++ packages/client-runtime/src/errors/index.ts | 1 + .../src/errors/orchestration.test.ts | 23 +++++ .../src/errors/orchestration.ts | 10 +++ packages/contracts/src/orchestration.test.ts | 14 ++++ packages/contracts/src/orchestration.ts | 1 + 9 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 packages/client-runtime/src/errors/orchestration.test.ts create mode 100644 packages/client-runtime/src/errors/orchestration.ts diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 89f903c4f895..7cda53f25326 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7893,6 +7893,89 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(result._tag === "Failure"); assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); assert.include(result.failure.message, "worktree exploded"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, "deleted"); + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.delete"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("does not report a deleted bootstrap thread when cleanup fails", () => + Effect.gen(function* () { + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.die(new Error("worktree exploded")), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + createWorktree, + }, + orchestrationEngine: { + dispatch: (command) => { + dispatchedCommands.push(command); + if (command.type === "thread.delete") { + return Effect.fail( + new OrchestrationListenerCallbackError({ + listener: "domain-event", + detail: "thread cleanup exploded", + }), + ); + } + return Effect.succeed({ sequence: dispatchedCommands.length }); + }, + readEvents: () => Stream.empty, + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-cleanup-defect"), + threadId: ThreadId.make("thread-bootstrap-cleanup-defect"), + message: { + messageId: MessageId.make("msg-bootstrap-cleanup-defect"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: false, + }, + createdAt, + }), + ).pipe(Effect.result), + ); + + assertTrue(result._tag === "Failure"); + assertTrue(result.failure._tag === "OrchestrationDispatchCommandError"); + assert.include(result.failure.message, "worktree exploded"); + assert.strictEqual(result.failure.bootstrapThreadDisposition, undefined); assert.deepEqual( dispatchedCommands.map((command) => command.type), ["thread.create", "thread.delete"], diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index ebcf65e4b47c..c5b7e50a8704 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -775,9 +775,9 @@ const makeWsRpcLayer = ( threadId: command.threadId, }), ), - Effect.ignoreCause({ log: true }), + Effect.as(true), ) - : Effect.void; + : Effect.succeed(false); const recordSetupScriptLaunchFailure = (input: { readonly error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError; @@ -964,7 +964,27 @@ const makeWsRpcLayer = ( if (Cause.hasInterruptsOnly(cause)) { return Effect.fail(dispatchError); } - return cleanupCreatedThread().pipe(Effect.flatMap(() => Effect.fail(dispatchError))); + return Effect.uninterruptible(cleanupCreatedThread()).pipe( + Effect.matchCauseEffect({ + onFailure: (cleanupCause) => + Effect.logWarning("bootstrap thread cleanup failed", { + threadId: command.threadId, + detail: Cause.pretty(cleanupCause), + }).pipe(Effect.flatMap(() => Effect.fail(dispatchError))), + onSuccess: (threadDeleted) => + Effect.fail( + threadDeleted + ? new OrchestrationDispatchCommandError({ + message: dispatchError.message, + ...(dispatchError.cause !== undefined + ? { cause: dispatchError.cause } + : {}), + bootstrapThreadDisposition: "deleted", + }) + : dispatchError, + ), + }), + ); }), ); }); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ec32db18240d..a84433749a15 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,6 +26,7 @@ import { connectionStatusTitle, type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; +import { wasBootstrapThreadDeleted } from "@t3tools/client-runtime/errors"; import { changeRequestAutoSettles, effectiveSettled, @@ -5454,6 +5455,20 @@ function ChatViewContent(props: ChatViewProps) { } if (!isAtomCommandInterrupted(failure)) { const error = squashAtomCommandFailure(failure); + if (isLocalDraftThread && draftId && wasBootstrapThreadDeleted(error)) { + const failedDraftSession = getDraftSession(draftId); + if (failedDraftSession?.threadId === threadIdForSend) { + setLogicalProjectDraftThreadId( + failedDraftSession.logicalProjectKey, + scopeProjectRef(failedDraftSession.environmentId, failedDraftSession.projectId), + draftId, + { + threadId: newThreadId(), + createdAt: new Date().toISOString(), + }, + ); + } + } setThreadError( threadIdForSend, error instanceof Error ? error.message : "Failed to send message.", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 3e4106c583f1..20c6603f773b 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -823,6 +823,43 @@ describe("composerDraftStore project draft thread mapping", () => { }); }); + it("rotates a failed bootstrap thread id without losing its draft", () => { + const store = useComposerDraftStore.getState(); + const retryThreadId = ThreadId.make("thread-retry"); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + branch: "feature/test", + worktreePath: null, + createdAt: "2026-01-01T00:00:00.000Z", + envMode: "worktree", + startFromOrigin: true, + runtimeMode: "approval-required", + interactionMode: "plan", + }); + store.setPrompt(draftId, "keep this prompt"); + markPromotedDraftThread(threadId); + + store.setLogicalProjectDraftThreadId(scopedProjectKey(projectRef), projectRef, draftId, { + threadId: retryThreadId, + createdAt: "2026-01-01T00:01:00.000Z", + }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + threadId: retryThreadId, + branch: "feature/test", + worktreePath: null, + createdAt: "2026-01-01T00:01:00.000Z", + envMode: "worktree", + startFromOrigin: true, + runtimeMode: "approval-required", + interactionMode: "plan", + promotedTo: null, + }); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.prompt).toBe( + "keep this prompt", + ); + }); + it("clears only matching project draft mapping entries", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { threadId }); diff --git a/packages/client-runtime/src/errors/index.ts b/packages/client-runtime/src/errors/index.ts index 7eb6244e5a7f..6c90b3aea966 100644 --- a/packages/client-runtime/src/errors/index.ts +++ b/packages/client-runtime/src/errors/index.ts @@ -1,3 +1,4 @@ export * from "./errorTrace.ts"; +export * from "./orchestration.ts"; export * from "./safeLog.ts"; export * from "./transport.ts"; diff --git a/packages/client-runtime/src/errors/orchestration.test.ts b/packages/client-runtime/src/errors/orchestration.test.ts new file mode 100644 index 000000000000..0e1a723d5471 --- /dev/null +++ b/packages/client-runtime/src/errors/orchestration.test.ts @@ -0,0 +1,23 @@ +import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { wasBootstrapThreadDeleted } from "./orchestration.ts"; + +describe("wasBootstrapThreadDeleted", () => { + it("accepts only a confirmed deleted bootstrap thread", () => { + expect( + wasBootstrapThreadDeleted( + new OrchestrationDispatchCommandError({ + message: "Failed to create worktree.", + bootstrapThreadDisposition: "deleted", + }), + ), + ).toBe(true); + expect( + wasBootstrapThreadDeleted( + new OrchestrationDispatchCommandError({ message: "Failed to create worktree." }), + ), + ).toBe(false); + expect(wasBootstrapThreadDeleted(new Error("connection lost"))).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/errors/orchestration.ts b/packages/client-runtime/src/errors/orchestration.ts new file mode 100644 index 000000000000..39ef26e46576 --- /dev/null +++ b/packages/client-runtime/src/errors/orchestration.ts @@ -0,0 +1,10 @@ +import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); + +export function wasBootstrapThreadDeleted(error: unknown): boolean { + return ( + isOrchestrationDispatchCommandError(error) && error.bootstrapThreadDisposition === "deleted" + ); +} diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index f403e6de26cc..3e1b9be0bba5 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -7,6 +7,7 @@ import { DEFAULT_RUNTIME_MODE, ModelSelection, OrchestrationCommand, + OrchestrationDispatchCommandError, OrchestrationEvent, OrchestrationGetFullThreadDiffInput, OrchestrationGetTurnDiffInput, @@ -54,6 +55,19 @@ const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPaylo const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); +const decodeDispatchCommandError = Schema.decodeUnknownEffect(OrchestrationDispatchCommandError); + +it.effect("decodes a dispatch error after its bootstrap thread was deleted", () => + Effect.gen(function* () { + const error = yield* decodeDispatchCommandError({ + _tag: "OrchestrationDispatchCommandError", + message: "Failed to create worktree.", + bootstrapThreadDisposition: "deleted", + }); + + assert.strictEqual(error.bootstrapThreadDisposition, "deleted"); + }), +); it.effect("parses turn diff input when fromTurnCount <= toTurnCount", () => Effect.gen(function* () { diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cd9f3a747876..c7c63270c8b5 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -1711,6 +1711,7 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass Date: Thu, 20 Aug 2026 15:56:58 -0400 Subject: [PATCH 003/125] fix(web): copy terminal selection instead of a blank clipboard (#7678) --- .../src/terminal/ghostty/runtimeAbi.test.ts | 83 ++++++++++++++ apps/web/src/terminal/ghostty/surface.test.ts | 99 +++++++++++++++++ apps/web/src/terminal/ghostty/surface.ts | 103 +++++++++++++++--- 3 files changed, 269 insertions(+), 16 deletions(-) diff --git a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts index 9f9fe189bf17..7d4782b4b409 100644 --- a/apps/web/src/terminal/ghostty/runtimeAbi.test.ts +++ b/apps/web/src/terminal/ghostty/runtimeAbi.test.ts @@ -320,6 +320,89 @@ describe("vendored libghostty-vt WebAssembly", () => { call("ghostty_wasm_free_u8_array", options, 8); }); + it("formats a cell-drag selection installed from screen grid refs", async () => { + const result = await WebAssembly.instantiate( + decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, + { env: { log: () => {} } }, + ); + const instance = result instanceof WebAssembly.Instance ? result : result.instance; + const memory = instance.exports.memory as WebAssembly.Memory; + const call = (name: string, ...args: number[]) => + (instance.exports[name] as WasmFunction)(...args); + const alloc = (size: number) => call("ghostty_wasm_alloc_u8_array", size); + const free = (pointer: number, size: number) => + call("ghostty_wasm_free_u8_array", pointer, size); + const options = alloc(8); + new DataView(memory.buffer, options, 8).setUint16(0, 80, true); + new DataView(memory.buffer, options, 8).setUint16(2, 24, true); + const terminalSlot = call("ghostty_wasm_alloc_opaque"); + expect(call("ghostty_terminal_new", 0, terminalSlot, options)).toBe(0); + const terminal = new DataView(memory.buffer).getUint32(terminalSlot, true); + const input = new TextEncoder().encode("hello\r\nworld"); + const inputPointer = alloc(input.length); + new Uint8Array(memory.buffer, inputPointer, input.length).set(input); + call("ghostty_terminal_vt_write", terminal, inputPointer, input.length); + + const gridRefAt = (x: number, y: number) => { + const point = alloc(24); + const pointView = new DataView(memory.buffer, point, 24); + pointView.setUint32(0, 2, true); + pointView.setUint16(8, x, true); + pointView.setUint32(12, y, true); + const ref = alloc(12); + new DataView(memory.buffer, ref, 12).setUint32(0, 12, true); + expect(call("ghostty_terminal_grid_ref", terminal, point, ref)).toBe(0); + free(point, 24); + return ref; + }; + const start = gridRefAt(0, 0); + const end = gridRefAt(4, 1); + const selection = alloc(32); + const selectionBytes = new Uint8Array(memory.buffer, selection, 32); + selectionBytes.fill(0); + new DataView(memory.buffer, selection, 32).setUint32(0, 32, true); + selectionBytes.set(new Uint8Array(memory.buffer, start, 12), 4); + selectionBytes.set(new Uint8Array(memory.buffer, end, 12), 16); + expect(call("ghostty_terminal_set", terminal, 21, selection)).toBe(0); + + const formatOptions = alloc(16); + new Uint8Array(memory.buffer, formatOptions, 16).fill(0); + const formatView = new DataView(memory.buffer, formatOptions, 16); + formatView.setUint32(0, 16, true); + formatView.setUint8(8, 1); + formatView.setUint8(9, 1); + const written = call("ghostty_wasm_alloc_usize"); + expect( + call("ghostty_terminal_selection_format_buf", terminal, formatOptions, 0, 0, written), + ).toBe(-3); + const outputSize = new DataView(memory.buffer, written, 4).getUint32(0, true); + const output = alloc(outputSize); + expect( + call( + "ghostty_terminal_selection_format_buf", + terminal, + formatOptions, + output, + outputSize, + written, + ), + ).toBe(0); + expect(new TextDecoder().decode(new Uint8Array(memory.buffer, output, outputSize))).toBe( + "hello\nworld", + ); + + free(output, outputSize); + call("ghostty_wasm_free_usize", written); + free(formatOptions, 16); + free(selection, 32); + free(end, 12); + free(start, 12); + free(inputPointer, input.length); + call("ghostty_terminal_free", terminal); + call("ghostty_wasm_free_opaque", terminalSlot); + free(options, 8); + }); + it("uses Ghostty for mouse encoding, word selection, and OSC 8 hit testing", async () => { const result = await WebAssembly.instantiate( decodeWasmDataUrl(wasmDataUrl).buffer as ArrayBuffer, diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index c11529e0c46c..1102d1b0bada 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -5,13 +5,17 @@ import { DEFAULT_TERMINAL_FONT_FAMILY, DEFAULT_TERMINAL_FONT_SIZE, advanceTerminalSelectionClickSequence, + applyTerminalCopyEvent, + clearPrimedTerminalCopyInput, ghosttyMouseButton, isTerminalAltGraphText, isTerminalCompositionCommitInput, + isTerminalCompositionKey, isTerminalCopyShortcut, isTerminalLinkPointerGesture, isTerminalPasteShortcut, loadTerminalFontFamily, + primeTerminalCopyInput, shouldBlinkTerminalCursor, shouldReportTerminalMouse, shouldShowTerminalLinkHover, @@ -233,6 +237,79 @@ describe("isTerminalCopyShortcut", () => { }); }); +describe("applyTerminalCopyEvent", () => { + it("writes the selection and claims the fallback when clipboardData is present", () => { + const setData = vi.fn(); + expect(applyTerminalCopyEvent("ls -la", { setData })).toEqual({ + preventDefault: true, + claimWriteFallback: true, + }); + expect(setData).toHaveBeenCalledWith("text/plain", "ls -la"); + }); + + it("leaves the writeText fallback alive when clipboardData is missing", () => { + // Electron's edit-menu Copy often delivers a copy event with no + // clipboardData. Claiming that event used to skip writeText and copy the + // empty IME textarea, which is the blank clipboard users paste. + expect(applyTerminalCopyEvent("ls -la", null)).toEqual({ + preventDefault: false, + claimWriteFallback: false, + }); + expect(applyTerminalCopyEvent("", { setData: vi.fn() })).toEqual({ + preventDefault: false, + claimWriteFallback: false, + }); + }); + + it("primes the current selection before a copy event with no clipboardData", () => { + const input = { + value: "stale", + selectionStart: 0, + selectionEnd: 0, + select() { + this.selectionStart = 0; + this.selectionEnd = this.value.length; + }, + }; + primeTerminalCopyInput(input, "git status"); + expect(applyTerminalCopyEvent("git status", null)).toEqual({ + preventDefault: false, + claimWriteFallback: false, + }); + expect(input.value).toBe("git status"); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(10); + }); +}); + +describe("primeTerminalCopyInput", () => { + it("selects the Ghostty selection in the hidden textarea so native copy has text", () => { + const input = { + value: "", + selectionStart: 0, + selectionEnd: 0, + select() { + this.selectionStart = 0; + this.selectionEnd = this.value.length; + }, + }; + primeTerminalCopyInput(input, "git status"); + expect(input.value).toBe("git status"); + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(10); + clearPrimedTerminalCopyInput(input, "git status"); + expect(input.value).toBe(""); + }); + + it("does not wipe an IME candidate that replaced the primed copy", () => { + const input = { value: "", select() {} }; + primeTerminalCopyInput(input, "git status"); + input.value = "あ"; + clearPrimedTerminalCopyInput(input, "git status"); + expect(input.value).toBe("あ"); + }); +}); + describe("isTerminalPasteShortcut", () => { const event = (overrides: Partial[0]> = {}) => ({ ctrlKey: false, @@ -283,6 +360,28 @@ describe("isTerminalCompositionCommitInput", () => { }); }); +describe("isTerminalCompositionKey", () => { + const event = ( + overrides: Partial> = {}, + ) => ({ + isComposing: false, + key: "a", + keyCode: 65, + ...overrides, + }); + + it("treats in-progress IME keydowns as composition so the copy primer cannot wipe them", () => { + expect(isTerminalCompositionKey(event({ isComposing: true }), false)).toBe(true); + expect(isTerminalCompositionKey(event(), true)).toBe(true); + expect(isTerminalCompositionKey(event({ key: "Process" }), false)).toBe(true); + expect(isTerminalCompositionKey(event({ keyCode: 229 }), false)).toBe(true); + }); + + it("lets ordinary keydowns clear a primed copy", () => { + expect(isTerminalCompositionKey(event(), false)).toBe(false); + }); +}); + describe("application mouse reporting", () => { const event = (overrides: Partial[1]> = {}) => ({ ctrlKey: false, diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 9492e2d02628..8ee4b633fcb8 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -336,6 +336,47 @@ export function isTerminalCopyShortcut( return isMacPlatform(platform) ? event.metaKey : event.ctrlKey; } +/** + * Canvas terminals have no DOM selection. Native copy and Electron's Edit + * menu `role: "copy"` both read the focused textarea, so an empty IME field + * writes blankness to the clipboard. Park the Ghostty selection there first. + */ +export function primeTerminalCopyInput( + input: Pick, + selection: string, +): void { + input.value = selection; + if (selection.length === 0) return; + input.select(); +} + +export function clearPrimedTerminalCopyInput( + input: Pick, + primedSelection: string, +): void { + // Only blank the copy we parked. The same textarea holds the IME candidate; + // wiping whatever is there would cancel CJK composition. + if (primedSelection.length === 0 || input.value !== primedSelection) return; + input.value = ""; +} + +/** + * Only a copy event that actually received the selection may cancel the + * clipboard.writeText fallback. Claiming without clipboardData (Electron's + * menu Copy) used to preventDefault an empty write and skip the fallback, + * which is how Cmd+C copied blankness. + */ +export function applyTerminalCopyEvent( + selection: string, + clipboardData: { setData: (type: string, data: string) => void } | null | undefined, +): { preventDefault: boolean; claimWriteFallback: boolean } { + if (selection.length === 0 || !clipboardData) { + return { preventDefault: false, claimWriteFallback: false }; + } + clipboardData.setData("text/plain", selection); + return { preventDefault: true, claimWriteFallback: true }; +} + export function isTerminalPasteShortcut( event: Pick, platform = navigator.platform, @@ -356,6 +397,14 @@ export function isTerminalCompositionCommitInput(event: Pick