diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx index e60c27f0381..059ed2b1449 100644 --- a/mobile/src/session/MobileSessionActiveContent.tsx +++ b/mobile/src/session/MobileSessionActiveContent.tsx @@ -70,6 +70,7 @@ export function MobileSessionActiveContent({ handleSelectionMode, handleSelectionCopy, handleSelectionEvicted, + handleClipboardWrite, handleModesChanged, handleKeyboardAvoidanceMetrics, handleHaptic, @@ -223,6 +224,7 @@ export function MobileSessionActiveContent({ onSelectionMode={handleSelectionMode} onSelectionCopy={handleSelectionCopy} onSelectionEvicted={handleSelectionEvicted} + onClipboardWrite={handleClipboardWrite} onModesChanged={handleModesChanged} onKeyboardAvoidanceMetrics={handleKeyboardAvoidanceMetrics} onHaptic={handleHaptic} diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index 16a03fd61b3..eae4e4f9f20 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -20,6 +20,7 @@ type TerminalPaneViewProps = { onSelectionMode: (handle: string, active: boolean) => void onSelectionCopy: (handle: string, text: string) => void onSelectionEvicted: (handle: string) => void + onClipboardWrite: (handle: string, text: string) => void onModesChanged: (handle: string, modes: TerminalModes) => void onKeyboardAvoidanceMetrics: (handle: string, metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void @@ -43,6 +44,7 @@ export function TerminalPaneView({ onSelectionMode, onSelectionCopy, onSelectionEvicted, + onClipboardWrite, onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, @@ -82,6 +84,7 @@ export function TerminalPaneView({ onSelectionMode={(a) => onSelectionMode(handle, a)} onSelectionCopy={(t) => onSelectionCopy(handle, t)} onSelectionEvicted={() => onSelectionEvicted(handle)} + onClipboardWrite={(t) => onClipboardWrite(handle, t)} onModesChanged={(m) => onModesChanged(handle, m)} onKeyboardAvoidanceMetrics={(m) => onKeyboardAvoidanceMetrics(handle, m)} onHaptic={onHaptic} diff --git a/mobile/src/session/use-mobile-session-accessory-selection.ts b/mobile/src/session/use-mobile-session-accessory-selection.ts index a9795d82b1f..3ef450d19dd 100644 --- a/mobile/src/session/use-mobile-session-accessory-selection.ts +++ b/mobile/src/session/use-mobile-session-accessory-selection.ts @@ -144,6 +144,20 @@ export function useMobileSessionAccessorySelection(scope: MobileSessionTerminalI [clipboard, showToast] ) + const handleClipboardWrite = useCallback( + async (handle: string, text: string) => { + if (handle !== activeHandleRef.current) { + return + } + try { + await clipboard.writeText(text) + } catch { + showToast("Couldn't copy", 1500) + } + }, + [activeHandleRef, clipboard, showToast] + ) + const handleSelectionEvicted = useCallback( (handle: string) => { if (handle !== activeHandleRef.current) { @@ -214,6 +228,7 @@ export function useMobileSessionAccessorySelection(scope: MobileSessionTerminalI handleSelectionMode, handleSelectionCopy, handleSelectionEvicted, + handleClipboardWrite, handleModesChanged, handleKeyboardAvoidanceMetrics, handleHaptic, diff --git a/mobile/src/terminal/document/document-terminal-shape.ts b/mobile/src/terminal/document/document-terminal-shape.ts index d155ee59ff4..dca85d11b40 100644 --- a/mobile/src/terminal/document/document-terminal-shape.ts +++ b/mobile/src/terminal/document/document-terminal-shape.ts @@ -91,6 +91,12 @@ export type TerminalDocumentTerminal = { readonly unicode: { activeVersion: string } attachCustomKeyEventHandler: (handler: () => boolean) => void onData: (listener: (data: string) => void) => TerminalDocumentDisposable + readonly parser?: { + registerOscHandler: ( + ident: number, + callback: (data: string) => boolean + ) => TerminalDocumentDisposable + } readonly textarea?: { readOnly: boolean tabIndex: number diff --git a/mobile/src/terminal/document/native-document-bundle.test.ts b/mobile/src/terminal/document/native-document-bundle.test.ts index 0b253dce9b2..8fe4cedf00f 100644 --- a/mobile/src/terminal/document/native-document-bundle.test.ts +++ b/mobile/src/terminal/document/native-document-bundle.test.ts @@ -316,7 +316,8 @@ describe('the bundled native document', () => { // both shared with the app. 52: `cell-metrics` and `selection-mode` split out to break cycles. // 53: the bottom row backdrop extends a uniform row into the fractional gap. // 54: `src/shared/prose-url-boundary`, so a tapped url ends where desktop's link does. - expect(inputs).toHaveLength(54) + // 56: OSC 52 copies reach the device through desktop's own parser, `src/shared/osc52-clipboard`. + expect(inputs).toHaveLength(56) expect(script).not.toContain('__commonJS') // `__esm` wrappers are esbuild's answer to a cycle, and a cycle would make a module's top level // run at first import rather than where the bundle places it. diff --git a/mobile/src/terminal/document/osc52-clipboard-bridge.test.ts b/mobile/src/terminal/document/osc52-clipboard-bridge.test.ts new file mode 100644 index 00000000000..178cb0e9b38 --- /dev/null +++ b/mobile/src/terminal/document/osc52-clipboard-bridge.test.ts @@ -0,0 +1,46 @@ +// @vitest-environment happy-dom +import { Terminal } from '@xterm/xterm' +import { afterEach, describe, expect, it } from 'vitest' +import { createTerminalDocumentScope, type TerminalDocumentScope } from './document-scope' +import { attachOsc52ClipboardBridge } from './osc52-clipboard-bridge' +import { resetTerminalDataReplyAuthority, resumeTerminalDataReplyAuthority } from './query-reply' + +const terminals: Terminal[] = [] + +afterEach(() => { + terminals.splice(0).forEach((term) => term.dispose()) +}) + +function fixture() { + const posted: unknown[] = [] + const scope = createTerminalDocumentScope({ postToHost: (message) => posted.push(message) }) + const term = new Terminal({ cols: 80, rows: 4, allowProposedApi: true }) + terminals.push(term) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the real xterm exposes the narrower document terminal API. + attachOsc52ClipboardBridge(scope, term as unknown as NonNullable) + const write = async (data: string) => { + await new Promise((resolve) => term.write(data, resolve)) + // The handler coalesces a parse's copies into one post on the next microtask. + await Promise.resolve() + } + return { scope, posted, write } +} + +// What Claude Code 2.1.308 writes when `c` is pressed in its /btw panel. +const BTW_COPY = `\x1b]52;c;${Buffer.from('hello from btw').toString('base64')}\x07` + +describe('OSC 52 in the mobile terminal document', () => { + it('posts a live copy to native as clipboard text', async () => { + const { scope, posted, write } = fixture() + resumeTerminalDataReplyAuthority(scope) + await write(BTW_COPY) + expect(posted).toEqual([{ type: 'clipboard-write', text: 'hello from btw' }]) + }) + + it('drops a copy parsed before the replay boundary', async () => { + const { scope, posted, write } = fixture() + resetTerminalDataReplyAuthority(scope) + await write(BTW_COPY) + expect(posted).toEqual([]) + }) +}) diff --git a/mobile/src/terminal/document/osc52-clipboard-bridge.ts b/mobile/src/terminal/document/osc52-clipboard-bridge.ts new file mode 100644 index 00000000000..0f0d734f37a --- /dev/null +++ b/mobile/src/terminal/document/osc52-clipboard-bridge.ts @@ -0,0 +1,23 @@ +import { createOsc52OscHandler } from '../../../../src/shared/osc52-clipboard' +import { notify } from './host-notify' +import type { TerminalDocumentScope, TerminalDocumentTerminal } from './document-scope' + +/** + * OSC 52 copies (Claude Code's /btw `c`, tmux, Neovim over SSH) land on the device viewing the + * terminal, through the parser and limits desktop uses. + */ +export function attachOsc52ClipboardBridge( + scope: TerminalDocumentScope, + term: TerminalDocumentTerminal +) { + term.parser?.registerOscHandler( + 52, + createOsc52OscHandler({ + // Why: the toggle belongs to the viewing device, and the phone has none yet; desktop's default is on. + getSettingEnabled: () => true, + // Why: parser replies earn authority only past the replay boundary; a copy needs the same fresh intent. + getReplaying: () => !scope.terminalDataRepliesEnabled, + writeClipboardText: async (text) => notify(scope, { type: 'clipboard-write', text }) + }) + ) +} diff --git a/mobile/src/terminal/document/terminal-init.ts b/mobile/src/terminal/document/terminal-init.ts index 64158584c0d..7f91ef63ebf 100644 --- a/mobile/src/terminal/document/terminal-init.ts +++ b/mobile/src/terminal/document/terminal-init.ts @@ -16,6 +16,7 @@ import { updateMouseModeFromData } from './mouse-mode-decset-scan' import { captureInitialOscLinkTexts } from './osc-link-tap' +import { attachOsc52ClipboardBridge } from './osc52-clipboard-bridge' import { attachTerminalQueryReplyBridge, resetTerminalDataReplyAuthority } from './query-reply' import { cancelSelect } from './selection-mode' import { resetEvictionCounter } from './selection-state-and-eviction' @@ -50,6 +51,7 @@ function openTerminal(scope: TerminalDocumentScope, cols: number, rows: number) allowProposedApi: true }) scope.term = term + attachOsc52ClipboardBridge(scope, term) term.open(scope.surface!) attachWebglAddon(scope, true) try { diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index 2d0e9600ca0..fac3ba74150 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -72,6 +72,8 @@ export type TerminalSelectionEvents = { onSelectionMode?: (active: boolean) => void onSelectionCopy?: (text: string) => void onSelectionEvicted?: () => void + // A program in the terminal copied text through OSC 52. + onClipboardWrite?: (text: string) => void onModesChanged?: (modes: TerminalModes) => void onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void diff --git a/mobile/src/terminal/terminal-webview-notification-dispatch.ts b/mobile/src/terminal/terminal-webview-notification-dispatch.ts index aed0d216f10..fb2bf780c98 100644 --- a/mobile/src/terminal/terminal-webview-notification-dispatch.ts +++ b/mobile/src/terminal/terminal-webview-notification-dispatch.ts @@ -29,6 +29,10 @@ export function dispatchTerminalWebViewNotification( } else if (msg.type === 'selection') { const text = typeof msg.text === 'string' ? msg.text : '' handlers.onSelectionCopy?.(text) + } else if (msg.type === 'clipboard-write') { + if (typeof msg.text === 'string') { + handlers.onClipboardWrite?.(msg.text) + } } else if (msg.type === 'selection-evicted') { handlers.onSelectionEvicted?.() } else if (msg.type === 'modes') { diff --git a/mobile/src/terminal/use-terminal-webview-controller.ts b/mobile/src/terminal/use-terminal-webview-controller.ts index e8ef4cc5700..01887c9cddf 100644 --- a/mobile/src/terminal/use-terminal-webview-controller.ts +++ b/mobile/src/terminal/use-terminal-webview-controller.ts @@ -59,6 +59,7 @@ export function useTerminalWebViewController( onSelectionMode, onSelectionCopy, onSelectionEvicted, + onClipboardWrite, onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, @@ -189,6 +190,7 @@ export function useTerminalWebViewController( onSelectionMode, onSelectionCopy, onSelectionEvicted, + onClipboardWrite, onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, @@ -207,6 +209,7 @@ export function useTerminalWebViewController( onSelectionMode, onSelectionCopy, onSelectionEvicted, + onClipboardWrite, onModesChanged, onKeyboardAvoidanceMetrics, onHaptic, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts index 219fe2b6c3e..615b09678e6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-pane-created.ts @@ -2,7 +2,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import type { PaneSpawnHints } from '@/lib/pane-manager/pane-manager-types' import { useAppStore } from '@/store' import { makePaneKey } from '../../../../shared/stable-pane-id' -import { createOsc52OscHandler } from './osc52-clipboard' +import { createOsc52OscHandler } from '../../../../shared/osc52-clipboard' import { showOsc52ClipboardBlockedToast, showOsc52ClipboardFailedToast diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts b/src/shared/osc52-clipboard.test.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts rename to src/shared/osc52-clipboard.test.ts diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard.ts b/src/shared/osc52-clipboard.ts similarity index 96% rename from src/renderer/src/components/terminal-pane/osc52-clipboard.ts rename to src/shared/osc52-clipboard.ts index 4bf6d782a3f..22cb8ad49d3 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard.ts +++ b/src/shared/osc52-clipboard.ts @@ -1,6 +1,6 @@ // OSC 52 — "Manipulate Selection Data". xterm.js does not implement this // handler itself; applications register it to let TUIs (Zellij, tmux, Neovim, -// fzf, Grok) copy to the host clipboard over SSH or through the PTY. +// fzf, Grok) copy to the viewing device's clipboard over SSH or through the PTY. // // Wire format (xterm.js strips the leading `\x1b]52;` and trailing BEL/ST // before handing us the payload string): @@ -19,8 +19,9 @@ // // Safety: OSC 52 is a classic clipboard-overwrite vector — piping an // attacker-controlled log into the terminal could silently replace the user's -// clipboard. Callers gate on `terminalAllowOsc52Clipboard` (default on; query -// stays blocked, so nothing is exfiltrated; payload size is capped). +// clipboard. Desktop gates on `terminalAllowOsc52Clipboard`; mobile has no toggle +// yet and follows its default (on). Query stays blocked, so nothing is +// exfiltrated; payload size is capped. // // Accepted residual risk of default-on: the decoded text is written verbatim, // newlines and all, so a hostile PTY can stage an execute-on-paste payload. @@ -88,7 +89,7 @@ export function createOsc52OscHandler(deps: { getSettingEnabled: () => boolean | null | undefined getReplaying: () => boolean writeClipboardText: (text: string) => Promise - showBlockedWriteToast: () => void + showBlockedWriteToast?: () => void showWriteFailedToast?: () => void }): (data: string) => boolean { // Why coalesce: each sequence is only ~15 bytes, so one hostile chunk can fire a