This commit is contained in:
Eason Goodale
2025-04-19 22:55:37 -07:00
parent 2bcc15a839
commit 1e0a7cc313
7 changed files with 168 additions and 39 deletions
@@ -2,8 +2,7 @@
import path from "node:path";
import { Box, Text, useInput } from "ink";
import { useStdin } from "ink";
import { Box, Text, useInput, useStdin } from "ink";
import SelectInput from "../select-input/select-input.js";
@@ -36,7 +35,7 @@ export default function ImagePickerOverlay({
return getDirectoryItems(cwd, rootDir);
}, [cwd, rootDir]);
if (process.env.DEBUG_OVERLAY) {
if (process.env["DEBUG_OVERLAY"]) {
// eslint-disable-next-line no-console
console.log('[overlay] mount, items:', items.map((i) => i.label).join(','));
}
@@ -52,7 +51,9 @@ export default function ImagePickerOverlay({
const actedRef = useRef(false);
function perform(action: () => void) {
if (actedRef.current) return;
if (actedRef.current) {
return;
}
actedRef.current = true;
action();
}
@@ -61,7 +62,7 @@ export default function ImagePickerOverlay({
const { stdin: inkStdin } = useStdin();
React.useEffect(() => {
function onData(data: Buffer) {
if (process.env.DEBUG_OVERLAY) {
if (process.env["DEBUG_OVERLAY"]) {
// eslint-disable-next-line no-console
console.log('[overlay] stdin data', JSON.stringify(data.toString()));
}
@@ -79,7 +80,9 @@ export default function ImagePickerOverlay({
// ENTER / RETURN (\r or \n)
if (str === "\r" || str === "\n") {
const item = highlighted.current;
if (!item) return;
if (!item) {
return;
}
perform(() => {
if (item.value === "__UP__") {
@@ -98,11 +101,15 @@ export default function ImagePickerOverlay({
perform(onCancel);
}
}
if (inkStdin) inkStdin.on('data', onData);
if (inkStdin) {
inkStdin.on("data", onData);
}
return () => {
if (inkStdin) inkStdin.off('data', onData);
if (inkStdin) {
inkStdin.off("data", onData);
}
};
}, [inkStdin]);
}, [inkStdin, cwd, onCancel, onChangeDir, onPick]);
// Only listen for Escape/backspace at the overlay level; <Enter> is handled
// by the SelectInput’s `onSelect` callback (it fires synchronously when the
@@ -110,12 +117,20 @@ export default function ImagePickerOverlay({
// in the spec).
useInput(
(input, key) => {
if (process.env.DEBUG_OVERLAY) {
if (process.env["DEBUG_OVERLAY"]) {
// eslint-disable-next-line no-console
console.log('[overlay] root useInput', JSON.stringify(input), key.return);
console.log(
"[overlay] root useInput",
JSON.stringify(input),
key.return,
);
}
if (key.escape || key.backspace || input === "\u007f") {
if (process.env.DEBUG_OVERLAY) console.log('[overlay] cancel');
if (key.escape || key.backspace || input === "\u007f") {
if (process.env["DEBUG_OVERLAY"]) {
// eslint-disable-next-line no-console
console.log("[overlay] cancel");
}
perform(onCancel);
} else if (key.return) {
// Act on the currently highlighted item synchronously so tests that
@@ -125,9 +140,11 @@ export default function ImagePickerOverlay({
// that implementation detail.
const item = highlighted.current;
if (!item) return;
if (!item) {
return;
}
if (process.env.DEBUG_OVERLAY) {
if (process.env["DEBUG_OVERLAY"]) {
// eslint-disable-next-line no-console
console.log('[overlay] return on', item.label, item.value);
}
@@ -19,6 +19,7 @@ import { clearTerminal, onExit } from "../../utils/terminal.js";
import { fileURLToPath } from "node:url";
import React, { useCallback, useState, Fragment, useEffect } from "react";
import path from "node:path";
import fs from "fs/promises";
import { Box, Text, useApp, useInput, useStdin } from "ink";
import Spinner from "../vendor/ink-spinner.js";
import TextInput from "../vendor/ink-text-input.js";
@@ -81,7 +82,7 @@ export default function TerminalChatInput({
const [pickerCwd, setPickerCwd] = useState<string | null>(null);
const [pickerRoot, setPickerRoot] = useState<string | null>(null);
if (process.env.DEBUG_TCI) {
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log('[TCI] render stage', { input, pickerCwd, attachedCount: attachedImages.length });
}
@@ -102,7 +103,9 @@ export default function TerminalChatInput({
const { stdin: inkStdin, setRawMode } = useStdin();
React.useEffect(() => {
if (!active) return;
if (!active) {
return;
}
// Ensure raw mode so we actually receive data events.
setRawMode?.(true);
@@ -110,7 +113,7 @@ export default function TerminalChatInput({
function onData(data: Buffer | string) {
const str = Buffer.isBuffer(data) ? data.toString("utf8") : data;
if (process.env.DEBUG_TCI) {
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log('[TCI] raw stdin', JSON.stringify(str));
}
@@ -140,8 +143,10 @@ export default function TerminalChatInput({
}
inkStdin?.on("data", onData);
return () => inkStdin?.off("data", onData);
}, [inkStdin, active, pickerCwd, attachedImages.length, input]);
return () => {
inkStdin?.off("data", onData);
};
}, [inkStdin, active, pickerCwd, attachedImages.length, input, setRawMode]);
// Load command history on component mount
useEffect(() => {
@@ -155,7 +160,7 @@ export default function TerminalChatInput({
useInput(
(_input, _key) => {
if (process.env.DEBUG_TCI) {
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log('[TCI] useInput raw', JSON.stringify(_input), _key);
}
@@ -165,7 +170,7 @@ export default function TerminalChatInput({
return; // ignore here; overlay has its own handlers
}
if (!confirmationPrompt && !loading) {
if (process.env.DEBUG_TCI) {
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log('useInput received', JSON.stringify(_input));
}
@@ -409,9 +414,49 @@ export default function TerminalChatInput({
images.push(...attachedImages);
}
const inputItem = await createInputItem(text, images);
// 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> = [];
const missingImages: Array<string> = [];
for (const filePath of images) {
try {
// eslint-disable-next-line no-await-in-loop
await fs.access(filePath);
existingImages.push(filePath);
} catch (err: unknown) {
const e = err as NodeJS.ErrnoException;
if (e?.code === "ENOENT") {
missingImages.push(filePath);
} else {
throw err;
}
}
}
const inputItem = await createInputItem(text, existingImages);
submitInput([inputItem]);
if (missingImages.length > 0) {
setItems((prev) => [
...prev,
{
id: `missing-images-${Date.now()}`,
type: "message",
role: "system",
content: [
{
type: "input_text",
text:
missingImages.length === 1
? `Warning: image "${missingImages[0]}" not found and was not attached.`
: `Warning: ${missingImages.length} images were not found and were skipped: ${missingImages.join(", ")}`,
},
],
},
]);
}
// Get config for history persistence
const config = loadConfig();
@@ -473,7 +518,7 @@ export default function TerminalChatInput({
prev.includes(filePath) ? prev : [...prev, filePath],
);
if (process.env.DEBUG_TCI) {
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log('[TCI] attached image added', filePath, 'total', attachedImages.length + 1);
}
@@ -488,7 +533,7 @@ export default function TerminalChatInput({
if (attachedImages.length === 0) {
return null;
}
if (process.env.DEBUG_TCI) {
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log('[TCI] render AttachmentPreview', attachedImages);
}
@@ -523,8 +568,10 @@ export default function TerminalChatInput({
showCursor
value={input}
onChange={(value) => {
// eslint-disable-next-line no-console
if (process.env.DEBUG_TCI) console.log('onChange', JSON.stringify(value));
if (process.env["DEBUG_TCI"]) {
// eslint-disable-next-line no-console
console.log("onChange", JSON.stringify(value));
}
// Detect trailing "@" to open image picker.
if (pickerCwd == null && value.endsWith("@")) {
// Open image picker immediately
+17 -1
View File
@@ -20,7 +20,23 @@ export async function createInputItem(
for (const filePath of images) {
/* eslint-disable no-await-in-loop */
const binary = await fs.readFile(filePath);
let binary: Buffer;
try {
binary = await fs.readFile(filePath);
} catch (err: unknown) {
// Gracefully skip files that no longer exist on disk. This can happen
// when an image was attached earlier but has since been moved or
// deleted before the user submitted the prompt. For any other error
// codes re‑throw so callers are still notified of unexpected issues
// (e.g. permission errors).
const e = err as NodeJS.ErrnoException;
if (e?.code === "ENOENT") {
// Skip silently – user will simply not include the missing image.
continue;
}
throw err as Error;
}
const kind = await fileTypeFromBuffer(binary);
/* eslint-enable no-await-in-loop */
const encoded = binary.toString("base64");
+7 -2
View File
@@ -15,12 +15,17 @@ vi.mock("../src/utils/input-utils.js", () => ({
// mock external deps used inside chat input
vi.mock("../../approvals.js", () => ({ isSafeCommand: () => null }));
vi.mock("../src/format-command.js", () => ({
formatCommandForDisplay: (c) => c.join(" "),
// Accept an array of command tokens and join them with spaces for display.
formatCommandForDisplay: (c: Array<string>): string => c.join(" "),
}));
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
async function type(stdin, text, flush) {
async function type(
stdin: NodeJS.WritableStream & { write(str: string): void },
text: string,
flush: () => Promise<void>,
): Promise<void> {
stdin.write(text);
await flush();
}
@@ -13,12 +13,16 @@ vi.mock("../src/utils/input-utils.js", () => ({
}));
vi.mock("../../approvals.js", () => ({ isSafeCommand: () => null }));
vi.mock("../src/format-command.js", () => ({
formatCommandForDisplay: (c) => c.join(" "),
formatCommandForDisplay: (c: Array<string>): string => c.join(" "),
}));
import TerminalChatInput from "../src/components/chat/terminal-chat-input.js";
async function type(stdin, text, flush) {
async function type(
stdin: NodeJS.WritableStream & { write(str: string): void },
text: string,
flush: () => Promise<void>,
): Promise<void> {
stdin.write(text);
await flush();
}
@@ -67,7 +71,6 @@ describe("Backspace deletes attached image", () => {
await flush();
await type(stdin, "@", flush);
console.log('AFTER @', lastFrameStripped());
await type(stdin, "\r", flush);
const frame1 = lastFrameStripped();
expect(frame1.match(/bar\.png/g)?.length ?? 0).toBe(1);
+7 -3
View File
@@ -12,14 +12,18 @@ vi.mock("../src/utils/input-utils.js", () => ({
import ImagePickerOverlay from "../src/components/chat/image-picker-overlay.js";
async function type(stdin, text, flush) {
async function type(
stdin: NodeJS.WritableStream & { write(str: string): void },
text: string,
flush: () => Promise<void>,
): Promise<void> {
stdin.write(text);
await flush();
}
describe("Image picker overlay", () => {
let TMP;
let CHILD;
let TMP: string;
let CHILD: string;
beforeAll(() => {
TMP = fs.mkdtempSync(path.join(process.cwd(), "overlay-test-"));
+40 -3
View File
@@ -5,7 +5,15 @@ import { renderTui } from "./ui-test-helpers.js";
import TerminalInlineImage from "../src/components/chat/terminal-inline-image.js";
import TerminalChatResponseItem from "../src/components/chat/terminal-chat-response-item.js";
import { imageFilenameByDataUrl } from "../src/utils/input-utils.js";
import { imageFilenameByDataUrl, createInputItem } from "../src/utils/input-utils.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
import path from "node:path";
import fs from "node:fs";
describe("TerminalInlineImage fallback", () => {
it("renders alt text in test env", () => {
@@ -16,10 +24,11 @@ describe("TerminalInlineImage fallback", () => {
});
});
function fakeImageMessage(filename) {
function fakeImageMessage(filename: string) {
const url = "data:image/png;base64,AAA";
imageFilenameByDataUrl.set(url, filename);
return {
id: "test-id",
type: "message",
role: "user",
content: [
@@ -33,8 +42,36 @@ describe("TerminalChatResponseItem image label", () => {
it("shows filename", () => {
const msg = fakeImageMessage("sample.png");
const { lastFrameStripped } = renderTui(
<TerminalChatResponseItem item={msg} />
<TerminalChatResponseItem item={msg as any} />
);
expect(lastFrameStripped()).toContain("sample.png");
});
});
// ---------------------------------------------------------------------------
// New tests – ensure createInputItem gracefully skips missing images.
// ---------------------------------------------------------------------------
describe("createInputItem – missing images", () => {
it("ignores images that never existed on disk (conversation start)", async () => {
const item = await createInputItem("hello", ["ghost.png"]);
expect(item.content.some((c) => c.type === "input_image")).toBe(false);
});
it("ignores images deleted before submit (mid‑conversation)", async () => {
const tmpDir = fs.mkdtempSync(path.join(process.cwd(), "missing-img-"));
const imgPath = path.join(tmpDir, "temp.png");
fs.writeFileSync(imgPath, "dummy");
// Remove the file before we construct the message.
fs.rmSync(imgPath);
const item = await createInputItem("", [imgPath]);
expect(item.content.some((c) => c.type === "input_image")).toBe(false);
fs.rmSync(tmpDir, { recursive: true, force: true });
});
// Additional integration tests for the system‑level warning are covered in
// higher‑level suites. This unit file focuses on createInputItem behaviour.
});