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
51 changes: 51 additions & 0 deletions docs/design/standalone-clipboard-native-addon.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Standalone clipboard native addon

## Problem

The CLI bundle keeps `@teddyzhu/clipboard` external so npm installations can
load the platform-specific native package at runtime. Standalone archives also
keep the import external, but currently copy only the audio-capture native
addon into `lib/node_modules`. Clipboard image paste therefore fails silently
in every standalone archive.

## Constraints

- Each archive must contain the `@teddyzhu/clipboard` JavaScript package and
exactly one native package matching the archive target.
- The release job creates all supported targets on one Ubuntu runner. A normal
`npm ci` only installs the runner's optional native package, so packaging
cannot rely on the repository `node_modules` for cross-target artifacts.
- Clipboard package versions must come from the lockfile and remain aligned
with the CLI optional dependencies.
- Local packaging should continue to work when a non-host clipboard artifact
is unavailable, while release packaging must fail rather than publish a
partially functional archive.

## Design

Before building release archives, install the locked clipboard meta package
and every supported target package into a temporary staging directory. Pass
that directory explicitly to the per-target packaging command.

The standalone packager maps each target to its native clipboard package and
copies only the meta package plus that target package into
`lib/node_modules/@teddyzhu`. When no explicit staging directory is supplied,
the packager uses the repository `node_modules`; a missing host artifact emits
a warning for local builds. Missing artifacts in an explicit staging directory
are fatal.

If the runtime module still cannot load, the input prompt reports a single
user-visible error on the first clipboard-image paste attempt. Existing Linux
`wl-paste` and `xclip` paths are unchanged.

## Verification

- Packaging tests cover target selection, exclusion of other native targets,
and failure for incomplete explicit staging.
- Clipboard and input prompt tests cover the unavailable-module callback and
one-time UI error.
- A real macOS arm64 archive is unpacked outside the repository, loaded with
its bundled Node.js runtime, and exercised against an actual PNG in the
system clipboard.

![Standalone clipboard paste before and after](standalone-clipboard-native-addon/assets/before-after.png)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export default {

'User level': 'User level',
'Project level': 'Project level',
'Clipboard image paste is unavailable because the native clipboard module could not be loaded. Reinstall Qwen Code or use the npm installation method.':
'Clipboard image paste is unavailable because the native clipboard module could not be loaded. Reinstall Qwen Code or use the npm installation method.',

// ==========================================================================
// Extensions manager dialog (Installed / Discover / Sources tabs)
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export default {

'User level': '使用者層級',
'Project level': '專案層級',
'Clipboard image paste is unavailable because the native clipboard module could not be loaded. Reinstall Qwen Code or use the npm installation method.':
'剪貼簿圖片貼上功能無法使用,因為原生剪貼簿模組載入失敗。請重新安裝 Qwen Code,或改用 npm 安裝方式。',

// ==========================================================================
// Extensions manager dialog (Installed / Discover / Sources tabs)
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export default {

'User level': '用户级',
'Project level': '项目级',
'Clipboard image paste is unavailable because the native clipboard module could not be loaded. Reinstall Qwen Code or use the npm installation method.':
'剪贴板图片粘贴不可用,因为原生剪贴板模块加载失败。请重新安装 Qwen Code,或改用 npm 安装方式。',

// ==========================================================================
// Extensions manager dialog (Installed / Discover / Sources tabs)
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/ui/components/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
*/

import { Box, Text, useIsScreenReaderEnabled } from 'ink';
import { useCallback, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { LoadingIndicator } from './LoadingIndicator.js';
import { InputPrompt } from './InputPrompt.js';
import { Footer } from './Footer.js';
Expand Down Expand Up @@ -61,6 +61,7 @@ export const Composer = () => {
// local to Composer because nothing outside this component needs the
// narrow signal.
const [showSuggestions, setShowSuggestions] = useState(false);
const clipboardUnavailableShownRef = useRef(false);

// Broad signal — any input-area Tab consumer. Forwarded to AppContainer
// via UIActionsContext so useAutoAcceptIndicator's `shouldBlockTab` can
Expand Down Expand Up @@ -140,6 +141,7 @@ export const Composer = () => {
}
promptSuggestion={uiState.promptSuggestion}
onPromptSuggestionDismiss={uiState.abortPromptSuggestion}
clipboardUnavailableShownRef={clipboardUnavailableShownRef}
/>
)}

Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/ui/components/InputPrompt.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1340,6 +1340,58 @@ describe('InputPrompt', () => {
unmount();
});

it('should show the native clipboard error only once across remounts', async () => {
const addItem = vi.fn();
const clipboardUnavailableShownRef = { current: false };
mockedUseUIState.mockReturnValue({
isFeedbackDialogOpen: false,
messageQueue: [],
pendingGeminiHistoryItems: [],
historyManager: { addItem },
} as unknown as ReturnType<typeof useUIState>);
vi.mocked(clipboardUtils.clipboardHasImage).mockImplementation(
async (onUnavailable) => {
onUnavailable?.();
return false;
},
);

const first = renderWithProviders(
<InputPrompt
{...props}
clipboardUnavailableShownRef={clipboardUnavailableShownRef}
/>,
);
await wait();

const pasteKey = isWindows ? '\x1Bv' : '\x16';
first.stdin.write(pasteKey);
await wait();
first.stdin.write(pasteKey);
await wait();
first.unmount();

const second = renderWithProviders(
<InputPrompt
{...props}
clipboardUnavailableShownRef={clipboardUnavailableShownRef}
/>,
);
await wait();
second.stdin.write(pasteKey);
await wait();

expect(addItem).toHaveBeenCalledTimes(1);
expect(addItem).toHaveBeenCalledWith(
{
type: 'error',
text: 'Clipboard image paste is unavailable because the native clipboard module could not be loaded. Reinstall Qwen Code or use the npm installation method.',
},
expect.any(Number),
);
second.unmount();
});

it('should handle image save failure gracefully', async () => {
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
Expand Down
75 changes: 53 additions & 22 deletions packages/cli/src/ui/components/InputPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,7 @@ export interface InputPromptProps {
promptSuggestion?: string | null;
/** Called when prompt suggestion is dismissed (user typed) */
onPromptSuggestionDismiss?: () => void;
clipboardUnavailableShownRef?: React.MutableRefObject<boolean>;
}

// Re-export from shared utils for backwards compatibility
Expand Down Expand Up @@ -212,6 +213,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
isEmbeddedShellFocused,
promptSuggestion,
onPromptSuggestionDismiss,
clipboardUnavailableShownRef: sessionClipboardUnavailableShownRef,
}) => {
const isShellFocused = useShellFocusState();
const uiState = useUIState();
Expand Down Expand Up @@ -274,6 +276,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [isAttachmentMode, setIsAttachmentMode] = useState(false);
const [selectedAttachmentIndex, setSelectedAttachmentIndex] = useState(-1);
const localClipboardUnavailableShownRef = useRef(false);
const clipboardUnavailableShownRef =
sessionClipboardUnavailableShownRef ?? localClipboardUnavailableShownRef;
// Large paste placeholder handling
const [pendingPastes, setPendingPastes] = useState<Map<string, string>>(
new Map(),
Expand Down Expand Up @@ -686,32 +691,54 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
resetCommandSearchCompletionState,
]);

const reportClipboardUnavailable = useCallback(() => {
if (clipboardUnavailableShownRef.current) {
return;
}
clipboardUnavailableShownRef.current = true;
uiState.historyManager?.addItem(
{
type: 'error',
text: t(
'Clipboard image paste is unavailable because the native clipboard module could not be loaded. Reinstall Qwen Code or use the npm installation method.',
),
},
Date.now(),
);
}, [clipboardUnavailableShownRef, uiState.historyManager]);

// Handle clipboard image pasting with Ctrl+V
const handleClipboardImage = useCallback(async (validated = false) => {
try {
const hasImage = validated || (await clipboardHasImage());
if (hasImage) {
const imagePath = await saveClipboardImage(Storage.getGlobalTempDir());
if (imagePath) {
// Clean up old images
cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => {
// Ignore cleanup errors
});
const handleClipboardImage = useCallback(
async (validated = false) => {
try {
const hasImage =
validated || (await clipboardHasImage(reportClipboardUnavailable));
if (hasImage) {
const imagePath = await saveClipboardImage(
Storage.getGlobalTempDir(),
);
if (imagePath) {
// Clean up old images
cleanupOldClipboardImages(Storage.getGlobalTempDir()).catch(() => {
// Ignore cleanup errors
});

// Add as attachment instead of inserting @reference into text
const filename = path.basename(imagePath);
const newAttachment: Attachment = {
id: String(Date.now()),
path: imagePath,
filename,
};
setAttachments((prev) => [...prev, newAttachment]);
// Add as attachment instead of inserting @reference into text
const filename = path.basename(imagePath);
const newAttachment: Attachment = {
id: String(Date.now()),
path: imagePath,
filename,
};
setAttachments((prev) => [...prev, newAttachment]);
}
}
} catch (error) {
debugLogger.error('Error handling clipboard image:', error);
}
} catch (error) {
debugLogger.error('Error handling clipboard image:', error);
}
}, []);
},
[reportClipboardUnavailable],
);

// Promote a paste that is purely image-file path(s) (e.g. a terminal/clipboard
// helper that injects `@<path>` text on Cmd+V) into attachment chips, so the
Expand Down Expand Up @@ -967,6 +994,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({

// Ensure we never accidentally interpret paste as regular input.
const pastedImagePaths = classifyPastedImagePaths(pasted);
if (key.clipboardImageUnavailable) {
reportClipboardUnavailable();
}
if (key.pasteImage) {
handleClipboardImage(true);
} else if (
Expand Down Expand Up @@ -1705,6 +1735,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shellHistory,
reverseSearchCompletion,
handleClipboardImage,
reportClipboardUnavailable,
promotePastedImagePaths,
resetCompletionState,
dismissCompletion,
Expand Down
28 changes: 28 additions & 0 deletions packages/cli/src/ui/contexts/KeypressContext.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ import {
import { useStdin } from 'ink';
import { EventEmitter } from 'node:events';

const mockClipboardHasImage = vi.hoisted(() => vi.fn());

vi.mock('../utils/clipboardUtils.js', () => ({
clipboardHasImage: mockClipboardHasImage,
}));

// Mock the 'ink' module to control stdin
vi.mock('ink', async (importOriginal) => {
const original = await importOriginal<typeof import('ink')>();
Expand Down Expand Up @@ -83,6 +89,7 @@ describe('KeypressContext - Kitty Protocol', () => {

beforeEach(() => {
vi.clearAllMocks();
mockClipboardHasImage.mockResolvedValue(false);
stdin = new MockStdin();
(useStdin as Mock).mockReturnValue({
stdin,
Expand Down Expand Up @@ -607,6 +614,27 @@ describe('KeypressContext - Kitty Protocol', () => {
);
});

it('reports an unavailable native module for an empty paste', async () => {
const keyHandler = vi.fn();
mockClipboardHasImage.mockImplementation(async (onUnavailable) => {
onUnavailable?.();
return false;
});
const { result } = renderHook(() => useKeypressContext(), { wrapper });
act(() => result.current.subscribe(keyHandler));

act(() => stdin.sendPaste(''));

await waitFor(() => {
expect(keyHandler).toHaveBeenCalledWith(
expect.objectContaining({
paste: true,
clipboardImageUnavailable: true,
}),
);
});
});

describe('paste mode markers', () => {
// These tests use pasteWorkaround=true to force passthrough mode for raw keypress testing

Expand Down
7 changes: 6 additions & 1 deletion packages/cli/src/ui/contexts/KeypressContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export interface Key {
sequence: string;
kittyProtocol?: boolean;
pasteImage?: boolean;
clipboardImageUnavailable?: boolean;
}

export type KeypressHandler = (key: Key) => void;
Expand Down Expand Up @@ -857,14 +858,18 @@ export function KeypressProvider({
sequence: pasteBuffer.toString(),
});
} else {
const hasImage = await clipboardHasImage();
let clipboardImageUnavailable = false;
const hasImage = await clipboardHasImage(() => {
clipboardImageUnavailable = true;
});
broadcast({
name: '',
ctrl: false,
meta: false,
shift: false,
paste: true,
pasteImage: hasImage,
clipboardImageUnavailable,
sequence: pasteBuffer.toString(),
});
}
Expand Down
Loading
Loading