Skip to content

Commit 812254b

Browse files
committed
pasting images fix
1 parent 214dd23 commit 812254b

8 files changed

Lines changed: 230 additions & 10 deletions

File tree

apps/desktop/scripts/dev.cjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,10 +260,12 @@ async function main() {
260260
const electronEnv = { VITE_DEV_SERVER_URL: devServerUrl };
261261
const launchElectron = () => {
262262
const electronArgs = ["electron", `--remote-debugging-port=${remoteDebugPort}`];
263+
// Electron treats the first non-switch argument as the app path. Keep "."
264+
// before paired macOS process flags so their value is not parsed as the app.
265+
electronArgs.push(".");
263266
if (process.platform === "darwin") {
264267
electronArgs.push("-ApplePersistenceIgnoreState", "YES");
265268
}
266-
electronArgs.push(".");
267269
const child = spawnProcess("electron", npxCommand, electronArgs, electronEnv);
268270
electron = child;
269271
children.add(child);

apps/desktop/src/main/services/ipc/registerIpc.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2717,6 +2717,10 @@ export function registerIpc({
27172717
clipboard.writeText(text);
27182718
});
27192719

2720+
ipcMain.handle(IPC.appHasClipboardImage, async (): Promise<boolean> => {
2721+
return !clipboard.readImage().isEmpty();
2722+
});
2723+
27202724
ipcMain.handle(IPC.appReadClipboardImage, async (): Promise<{ data: string; filename: string; mimeType: string } | null> => {
27212725
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
27222726
const image = clipboard.readImage();

apps/desktop/src/preload/global.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -683,6 +683,7 @@ declare global {
683683
revealPath: (path: string) => Promise<void>;
684684
openPath: (path: string) => Promise<void>;
685685
writeClipboardText: (text: string) => Promise<void>;
686+
hasClipboardImage: () => Promise<boolean>;
686687
readClipboardImage: () => Promise<{ data: string; filename: string; mimeType: string } | null>;
687688
getImageDataUrl: (path: string) => Promise<{ dataUrl: string }>;
688689
writeClipboardImage: (path: string) => Promise<void>;

apps/desktop/src/preload/preload.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1021,6 +1021,8 @@ contextBridge.exposeInMainWorld("ade", {
10211021
ipcRenderer.invoke(IPC.appOpenPath, { path }),
10221022
writeClipboardText: async (text: string): Promise<void> =>
10231023
ipcRenderer.invoke(IPC.appWriteClipboardText, { text }),
1024+
hasClipboardImage: async (): Promise<boolean> =>
1025+
ipcRenderer.invoke(IPC.appHasClipboardImage),
10241026
readClipboardImage: async (): Promise<{ data: string; filename: string; mimeType: string } | null> =>
10251027
ipcRenderer.invoke(IPC.appReadClipboardImage),
10261028
getImageDataUrl: async (path: string): Promise<{ dataUrl: string }> =>

apps/desktop/src/renderer/browserMock.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2638,6 +2638,7 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) {
26382638
openExternal: resolvedArg(undefined),
26392639
revealPath: resolvedArg(undefined),
26402640
writeClipboardText: resolvedArg(undefined),
2641+
hasClipboardImage: resolved(false),
26412642
readClipboardImage: resolved(null),
26422643
getImageDataUrl: resolvedArg({ dataUrl: "" }),
26432644
writeClipboardImage: resolvedArg(undefined),

apps/desktop/src/renderer/components/terminals/TerminalView.test.tsx

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,9 @@ import { WORK_SURFACE_REVEALED_EVENT } from "./workSurfaceVisibility";
151151

152152
function installWindowAde() {
153153
(window as any).ade = {
154+
app: {
155+
hasClipboardImage: vi.fn().mockResolvedValue(false),
156+
},
154157
pty: {
155158
resize: vi.fn().mockResolvedValue(undefined),
156159
write: vi.fn().mockResolvedValue(undefined),
@@ -185,6 +188,27 @@ async function flushAnimationFrame() {
185188
});
186189
}
187190

191+
async function flushPromises() {
192+
await act(async () => {
193+
await Promise.resolve();
194+
});
195+
}
196+
197+
function createPasteEvent(text: string): Event {
198+
const event = new Event("paste", { bubbles: true, cancelable: true });
199+
Object.defineProperty(event, "clipboardData", {
200+
configurable: true,
201+
value: {
202+
files: [],
203+
items: [],
204+
getData: vi.fn((type: string) => (
205+
type === "text/plain" || type === "text" ? text : ""
206+
)),
207+
},
208+
});
209+
return event;
210+
}
211+
188212
function triggerResizeObserver() {
189213
const latest = resizeObservers.at(-1);
190214
if (!latest) throw new Error("ResizeObserver not installed");
@@ -525,6 +549,162 @@ describe("TerminalView", () => {
525549
expect(terminal?.options.scrollback).toBe(20_000);
526550
});
527551

552+
it("writes text paste contents directly to the PTY", async () => {
553+
render(<TerminalView ptyId="pty-text-paste" sessionId="session-text-paste" isActive />);
554+
await flushAllTimers();
555+
556+
const terminal = mockState.terminalInstances.at(-1) as {
557+
element: HTMLElement | null;
558+
} | undefined;
559+
expect(terminal?.element).toBeTruthy();
560+
561+
const ptyWrite = window.ade.pty.write as unknown as ReturnType<typeof vi.fn>;
562+
const hasClipboardImage = window.ade.app.hasClipboardImage as unknown as ReturnType<typeof vi.fn>;
563+
ptyWrite.mockClear();
564+
hasClipboardImage.mockClear();
565+
566+
const event = createPasteEvent("hello from clipboard");
567+
terminal!.element!.dispatchEvent(event);
568+
569+
expect(event.defaultPrevented).toBe(true);
570+
expect(ptyWrite).toHaveBeenCalledWith({
571+
ptyId: "pty-text-paste",
572+
data: "hello from clipboard",
573+
});
574+
expect(hasClipboardImage).not.toHaveBeenCalled();
575+
});
576+
577+
it("maps macOS Cmd+V with an image-only clipboard to Ctrl+V terminal input", async () => {
578+
const platformDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "platform");
579+
const originalPlatform = window.navigator.platform;
580+
try {
581+
Object.defineProperty(window.navigator, "platform", {
582+
configurable: true,
583+
value: "MacIntel",
584+
});
585+
(window.ade.app.hasClipboardImage as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(true);
586+
587+
render(<TerminalView ptyId="pty-image-paste" sessionId="session-image-paste" isActive />);
588+
await flushAllTimers();
589+
590+
const terminal = mockState.terminalInstances.at(-1) as {
591+
attachCustomKeyEventHandler: ReturnType<typeof vi.fn>;
592+
element: HTMLElement | null;
593+
} | undefined;
594+
expect(terminal?.element).toBeTruthy();
595+
const keyHandler = terminal?.attachCustomKeyEventHandler.mock.calls.at(-1)?.[0] as ((ev: KeyboardEvent) => boolean) | undefined;
596+
expect(keyHandler).toBeTruthy();
597+
598+
const ptyWrite = window.ade.pty.write as unknown as ReturnType<typeof vi.fn>;
599+
ptyWrite.mockClear();
600+
601+
const handled = keyHandler!({
602+
type: "keydown",
603+
key: "v",
604+
metaKey: true,
605+
ctrlKey: false,
606+
altKey: false,
607+
shiftKey: false,
608+
preventDefault: vi.fn(),
609+
} as unknown as KeyboardEvent);
610+
expect(handled).toBe(false);
611+
612+
const event = createPasteEvent("");
613+
terminal!.element!.dispatchEvent(event);
614+
await flushPromises();
615+
616+
expect(event.defaultPrevented).toBe(true);
617+
expect(window.ade.app.hasClipboardImage).toHaveBeenCalledTimes(1);
618+
expect(ptyWrite).toHaveBeenCalledWith({
619+
ptyId: "pty-image-paste",
620+
data: "\x16",
621+
});
622+
623+
await act(async () => {
624+
await vi.advanceTimersByTimeAsync(130);
625+
});
626+
expect(ptyWrite).toHaveBeenCalledTimes(1);
627+
} finally {
628+
if (platformDescriptor) {
629+
Object.defineProperty(window.navigator, "platform", platformDescriptor);
630+
} else {
631+
Object.defineProperty(window.navigator, "platform", {
632+
configurable: true,
633+
value: originalPlatform,
634+
});
635+
}
636+
}
637+
});
638+
639+
it("falls back to native image paste when macOS Cmd+V does not fire a paste event", async () => {
640+
const platformDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "platform");
641+
const clipboardDescriptor = Object.getOwnPropertyDescriptor(window.navigator, "clipboard");
642+
const originalPlatform = window.navigator.platform;
643+
try {
644+
Object.defineProperty(window.navigator, "platform", {
645+
configurable: true,
646+
value: "MacIntel",
647+
});
648+
Object.defineProperty(window.navigator, "clipboard", {
649+
configurable: true,
650+
value: {
651+
readText: vi.fn().mockResolvedValue(""),
652+
writeText: vi.fn().mockResolvedValue(undefined),
653+
},
654+
});
655+
(window.ade.app.hasClipboardImage as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(true);
656+
657+
render(<TerminalView ptyId="pty-image-fallback" sessionId="session-image-fallback" isActive />);
658+
await flushAllTimers();
659+
660+
const terminal = mockState.terminalInstances.at(-1) as {
661+
attachCustomKeyEventHandler: ReturnType<typeof vi.fn>;
662+
} | undefined;
663+
const keyHandler = terminal?.attachCustomKeyEventHandler.mock.calls.at(-1)?.[0] as ((ev: KeyboardEvent) => boolean) | undefined;
664+
expect(keyHandler).toBeTruthy();
665+
666+
const ptyWrite = window.ade.pty.write as unknown as ReturnType<typeof vi.fn>;
667+
ptyWrite.mockClear();
668+
669+
const handled = keyHandler!({
670+
type: "keydown",
671+
key: "v",
672+
metaKey: true,
673+
ctrlKey: false,
674+
altKey: false,
675+
shiftKey: false,
676+
preventDefault: vi.fn(),
677+
} as unknown as KeyboardEvent);
678+
expect(handled).toBe(false);
679+
680+
await act(async () => {
681+
await vi.advanceTimersByTimeAsync(130);
682+
});
683+
await flushPromises();
684+
685+
expect(window.navigator.clipboard.readText).toHaveBeenCalledTimes(1);
686+
expect(window.ade.app.hasClipboardImage).toHaveBeenCalledTimes(1);
687+
expect(ptyWrite).toHaveBeenCalledWith({
688+
ptyId: "pty-image-fallback",
689+
data: "\x16",
690+
});
691+
} finally {
692+
if (platformDescriptor) {
693+
Object.defineProperty(window.navigator, "platform", platformDescriptor);
694+
} else {
695+
Object.defineProperty(window.navigator, "platform", {
696+
configurable: true,
697+
value: originalPlatform,
698+
});
699+
}
700+
if (clipboardDescriptor) {
701+
Object.defineProperty(window.navigator, "clipboard", clipboardDescriptor);
702+
} else {
703+
Reflect.deleteProperty(window.navigator, "clipboard");
704+
}
705+
}
706+
});
707+
528708
it("keeps live parked runtimes available so switching away does not discard TUI state", async () => {
529709
const view = render(<TerminalView ptyId="pty-live" sessionId="session-live" isActive />);
530710
await flushAllTimers();

apps/desktop/src/renderer/components/terminals/TerminalView.tsx

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ const MIN_HOST_HEIGHT_PX = 48;
9696
const INVALID_FIT_RETRY_MS = 90;
9797
const RENDERER_RESET_COOLDOWN_MS = 250;
9898
const TERMINAL_RENDERER_STORAGE_KEY = "ade.terminalRenderer";
99+
const TERMINAL_CTRL_V = "\x16";
99100
const runtimeCache = new Map<string, CachedRuntime>();
100101
let parkedRoot: HTMLDivElement | null = null;
101102

@@ -360,6 +361,23 @@ function setRuntimeVisibilityState(runtime: CachedRuntime, visible: boolean) {
360361
runtime.visible = visible;
361362
}
362363

364+
function writePtyInput(runtime: CachedRuntime, data: string) {
365+
if (!data || runtime.disposed) return;
366+
window.ade.pty.write({ ptyId: runtime.ptyId, data }).catch(() => {});
367+
}
368+
369+
async function pasteNativeClipboardImageShortcut(runtime: CachedRuntime): Promise<boolean> {
370+
if (runtime.disposed) return false;
371+
try {
372+
const hasImage = await window.ade.app.hasClipboardImage();
373+
if (!hasImage || runtime.disposed) return false;
374+
writePtyInput(runtime, TERMINAL_CTRL_V);
375+
return true;
376+
} catch {
377+
return false;
378+
}
379+
}
380+
363381
function teardownRuntime(runtime: CachedRuntime) {
364382
runtime.disposed = true;
365383
clearDisposeTimer(runtime);
@@ -875,8 +893,10 @@ function createRuntime(args: {
875893
lastPasteEventAt = Date.now();
876894
const text = ev.clipboardData?.getData("text/plain") ?? ev.clipboardData?.getData("text");
877895
if (text && !runtime.disposed) {
878-
window.ade.pty.write({ ptyId: runtime.ptyId, data: text }).catch(() => {});
896+
writePtyInput(runtime, text);
897+
return;
879898
}
899+
void pasteNativeClipboardImageShortcut(runtime);
880900
}, true);
881901

882902
term.attachCustomKeyEventHandler((ev) => {
@@ -896,11 +916,20 @@ function createRuntime(args: {
896916
const before = lastPasteEventAt;
897917
setTimeout(() => {
898918
if (lastPasteEventAt !== before || runtime.disposed) return;
899-
navigator.clipboard.readText().then((text) => {
919+
const readText = navigator.clipboard?.readText;
920+
if (typeof readText !== "function") {
921+
void pasteNativeClipboardImageShortcut(runtime);
922+
return;
923+
}
924+
readText.call(navigator.clipboard).then((text) => {
900925
if (text && !runtime.disposed) {
901-
window.ade.pty.write({ ptyId: runtime.ptyId, data: text }).catch(() => {});
926+
writePtyInput(runtime, text);
927+
return;
902928
}
903-
}).catch(() => {});
929+
void pasteNativeClipboardImageShortcut(runtime);
930+
}).catch(() => {
931+
void pasteNativeClipboardImageShortcut(runtime);
932+
});
904933
}, 120);
905934
return false;
906935
}
@@ -917,27 +946,27 @@ function createRuntime(args: {
917946
// Shift+Enter should insert a newline in tools like Claude/Codex prompts.
918947
if (ev.shiftKey && ev.key === "Enter") {
919948
ev.preventDefault();
920-
window.ade.pty.write({ ptyId: runtime.ptyId, data: "\n" }).catch(() => {});
949+
writePtyInput(runtime, "\n");
921950
return false;
922951
}
923952

924953
if (isMac && ev.altKey && ev.key === "Backspace") {
925954
ev.preventDefault();
926-
window.ade.pty.write({ ptyId: runtime.ptyId, data: "\x1b\x7f" }).catch(() => {});
955+
writePtyInput(runtime, "\x1b\x7f");
927956
return false;
928957
}
929958

930959
if (isMac && ev.metaKey && ev.key === "Backspace") {
931960
ev.preventDefault();
932961
// Ctrl+U: kill to beginning of line
933-
window.ade.pty.write({ ptyId: runtime.ptyId, data: "\x15" }).catch(() => {});
962+
writePtyInput(runtime, "\x15");
934963
return false;
935964
}
936965

937966
// Ctrl+Backspace: delete word backward (same as Ctrl+W)
938967
if (ev.ctrlKey && ev.key === "Backspace") {
939968
ev.preventDefault();
940-
window.ade.pty.write({ ptyId: runtime.ptyId, data: "\x17" }).catch(() => {});
969+
writePtyInput(runtime, "\x17");
941970
return false;
942971
}
943972

@@ -958,7 +987,7 @@ function createRuntime(args: {
958987
const merged = inputBuf.join("");
959988
inputBuf = [];
960989
if (merged && !runtime.disposed) {
961-
window.ade.pty.write({ ptyId: runtime.ptyId, data: merged }).catch(() => {});
990+
writePtyInput(runtime, merged);
962991
}
963992
});
964993
}

apps/desktop/src/shared/ipc.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const IPC = {
77
appRevealPath: "ade.app.revealPath",
88
appOpenPath: "ade.app.openPath",
99
appWriteClipboardText: "ade.app.writeClipboardText",
10+
appHasClipboardImage: "ade.app.hasClipboardImage",
1011
appReadClipboardImage: "ade.app.readClipboardImage",
1112
appGetImageDataUrl: "ade.app.getImageDataUrl",
1213
appWriteClipboardImage: "ade.app.writeClipboardImage",

0 commit comments

Comments
 (0)