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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
- Terminal: execute quick commands after session attachment, preserving one-time execution and cancelling pending input when its window is removed. (#408)
- 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)
Expand Down
34 changes: 34 additions & 0 deletions docs/terminal/CLIPBOARD_INPUT.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/app/preload/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export interface OpenCoveApi {
getDisplayInfo: () => Promise<WindowDisplayInfo>
}
clipboard: {
readTerminalPaste: () => Promise<import('../../shared/contracts/dto').TerminalClipboardSnapshot>
readText: () => Promise<string>
writeText: (text: string) => Promise<void>
}
Expand Down
1 change: 1 addition & 0 deletions src/app/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ const opencoveApi = {
invokeIpc(IPC_CHANNELS.windowMetricsGetDisplayInfo),
},
clipboard: {
readTerminalPaste: () => invokeIpc(IPC_CHANNELS.clipboardReadTerminalPaste),
readText: (): Promise<string> => invokeIpc(IPC_CHANNELS.clipboardReadText),
writeText: (text: string): Promise<void> =>
invokeIpc(IPC_CHANNELS.clipboardWriteText, { text }),
Expand Down
4 changes: 4 additions & 0 deletions src/app/renderer/browser/browserOpenCoveApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
14 changes: 13 additions & 1 deletion src/contexts/clipboard/presentation/main-ipc/register.ts
Original file line number Diff line number Diff line change
@@ -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<TerminalClipboardSnapshot> => ({
text: clipboard.readText(),
hasImage: !clipboard.readImage().isEmpty(),
}),
{ defaultErrorCode: 'common.unexpected' },
)
registerHandledIpc(
IPC_CHANNELS.clipboardReadText,
async (): Promise<string> => clipboard.readText(),
Expand All @@ -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)
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof createPtyWriteQueue>
Expand All @@ -31,6 +32,8 @@ function formatInputHeadHex(value: string, limit = 12): string {

export function createRuntimeTerminalInputBridge({
terminal,
terminalProvider = null,
getTerminalProvider,
sessionId,
openTerminalFind,
onCommandRunRef,
Expand All @@ -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 }
Expand Down Expand Up @@ -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 => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<TerminalClipboardSnapshot>
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
}
}
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -190,6 +192,8 @@ export function useTerminalRuntimeSession({
})
const runtimeInputBridge = createRuntimeTerminalInputBridge({
terminal,
terminalProvider,
getTerminalProvider: () => terminalProviderRef.current,
sessionId,
openTerminalFind,
onCommandRunRef,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,5 +27,9 @@ export function resolveTerminalProviderHintFromCommand(command: string): AgentPr
return 'gemini'
}

if (executableName === 'pi' || executableName === 'kimi') {
return executableName
}

return null
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions src/shared/contracts/dto/clipboard.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export interface TerminalClipboardSnapshot {
text: string
hasImage: boolean
}

export interface WriteClipboardTextInput {
text: string
}
1 change: 1 addition & 0 deletions src/shared/contracts/ipc/channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
32 changes: 32 additions & 0 deletions tests/contract/ipc/clipboardTerminalPaste.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { handlers, clipboard, removeHandler } = vi.hoisted(() => ({
handlers: new Map<string, () => 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')
})
})
Loading