fix(coding-agent,tui): paste Finder file paths instead of icons (#10136)

Read Finder file URLs before icon image data on macOS. Quote paths in
bash mode and reject terminal control characters before rendering.

Fixes #9999

Co-authored-by: 1dustycy <onedustycy@gmail.com>
This commit is contained in:
Christian Klotz 2026-09-28 20:59:29 +01:00 • committed by GitHub
parent 3883fcb1f3
commit fd889a2741
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 272 additions and 14 deletions

View file

@ -125,7 +125,7 @@ In fullscreen mode, these actions control the transcript and take precedence ove
| `app.exit` | `ctrl+d` | Exit (when editor empty) |
| `app.suspend` | `ctrl+z` (None on Windows) | Suspend to background |
| `app.editor.external` | `ctrl+g` | Open in external editor (`externalEditor`, `$VISUAL`, `$EDITOR`, Notepad on Windows, or `nano` elsewhere) |
| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows and WSL) | Paste image or text from clipboard |
| `app.clipboard.pasteImage` | `ctrl+v` (`alt+v` on Windows and WSL) | Paste files on macOS, images, or text from clipboard |
On native Windows, `app.suspend` has no default because Windows terminals do not support Unix job control. If you assign it manually, Pi shows a status message instead of suspending. WSL uses the normal `ctrl+z` and `fg` behavior.

View file

@ -141,7 +141,7 @@ export const KEYBINDINGS = {
},
"app.clipboard.pasteImage": {
defaultKeys: windowsKeybindings ? "alt+v" : "ctrl+v",
description: "Paste image from clipboard (text fallback)",
description: "Paste files on macOS, images, or text from clipboard",
},
"app.session.new": { defaultKeys: [], description: "Start a new session" },
"app.session.tree": { defaultKeys: [], description: "Open session tree" },

View file

@ -117,7 +117,7 @@ import type { TruncationResult } from "../../core/tools/truncate.ts";
import { hasTrustRequiringProjectResources, ProjectTrustStore } from "../../core/trust-manager.ts";
import { getUsageCostBreakdown } from "../../core/usage-totals.ts";
import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelog } from "../../utils/changelog.ts";
import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts";
import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../../utils/clipboard.ts";
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
import { parseGitUrl } from "../../utils/git.ts";
import { getCwdRelativePath } from "../../utils/paths.ts";
@ -200,6 +200,10 @@ interface Expandable {
setExpanded(expanded: boolean): void;
}
interface ActiveEditor extends EditorComponent {
getCursor?(): { line: number; col: number };
}
interface WorkingStatusEditor extends EditorComponent {
readonly embedWorkingStatus: boolean;
setWorkingStatusIndicator(indicator: StatusIndicator | undefined): void;
@ -428,7 +432,7 @@ export class InteractiveMode {
private pendingMessagesContainer: Container;
private statusContainer: Container;
private defaultEditor: CustomEditor;
private editor: EditorComponent;
private editor: ActiveEditor;
private editorComponentFactory: EditorFactory | undefined;
private autocompleteProvider: AutocompleteProvider | undefined;
private autocompleteProviderWrappers: AutocompleteProviderFactory[] = [];
@ -994,7 +998,7 @@ export class InteractiveMode {
rawKeyHint("!!", "to run bash (no context)"),
hint("app.message.followUp", "to queue follow-up"),
hint("app.message.dequeue", "to edit all queued messages"),
hint("app.clipboard.pasteImage", "to paste image (with text fallback)"),
hint("app.clipboard.pasteImage", "to paste files on macOS, images, or text"),
rawKeyHint("drop files", "to attach"),
].join("\n");
const compactInstructions = () =>
@ -3017,8 +3021,8 @@ export class InteractiveMode {
}
};
// Handle clipboard paste (triggered on Ctrl+V). Images are attached by path;
// otherwise, paste plain text from the system clipboard.
// Handle clipboard paste (triggered on Ctrl+V). Copied files use their original paths,
// images are attached via temporary files, and plain text is the final fallback.
this.defaultEditor.onPasteImage = () => {
void this.handleClipboardPaste();
};
@ -3040,6 +3044,23 @@ export class InteractiveMode {
private async handleClipboardPaste(): Promise<void> {
try {
const filePaths = await readClipboardFilePaths();
if (filePaths) {
if (filePaths.some((filePath) => /\p{Cc}/u.test(filePath))) {
throw new Error("Clipboard file path contains control characters");
}
const paths = this.isBashMode ? filePaths.map(quoteIfNeeded).join(" ") : filePaths.join("\n");
const cursor = this.editor.getCursor?.();
const currentLine = cursor ? (this.editor.getText().split("\n")[cursor.line] ?? "") : "";
const characterBeforeCursor = cursor && cursor.col > 0 ? currentLine[cursor.col - 1] : "";
const characterAfterCursor = cursor ? currentLine[cursor.col] : "";
const leadingSpace = characterBeforeCursor && !/\s/.test(characterBeforeCursor) ? " " : "";
const trailingSpace = characterAfterCursor && !/\s/.test(characterAfterCursor) ? " " : "";
this.editor.insertTextAtCursor?.(`${leadingSpace}${paths}${trailingSpace}`);
this.ui.requestRender();
return;
}
const image = await readClipboardImage();
if (image) {
const tmpDir = os.tmpdir();
@ -3058,8 +3079,8 @@ export class InteractiveMode {
this.editor.insertTextAtCursor?.(text);
this.ui.requestRender();
}
} catch {
// Silently ignore clipboard errors (may not have permission, etc.)
} catch (error) {
this.showError(`Failed to paste from clipboard: ${error instanceof Error ? error.message : String(error)}`);
}
}
@ -6636,7 +6657,7 @@ export class InteractiveMode {
| \`${copyMessage}\` | Copy selection or last assistant message |
| \`${followUp}\` | Queue follow-up message |
| \`${dequeue}\` | Restore queued messages |
| \`${pasteImage}\` | Paste image or text from clipboard |
| \`${pasteImage}\` | Paste files on macOS, images, or text from clipboard |
| \`/\` | Slash commands |
| \`!\` | Run bash command |
| \`!!\` | Run bash command (excluded from context) |

View file

@ -71,6 +71,12 @@ export async function readClipboardText(): Promise<string | null> {
}
}
/** Read file paths, such as Finder file copies, from the native clipboard. */
export async function readClipboardFilePaths(): Promise<string[] | null> {
const paths = await getNativeClipboard()?.getFilePaths?.();
return paths?.length ? paths : null;
}
export async function copyToClipboard(text: string): Promise<void> {
const p = platform();
const env = process.env;

View file

@ -11,7 +11,11 @@ vi.mock("@earendil-works/pi-tui", async (importOriginal) => ({
},
}),
}));
vi.mock("../src/utils/clipboard.ts", () => ({ copyToClipboard: vi.fn(), readClipboardText }));
vi.mock("../src/utils/clipboard.ts", () => ({
copyToClipboard: vi.fn(),
readClipboardFilePaths: vi.fn(async () => null),
readClipboardText,
}));
vi.mock("../src/utils/clipboard-command.ts", () => ({ runClipboardCommand: vi.fn(async () => undefined) }));
afterEach(() => vi.unstubAllEnvs());
@ -19,6 +23,7 @@ test("native image errors abort paste without reading text or changing the edito
vi.stubEnv("TERMUX_VERSION", "");
const context = {
editor: { insertTextAtCursor: vi.fn() },
showError: vi.fn(),
ui: { requestRender: vi.fn() },
};
const prototype = InteractiveMode.prototype as unknown as {
@ -28,4 +33,7 @@ test("native image errors abort paste without reading text or changing the edito
expect(readClipboardText).not.toHaveBeenCalled();
expect(context.editor.insertTextAtCursor).not.toHaveBeenCalled();
expect(context.ui.requestRender).not.toHaveBeenCalled();
expect(context.showError).toHaveBeenCalledExactlyOnceWith(
"Failed to paste from clipboard: Native clipboard operation failed",
);
});

View file

@ -0,0 +1,141 @@
import { beforeEach, expect, test, vi } from "vitest";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
const mocks = vi.hoisted(() => ({
readClipboardFilePaths: vi.fn<() => Promise<string[] | null>>(),
readClipboardImage: vi.fn<() => Promise<{ bytes: Uint8Array; mimeType: string } | null>>(),
readClipboardText: vi.fn<() => Promise<string | null>>(),
}));
vi.mock("../src/utils/clipboard.ts", () => ({
copyToClipboard: vi.fn(),
readClipboardFilePaths: mocks.readClipboardFilePaths,
readClipboardText: mocks.readClipboardText,
}));
vi.mock("../src/utils/clipboard-image.ts", () => ({
extensionForImageMimeType: () => "png",
readClipboardImage: mocks.readClipboardImage,
}));
beforeEach(() => vi.resetAllMocks());
test("Finder file paths take precedence over their icon image", async () => {
// Regression test for #9999.
const filePaths = ["/tmp/screenshot.png", "/tmp/My Photos/photo.png"];
mocks.readClipboardFilePaths.mockResolvedValue(filePaths);
mocks.readClipboardImage.mockResolvedValue({
bytes: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
mimeType: "image/png",
});
mocks.readClipboardText.mockResolvedValue(null);
const insertTextAtCursor = vi.fn<(text: string) => void>();
const context = {
editor: { insertTextAtCursor },
showError: vi.fn(),
ui: { requestRender: vi.fn() },
};
const prototype = InteractiveMode.prototype as unknown as {
handleClipboardPaste(this: typeof context): Promise<void>;
};
await prototype.handleClipboardPaste.call(context);
expect(insertTextAtCursor).toHaveBeenCalledExactlyOnceWith(filePaths.join("\n"));
expect(mocks.readClipboardImage).not.toHaveBeenCalled();
});
test("clipboard file paths containing terminal control characters are rejected", async () => {
const insertTextAtCursor = vi.fn<(text: string) => void>();
const showError = vi.fn<(message: string) => void>();
mocks.readClipboardFilePaths.mockResolvedValue(["/tmp/photo\x1b]0;unsafe\x07.png"]);
const context = {
editor: { insertTextAtCursor },
showError,
ui: { requestRender: vi.fn() },
};
const prototype = InteractiveMode.prototype as unknown as {
handleClipboardPaste(this: typeof context): Promise<void>;
};
await prototype.handleClipboardPaste.call(context);
expect(insertTextAtCursor).not.toHaveBeenCalled();
expect(mocks.readClipboardImage).not.toHaveBeenCalled();
expect(showError).toHaveBeenCalledExactlyOnceWith(
"Failed to paste from clipboard: Clipboard file path contains control characters",
);
});
test("native file-path errors are shown without falling through to the icon image", async () => {
const showError = vi.fn<(message: string) => void>();
mocks.readClipboardFilePaths.mockRejectedValue(new Error("Native clipboard file read failed"));
const context = {
editor: { insertTextAtCursor: vi.fn() },
showError,
ui: { requestRender: vi.fn() },
};
const prototype = InteractiveMode.prototype as unknown as {
handleClipboardPaste(this: typeof context): Promise<void>;
};
await prototype.handleClipboardPaste.call(context);
expect(context.editor.insertTextAtCursor).not.toHaveBeenCalled();
expect(mocks.readClipboardImage).not.toHaveBeenCalled();
expect(showError).toHaveBeenCalledExactlyOnceWith(
"Failed to paste from clipboard: Native clipboard file read failed",
);
});
test.each([
["punctuation", "Review:", 7],
["Unicode text", "確認", 2],
])("clipboard file paths are separated from preceding %s", async (_description, editorText, cursorCol) => {
const insertTextAtCursor = vi.fn<(text: string) => void>();
mocks.readClipboardFilePaths.mockResolvedValue(["/tmp/photo.png"]);
const context = {
editor: {
getCursor: () => ({ line: 0, col: cursorCol }),
getText: () => editorText,
insertTextAtCursor,
},
showError: vi.fn(),
ui: { requestRender: vi.fn() },
};
const prototype = InteractiveMode.prototype as unknown as {
handleClipboardPaste(this: typeof context): Promise<void>;
};
await prototype.handleClipboardPaste.call(context);
expect(insertTextAtCursor).toHaveBeenCalledExactlyOnceWith(" /tmp/photo.png");
expect(mocks.readClipboardImage).not.toHaveBeenCalled();
});
test("bash mode shell-quotes file paths and inserts them as arguments", async () => {
const insertTextAtCursor = vi.fn<(text: string) => void>();
mocks.readClipboardFilePaths.mockResolvedValue([
"/tmp/My Photos/photo.png",
"/tmp/$(touch hacked).png",
"/tmp/plain.png",
]);
const context = {
editor: {
getCursor: () => ({ line: 0, col: 3 }),
getText: () => "catDEST",
insertTextAtCursor,
},
isBashMode: true,
showError: vi.fn(),
ui: { requestRender: vi.fn() },
};
const prototype = InteractiveMode.prototype as unknown as {
handleClipboardPaste(this: typeof context): Promise<void>;
};
await prototype.handleClipboardPaste.call(context);
expect(insertTextAtCursor).toHaveBeenCalledExactlyOnceWith(
" '/tmp/My Photos/photo.png' '/tmp/$(touch hacked).png' /tmp/plain.png ",
);
expect(mocks.readClipboardImage).not.toHaveBeenCalled();
});

View file

@ -21,6 +21,7 @@ import { initTheme } from "../src/modes/interactive/theme/theme.ts";
const clipboardMocks = vi.hoisted(() => ({
copyToClipboard: vi.fn<(text: string) => Promise<void>>(),
readClipboardFilePaths: vi.fn<() => Promise<string[] | null>>(),
readClipboardText: vi.fn<() => Promise<string | null>>(),
}));

View file

@ -7,8 +7,17 @@
#include <string.h>
#endif
typedef enum { CLIPBOARD_TEXT, CLIPBOARD_IMAGE, CLIPBOARD_WRITE } clipboard_operation;
typedef enum { CLIPBOARD_UNAVAILABLE, CLIPBOARD_EMPTY, CLIPBOARD_UTF8, CLIPBOARD_LATIN1, CLIPBOARD_UTF16, CLIPBOARD_BUFFER } clipboard_format;
typedef enum { CLIPBOARD_TEXT, CLIPBOARD_IMAGE, CLIPBOARD_WRITE, CLIPBOARD_FILES } clipboard_operation;
// CLIPBOARD_PATHS stores concatenated, NUL-terminated UTF-8 paths in clipboard_job.data.
typedef enum {
CLIPBOARD_UNAVAILABLE,
CLIPBOARD_EMPTY,
CLIPBOARD_UTF8,
CLIPBOARD_LATIN1,
CLIPBOARD_UTF16,
CLIPBOARD_BUFFER,
CLIPBOARD_PATHS
} clipboard_format;
typedef struct {
napi_async_work work;
@ -63,6 +72,33 @@ static void PI_NAPI_CALL execute_clipboard_work(napi_env env, void* data) {
clipboard_execute(data);
}
static int create_clipboard_paths_value(
napi_env env,
const clipboard_job* job,
napi_create_string_utf8_fn create_string,
napi_value* result
) {
napi_create_array_fn create_array = (napi_create_array_fn)node_symbol("napi_create_array");
napi_set_element_fn set_element = (napi_set_element_fn)node_symbol("napi_set_element");
if (!create_array || !set_element || create_array(env, result) != 0) return 1;
const char* cursor = job->data;
const char* end = cursor + job->length;
for (uint32_t index = 0; cursor < end; index++) {
const char* terminator = cursor;
while (terminator < end && *terminator != '\0') terminator++;
if (terminator == end) return 1;
napi_value item = 0;
if (create_string(env, cursor, (size_t)(terminator - cursor), &item) != 0 ||
set_element(env, *result, index, item) != 0) {
return 1;
}
cursor = terminator + 1;
}
return 0;
}
static void PI_NAPI_CALL complete_clipboard_work(napi_env env, int status, void* data) {
clipboard_job* job = data;
napi_create_string_utf8_fn create_string = (napi_create_string_utf8_fn)node_symbol("napi_create_string_utf8");
@ -71,6 +107,8 @@ static void PI_NAPI_CALL complete_clipboard_work(napi_env env, int status, void*
if (!job->error && job->operation != CLIPBOARD_WRITE) {
if (job->format == CLIPBOARD_EMPTY) {
result = null_value(env);
} else if (job->format == CLIPBOARD_PATHS) {
status = create_clipboard_paths_value(env, job, create_string, &result);
} else if (job->format == CLIPBOARD_BUFFER) {
napi_create_buffer_copy_fn create_buffer = (napi_create_buffer_copy_fn)node_symbol("napi_create_buffer_copy");
status = create_buffer(env, job->length, job->data, 0, &result);

View file

@ -1,6 +1,6 @@
# Darwin native prebuilds
Provides modifier-key state and asynchronous text/image clipboard access using AppKit.
Provides modifier-key state and asynchronous text, image, and file-path clipboard access using AppKit.
## Building

View file

@ -66,6 +66,40 @@ static void clipboard_execute(clipboard_job* job) {
} else {
clipboard_copy(job, text.UTF8String, [text lengthOfBytesUsingEncoding:NSUTF8StringEncoding], CLIPBOARD_UTF8);
}
} else if (job->operation == CLIPBOARD_FILES) {
NSArray* urls = [pasteboard readObjectsForClasses:@[[NSURL class]]
options:@{NSPasteboardURLReadingFileURLsOnlyKey: @YES}];
size_t total = 0;
for (NSURL* url in urls) {
const char* path = url.fileSystemRepresentation;
if (!path) continue;
size_t length = strlen(path);
if (length == SIZE_MAX || total > SIZE_MAX - length - 1) {
job->error = "Clipboard file paths are too large";
return;
}
total += length + 1;
}
if (total == 0) {
job->format = CLIPBOARD_EMPTY;
return;
}
char* paths = clipboard_alloc(total);
if (!paths) {
job->error = "Out of memory";
return;
}
char* cursor = paths;
for (NSURL* url in urls) {
const char* path = url.fileSystemRepresentation;
if (!path) continue;
size_t length = strlen(path);
memcpy(cursor, path, length + 1);
cursor += length + 1;
}
job->data = paths;
job->length = total;
job->format = CLIPBOARD_PATHS;
} else {
if (![pasteboard availableTypeFromArray:@[ NSPasteboardTypePNG, NSPasteboardTypeTIFF ]]) {
job->format = CLIPBOARD_EMPTY;
@ -84,10 +118,15 @@ static void clipboard_execute(clipboard_job* job) {
}
}
static napi_value PI_NAPI_CALL get_clipboard_file_paths(napi_env env, napi_callback_info info) {
return queue_clipboard(env, info, CLIPBOARD_FILES);
}
PI_NAPI_EXPORT napi_value napi_register_module_v1(napi_env env, napi_value exports) {
set_function_export(env, exports, "isModifierPressed", is_modifier_pressed);
set_function_export(env, exports, "getText", get_clipboard_text);
set_function_export(env, exports, "setText", set_clipboard_text);
set_function_export(env, exports, "getImage", get_clipboard_image);
set_function_export(env, exports, "getFilePaths", get_clipboard_file_paths);
return exports;
}

View file

@ -26,6 +26,7 @@ typedef void* napi_deferred;
typedef void (PI_NAPI_CALL *napi_async_execute_callback)(napi_env, void*);
typedef void (PI_NAPI_CALL *napi_async_complete_callback)(napi_env, int, void*);
typedef int (PI_NAPI_CALL *napi_create_async_work_fn)(napi_env, napi_value, napi_value, napi_async_execute_callback, napi_async_complete_callback, void*, napi_async_work*);
typedef int (PI_NAPI_CALL *napi_create_array_fn)(napi_env, napi_value*);
typedef int (PI_NAPI_CALL *napi_async_work_fn)(napi_env, napi_async_work);
typedef int (PI_NAPI_CALL *napi_create_promise_fn)(napi_env, napi_deferred*, napi_value*);
typedef int (PI_NAPI_CALL *napi_settle_deferred_fn)(napi_env, napi_deferred, napi_value);
@ -41,6 +42,7 @@ typedef int (PI_NAPI_CALL *napi_get_value_fn)(napi_env, napi_value*);
typedef int (PI_NAPI_CALL *napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, size_t, size_t*);
typedef int (PI_NAPI_CALL *napi_get_value_string_utf16_fn)(napi_env, napi_value, uint16_t*, size_t, size_t*);
typedef int (PI_NAPI_CALL *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value);
typedef int (PI_NAPI_CALL *napi_set_element_fn)(napi_env, napi_value, uint32_t, napi_value);
typedef int (PI_NAPI_CALL *napi_throw_error_fn)(napi_env, const char*, const char*);
static void* node_symbol(const char* name) {

View file

@ -11,6 +11,8 @@ export interface NativeClipboard {
getText(): Promise<string | null | undefined>;
/** Undefined means unavailable, null means no image; transfer failures reject. */
getImage(): Promise<Uint8Array | null | undefined>;
/** POSIX paths of file URLs on the clipboard. Undefined means unsupported, null means no files. */
getFilePaths?(): Promise<string[] | null | undefined>;
/** Linux uses command-line tools to retain clipboard ownership instead. */
setText?(text: string): Promise<void>;
}