Files
codex/codex-cli/src/components/chat/image-picker-overlay.tsx
Eason Goodale 35148c2ba9 tests pass
2025-04-19 18:49:29 -07:00

180 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable import/order */
import path from "node:path";
import { Box, Text, useInput } from "ink";
import { useStdin } from "ink";
import SelectInput from "../select-input/select-input.js";
import { getDirectoryItems } from "../../utils/image-picker-utils.js";
import type { PickerItem } from "../../utils/image-picker-utils.js";
import React, { useMemo, useRef } from "react";
interface Props {
/** Directory the user cannot move above. */
rootDir: string;
/** Current working directory displayed. */
cwd: string;
/** Called when a file is chosen. */
onPick: (filePath: string) => void;
/** Close overlay without selecting. */
onCancel: () => void;
/** Navigate into another directory. */
onChangeDir: (nextDir: string) => void;
}
/** Simple terminal image picker overlay. */
export default function ImagePickerOverlay({
rootDir,
cwd,
onPick,
onCancel,
onChangeDir,
}: Props): JSX.Element {
const items: Array<PickerItem> = useMemo(() => {
return getDirectoryItems(cwd, rootDir);
}, [cwd, rootDir]);
if (process.env.DEBUG_OVERLAY) {
// eslint-disable-next-line no-console
console.log('[overlay] mount, items:', items.map((i) => i.label).join(','));
}
// Keep track of currently highlighted item so <Enter> can act synchronously.
const highlighted = useRef<PickerItem | null>(items[0] ?? null);
// DEBUG: log all raw data when DEBUG_OVERLAY enabled (useful for tests)
const { stdin: inkStdin } = useStdin();
React.useEffect(() => {
function onData(data: Buffer) {
if (process.env.DEBUG_OVERLAY) {
// eslint-disable-next-line no-console
console.log('[overlay] stdin data', JSON.stringify(data.toString()));
}
// ink-testing-library pipes mocked input through `stdin.emit("data", …)`
// but **does not** trigger the lowlevel `readable` event that Inks
// builtin `useInput` hook relies on. As a consequence, our handler
// registered via `useInput` above never fires when running under the
// test harness. Detect the most common keystrokes we care about and
// invoke the same logic manually so that the public behaviour remains
// identical in both real TTY and mocked environments.
const str = data.toString();
// ENTER / RETURN (\r or \n)
if (str === "\r" || str === "\n") {
const item = highlighted.current;
if (!item) return;
if (item.value === "__UP__") {
onChangeDir(path.dirname(cwd));
} else if (item.label.endsWith("/")) {
onChangeDir(item.value);
} else {
onPick(item.value);
}
return;
}
// ESC (\u001B) or Backspace (\x7f)
if (str === "\u001b" || str === "\x7f") {
onCancel();
}
}
if (inkStdin) inkStdin.on('data', onData);
return () => {
if (inkStdin) inkStdin.off('data', onData);
};
}, [inkStdin]);
// Only listen for Escape/backspace at the overlay level; <Enter> is handled
// by the SelectInputs `onSelect` callback (it fires synchronously when the
// user presses Return which is exactly what the inktestinglibrary sends
// in the spec).
useInput(
(input, key) => {
if (process.env.DEBUG_OVERLAY) {
// eslint-disable-next-line no-console
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');
onCancel();
} else if (key.return) {
// Act on the currently highlighted item synchronously so tests that
// simulate a bare "\r" keypress without triggering SelectInputs
// onSelect callback still work. This mirrors <SelectInput>s own
// behaviour but executing the logic here avoids having to depend on
// that implementation detail.
const item = highlighted.current;
if (!item) return;
if (process.env.DEBUG_OVERLAY) {
// eslint-disable-next-line no-console
console.log('[overlay] return on', item.label, item.value);
}
if (item.value === "__UP__") {
onChangeDir(path.dirname(cwd));
} else if (item.label.endsWith("/")) {
onChangeDir(item.value);
} else {
onPick(item.value);
}
}
},
{ isActive: true },
);
return (
<Box
flexDirection="column"
borderStyle="round"
borderColor="gray"
width={60}
>
<Box paddingX={1}>
<Text bold>Select image</Text>
</Box>
{items.length === 0 ? (
<Box paddingX={1}>
<Text dimColor>No images</Text>
</Box>
) : (
<Box flexDirection="column" paddingX={1}>
<SelectInput
key={cwd}
items={items}
limit={10}
isFocused
onHighlight={(item) => {
highlighted.current = item as PickerItem;
}}
onSelect={(item) => {
// We already handle <Enter> via useInput for synchronous action,
// but in case mouse/other events trigger onSelect we replicate.
highlighted.current = item as PickerItem;
// simulate return press behaviour
if (item.value === "__UP__") {
onChangeDir(path.dirname(cwd));
} else if (item.label.endsWith("/")) {
onChangeDir(item.value);
} else {
onPick(item.value);
}
}}
/>
</Box>
)}
<Box paddingX={1}>
<Text dimColor>enter to confirm · esc to cancel</Text>
</Box>
</Box>
);
}