diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs
index 67b31eb4d75..0e20c89d0d2 100644
--- a/apps/desktop/electron/main.cjs
+++ b/apps/desktop/electron/main.cjs
@@ -43,6 +43,7 @@ const { serializeJsonBody, setJsonRequestHeaders } = require('./oauth-net-reques
const { fetchMarketplaceThemes, searchMarketplaceThemes } = require('./vscode-marketplace.cjs')
const { buildDesktopBackendEnv, normalizeHermesHomeRoot } = require('./backend-env.cjs')
const { readWindowsUserEnvVar } = require('./windows-user-env.cjs')
+const { readWslWindowsClipboardImage } = require('./wsl-clipboard-image.cjs')
const { readDirForIpc } = require('./fs-read-dir.cjs')
const { readLiveUpdateMarker } = require('./update-marker.cjs')
const {
@@ -524,6 +525,14 @@ function getTitleBarOverlayOptions() {
return { height: TITLEBAR_HEIGHT }
}
+ // Window Controls Overlay is a Windows/macOS-only Electron feature. On Linux
+ // (including WSLg, where the RDP host draws its own min/max/close) requesting
+ // it does nothing useful, so disable it and let the frameless window stand on
+ // its own (titleBarStyle stays 'hidden').
+ if (!IS_WINDOWS) {
+ return false
+ }
+
if (rendererTitleBarTheme) {
return {
color: rendererTitleBarTheme.background,
@@ -541,6 +550,24 @@ function getTitleBarOverlayOptions() {
}
}
+// Push refreshed overlay options to a live window after a theme/appearance
+// change. No-op on Linux (incl. WSLg), where the overlay is unsupported and
+// getTitleBarOverlayOptions() returns false — calling setTitleBarOverlay there
+// can throw on some Electron Linux builds.
+function applyTitleBarOverlay(win) {
+ const options = getTitleBarOverlayOptions()
+ if (!options || typeof options !== 'object') {
+ return
+ }
+
+ try {
+ win?.setTitleBarOverlay?.(options)
+ } catch {
+ // Overlay not supported on this platform/build — leave the frameless
+ // titlebar as-is.
+ }
+}
+
const MEDIA_MIME_TYPES = {
'.avi': 'video/x-msvideo',
'.bmp': 'image/bmp',
@@ -3760,11 +3787,15 @@ function getWindowButtonPosition() {
}
function getNativeOverlayWidth() {
- // macOS reports traffic-light coords via windowButtonPosition; the
- // titlebarOverlay there doesn't reserve right-edge space. Windows/Linux
- // render the native window-controls overlay on the right, so the renderer
- // needs to inset its right cluster by this much to clear them.
- return IS_MAC ? 0 : NATIVE_OVERLAY_BUTTON_WIDTH
+ // Only Windows paints an Electron Window Controls Overlay on the right that
+ // the renderer must inset its right cluster to clear.
+ // - macOS reports traffic-light coords via windowButtonPosition; its
+ // titleBarOverlay doesn't reserve right-edge space.
+ // - Linux (incl. WSLg) doesn't support titleBarOverlay at all — Electron
+ // paints no native controls there, so reserving width just leaves a dead
+ // gap and, under WSLg (where the RDP host draws its own min/max/close),
+ // pushes the right tools out of alignment with those host buttons.
+ return IS_WINDOWS ? NATIVE_OVERLAY_BUTTON_WIDTH : 0
}
function getWindowState() {
@@ -5820,7 +5851,7 @@ function createWindow() {
if (!nativeThemeListenerInstalled) {
nativeThemeListenerInstalled = true
nativeTheme.on('updated', () => {
- mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
+ applyTitleBarOverlay(mainWindow)
})
}
}
@@ -6482,11 +6513,21 @@ ipcMain.handle('hermes:saveImageBuffer', async (_event, payload) => {
ipcMain.handle('hermes:saveClipboardImage', async () => {
const image = clipboard.readImage()
- if (!image || image.isEmpty()) {
- return ''
+ if (image && !image.isEmpty()) {
+ return writeComposerImage(image.toPNG(), '.png')
}
- return writeComposerImage(image.toPNG(), '.png')
+ // WSL2/WSLg doesn't bridge clipboard *images* from the Windows host to the
+ // Linux clipboard Electron reads, so a host screenshot looks empty above.
+ // Pull it straight off the Windows clipboard via PowerShell as a fallback.
+ if (IS_WSL) {
+ const png = readWslWindowsClipboardImage()
+ if (png) {
+ return writeComposerImage(png, '.png')
+ }
+ }
+
+ return ''
})
ipcMain.handle('hermes:normalizePreviewTarget', (_event, target, baseDir) =>
@@ -6506,7 +6547,7 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => {
background: payload.background,
foreground: payload.foreground
}
- mainWindow?.setTitleBarOverlay?.(getTitleBarOverlayOptions())
+ applyTitleBarOverlay(mainWindow)
})
// Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH).
diff --git a/apps/desktop/electron/wsl-clipboard-image.cjs b/apps/desktop/electron/wsl-clipboard-image.cjs
new file mode 100644
index 00000000000..c81fe7b2a60
--- /dev/null
+++ b/apps/desktop/electron/wsl-clipboard-image.cjs
@@ -0,0 +1,92 @@
+// Pull a Windows-host clipboard image from inside WSL2 via PowerShell (WSLg
+// bridges text but not images). Returns PNG bytes or null; exec injectable.
+
+const { execFileSync } = require('node:child_process')
+
+// STA is mandatory: System.Windows.Forms.Clipboard throws ThreadStateException
+// off a single-threaded apartment. We emit base64 (not raw bytes) so the PNG
+// survives stdout's text decoding intact, and write with [Console]::Out.Write
+// to avoid a trailing newline.
+const PS_SCRIPT = [
+ 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
+ '$img = [System.Windows.Forms.Clipboard]::GetImage()',
+ 'if ($null -eq $img) { exit 0 }',
+ '$ms = New-Object System.IO.MemoryStream',
+ '$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)',
+ '[Console]::Out.Write([System.Convert]::ToBase64String($ms.ToArray()))'
+].join('\n')
+
+// PowerShell's -EncodedCommand takes UTF-16LE base64. Encoding the whole script
+// this way sidesteps every layer of WSL→Windows quoting (spaces, quotes,
+// brackets, newlines) that plain -Command arguments would mangle.
+function encodePowerShellCommand(script) {
+ return Buffer.from(String(script), 'utf16le').toString('base64')
+}
+
+// Locate powershell.exe. The bare name resolves through WSL's Windows-interop
+// PATH on every standard WSL2 setup; the absolute fallback covers a stripped
+// PATH. Returns the first candidate — execFile surfaces ENOENT if it's wrong
+// and we fall back to null.
+function powershellCandidates() {
+ return ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
+}
+
+function decodeClipboardImageBase64(stdout) {
+ const b64 = String(stdout || '').trim()
+ if (!b64) return null
+
+ let buffer
+ try {
+ buffer = Buffer.from(b64, 'base64')
+ } catch {
+ return null
+ }
+
+ // Guard against partial / garbage output: require a real PNG signature.
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+ if (buffer.length < PNG_SIGNATURE.length || !buffer.subarray(0, PNG_SIGNATURE.length).equals(PNG_SIGNATURE)) {
+ return null
+ }
+
+ return buffer
+}
+
+// Read the Windows clipboard image from inside WSL. Returns a PNG Buffer, or
+// null when there's no image, PowerShell is unreachable, or output is invalid.
+// Linux-only by contract (caller gates on IS_WSL); never throws.
+function readWslWindowsClipboardImage({ exec = execFileSync, candidates = powershellCandidates() } = {}) {
+ const encoded = encodePowerShellCommand(PS_SCRIPT)
+
+ for (const ps of candidates) {
+ try {
+ const stdout = exec(
+ ps,
+ ['-NoProfile', '-NonInteractive', '-STA', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded],
+ {
+ encoding: 'utf8',
+ windowsHide: true,
+ timeout: 8000,
+ // A 4K screenshot base64s to a few MB; give stdout generous headroom.
+ maxBuffer: 64 * 1024 * 1024,
+ // PowerShell writes progress/CLIXML noise to stderr — ignore it.
+ stdio: ['ignore', 'pipe', 'ignore']
+ }
+ )
+ const decoded = decodeClipboardImageBase64(stdout)
+ if (decoded) return decoded
+ // Empty stdout = no image on the clipboard; stop, don't try fallbacks.
+ if (String(stdout || '').trim() === '') return null
+ } catch {
+ // This powershell.exe candidate is missing/failed — try the next one.
+ }
+ }
+
+ return null
+}
+
+module.exports = {
+ decodeClipboardImageBase64,
+ encodePowerShellCommand,
+ powershellCandidates,
+ readWslWindowsClipboardImage
+}
diff --git a/apps/desktop/electron/wsl-clipboard-image.test.cjs b/apps/desktop/electron/wsl-clipboard-image.test.cjs
new file mode 100644
index 00000000000..343adc1f6d6
--- /dev/null
+++ b/apps/desktop/electron/wsl-clipboard-image.test.cjs
@@ -0,0 +1,114 @@
+const assert = require('node:assert/strict')
+const test = require('node:test')
+
+const {
+ decodeClipboardImageBase64,
+ encodePowerShellCommand,
+ powershellCandidates,
+ readWslWindowsClipboardImage
+} = require('./wsl-clipboard-image.cjs')
+
+const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+
+function fakePngBuffer(extraBytes = 16) {
+ return Buffer.concat([PNG_SIGNATURE, Buffer.alloc(extraBytes, 0x42)])
+}
+
+test('encodePowerShellCommand produces UTF-16LE base64 PowerShell can decode', () => {
+ const encoded = encodePowerShellCommand('Write-Output "hi"')
+ const roundTripped = Buffer.from(encoded, 'base64').toString('utf16le')
+ assert.equal(roundTripped, 'Write-Output "hi"')
+})
+
+test('decodeClipboardImageBase64 returns a Buffer for valid PNG base64', () => {
+ const png = fakePngBuffer()
+ const decoded = decodeClipboardImageBase64(png.toString('base64'))
+ assert.ok(Buffer.isBuffer(decoded))
+ assert.ok(decoded.equals(png))
+})
+
+test('decodeClipboardImageBase64 trims surrounding whitespace before decoding', () => {
+ const png = fakePngBuffer()
+ const decoded = decodeClipboardImageBase64(`\n ${png.toString('base64')} \r\n`)
+ assert.ok(decoded && decoded.equals(png))
+})
+
+test('decodeClipboardImageBase64 returns null for empty / whitespace input', () => {
+ assert.equal(decodeClipboardImageBase64(''), null)
+ assert.equal(decodeClipboardImageBase64(' \n '), null)
+ assert.equal(decodeClipboardImageBase64(null), null)
+ assert.equal(decodeClipboardImageBase64(undefined), null)
+})
+
+test('decodeClipboardImageBase64 rejects base64 without a PNG signature', () => {
+ // Valid base64, but the decoded bytes are not a PNG.
+ const notPng = Buffer.from('this is not a png at all').toString('base64')
+ assert.equal(decodeClipboardImageBase64(notPng), null)
+})
+
+test('readWslWindowsClipboardImage decodes the first candidate that returns a PNG', () => {
+ const png = fakePngBuffer()
+ const calls = []
+ const exec = (cmd, args) => {
+ calls.push({ cmd, args })
+ return png.toString('base64')
+ }
+
+ const result = readWslWindowsClipboardImage({ exec, candidates: ['powershell.exe'] })
+ assert.ok(result && result.equals(png))
+ assert.equal(calls.length, 1)
+ assert.equal(calls[0].cmd, 'powershell.exe')
+ // -STA is mandatory for System.Windows.Forms.Clipboard.
+ assert.ok(calls[0].args.includes('-STA'))
+ assert.ok(calls[0].args.includes('-EncodedCommand'))
+})
+
+test('readWslWindowsClipboardImage returns null and stops when stdout is empty (no image)', () => {
+ let count = 0
+ const exec = () => {
+ count += 1
+ return ''
+ }
+
+ const result = readWslWindowsClipboardImage({
+ exec,
+ candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
+ })
+ assert.equal(result, null)
+ // Empty stdout means "no image on the clipboard" — don't probe further candidates.
+ assert.equal(count, 1)
+})
+
+test('readWslWindowsClipboardImage falls through to the next candidate when one throws', () => {
+ const png = fakePngBuffer()
+ const seen = []
+ const exec = cmd => {
+ seen.push(cmd)
+ if (cmd === 'powershell.exe') {
+ throw Object.assign(new Error('not found'), { code: 'ENOENT' })
+ }
+ return png.toString('base64')
+ }
+
+ const result = readWslWindowsClipboardImage({
+ exec,
+ candidates: ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe']
+ })
+ assert.ok(result && result.equals(png))
+ assert.deepEqual(seen, ['powershell.exe', '/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe'])
+})
+
+test('readWslWindowsClipboardImage returns null when every candidate throws', () => {
+ const exec = () => {
+ throw new Error('boom')
+ }
+
+ const result = readWslWindowsClipboardImage({ exec, candidates: ['a', 'b'] })
+ assert.equal(result, null)
+})
+
+test('powershellCandidates lists the bare name first, then the absolute fallback', () => {
+ const candidates = powershellCandidates()
+ assert.equal(candidates[0], 'powershell.exe')
+ assert.ok(candidates.some(c => c.endsWith('WindowsPowerShell/v1.0/powershell.exe')))
+})
diff --git a/apps/desktop/package.json b/apps/desktop/package.json
index 141f8219982..8b26d89b3bc 100644
--- a/apps/desktop/package.json
+++ b/apps/desktop/package.json
@@ -37,7 +37,7 @@
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
- "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/window-state.test.cjs",
+ "test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/window-state.test.cjs",
"typecheck": "tsc -p . --noEmit",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
diff --git a/apps/desktop/src/app/chat/composer/context-menu.tsx b/apps/desktop/src/app/chat/composer/context-menu.tsx
index 580416dea5b..57c34ebde38 100644
--- a/apps/desktop/src/app/chat/composer/context-menu.tsx
+++ b/apps/desktop/src/app/chat/composer/context-menu.tsx
@@ -73,7 +73,11 @@ export function ContextMenu({
{c.images}
-
+ void onPasteClipboardImage() : undefined}
+ >
{c.pasteImage}
@@ -167,7 +171,7 @@ interface ContextMenuItemProps {
interface ContextMenuProps {
onInsertText: (text: string) => void
onOpenUrlDialog: () => void
- onPasteClipboardImage?: () => void
+ onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise | void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index 8a0ec509b0b..cca8f00638f 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -784,6 +784,16 @@ export function ChatBar({
if (!pastedText) {
event.preventDefault()
+ // Under WSL2/WSLg the Windows host clipboard doesn't bridge *images* to
+ // the Linux clipboard the DOM paste event reads, so a host screenshot
+ // arrives as an empty paste (no blobs, no text). Fall back to the main
+ // process, which pulls the image straight off the Windows clipboard.
+ // Silent so a genuinely-empty paste doesn't pop a "no image" warning.
+ if (onPasteClipboardImage) {
+ triggerHaptic('selection')
+ void onPasteClipboardImage({ silent: true })
+ }
+
return
}
diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts
index 6d9444a6d93..59c7c17274c 100644
--- a/apps/desktop/src/app/chat/composer/types.ts
+++ b/apps/desktop/src/app/chat/composer/types.ts
@@ -46,7 +46,7 @@ export interface ChatBarProps {
onAddUrl?: (url: string) => void
onAttachImageBlob?: (blob: Blob) => Promise | boolean | void
onAttachDroppedItems?: (candidates: DroppedFile[]) => Promise | boolean | void
- onPasteClipboardImage?: () => void
+ onPasteClipboardImage?: (opts?: { silent?: boolean }) => Promise | void
onPickFiles?: () => void
onPickFolders?: () => void
onPickImages?: () => void
diff --git a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
index ddf38340235..26f41864baf 100644
--- a/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
+++ b/apps/desktop/src/app/chat/hooks/use-composer-actions.ts
@@ -411,25 +411,36 @@ export function useComposerActions({ activeSessionId, currentCwd, requestGateway
}
}, [attachImagePath, copy.attachImages, currentCwd, t.composer.images])
- const pasteClipboardImage = useCallback(async () => {
- try {
- const path = await window.hermesDesktop?.saveClipboardImage()
+ const pasteClipboardImage = useCallback(
+ async ({ silent = false }: { silent?: boolean } = {}) => {
+ try {
+ const path = await window.hermesDesktop?.saveClipboardImage()
- if (!path) {
- notify({
- kind: 'warning',
- title: copy.clipboard,
- message: copy.noClipboardImage
- })
+ if (!path) {
+ if (!silent) {
+ notify({
+ kind: 'warning',
+ title: copy.clipboard,
+ message: copy.noClipboardImage
+ })
+ }
- return
+ return false
+ }
+
+ await attachImagePath(path)
+
+ return true
+ } catch (err) {
+ if (!silent) {
+ notifyError(err, copy.clipboardPasteFailed)
+ }
+
+ return false
}
-
- await attachImagePath(path)
- } catch (err) {
- notifyError(err, copy.clipboardPasteFailed)
- }
- }, [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage])
+ },
+ [attachImagePath, copy.clipboard, copy.clipboardPasteFailed, copy.noClipboardImage]
+ )
const attachContextFolderPath = useCallback(
(folderPath: string) => {
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index e4a80e61273..51697829d17 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -75,7 +75,7 @@ interface ChatViewProps extends Omit, 'onSubmit'> {
maxVoiceRecordingSeconds?: number
onAttachImageBlob: (blob: Blob) => Promise | boolean | void
onAttachDroppedItems: (candidates: DroppedFile[]) => Promise | boolean | void
- onPasteClipboardImage: () => void
+ onPasteClipboardImage: (opts?: { silent?: boolean }) => Promise | void
onPickFiles: () => void
onPickFolders: () => void
onPickImages: () => void
diff --git a/apps/desktop/src/app/desktop-controller.tsx b/apps/desktop/src/app/desktop-controller.tsx
index b656afe0743..d36e63fe74f 100644
--- a/apps/desktop/src/app/desktop-controller.tsx
+++ b/apps/desktop/src/app/desktop-controller.tsx
@@ -1191,7 +1191,7 @@ export function DesktopController() {
}}
onDismissError={dismissError}
onEdit={editMessage}
- onPasteClipboardImage={() => void composer.pasteClipboardImage()}
+ onPasteClipboardImage={opts => composer.pasteClipboardImage(opts)}
onPickFiles={() => void composer.pickContextPaths('file')}
onPickFolders={() => void composer.pickContextPaths('folder')}
onPickImages={() => void composer.pickImages()}