From 581bca2f235a0571c11867a3880cefcb3fbacaa8 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Thu, 10 Sep 2026 03:26:51 +0800 Subject: [PATCH 1/2] fix(terminal): route image paste to native provider bindings --- docs/terminal/CLIPBOARD_INPUT.md | 34 +++++ src/app/preload/index.d.ts | 1 + src/app/preload/index.ts | 1 + .../renderer/browser/browserOpenCoveApi.ts | 4 + .../presentation/main-ipc/register.ts | 14 +- .../createRuntimeTerminalInputBridge.ts | 31 ++-- .../terminalNode/terminalClipboard.ts | 101 ++++++++++++++ .../terminalNode/useTerminalRuntimeSession.ts | 6 +- .../useNodesStore.terminalProviderHint.ts | 4 + .../renderer/utils/persistence/ensure.ts | 4 +- src/shared/contracts/dto/clipboard.ts | 5 + src/shared/contracts/ipc/channels.ts | 1 + .../ipc/clipboardTerminalPaste.spec.ts | 32 +++++ ...ce-canvas.terminal-image-paste.mac.spec.ts | 52 +++++++ ...anvas.terminal-image-paste.windows.spec.ts | 63 +++++++++ tests/unit/contexts/terminalClipboard.spec.ts | 132 ++++++++++++++++++ .../terminalProviderPasteHint.spec.ts | 27 ++++ 17 files changed, 501 insertions(+), 11 deletions(-) create mode 100644 docs/terminal/CLIPBOARD_INPUT.md create mode 100644 src/contexts/workspace/presentation/renderer/components/terminalNode/terminalClipboard.ts create mode 100644 tests/contract/ipc/clipboardTerminalPaste.spec.ts create mode 100644 tests/e2e/workspace-canvas.terminal-image-paste.mac.spec.ts create mode 100644 tests/e2e/workspace-canvas.terminal-image-paste.windows.spec.ts create mode 100644 tests/unit/contexts/terminalClipboard.spec.ts create mode 100644 tests/unit/contexts/terminalProviderPasteHint.spec.ts diff --git a/docs/terminal/CLIPBOARD_INPUT.md b/docs/terminal/CLIPBOARD_INPUT.md new file mode 100644 index 000000000..ef7e2bbcc --- /dev/null +++ b/docs/terminal/CLIPBOARD_INPUT.md @@ -0,0 +1,34 @@ +# Terminal Clipboard Input + +The desktop clipboard boundary reads text and native image presence together. +The terminal clipboard handler owns shortcut routing; the provider CLI owns +reading and attaching the native image. Image triggers are raw terminal input, +never bracketed paste. Text continues through the normal bracketed-paste encoder. + +| Provider | Windows image trigger | macOS image trigger | +| --- | --- | --- | +| Pi | Alt+V | Ctrl+V | +| Kimi | Ctrl+V | Ctrl+V | +| Codex | Ctrl+V (also accepts Alt+V) | Ctrl+V | +| Claude Code | Alt+V | Ctrl+V | +| Unidentified TUI, including OMP | Ctrl+V | Ctrl+V | + +Native platform paste shortcuts read clipboard content before choosing text or +image delivery. Windows Alt+V is normalized only for an identified supported +provider. Unidentified shells retain their Alt+V behavior. An image pasted with +Cmd+V into an unidentified macOS terminal emits Ctrl+V, which OMP accepts. + +Clipboard reads preserve paste order, and late results cannot write after the +terminal session has been disposed. No image files or new durable state are +created. Browser clients retain text-only paste: the remote provider cannot read +the browser machine's native clipboard. Remote desktop sessions likewise cannot +attach local images through a native provider shortcut; file transfer is separate. +Custom provider keybinding overrides are not inspected. + +## Upstream References + +- [Pi keybindings](https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/src/core/keybindings.ts): `app.clipboard.pasteImage` defaults to Alt+V on Windows and Ctrl+V elsewhere. Verified against the locally installed official package and its keybindings documentation. +- [OMP keybindings](https://github.com/can1357/oh-my-pi/blob/main/packages/coding-agent/src/config/keybindings.ts): `getDefaultPasteImageKeys` accepts Ctrl+V and Alt+V on Windows, Ctrl+V and Super+V on macOS. +- [Codex interaction routing](https://github.com/openai/codex/blob/main/codex-rs/tui/src/chatwidget/interaction.rs): Ctrl/Alt+V invokes `paste_image_to_temp_png` and attaches its result. +- [Claude Code interactive mode](https://code.claude.com/docs/en/interactive-mode): Ctrl+V, Cmd+V in iTerm2, and Alt+V on Windows/WSL paste images. +- [Kimi prompt](https://github.com/MoonshotAI/kimi-cli/blob/main/src/kimi_cli/ui/shell/prompt.py): the installed official package binds `c-v` and tries `grab_image_from_clipboard` before text fallback. diff --git a/src/app/preload/index.d.ts b/src/app/preload/index.d.ts index e1c213bb6..e3e7b31cb 100644 --- a/src/app/preload/index.d.ts +++ b/src/app/preload/index.d.ts @@ -182,6 +182,7 @@ export interface OpenCoveApi { getDisplayInfo: () => Promise } clipboard: { + readTerminalPaste: () => Promise readText: () => Promise writeText: (text: string) => Promise } diff --git a/src/app/preload/index.ts b/src/app/preload/index.ts index 6749d333a..8f07c382d 100644 --- a/src/app/preload/index.ts +++ b/src/app/preload/index.ts @@ -158,6 +158,7 @@ const opencoveApi = { invokeIpc(IPC_CHANNELS.windowMetricsGetDisplayInfo), }, clipboard: { + readTerminalPaste: () => invokeIpc(IPC_CHANNELS.clipboardReadTerminalPaste), readText: (): Promise => invokeIpc(IPC_CHANNELS.clipboardReadText), writeText: (text: string): Promise => invokeIpc(IPC_CHANNELS.clipboardWriteText, { text }), diff --git a/src/app/renderer/browser/browserOpenCoveApi.ts b/src/app/renderer/browser/browserOpenCoveApi.ts index fdc537b72..d9f713bc6 100644 --- a/src/app/renderer/browser/browserOpenCoveApi.ts +++ b/src/app/renderer/browser/browserOpenCoveApi.ts @@ -154,6 +154,10 @@ export function installBrowserOpenCoveApi(): void { }), }, clipboard: { + readTerminalPaste: async () => ({ + text: navigator.clipboard?.readText ? await navigator.clipboard.readText() : '', + hasImage: false, + }), readText: async () => { if (navigator.clipboard?.readText) { return await navigator.clipboard.readText() diff --git a/src/contexts/clipboard/presentation/main-ipc/register.ts b/src/contexts/clipboard/presentation/main-ipc/register.ts index 164aa10b0..9f31a3e08 100644 --- a/src/contexts/clipboard/presentation/main-ipc/register.ts +++ b/src/contexts/clipboard/presentation/main-ipc/register.ts @@ -1,11 +1,22 @@ import { clipboard, ipcMain } from 'electron' import { IPC_CHANNELS } from '../../../../shared/contracts/ipc' -import type { WriteClipboardTextInput } from '../../../../shared/contracts/dto' +import type { + TerminalClipboardSnapshot, + WriteClipboardTextInput, +} from '../../../../shared/contracts/dto' import type { IpcRegistrationDisposable } from '../../../../app/main/ipc/types' import { registerHandledIpc } from '../../../../app/main/ipc/handle' import { normalizeWriteClipboardTextPayload } from './validate' export function registerClipboardIpcHandlers(): IpcRegistrationDisposable { + registerHandledIpc( + IPC_CHANNELS.clipboardReadTerminalPaste, + async (): Promise => ({ + text: clipboard.readText(), + hasImage: !clipboard.readImage().isEmpty(), + }), + { defaultErrorCode: 'common.unexpected' }, + ) registerHandledIpc( IPC_CHANNELS.clipboardReadText, async (): Promise => clipboard.readText(), @@ -23,6 +34,7 @@ export function registerClipboardIpcHandlers(): IpcRegistrationDisposable { return { dispose: () => { + ipcMain.removeHandler(IPC_CHANNELS.clipboardReadTerminalPaste) ipcMain.removeHandler(IPC_CHANNELS.clipboardReadText) ipcMain.removeHandler(IPC_CHANNELS.clipboardWriteText) }, diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/createRuntimeTerminalInputBridge.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/createRuntimeTerminalInputBridge.ts index 64164208f..05fb6a672 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/createRuntimeTerminalInputBridge.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/createRuntimeTerminalInputBridge.ts @@ -5,6 +5,7 @@ import { createPtyWriteQueue, handleTerminalCustomKeyEvent } from './inputBridge import { isAutomaticTerminalReply } from './inputClassification' import { createTerminalInputModeTracker } from './terminalInputModes' import { hasRecentTerminalUserInteraction } from './userInteractionWindow' +import { createTerminalClipboardHandler } from './terminalClipboard' export interface RuntimeTerminalInputBridge { ptyWriteQueue: ReturnType @@ -31,6 +32,8 @@ function formatInputHeadHex(value: string, limit = 12): string { export function createRuntimeTerminalInputBridge({ terminal, + terminalProvider = null, + getTerminalProvider, sessionId, openTerminalFind, onCommandRunRef, @@ -45,6 +48,8 @@ export function createRuntimeTerminalInputBridge({ terminalDiagnostics, }: { terminal: Terminal + terminalProvider?: string | null + getTerminalProvider?: () => string | null sessionId: string openTerminalFind: () => void onCommandRunRef: { current: ((command: string, startedAtMs: number) => void) | undefined } @@ -215,15 +220,25 @@ export function createRuntimeTerminalInputBridge({ forwardAcceptedUtf8UserInput(data) } + const handleClipboard = createTerminalClipboardHandler({ + provider: terminalProvider, + getProvider: getTerminalProvider, + platform: window.opencoveApi?.meta?.platform ?? navigator.platform, + write: forwardUtf8UserInput, + isBracketedPasteMode: inputModeTracker.isBracketedPasteMode, + isDisposed: () => isDisposed, + }) terminal.attachCustomKeyEventHandler(event => - handleTerminalCustomKeyEvent({ - event, - ptyWriteQueue, - terminal, - isBracketedPasteMode: inputModeTracker.isBracketedPasteMode, - writePastePayload: forwardUtf8UserInput, - onOpenFind: openTerminalFind, - }), + handleClipboard(event) + ? false + : handleTerminalCustomKeyEvent({ + event, + ptyWriteQueue, + terminal, + isBracketedPasteMode: inputModeTracker.isBracketedPasteMode, + writePastePayload: forwardUtf8UserInput, + onOpenFind: openTerminalFind, + }), ) const dataDisposable = terminal.onData(data => { diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/terminalClipboard.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/terminalClipboard.ts new file mode 100644 index 000000000..efd8970cc --- /dev/null +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/terminalClipboard.ts @@ -0,0 +1,101 @@ +import type { TerminalClipboardSnapshot } from '@shared/contracts/dto' +import { + isLinuxTerminalPasteShortcut, + isMacTerminalPasteShortcut, + isWindowsTerminalPasteShortcut, + pasteTextFromClipboard, + readTextFromClipboard, +} from './inputBridge' + +export function resolveTerminalImagePasteSequence( + provider: string | null, + platform: string, +): string { + // Pi changed its Windows default to Alt+V; Kimi still binds Ctrl+V. + return platform === 'win32' && (provider === 'pi' || provider === 'claude-code') + ? '\u001bv' + : '\u0016' +} + +export function createTerminalClipboardHandler(options: { + provider: string | null + getProvider?: () => string | null + platform: string + readClipboard?: () => Promise + write: (data: string) => void + isBracketedPasteMode: () => boolean + isDisposed: () => boolean +}): (event: KeyboardEvent) => boolean { + let pending = Promise.resolve() + const platformInfo = { platform: options.platform === 'darwin' ? 'MacIntel' : options.platform } + const readClipboard = + options.readClipboard ?? + (async () => { + const clipboard = window.opencoveApi?.clipboard + return typeof clipboard?.readTerminalPaste === 'function' + ? await clipboard.readTerminalPaste() + : { text: await readTextFromClipboard(), hasImage: false } + }) + + return event => { + if (event.type !== 'keydown') { + return false + } + const provider = options.getProvider ? options.getProvider() : options.provider + const isNativeAlias = + options.platform === 'win32' && + provider !== null && + ['pi', 'kimi', 'codex', 'claude-code'].includes(provider) && + event.key.toLowerCase() === 'v' && + event.altKey && + !event.ctrlKey && + !event.metaKey && + !event.shiftKey + if ( + !isNativeAlias && + !isWindowsTerminalPasteShortcut(event, platformInfo) && + !isMacTerminalPasteShortcut(event, platformInfo) && + !isLinuxTerminalPasteShortcut(event, platformInfo) + ) { + return false + } + + event.preventDefault() + event.stopPropagation() + if (isNativeAlias) { + if (!options.isDisposed()) { + options.write(resolveTerminalImagePasteSequence(provider, options.platform)) + } + return true + } + // Start reading at the gesture; serialize only delivery so repeated pastes keep order. + const snapshot = Promise.resolve() + .then(readClipboard) + .catch(() => null) + pending = pending + .then(async () => { + const clipboard = await snapshot + if (!clipboard || options.isDisposed()) { + return + } + if (clipboard.hasImage) { + if (options.getProvider && options.getProvider() !== provider) { + return + } + options.write(resolveTerminalImagePasteSequence(provider, options.platform)) + return + } + await pasteTextFromClipboard({ + readClipboardText: () => clipboard.text, + writePastePayload: data => { + if (!options.isDisposed()) { + options.write(data) + } + }, + isBracketedPasteMode: options.isBracketedPasteMode, + }) + }) + .catch(() => undefined) + return true + } +} diff --git a/src/contexts/workspace/presentation/renderer/components/terminalNode/useTerminalRuntimeSession.ts b/src/contexts/workspace/presentation/renderer/components/terminalNode/useTerminalRuntimeSession.ts index 09681e7c0..a90326b06 100644 --- a/src/contexts/workspace/presentation/renderer/components/terminalNode/useTerminalRuntimeSession.ts +++ b/src/contexts/workspace/presentation/renderer/components/terminalNode/useTerminalRuntimeSession.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { getPtyEventHub } from '@app/renderer/shell/utils/ptyEventHub' import { createRollingTextBuffer } from '../../utils/rollingTextBuffer' import { createRuntimeTerminalInputBridge } from './createRuntimeTerminalInputBridge' @@ -94,6 +94,8 @@ export function useTerminalRuntimeSession({ terminalClientResetVersion, requestTerminalRendererRecovery, }: TerminalRuntimeSessionOptions): void { + const terminalProviderRef = useRef(terminalProvider) + terminalProviderRef.current = terminalProvider useEffect(() => { if (sessionId.trim().length === 0 || !containerRef.current) { return undefined @@ -190,6 +192,8 @@ export function useTerminalRuntimeSession({ }) const runtimeInputBridge = createRuntimeTerminalInputBridge({ terminal, + terminalProvider, + getTerminalProvider: () => terminalProviderRef.current, sessionId, openTerminalFind, onCommandRunRef, diff --git a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodesStore.terminalProviderHint.ts b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodesStore.terminalProviderHint.ts index f7784607b..c1e40bf90 100644 --- a/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodesStore.terminalProviderHint.ts +++ b/src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodesStore.terminalProviderHint.ts @@ -27,5 +27,9 @@ export function resolveTerminalProviderHintFromCommand(command: string): AgentPr return 'gemini' } + if (executableName === 'pi' || executableName === 'kimi') { + return executableName + } + return null } diff --git a/src/contexts/workspace/presentation/renderer/utils/persistence/ensure.ts b/src/contexts/workspace/presentation/renderer/utils/persistence/ensure.ts index 27f026cd4..b6bf310e0 100644 --- a/src/contexts/workspace/presentation/renderer/utils/persistence/ensure.ts +++ b/src/contexts/workspace/presentation/renderer/utils/persistence/ensure.ts @@ -383,7 +383,9 @@ function ensurePersistedNode(node: unknown): PersistedTerminalNode | null { record.terminalProviderHint === 'claude-code' || record.terminalProviderHint === 'codex' || record.terminalProviderHint === 'opencode' || - record.terminalProviderHint === 'gemini' + record.terminalProviderHint === 'gemini' || + record.terminalProviderHint === 'pi' || + record.terminalProviderHint === 'kimi' ? record.terminalProviderHint : kind === 'terminal' ? (terminalAgentBindingCandidate?.provider ?? null) diff --git a/src/shared/contracts/dto/clipboard.ts b/src/shared/contracts/dto/clipboard.ts index 6dc7ac616..96f2be1ec 100644 --- a/src/shared/contracts/dto/clipboard.ts +++ b/src/shared/contracts/dto/clipboard.ts @@ -1,3 +1,8 @@ +export interface TerminalClipboardSnapshot { + text: string + hasImage: boolean +} + export interface WriteClipboardTextInput { text: string } diff --git a/src/shared/contracts/ipc/channels.ts b/src/shared/contracts/ipc/channels.ts index d6251c543..db98d8a9e 100644 --- a/src/shared/contracts/ipc/channels.ts +++ b/src/shared/contracts/ipc/channels.ts @@ -5,6 +5,7 @@ export const IPC_CHANNELS = { cliInstall: 'cli:install', cliUninstall: 'cli:uninstall', clipboardReadText: 'clipboard:read-text', + clipboardReadTerminalPaste: 'clipboard:read-terminal-paste', clipboardWriteText: 'clipboard:write-text', filesystemCreateDirectory: 'filesystem:create-directory', filesystemReadFileBytes: 'filesystem:read-file-bytes', diff --git a/tests/contract/ipc/clipboardTerminalPaste.spec.ts b/tests/contract/ipc/clipboardTerminalPaste.spec.ts new file mode 100644 index 000000000..616540ab4 --- /dev/null +++ b/tests/contract/ipc/clipboardTerminalPaste.spec.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { handlers, clipboard, removeHandler } = vi.hoisted(() => ({ + handlers: new Map unknown>(), + clipboard: { readText: vi.fn(), readImage: vi.fn(), writeText: vi.fn() }, + removeHandler: vi.fn(), +})) + +vi.mock('electron', () => ({ clipboard, ipcMain: { removeHandler } })) +vi.mock('../../../src/app/main/ipc/handle', () => ({ + registerHandledIpc: (channel: string, handler: () => unknown) => handlers.set(channel, handler), +})) + +import { registerClipboardIpcHandlers } from '../../../src/contexts/clipboard/presentation/main-ipc/register' + +describe('terminal clipboard snapshot', () => { + beforeEach(() => { + handlers.clear() + vi.clearAllMocks() + }) + + it.each([true, false])('reports native image presence %s alongside text', async hasImage => { + clipboard.readText.mockReturnValue('clipboard text') + clipboard.readImage.mockReturnValue({ isEmpty: () => !hasImage }) + const registration = registerClipboardIpcHandlers() + const handler = handlers.get('clipboard:read-terminal-paste') + expect(handler).toBeDefined() + expect(await handler?.()).toEqual({ text: 'clipboard text', hasImage }) + registration.dispose() + expect(removeHandler).toHaveBeenCalledWith('clipboard:read-terminal-paste') + }) +}) diff --git a/tests/e2e/workspace-canvas.terminal-image-paste.mac.spec.ts b/tests/e2e/workspace-canvas.terminal-image-paste.mac.spec.ts new file mode 100644 index 000000000..16cfdf4ee --- /dev/null +++ b/tests/e2e/workspace-canvas.terminal-image-paste.mac.spec.ts @@ -0,0 +1,52 @@ +import { expect, test } from '@playwright/test' +import { clearAndSeedWorkspace, launchApp } from './workspace-canvas.helpers' +import { buildNodeEvalCommand } from './workspace-canvas.testUtils' + +const png = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP8z8DAwMDAxMDAwMDAAAANHQEDasKb6QAAAABJRU5ErkJggg==' + +test.describe('Terminal image paste (macOS)', () => { + test.skip(process.platform !== 'darwin', 'macOS keyboard integration') + test('maps Cmd+V images to the native TUI binding and preserves text paste', async () => { + const { electronApp, window } = await launchApp() + try { + await clearAndSeedWorkspace(window, [ + { + id: 'paste-image', + title: 'Image paste', + position: { x: 120, y: 120 }, + width: 640, + height: 360, + }, + ]) + const terminal = window.locator('.terminal-node').first() + await terminal.locator('.xterm').click() + await window.keyboard.type( + buildNodeEvalCommand(` + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', chunk => { + process.stdout.write('INPUT_HEX:' + chunk.toString('hex') + '\\r\\n'); + if (chunk.includes(3)) process.exit(); + }); + process.stdout.write('\\x1b[?2004hPASTE_READY\\r\\n'); + `), + ) + await window.keyboard.press('Enter') + await expect(terminal).toContainText('PASTE_READY') + await electronApp.evaluate(({ clipboard, nativeImage }, dataUrl) => { + clipboard.clear() + clipboard.writeImage(nativeImage.createFromDataURL(dataUrl)) + }, png) + await window.keyboard.press('Meta+V') + await expect(terminal).toContainText('INPUT_HEX:16') + await expect(window.locator('.image-node')).toHaveCount(0) + await electronApp.evaluate(({ clipboard }) => clipboard.writeText('PASTE_TEXT')) + await window.keyboard.press('Meta+V') + await expect(terminal).toContainText('INPUT_HEX:1b5b3230307e50415354455f544558541b5b3230317e') + await window.keyboard.press('Control+C') + } finally { + await electronApp.close() + } + }) +}) diff --git a/tests/e2e/workspace-canvas.terminal-image-paste.windows.spec.ts b/tests/e2e/workspace-canvas.terminal-image-paste.windows.spec.ts new file mode 100644 index 000000000..9f224dfea --- /dev/null +++ b/tests/e2e/workspace-canvas.terminal-image-paste.windows.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test' +import { clearAndSeedWorkspace, launchApp } from './workspace-canvas.helpers' +import { buildNodeEvalCommand } from './workspace-canvas.testUtils' + +const png = + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAFklEQVR4nGP8z8DAwMDAxMDAwMDAAAANHQEDasKb6QAAAABJRU5ErkJggg==' + +test.describe('Terminal image paste (Windows)', () => { + test.skip(process.platform !== 'win32', 'Windows keyboard integration') + for (const provider of ['pi', 'kimi', 'codex', 'claude-code'] as const) { + test(`${provider} receives its native image binding for Alt+V and Ctrl+V`, async () => { + const { electronApp, window } = await launchApp() + try { + const node = { + id: 'paste-image', + title: 'Image paste', + position: { x: 120, y: 120 }, + width: 640, + height: 360, + terminalProviderHint: provider, + } + await clearAndSeedWorkspace(window, [node]) + const terminal = window.locator('.terminal-node').first() + await terminal.locator('.xterm').click() + await window.keyboard.type( + buildNodeEvalCommand(` + process.stdin.setRawMode(true); + process.stdin.resume(); + process.stdin.on('data', chunk => { + process.stdout.write('INPUT_HEX:' + chunk.toString('hex') + '\\r\\n'); + if (chunk.includes(3)) process.exit(); + }); + process.stdout.write('\\x1b[?2004hPASTE_READY\\r\\n'); + `), + ) + await window.keyboard.press('Enter') + await expect(terminal).toContainText('PASTE_READY') + await electronApp.evaluate(({ clipboard, nativeImage }, dataUrl) => { + clipboard.clear() + clipboard.writeImage(nativeImage.createFromDataURL(dataUrl)) + }, png) + const sequence = provider === 'pi' || provider === 'claude-code' ? '1b76' : '16' + await window.keyboard.press('Alt+V') + await expect(terminal).toContainText(`INPUT_HEX:${sequence}`) + await window.keyboard.press('Control+V') + await expect + .poll(async () => { + const text = await terminal.locator('.terminal-node__transcript').textContent() + return text?.split(`INPUT_HEX:${sequence}`).length + }) + .toBe(3) + await electronApp.evaluate(({ clipboard }) => clipboard.writeText('PASTE_TEXT')) + await window.keyboard.press('Control+V') + await expect(terminal).toContainText( + 'INPUT_HEX:1b5b3230307e50415354455f544558541b5b3230317e', + ) + await window.keyboard.press('Control+C') + } finally { + await electronApp.close() + } + }) + } +}) diff --git a/tests/unit/contexts/terminalClipboard.spec.ts b/tests/unit/contexts/terminalClipboard.spec.ts new file mode 100644 index 000000000..0b80d6da7 --- /dev/null +++ b/tests/unit/contexts/terminalClipboard.spec.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from 'vitest' +import { + createTerminalClipboardHandler, + resolveTerminalImagePasteSequence, +} from '../../../src/contexts/workspace/presentation/renderer/components/terminalNode/terminalClipboard' + +describe('terminal clipboard routing', () => { + it('reads provider changes without replacing the terminal clipboard handler', () => { + let provider: string | null = null + const write = vi.fn() + const readClipboard = vi.fn() + const handle = createTerminalClipboardHandler({ + provider: null, + getProvider: () => provider, + platform: 'win32', + write, + readClipboard, + isDisposed: () => false, + isBracketedPasteMode: () => false, + }) + const press = () => handle(new KeyboardEvent('keydown', { key: 'v', altKey: true })) + expect(press()).toBe(false) + provider = 'pi' + expect(press()).toBe(true) + expect(write).toHaveBeenLastCalledWith('\u001bv') + provider = 'kimi' + expect(press()).toBe(true) + expect(write).toHaveBeenLastCalledWith('\u0016') + provider = null + expect(press()).toBe(false) + expect(write).toHaveBeenCalledTimes(2) + expect(readClipboard).not.toHaveBeenCalled() + }) + it('drops an image read that completes after disposal', async () => { + let complete!: (value: { text: string; hasImage: boolean }) => void + const read = new Promise<{ text: string; hasImage: boolean }>(resolve => { + complete = resolve + }) + let disposed = false + const write = vi.fn() + const handle = createTerminalClipboardHandler({ + provider: 'pi', + platform: 'darwin', + write, + readClipboard: () => read, + isDisposed: () => disposed, + isBracketedPasteMode: () => false, + }) + handle(new KeyboardEvent('keydown', { key: 'v', metaKey: true })) + disposed = true + complete({ text: '', hasImage: true }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(write).not.toHaveBeenCalled() + }) + + it.each([ + ['pi', '\u001bv'], + ['kimi', '\u0016'], + ['codex', '\u0016'], + ['claude-code', '\u001bv'], + ])('routes Windows Alt+V images for %s once', async (provider, expected) => { + const write = vi.fn() + const handle = createTerminalClipboardHandler({ + provider: provider!, + platform: 'win32', + write, + readClipboard: async () => ({ text: '', hasImage: true }), + isDisposed: () => false, + isBracketedPasteMode: () => true, + }) + expect(handle(new KeyboardEvent('keydown', { key: 'v', altKey: true }))).toBe(true) + await vi.waitFor(() => expect(write).toHaveBeenCalledExactlyOnceWith(expected)) + }) + it.each([ + ['pi', 'win32', '\u001bv'], + ['pi', 'darwin', '\u0016'], + ['kimi', 'win32', '\u0016'], + ['codex', 'darwin', '\u0016'], + ['claude-code', 'win32', '\u001bv'], + ['claude-code', 'darwin', '\u0016'], + [null, 'darwin', '\u0016'], + ])('maps %s on %s to its native image binding', (provider, platform, sequence) => { + expect(resolveTerminalImagePasteSequence(provider, platform!)).toBe(sequence) + }) + + it.each(['pi', 'kimi', 'codex', 'claude-code', null])( + 'routes macOS images for %s without text or bracket wrappers', + async provider => { + const write = vi.fn() + const handle = createTerminalClipboardHandler({ + provider, + platform: 'darwin', + write, + readClipboard: async () => ({ text: 'image label', hasImage: true }), + isDisposed: () => false, + isBracketedPasteMode: () => true, + }) + expect(handle(new KeyboardEvent('keydown', { key: 'v', metaKey: true }))).toBe(true) + await vi.waitFor(() => expect(write).toHaveBeenCalledExactlyOnceWith('\u0016')) + }, + ) + + it('preserves shell Alt+V', () => { + const readClipboard = vi.fn() + const handle = createTerminalClipboardHandler({ + provider: null, + platform: 'win32', + readClipboard, + write: vi.fn(), + isDisposed: () => false, + isBracketedPasteMode: () => false, + }) + expect(handle(new KeyboardEvent('keydown', { key: 'v', altKey: true }))).toBe(false) + expect(readClipboard).not.toHaveBeenCalled() + }) + + it('preserves text normalization and bracketed paste', async () => { + const write = vi.fn() + const handle = createTerminalClipboardHandler({ + provider: 'kimi', + platform: 'win32', + write, + readClipboard: async () => ({ text: 'one\ntwo', hasImage: false }), + isDisposed: () => false, + isBracketedPasteMode: () => true, + }) + handle(new KeyboardEvent('keydown', { key: 'v', ctrlKey: true })) + await vi.waitFor(() => + expect(write).toHaveBeenCalledExactlyOnceWith('\u001b[200~one\rtwo\u001b[201~'), + ) + }) +}) diff --git a/tests/unit/contexts/terminalProviderPasteHint.spec.ts b/tests/unit/contexts/terminalProviderPasteHint.spec.ts new file mode 100644 index 000000000..446c3e53b --- /dev/null +++ b/tests/unit/contexts/terminalProviderPasteHint.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { resolveTerminalProviderHintFromCommand } from '../../../src/contexts/workspace/presentation/renderer/components/workspaceCanvas/hooks/useNodesStore.terminalProviderHint' +import { ensurePersistedWorkspace } from '../../../src/contexts/workspace/presentation/renderer/utils/persistence/ensure' + +describe('terminal provider paste identity', () => { + it.each(['pi', 'kimi'])('recognizes and restores %s provider identity', provider => { + expect(resolveTerminalProviderHintFromCommand(`${provider} --model example`)).toBe(provider) + expect(resolveTerminalProviderHintFromCommand(`C:\\bin\\${provider}.cmd`)).toBe(provider) + const workspace = ensurePersistedWorkspace({ + id: 'workspace', + name: 'Workspace', + path: '/workspace', + nodes: [ + { + id: 'terminal', + title: 'Terminal', + kind: 'terminal', + width: 640, + height: 360, + position: { x: 0, y: 0 }, + terminalProviderHint: provider, + }, + ], + }) + expect(workspace?.nodes[0]?.terminalProviderHint).toBe(provider) + }) +}) From faff57c6e34f08b30c6e73388dbd1faa00f53678 Mon Sep 17 00:00:00 2001 From: DeadWaveWave Date: Thu, 10 Sep 2026 03:27:33 +0800 Subject: [PATCH 2/2] docs: note terminal image paste fix in changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c7c6fc264..aba481057 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### 🐛 Fixed +- Terminal: route local desktop image paste to native Pi, Kimi, Codex, Claude Code, and OMP keybindings while preserving text paste. (#409) - Workspace: keep Space archive and other operation overlays local to their target Space, preventing unrelated Space backgrounds from tinting window nodes. (#401) - Canvas: center windows created by double-click or the context menu within 120 pixels of the viewport center, avoiding small unnecessary pans while preserving Space ownership and pointer placement farther away. (#399) - Agent: launch remote Project agents in the default remote mount instead of the local project metadata directory, while preserving explicit Space and worktree directories. (#398)