Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ export function registerIpcHandlers(windowManager: WindowManager, cdpProxyInstan
clipboard.writeText(text);
});

// Use Electron's clipboard for reads too — navigator.clipboard.readText() can
// return garbled text on Windows when the source app wrote a non-UTF-8 format.
ipcMain.handle('clipboard:read-text', () => clipboard.readText());

// Clipboard image paste: save clipboard image to temp file, return path
ipcMain.handle('clipboard:paste-image', async () => {
const img = clipboard.readImage();
Expand Down
1 change: 1 addition & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ contextBridge.exposeInMainWorld('wmux', {
clipboard: {
pasteImage: () => ipcRenderer.invoke('clipboard:paste-image'),
writeText: (text: string) => ipcRenderer.invoke('clipboard:write-text', text),
readText: () => ipcRenderer.invoke('clipboard:read-text') as Promise<string>,
},
shell: {
// Resolve a dropped File to its real filesystem path. Electron 33 removed
Expand Down
28 changes: 17 additions & 11 deletions src/renderer/hooks/useKeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,17 +398,23 @@ export function useKeyboardShortcuts(
}

case 'paste': {
navigator.clipboard.readText().then((text) => {
if (!text || !focusedPaneId || !activeWorkspaceId) return;
const ws = useStore.getState().workspaces.find((w) => w.id === activeWorkspaceId);
if (!ws) return;
const leaf = findLeaf(ws.splitTree, focusedPaneId);
if (!leaf) return;
const activeSurf = leaf.surfaces[leaf.activeSurfaceIndex];
if (activeSurf?.type === 'terminal') {
window.wmux?.pty?.write(activeSurf.id, text);
}
});
if (!focusedPaneId || !activeWorkspaceId) break;
const ws = useStore.getState().workspaces.find((w) => w.id === activeWorkspaceId);
if (!ws) break;
const leaf = findLeaf(ws.splitTree, focusedPaneId);
if (!leaf) break;
const activeSurf = leaf.surfaces[leaf.activeSurfaceIndex];
if (activeSurf?.type === 'terminal') {
// Delegate to the focused terminal instead of reading the clipboard
// here: navigator.clipboard.readText() garbles non-UTF-8 Windows
// clipboard formats (em dash → "â"), and a raw pty.write strips
// bracketed-paste markers (breaking multi-line paste in Claude Code).
// The terminal's handler reads via Electron's clipboard and uses
// terminal.paste(), matching the Ctrl+V path.
document.dispatchEvent(
new CustomEvent('wmux:paste-terminal', { detail: { surfaceId: activeSurf.id } }),
);
}
break;
}

Expand Down
35 changes: 28 additions & 7 deletions src/renderer/hooks/useTerminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,11 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true, focused = t
// (handled) either way.
if (b64 && b64 !== '?') {
try {
const text = atob(b64);
// atob() yields a binary (Latin-1) string — one code point per byte.
// OSC 52 payloads are UTF-8, so decode the bytes as UTF-8; otherwise
// multi-byte chars (em dash E2 80 94) become mojibake (â€").
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const text = new TextDecoder('utf-8').decode(bytes);
if (text) window.wmux?.clipboard?.writeText?.(text);
} catch {}
}
Expand Down Expand Up @@ -406,14 +410,11 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true, focused = t
handled = true;
}
}
// If no image, paste text
// If no image, paste text via Electron's clipboard API — navigator.clipboard
// can return garbled bytes on Windows when the source wrote a non-UTF-8 format.
if (!handled && ptyIdRef.current) {
try {
const text = await navigator.clipboard.readText();
// Use terminal.paste() — it honors bracketed-paste mode and emits
// the data through onData (already wired to PTY). Writing raw to
// pty.write strips the \x1b[200~/\x1b[201~ wrappers, so apps like
// Claude Code see each \n as Enter and only the first line lands.
const text = await window.wmux.clipboard.readText();
if (text) terminal.paste(text);
} catch {}
}
Expand Down Expand Up @@ -546,6 +547,26 @@ export function useTerminal({ surfaceId, shell, cwd, visible = true, focused = t
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// Paste delegated from the keyboard-shortcut handler (e.g. Ctrl+Shift+V).
// Routed here so it shares the Ctrl+V path's correctness: Electron's
// clipboard.readText() (navigator.clipboard garbles non-UTF-8 Windows
// formats — em dash → "â") and terminal.paste() (honors bracketed-paste
// mode, so multi-line paste into Claude Code doesn't submit on the first \n).
useEffect(() => {
const handler = async (e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.surfaceId !== surfaceId) return;
const term = xtermRef.current;
if (!term || !ptyIdRef.current) return;
try {
const text = await window.wmux.clipboard.readText();
if (text) term.paste(text);
} catch {}
};
document.addEventListener('wmux:paste-terminal', handler);
return () => document.removeEventListener('wmux:paste-terminal', handler);
}, [surfaceId]);

// Apply theme + font whenever the resolved scheme or prefs change.
// Keeps terminals reactive: changing the global theme in Settings, or
// assigning a per-pane `--color-scheme`, repaints without recreation.
Expand Down