mirror of
https://github.com/openai/codex.git
synced 2026-04-28 02:11:08 +03:00
file drag and drop
This commit is contained in:
@@ -21,7 +21,9 @@ import { clearTerminal, onExit } from "../../utils/terminal.js";
|
||||
// External UI components / Ink helpers
|
||||
import TextInput from "../vendor/ink-text-input.js";
|
||||
import { Box, Text, useApp, useInput, useStdin } from "ink";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Image path detection helper
|
||||
import { extractImagePaths } from "../../utils/image-detector.js";
|
||||
import React, { useCallback, useState, Fragment, useEffect } from "react";
|
||||
import path from "node:path";
|
||||
import fs from "fs/promises";
|
||||
@@ -572,40 +574,30 @@ export default function TerminalChatInput({
|
||||
}
|
||||
}
|
||||
|
||||
// detect image file paths for dynamic inclusion
|
||||
const images: Array<string> = [];
|
||||
let text = inputValue;
|
||||
// markdown-style image syntax: 
|
||||
text = text.replace(/!\[[^\]]*?\]\(([^)]+)\)/g, (_m, p1: string) => {
|
||||
images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
|
||||
return "";
|
||||
});
|
||||
// quoted file paths ending with common image extensions (e.g. '/path/to/img.png')
|
||||
text = text.replace(
|
||||
/['"]([^'"]+?\.(?:png|jpe?g|gif|bmp|webp|svg))['"]/gi,
|
||||
(_m, p1: string) => {
|
||||
images.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
|
||||
return "";
|
||||
},
|
||||
);
|
||||
// bare file paths ending with common image extensions
|
||||
text = text.replace(
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
/\b(?:\.[\/\\]|[\/\\]|[A-Za-z]:[\/\\])?[\w-]+(?:[\/\\][\w-]+)*\.(?:png|jpe?g|gif|bmp|webp|svg)\b/gi,
|
||||
(match: string) => {
|
||||
images.push(
|
||||
match.startsWith("file://") ? fileURLToPath(match) : match,
|
||||
);
|
||||
return "";
|
||||
},
|
||||
);
|
||||
text = text.trim();
|
||||
// Extract image paths from the final draft *once*, right before submit.
|
||||
const { paths: dropped, text } = extractImagePaths(inputValue);
|
||||
|
||||
// Merge images detected from text with those explicitly attached via picker.
|
||||
if (attachedImages.length > 0) {
|
||||
images.push(...attachedImages);
|
||||
// Merge any newly-detected images into state so the preview updates
|
||||
// immediately. Also deduplicate against existing attachments.
|
||||
if (dropped.length > 0) {
|
||||
setAttachedImages((prev) => {
|
||||
const merged = [...prev];
|
||||
for (const p of dropped) {
|
||||
if (!merged.includes(p)) {
|
||||
merged.push(p);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
|
||||
// Build the list we will actually attach to the outgoing message. We
|
||||
// cannot rely on the state update above having flushed yet, so combine
|
||||
// the previous value with the new drops locally.
|
||||
const images: Array<string> = Array.from(
|
||||
new Set([...attachedImages, ...dropped]),
|
||||
);
|
||||
|
||||
// Filter out images that no longer exist on disk. Emit a system
|
||||
// notification for any skipped files so the user is aware.
|
||||
const existingImages: Array<string> = [];
|
||||
@@ -776,11 +768,35 @@ export default function TerminalChatInput({
|
||||
}
|
||||
showCursor
|
||||
value={input}
|
||||
onChange={(value) => {
|
||||
onChange={(rawValue) => {
|
||||
let value = rawValue; // will be replaced after extraction
|
||||
|
||||
// --------------------------------------------------------
|
||||
// Detect freshly-dropped image paths _while the user is
|
||||
// editing_ so the attachment preview updates instantly.
|
||||
// --------------------------------------------------------
|
||||
|
||||
const { paths: newlyDropped, text: cleaned } = extractImagePaths(rawValue);
|
||||
|
||||
value = cleaned; // do not trim spaces – preserve exact typing
|
||||
|
||||
if (newlyDropped.length > 0) {
|
||||
setAttachedImages((prev) => {
|
||||
const merged = [...prev];
|
||||
for (const p of newlyDropped) {
|
||||
if (!merged.includes(p)) {
|
||||
merged.push(p);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env["DEBUG_TCI"]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("onChange", JSON.stringify(value));
|
||||
console.log("onChange", JSON.stringify(value), newlyDropped);
|
||||
}
|
||||
|
||||
// Detect trailing "@" to open image picker.
|
||||
if (pickerCwd == null && value.endsWith("@")) {
|
||||
// Open image picker immediately
|
||||
|
||||
74
codex-cli/src/utils/image-detector.ts
Normal file
74
codex-cli/src/utils/image-detector.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper to find image file paths inside free-form text that users may paste
|
||||
// or drag-drop into the terminal. Returns the cleaned-up text (with the image
|
||||
// references removed) *and* the list of absolute or relative paths that were
|
||||
// found.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const IMAGE_EXT_REGEX =
|
||||
"(?:png|jpe?g|gif|bmp|webp|svg)"; // deliberately kept simple
|
||||
|
||||
// Pattern helpers – compiled lazily so the whole file can be tree-shaken if
|
||||
// unused by a particular build target.
|
||||
let MARKDOWN_LINK_RE: RegExp;
|
||||
let QUOTED_PATH_RE: RegExp;
|
||||
let BARE_PATH_RE: RegExp;
|
||||
|
||||
function compileRegexes() {
|
||||
if (MARKDOWN_LINK_RE) {
|
||||
return;
|
||||
}
|
||||
|
||||
MARKDOWN_LINK_RE = /!\[[^\]]*?\]\(([^)]+)\)/g; // capture path inside ()
|
||||
QUOTED_PATH_RE = new RegExp(
|
||||
`[\'\"]([^\'\"]+?\.${IMAGE_EXT_REGEX})[\'\"]`,
|
||||
"gi",
|
||||
);
|
||||
// eslint-disable-next-line no-useless-escape
|
||||
BARE_PATH_RE = new RegExp(
|
||||
`\\b(?:\\.[\\/\\\\]|[\\/\\\\]|[A-Za-z]:[\\/\\\\])?[\\w-]+(?:[\\/\\\\][\\w-]+)*\\.${IMAGE_EXT_REGEX}\\b`,
|
||||
"gi",
|
||||
);
|
||||
}
|
||||
|
||||
export interface ExtractResult {
|
||||
paths: Array<string>;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function extractImagePaths(input: string): ExtractResult {
|
||||
compileRegexes();
|
||||
|
||||
const paths: Array<string> = [];
|
||||
|
||||
let text = input;
|
||||
|
||||
const replace = (
|
||||
re: RegExp,
|
||||
mapper: (match: string, path: string) => string,
|
||||
) => {
|
||||
text = text.replace(re, mapper);
|
||||
};
|
||||
|
||||
// 1) Markdown 
|
||||
replace(MARKDOWN_LINK_RE, (_m, p1: string) => {
|
||||
paths.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
|
||||
return "";
|
||||
});
|
||||
|
||||
// 2) Quoted
|
||||
replace(QUOTED_PATH_RE, (_m, p1: string) => {
|
||||
paths.push(p1.startsWith("file://") ? fileURLToPath(p1) : p1);
|
||||
return "";
|
||||
});
|
||||
|
||||
// 3) Bare
|
||||
replace(BARE_PATH_RE, (match: string) => {
|
||||
paths.push(match.startsWith("file://") ? fileURLToPath(match) : match);
|
||||
return "";
|
||||
});
|
||||
|
||||
return { paths, text };
|
||||
}
|
||||
@@ -87,9 +87,16 @@ import { loadConfig } from "../src/utils/config.js";
|
||||
|
||||
let projectDir: string;
|
||||
|
||||
# beforeEach runs once per test; when the sandbox blocks mkdtemp under the OS
|
||||
# tmp directory (e.g. GitHub Codespaces or certain container runtimes) fall
|
||||
# back to creating the directory under the current working directory so the
|
||||
# suite can still run.
|
||||
beforeEach(() => {
|
||||
// Create a fresh temporary directory to act as an isolated git repo.
|
||||
projectDir = mkdtempSync(join(tmpdir(), "codex-proj-"));
|
||||
try {
|
||||
projectDir = mkdtempSync(join(tmpdir(), "codex-proj-"));
|
||||
} catch {
|
||||
projectDir = mkdtempSync(join(process.cwd(), "codex-proj-"));
|
||||
}
|
||||
mkdirSync(join(projectDir, ".git")); // mark as project root
|
||||
|
||||
// Write a small project doc that we expect to be included in the prompt.
|
||||
|
||||
109
codex-cli/tests/drag-drop-attach-image.test.tsx
Normal file
109
codex-cli/tests/drag-drop-attach-image.test.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
// Dropping / pasting an image path into the chat input should immediately move
|
||||
// that image into the attached-images preview and remove the path from the draft
|
||||
// text.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import React from "react";
|
||||
import { beforeAll, afterAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderTui } from "./ui-test-helpers.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mocks – keep in sync with other TerminalChatInput UI tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const createInputItemMock = vi.fn(async (_text: string, _imgs: Array<string>) => ({}));
|
||||
|
||||
vi.mock("../src/utils/input-utils.js", () => ({
|
||||
createInputItem: createInputItemMock,
|
||||
imageFilenameByDataUrl: new Map(),
|
||||
}));
|
||||
vi.mock("../src/approvals.js", () => ({ isSafeCommand: () => null }));
|
||||
vi.mock("../src/format-command.js", () => ({
|
||||
formatCommandForDisplay: (c: Array<string>): string => c.join(" "),
|
||||
}));
|
||||
|
||||
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
|
||||
|
||||
async function type(
|
||||
stdin: NodeJS.WritableStream & { write(str: string): void },
|
||||
text: string,
|
||||
flush: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
stdin.write(text);
|
||||
await flush();
|
||||
}
|
||||
|
||||
function props() {
|
||||
return {
|
||||
isNew: true,
|
||||
loading: false,
|
||||
submitInput: () => {},
|
||||
confirmationPrompt: null,
|
||||
submitConfirmation: () => {},
|
||||
setLastResponseId: () => {},
|
||||
setItems: () => {},
|
||||
contextLeftPercent: 100,
|
||||
openOverlay: () => {},
|
||||
openModelOverlay: () => {},
|
||||
openApprovalOverlay: () => {},
|
||||
openHelpOverlay: () => {},
|
||||
interruptAgent: () => {},
|
||||
active: true,
|
||||
onCompact: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
describe("Drag-and-drop image attachment", () => {
|
||||
const TMP = path.join(process.cwd(), "drag-drop-image-test");
|
||||
const IMG = path.join(TMP, "dropped.png");
|
||||
|
||||
beforeAll(() => {
|
||||
fs.mkdirSync(TMP, { recursive: true });
|
||||
fs.writeFileSync(IMG, "");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("moves pasted path to attachment preview", async () => {
|
||||
process.env.DEBUG_TCI = "1";
|
||||
const orig = process.cwd();
|
||||
process.chdir(TMP);
|
||||
|
||||
const { stdin, flush, lastFrameStripped, cleanup } = renderTui(
|
||||
React.createElement(TerminalChatInput, props()),
|
||||
);
|
||||
|
||||
await flush(); // initial render
|
||||
|
||||
// Simulate user pasting the bare filename (as most terminals do when you
|
||||
// drag a file).
|
||||
await type(stdin, "dropped.png ", flush);
|
||||
|
||||
await flush();
|
||||
|
||||
// A second flush to allow state updates triggered asynchronously by
|
||||
// setState inside the onChange handler.
|
||||
await flush();
|
||||
|
||||
let frame = lastFrameStripped();
|
||||
|
||||
|
||||
expect(frame.match(/dropped\.png/g)?.length ?? 0).toBe(1);
|
||||
|
||||
// Now submit the message.
|
||||
await type(stdin, "\r", flush);
|
||||
await flush();
|
||||
|
||||
// createInputItem should have been called with the dropped image path
|
||||
expect(createInputItemMock).toHaveBeenCalled();
|
||||
const lastCall = createInputItemMock.mock.calls.at(-1);
|
||||
expect(lastCall?.[1]).toEqual(["dropped.png"]);
|
||||
|
||||
cleanup();
|
||||
process.chdir(orig);
|
||||
});
|
||||
});
|
||||
25
codex-cli/tests/extract-image-paths.test.ts
Normal file
25
codex-cli/tests/extract-image-paths.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractImagePaths } from "../src/utils/image-detector.js";
|
||||
|
||||
describe("extractImagePaths", () => {
|
||||
it("detects markdown image", () => {
|
||||
const { paths, text } = extractImagePaths(
|
||||
"hello  world",
|
||||
);
|
||||
expect(paths).toEqual(["foo/bar.png"]);
|
||||
expect(text).toBe("hello world");
|
||||
});
|
||||
|
||||
it("detects quoted image", () => {
|
||||
const { paths, text } = extractImagePaths("drag \"baz.jpg\" here");
|
||||
expect(paths).toEqual(["baz.jpg"]);
|
||||
expect(text).toBe("drag here");
|
||||
});
|
||||
|
||||
it("detects bare path", () => {
|
||||
const { paths, text } = extractImagePaths("see /tmp/img.gif please");
|
||||
expect(paths).toEqual(["/tmp/img.gif"]);
|
||||
expect(text).toBe("see please");
|
||||
});
|
||||
});
|
||||
@@ -8,8 +8,13 @@ let projectDir: string;
|
||||
let configPath: string;
|
||||
let instructionsPath: string;
|
||||
|
||||
# Use OS tmpdir unless blocked; fallback to cwd.
|
||||
beforeEach(() => {
|
||||
projectDir = mkdtempSync(join(tmpdir(), "codex-proj-"));
|
||||
try {
|
||||
projectDir = mkdtempSync(join(tmpdir(), "codex-proj-"));
|
||||
} catch {
|
||||
projectDir = mkdtempSync(join(process.cwd(), "codex-proj-"));
|
||||
}
|
||||
// Create fake .git dir to mark project root
|
||||
mkdirSync(join(projectDir, ".git"));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user