From e3f44dc2f99748dbc2190fb408c6058ba86cf006 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:29:02 -0700 Subject: [PATCH] fix: verify terminal clipboard writes so success never lies (#5611) Terminal copy paths (Ctrl+Shift+C, right-click, context-menu copy, copy-on-select, and OSC 52) wrote to the clipboard via window.api.ui.writeClipboardText but swallowed write failures and never checked that the OS clipboard actually changed. The main-process clipboard:writeText handler called clipboard.writeText() and returned without reading back, so a stale OS clipboard (Electron returns without throwing even when the write did not land, seen on Windows with Copilot conversation text) looked like success while the clipboard was unchanged. - clipboard-ipc-handlers.ts: after the existing trusted-sender auth and text-size-limit checks, write then read back the standard clipboard and throw on mismatch. The X11 PRIMARY "selection" clipboard is deliberately not verified, since ownership can pass to another app and a read-back would surface false failures on Linux primary-selection copies. - terminal-selection-copy.ts: shared helper so every renderer copy path propagates a write rejection instead of silently claiming success, and only clears the xterm selection after the write resolves. - osc52-clipboard.ts: add onWriteFailure so a TUI yank that never reaches the host clipboard surfaces a toast instead of a silent no-op. - osc52-clipboard-blocked-toast.ts: add showOsc52ClipboardFailedToast. Supersedes community PR #5634. Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com> --- .../window/clipboard-ipc-handlers.test.ts | 43 ++++++++++++++- src/main/window/clipboard-ipc-handlers.ts | 26 ++++++++- .../terminal-pane/keyboard-handlers.ts | 9 ++- .../osc52-clipboard-blocked-toast.test.ts | 34 +++++++++++- .../osc52-clipboard-blocked-toast.ts | 22 ++++++++ .../terminal-pane/osc52-clipboard.test.ts | 14 +++++ .../terminal-pane/osc52-clipboard.ts | 5 +- .../terminal-selection-copy.test.ts | 55 +++++++++++++++++++ .../terminal-pane/terminal-selection-copy.ts | 29 ++++++++++ .../use-terminal-pane-context-menu.ts | 21 ++++--- .../use-terminal-pane-lifecycle.ts | 18 +++--- src/renderer/src/i18n/locales/en.json | 6 ++ src/renderer/src/i18n/locales/es.json | 6 ++ src/renderer/src/i18n/locales/ja.json | 6 ++ src/renderer/src/i18n/locales/ko.json | 6 ++ src/renderer/src/i18n/locales/zh.json | 6 ++ 16 files changed, 282 insertions(+), 24 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-selection-copy.ts diff --git a/src/main/window/clipboard-ipc-handlers.test.ts b/src/main/window/clipboard-ipc-handlers.test.ts index e679e4ae034..9a0879de55e 100644 --- a/src/main/window/clipboard-ipc-handlers.test.ts +++ b/src/main/window/clipboard-ipc-handlers.test.ts @@ -208,9 +208,18 @@ describe('registerClipboardHandlers', () => { }) it('registers normal and selection text clipboard IPC handlers', async () => { + let normalClipboard = 'standard text' + let selectionClipboard = 'selection text' clipboardReadTextMock.mockImplementation((clipboardType?: string) => - clipboardType === 'selection' ? 'selection text' : 'standard text' + clipboardType === 'selection' ? selectionClipboard : normalClipboard ) + clipboardWriteTextMock.mockImplementation((text: string, clipboardType?: string) => { + if (clipboardType === 'selection') { + selectionClipboard = text + } else { + normalClipboard = text + } + }) registerClipboardHandlers({} as never) @@ -230,6 +239,35 @@ describe('registerClipboardHandlers', () => { expect(clipboardWriteTextMock).toHaveBeenCalledWith('primary text', 'selection') }) + it('rejects standard text writes when the clipboard read-back does not match', async () => { + // Why: Electron can return without throwing while the OS clipboard keeps its + // previous contents (#5611), so the handler must surface a failure instead. + clipboardReadTextMock.mockReturnValue('old clipboard') + + registerClipboardHandlers({} as never) + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:writeText')?.(makeClipboardEvent(), 'copilot answer') + ).rejects.toThrow('Clipboard write verification failed') + expect(clipboardWriteTextMock).toHaveBeenCalledWith('copilot answer') + }) + + it('does not verify the X11 PRIMARY selection clipboard read-back', async () => { + // Why: the PRIMARY selection hands ownership to the last selector, so a + // read-back can legitimately differ from what we wrote — verifying it would + // surface false failures on Linux primary-selection copies. + clipboardReadTextMock.mockReturnValue('owned by another app') + + registerClipboardHandlers({} as never) + + const handlers = getRegisteredHandlers() + await expect( + handlers.get('clipboard:writeSelectionText')?.(makeClipboardEvent(), 'primary text') + ).resolves.toBeUndefined() + expect(clipboardWriteTextMock).toHaveBeenCalledWith('primary text', 'selection') + }) + it('rejects clipboard IPC from senders outside the current main renderer', async () => { setTrustedClipboardRendererWebContentsId(17) registerClipboardHandlers({} as never) @@ -473,6 +511,9 @@ describe('registerClipboardHandlers', () => { it('yields before writing large text clipboard IPC payloads', async () => { vi.useFakeTimers() const text = 'é'.repeat(300_000) + // Why: the handler reads the clipboard back to verify the write landed, so + // the mock must echo what was written for the success path under test. + clipboardReadTextMock.mockReturnValue(text) registerClipboardHandlers({} as never) diff --git a/src/main/window/clipboard-ipc-handlers.ts b/src/main/window/clipboard-ipc-handlers.ts index f6c98d6bb2d..c5cc9d7b7d5 100644 --- a/src/main/window/clipboard-ipc-handlers.ts +++ b/src/main/window/clipboard-ipc-handlers.ts @@ -71,6 +71,28 @@ function runCommand(command: string, args: string[], stdin?: string): Promise { assertTrustedClipboardSender(event) - return clipboard.writeText(await assertClipboardTextWriteWithinLimitWithYield(text)) + return writeClipboardTextAndVerify(await assertClipboardTextWriteWithinLimitWithYield(text)) }) ipcMain.handle('clipboard:writeSelectionText', async (event, text: string) => { assertTrustedClipboardSender(event) - return clipboard.writeText( + return writeClipboardTextAndVerify( await assertClipboardTextWriteWithinLimitWithYield(text), 'selection' ) diff --git a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts index ebedf8bed89..4f9a5815a90 100644 --- a/src/renderer/src/components/terminal-pane/keyboard-handlers.ts +++ b/src/renderer/src/components/terminal-pane/keyboard-handlers.ts @@ -21,6 +21,7 @@ import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion import { splitTerminalPaneWithInheritedCwd } from './terminal-pane-split-with-inherited-cwd' import { useAppStore } from '@/store' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' +import { copyTerminalSelection } from './terminal-selection-copy' import { markTerminalFollowOutput, markTerminalPinnedViewport, @@ -279,13 +280,15 @@ export function useTerminalKeyboardShortcuts({ if (!pane) { return } - const selection = pane.terminal.getSelection() - if (!selection) { + if (!pane.terminal.getSelection()) { return } e.preventDefault() e.stopImmediatePropagation() - void window.api.ui.writeClipboardText(selection).catch(() => { + void copyTerminalSelection({ + terminal: pane.terminal, + writeClipboardText: window.api.ui.writeClipboardText + }).catch(() => { /* ignore clipboard write failures */ }) return diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts index efdac51caef..fb00b494d8e 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.test.ts @@ -2,8 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor' import type * as Osc52ClipboardBlockedToastModule from './osc52-clipboard-blocked-toast' -const { toastInfoMock, storeMock } = vi.hoisted(() => ({ +const { toastInfoMock, toastErrorMock, storeMock } = vi.hoisted(() => ({ toastInfoMock: vi.fn(), + toastErrorMock: vi.fn(), storeMock: { setSettingsSearchQuery: vi.fn(), openSettingsTarget: vi.fn(), @@ -13,7 +14,8 @@ const { toastInfoMock, storeMock } = vi.hoisted(() => ({ vi.mock('sonner', () => ({ toast: { - info: toastInfoMock + info: toastInfoMock, + error: toastErrorMock } })) @@ -31,6 +33,7 @@ describe('showOsc52ClipboardBlockedToast', () => { beforeEach(() => { vi.resetModules() toastInfoMock.mockReset() + toastErrorMock.mockReset() storeMock.setSettingsSearchQuery.mockReset() storeMock.openSettingsTarget.mockReset() storeMock.openSettingsPage.mockReset() @@ -68,3 +71,30 @@ describe('showOsc52ClipboardBlockedToast', () => { expect(toastInfoMock).toHaveBeenCalledTimes(1) }) }) + +describe('showOsc52ClipboardFailedToast', () => { + beforeEach(() => { + vi.resetModules() + toastErrorMock.mockReset() + }) + + it('reports that the host clipboard did not update', async () => { + const { showOsc52ClipboardFailedToast } = await importToastModule() + + showOsc52ClipboardFailedToast() + + expect(toastErrorMock).toHaveBeenCalledWith('Terminal clipboard write failed', { + description: 'The terminal app requested a copy, but the system clipboard did not update.', + duration: 12_000 + }) + }) + + it('only shows once per renderer session', async () => { + const { showOsc52ClipboardFailedToast } = await importToastModule() + + showOsc52ClipboardFailedToast() + showOsc52ClipboardFailedToast() + + expect(toastErrorMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts index 127f40f7a99..2bc4ae38de3 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard-blocked-toast.ts @@ -4,6 +4,7 @@ import { OSC52_CLIPBOARD_SETTING_ID } from './osc52-clipboard-setting-anchor' import { translate } from '@/i18n/i18n' let hasShownOsc52ClipboardBlockedToast = false +let hasShownOsc52ClipboardFailedToast = false export function showOsc52ClipboardBlockedToast(): void { if (hasShownOsc52ClipboardBlockedToast) { @@ -43,3 +44,24 @@ export function showOsc52ClipboardBlockedToast(): void { } ) } + +export function showOsc52ClipboardFailedToast(): void { + if (hasShownOsc52ClipboardFailedToast) { + return + } + hasShownOsc52ClipboardFailedToast = true + + toast.error( + translate( + 'auto.components.terminal.pane.osc52.clipboard.failed.toast.62a0af2cb4', + 'Terminal clipboard write failed' + ), + { + description: translate( + 'auto.components.terminal.pane.osc52.clipboard.failed.toast.fdd3e7e977', + 'The terminal app requested a copy, but the system clipboard did not update.' + ), + duration: 12_000 + } + ) +} diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts index c714dc61f40..15ff10daa70 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard.test.ts @@ -113,4 +113,18 @@ describe('handleOsc52ClipboardRequest', () => { expect(onBlockedWrite).not.toHaveBeenCalled() }) + + it('surfaces host clipboard write failures for OSC 52 requests', async () => { + const onWriteFailure = vi.fn() + + handleOsc52ClipboardRequest(`c;${b64('from copilot')}`, { + allowClipboardWrite: true, + writeClipboardText: vi + .fn<(text: string) => Promise>() + .mockRejectedValue(new Error('clipboard unchanged')), + onWriteFailure + }) + + await vi.waitFor(() => expect(onWriteFailure).toHaveBeenCalledTimes(1)) + }) }) diff --git a/src/renderer/src/components/terminal-pane/osc52-clipboard.ts b/src/renderer/src/components/terminal-pane/osc52-clipboard.ts index bcde6890212..dfaeb5c2e7d 100644 --- a/src/renderer/src/components/terminal-pane/osc52-clipboard.ts +++ b/src/renderer/src/components/terminal-pane/osc52-clipboard.ts @@ -26,6 +26,7 @@ export type Osc52ClipboardRequestOptions = { allowClipboardWrite: boolean writeClipboardText: (text: string) => Promise onBlockedWrite?: () => void + onWriteFailure?: () => void } const MAX_OSC52_BYTES = 128 * 1024 @@ -45,7 +46,9 @@ export function handleOsc52ClipboardRequest( } void options.writeClipboardText(parsed.text).catch(() => { - /* ignore clipboard write failures */ + // Why: a TUI yank (tmux/nvim/fzf/Copilot) that reports success but never + // reached the OS clipboard must surface a toast, not silently no-op. + options.onWriteFailure?.() }) return true } diff --git a/src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts b/src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts new file mode 100644 index 00000000000..e03230fbeff --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-selection-copy.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from 'vitest' +import { copyTerminalSelection } from './terminal-selection-copy' + +function makeTerminal(selection: string) { + return { + getSelection: vi.fn(() => selection), + clearSelection: vi.fn() + } +} + +describe('copyTerminalSelection', () => { + it('writes selected terminal text to the clipboard', async () => { + const terminal = makeTerminal('copilot answer') + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue() + + await expect(copyTerminalSelection({ terminal, writeClipboardText })).resolves.toBe(true) + + expect(writeClipboardText).toHaveBeenCalledWith('copilot answer') + expect(terminal.clearSelection).not.toHaveBeenCalled() + }) + + it('does not claim success for an empty xterm selection', async () => { + const terminal = makeTerminal('') + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue() + + await expect(copyTerminalSelection({ terminal, writeClipboardText })).resolves.toBe(false) + + expect(writeClipboardText).not.toHaveBeenCalled() + expect(terminal.clearSelection).not.toHaveBeenCalled() + }) + + it('clears xterm selection only after the clipboard write succeeds', async () => { + const terminal = makeTerminal('copilot answer') + const writeClipboardText = vi + .fn<(text: string) => Promise>() + .mockRejectedValue(new Error('clipboard unchanged')) + + await expect( + copyTerminalSelection({ terminal, writeClipboardText, clearSelectionOnSuccess: true }) + ).rejects.toThrow('clipboard unchanged') + + expect(terminal.clearSelection).not.toHaveBeenCalled() + }) + + it('clears the xterm selection after a successful write when requested', async () => { + const terminal = makeTerminal('copilot answer') + const writeClipboardText = vi.fn<(text: string) => Promise>().mockResolvedValue() + + await expect( + copyTerminalSelection({ terminal, writeClipboardText, clearSelectionOnSuccess: true }) + ).resolves.toBe(true) + + expect(terminal.clearSelection).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-selection-copy.ts b/src/renderer/src/components/terminal-pane/terminal-selection-copy.ts new file mode 100644 index 00000000000..1f540820b21 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-selection-copy.ts @@ -0,0 +1,29 @@ +import type { Terminal } from '@xterm/xterm' + +type TerminalSelectionCopyOptions = { + terminal: Pick + writeClipboardText: (text: string) => Promise + clearSelectionOnSuccess?: boolean +} + +// Why: route every terminal copy path through one helper so a clipboard write +// failure rejects instead of being swallowed — callers can then withhold their +// "Copied" success UI rather than lying about an unchanged clipboard (#5611). +export async function copyTerminalSelection({ + terminal, + writeClipboardText, + clearSelectionOnSuccess = false +}: TerminalSelectionCopyOptions): Promise { + const selection = terminal.getSelection() + if (!selection) { + return false + } + + await writeClipboardText(selection) + // Why: only drop the xterm selection once the write resolved, so a failed + // copy leaves the text selected for the user to retry. + if (clearSelectionOnSuccess) { + terminal.clearSelection() + } + return true +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 61c41e79313..3df7edf3148 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -40,6 +40,7 @@ import { useAppStore } from '@/store' import { translate } from '@/i18n/i18n' import { recordTerminalUserInputForLeaf } from './terminal-input-activity' import { copyTerminalHandleForPane } from './terminal-handle-copy' +import { copyTerminalSelection } from './terminal-selection-copy' const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' @@ -148,10 +149,10 @@ export function useTerminalPaneContextMenu({ if (!pane) { return } - const selection = pane.terminal.getSelection() - if (selection) { - await window.api.ui.writeClipboardText(selection) - } + await copyTerminalSelection({ + terminal: pane.terminal, + writeClipboardText: window.api.ui.writeClipboardText + }) // Why: Radix returns focus to the menu trigger (the pane container) on // close, but xterm.js only accepts input when its own helper textarea is // focused. Without this, the user has to click the pane again before @@ -453,10 +454,14 @@ export function useTerminalPaneContextMenu({ if (!clickedPane) { return } - const selection = clickedPane.terminal.getSelection() - if (selection) { - void window.api.ui.writeClipboardText(selection) - clickedPane.terminal.clearSelection() + if (clickedPane.terminal.getSelection()) { + void copyTerminalSelection({ + terminal: clickedPane.terminal, + writeClipboardText: window.api.ui.writeClipboardText, + clearSelectionOnSuccess: true + }).catch(() => { + /* ignore clipboard write failures */ + }) } else { void pasteResolvedPane('right-click') } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index fe67c1ad83a..a2e0badc2a4 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -51,7 +51,10 @@ import { mode2031SequenceFor } from './terminal-appearance' import { handleOsc52ClipboardRequest } from './osc52-clipboard' -import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' +import { + showOsc52ClipboardBlockedToast, + showOsc52ClipboardFailedToast +} from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' @@ -97,6 +100,7 @@ import { acquireWebviewsDragPassthrough } from '../browser-pane/webview-registry import { recordCreatedTerminalPaneSplit } from './terminal-pane-split-completion' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { seedStartupSessionRestoredBanner } from './session-restored-banner-pane-state' +import { copyTerminalSelection } from './terminal-selection-copy' export function recordRuntimeCreatedTerminalPaneSplit( createdPane: unknown, @@ -650,7 +654,8 @@ export function useTerminalPaneLifecycle({ handleOsc52ClipboardRequest(data, { allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true, writeClipboardText: window.api.ui.writeClipboardText, - onBlockedWrite: showOsc52ClipboardBlockedToast + onBlockedWrite: showOsc52ClipboardBlockedToast, + onWriteFailure: showOsc52ClipboardFailedToast }) ) osc52DisposablesRef.current.set(pane.id, osc52Disposable) @@ -855,11 +860,10 @@ export function useTerminalPaneLifecycle({ if (!shouldWriteClipboard) { return } - const selection = pane.terminal.getSelection() - if (!selection) { - return - } - void window.api.ui.writeClipboardText(selection).catch(() => { + void copyTerminalSelection({ + terminal: pane.terminal, + writeClipboardText: window.api.ui.writeClipboardText + }).catch(() => { /* ignore clipboard write failures */ }) }) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f082ae69916..01bab0c1d72 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2407,6 +2407,12 @@ "7cf51f74fd": "Enable TUI clipboard writes in Terminal settings to copy from SSH, tmux, Neovim, or fzf.", "89eaa3e80b": "Terminal clipboard write blocked" } + }, + "failed": { + "toast": { + "62a0af2cb4": "Terminal clipboard write failed", + "fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update." + } } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d9da0fd6124..d63d7fd837f 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2407,6 +2407,12 @@ "7cf51f74fd": "Habilite las escrituras en el portapapeles TUI en la configuración de Terminal para copiar desde SSH, tmux, Neovim o fzf.", "89eaa3e80b": "Escritura en el portapapeles del terminal bloqueada" } + }, + "failed": { + "toast": { + "62a0af2cb4": "Terminal clipboard write failed", + "fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update." + } } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6f87487774c..05d092555c8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2407,6 +2407,12 @@ "7cf51f74fd": "SSH、tmux、Neovim、または fzf からコピーするには、Terminal 設定で TUI クリップボードへの書き込みを有効にします。", "89eaa3e80b": "Terminal のクリップボードへの書き込みがブロックされました" } + }, + "failed": { + "toast": { + "62a0af2cb4": "Terminal clipboard write failed", + "fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update." + } } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4f7a5f168bf..4c42a5d3119 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2407,6 +2407,12 @@ "7cf51f74fd": "SSH, tmux, Neovim 또는 fzf에서 복사하려면 Terminal 설정에서 TUI 클립보드 쓰기를 활성화하세요.", "89eaa3e80b": "Terminal 클립보드 쓰기가 차단되었습니다." } + }, + "failed": { + "toast": { + "62a0af2cb4": "Terminal clipboard write failed", + "fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update." + } } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 41fc54e3f27..381446aaa91 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2407,6 +2407,12 @@ "7cf51f74fd": "在终端设置中启用 TUI 剪贴板写入,以从 SSH、tmux、Neovim 或 fzf 进行复制。", "89eaa3e80b": "终端剪贴板写入被阻止" } + }, + "failed": { + "toast": { + "62a0af2cb4": "Terminal clipboard write failed", + "fdd3e7e977": "The terminal app requested a copy, but the system clipboard did not update." + } } } },