file drag and drop

This commit is contained in:
Eason Goodale
2025-04-26 12:53:47 -07:00
parent 28410d62af
commit 1a0f4a5e93
6 changed files with 273 additions and 37 deletions

View File

@@ -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: ![alt](path)
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

View 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 ![alt](path)
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 };
}

View File

@@ -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.

View 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);
});
});

View 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 ![alt](foo/bar.png) 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");
});
});

View File

@@ -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"));