diff --git a/codex-cli/package-lock.json b/codex-cli/package-lock.json
index 241a96a5..407e78de 100644
--- a/codex-cli/package-lock.json
+++ b/codex-cli/package-lock.json
@@ -13,9 +13,9 @@
"chalk": "^5.2.0",
"diff": "^7.0.0",
"dotenv": "^16.1.4",
+ "fast-deep-equal": "^3.1.3",
"file-type": "^20.1.0",
"ink": "^5.2.0",
- "ink-select-input": "^6.0.0",
"marked": "^15.0.7",
"marked-terminal": "^7.3.0",
"meow": "^13.2.0",
@@ -23,6 +23,7 @@
"openai": "^4.89.0",
"react": "^18.2.0",
"shell-quote": "^1.8.2",
+ "to-rotated": "^1.0.0",
"use-interval": "1.4.0"
},
"bin": {
@@ -3204,9 +3205,7 @@
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
- "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
- "dev": true,
- "peer": true
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/fast-glob": {
"version": "3.3.3",
@@ -3912,23 +3911,6 @@
}
}
},
- "node_modules/ink-select-input": {
- "version": "6.0.0",
- "resolved": "https://registry.npmjs.org/ink-select-input/-/ink-select-input-6.0.0.tgz",
- "integrity": "sha512-2mCbn1b9xeguA3qJiaf8Sx8W4MM005wACcLKwHWWJmJ8BapjsahmQPuY2U2qyGc817IdWFjNk/K41Vn39UlO4Q==",
- "dependencies": {
- "figures": "^6.1.0",
- "lodash.isequal": "^4.5.0",
- "to-rotated": "^1.0.0"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "ink": ">=5.0.0",
- "react": ">=18.0.0"
- }
- },
"node_modules/ink-testing-library": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/ink-testing-library/-/ink-testing-library-3.0.0.tgz",
@@ -4552,12 +4534,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/lodash.isequal": {
- "version": "4.5.0",
- "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
- "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
- "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead."
- },
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
diff --git a/codex-cli/package.json b/codex-cli/package.json
index c5e0b9df..d9e61f63 100644
--- a/codex-cli/package.json
+++ b/codex-cli/package.json
@@ -34,9 +34,9 @@
"chalk": "^5.2.0",
"diff": "^7.0.0",
"dotenv": "^16.1.4",
+ "fast-deep-equal": "^3.1.3",
"file-type": "^20.1.0",
"ink": "^5.2.0",
- "ink-select-input": "^6.0.0",
"marked": "^15.0.7",
"marked-terminal": "^7.3.0",
"meow": "^13.2.0",
@@ -44,6 +44,7 @@
"openai": "^4.89.0",
"react": "^18.2.0",
"shell-quote": "^1.8.2",
+ "to-rotated": "^1.0.0",
"use-interval": "1.4.0"
},
"devDependencies": {
diff --git a/codex-cli/src/components/select-input/Indicator.tsx b/codex-cli/src/components/select-input/Indicator.tsx
new file mode 100644
index 00000000..713bf086
--- /dev/null
+++ b/codex-cli/src/components/select-input/Indicator.tsx
@@ -0,0 +1,21 @@
+import figures from "figures";
+import { Box, Text } from "ink";
+import React from "react";
+
+export type Props = {
+ readonly isSelected?: boolean;
+};
+
+function Indicator({ isSelected = false }: Props): JSX.Element {
+ return (
+
+ {isSelected ? (
+ {figures.pointer}
+ ) : (
+
+ )}
+
+ );
+}
+
+export default Indicator;
diff --git a/codex-cli/src/components/select-input/Item.tsx b/codex-cli/src/components/select-input/Item.tsx
new file mode 100644
index 00000000..d8b9489c
--- /dev/null
+++ b/codex-cli/src/components/select-input/Item.tsx
@@ -0,0 +1,13 @@
+import { Text } from "ink";
+import * as React from "react";
+
+export type Props = {
+ readonly isSelected?: boolean;
+ readonly label: string;
+};
+
+function Item({ isSelected = false, label }: Props): JSX.Element {
+ return {label};
+}
+
+export default Item;
diff --git a/codex-cli/src/components/select-input/select-input.tsx b/codex-cli/src/components/select-input/select-input.tsx
new file mode 100644
index 00000000..264f3f9e
--- /dev/null
+++ b/codex-cli/src/components/select-input/select-input.tsx
@@ -0,0 +1,189 @@
+import Indicator, { type Props as IndicatorProps } from "./Indicator.js";
+import ItemComponent, { type Props as ItemProps } from "./Item.js";
+import isEqual from "fast-deep-equal";
+import { Box, useInput } from "ink";
+import React, {
+ type FC,
+ useState,
+ useEffect,
+ useRef,
+ useCallback,
+} from "react";
+import arrayToRotated from "to-rotated";
+
+type Props = {
+ /**
+ * Items to display in a list. Each item must be an object and have `label` and `value` props, it may also optionally have a `key` prop.
+ * If no `key` prop is provided, `value` will be used as the item key.
+ */
+ readonly items?: Array- >;
+
+ /**
+ * Listen to user's input. Useful in case there are multiple input components at the same time and input must be "routed" to a specific component.
+ *
+ * @default true
+ */
+ readonly isFocused?: boolean;
+
+ /**
+ * Index of initially-selected item in `items` array.
+ *
+ * @default 0
+ */
+ readonly initialIndex?: number;
+
+ /**
+ * Number of items to display.
+ */
+ readonly limit?: number;
+
+ /**
+ * Custom component to override the default indicator component.
+ */
+ readonly indicatorComponent?: FC;
+
+ /**
+ * Custom component to override the default item component.
+ */
+ readonly itemComponent?: FC;
+
+ /**
+ * Function to call when user selects an item. Item object is passed to that function as an argument.
+ */
+ readonly onSelect?: (item: Item) => void;
+
+ /**
+ * Function to call when user highlights an item. Item object is passed to that function as an argument.
+ */
+ readonly onHighlight?: (item: Item) => void;
+};
+
+export type Item = {
+ key?: string;
+ label: string;
+ value: V;
+};
+
+function SelectInput({
+ items = [],
+ isFocused = true,
+ initialIndex = 0,
+ indicatorComponent = Indicator,
+ itemComponent = ItemComponent,
+ limit: customLimit,
+ onSelect,
+ onHighlight,
+}: Props): JSX.Element {
+ const hasLimit =
+ typeof customLimit === "number" && items.length > customLimit;
+ const limit = hasLimit ? Math.min(customLimit, items.length) : items.length;
+ const lastIndex = limit - 1;
+ const [rotateIndex, setRotateIndex] = useState(
+ initialIndex > lastIndex ? lastIndex - initialIndex : 0,
+ );
+ const [selectedIndex, setSelectedIndex] = useState(
+ initialIndex ? (initialIndex > lastIndex ? lastIndex : initialIndex) : 0,
+ );
+ const previousItems = useRef>>(items);
+
+ useEffect(() => {
+ if (
+ !isEqual(
+ previousItems.current.map((item) => item.value),
+ items.map((item) => item.value),
+ )
+ ) {
+ setRotateIndex(0);
+ setSelectedIndex(0);
+ }
+
+ previousItems.current = items;
+ }, [items]);
+
+ useInput(
+ useCallback(
+ (input, key) => {
+ if (input === "k" || key.upArrow) {
+ const lastIndex = (hasLimit ? limit : items.length) - 1;
+ const atFirstIndex = selectedIndex === 0;
+ const nextIndex = hasLimit ? selectedIndex : lastIndex;
+ const nextRotateIndex = atFirstIndex ? rotateIndex + 1 : rotateIndex;
+ const nextSelectedIndex = atFirstIndex
+ ? nextIndex
+ : selectedIndex - 1;
+
+ setRotateIndex(nextRotateIndex);
+ setSelectedIndex(nextSelectedIndex);
+
+ const slicedItems = hasLimit
+ ? arrayToRotated(items, nextRotateIndex).slice(0, limit)
+ : items;
+
+ if (typeof onHighlight === "function") {
+ onHighlight(slicedItems[nextSelectedIndex]!);
+ }
+ }
+
+ if (input === "j" || key.downArrow) {
+ const atLastIndex =
+ selectedIndex === (hasLimit ? limit : items.length) - 1;
+ const nextIndex = hasLimit ? selectedIndex : 0;
+ const nextRotateIndex = atLastIndex ? rotateIndex - 1 : rotateIndex;
+ const nextSelectedIndex = atLastIndex ? nextIndex : selectedIndex + 1;
+
+ setRotateIndex(nextRotateIndex);
+ setSelectedIndex(nextSelectedIndex);
+
+ const slicedItems = hasLimit
+ ? arrayToRotated(items, nextRotateIndex).slice(0, limit)
+ : items;
+
+ if (typeof onHighlight === "function") {
+ onHighlight(slicedItems[nextSelectedIndex]!);
+ }
+ }
+
+ if (key.return) {
+ const slicedItems = hasLimit
+ ? arrayToRotated(items, rotateIndex).slice(0, limit)
+ : items;
+
+ if (typeof onSelect === "function") {
+ onSelect(slicedItems[selectedIndex]!);
+ }
+ }
+ },
+ [
+ hasLimit,
+ limit,
+ rotateIndex,
+ selectedIndex,
+ items,
+ onSelect,
+ onHighlight,
+ ],
+ ),
+ { isActive: isFocused },
+ );
+
+ const slicedItems = hasLimit
+ ? arrayToRotated(items, rotateIndex).slice(0, limit)
+ : items;
+
+ return (
+
+ {slicedItems.map((item, index) => {
+ const isSelected = index === selectedIndex;
+
+ return (
+
+ {React.createElement(indicatorComponent, { isSelected })}
+ {React.createElement(itemComponent, { ...item, isSelected })}
+
+ );
+ })}
+
+ );
+}
+
+export default SelectInput;
diff --git a/codex-cli/src/components/typeahead-overlay.tsx b/codex-cli/src/components/typeahead-overlay.tsx
index d7c3d654..df1610be 100644
--- a/codex-cli/src/components/typeahead-overlay.tsx
+++ b/codex-cli/src/components/typeahead-overlay.tsx
@@ -1,6 +1,6 @@
+import SelectInput from "./select-input/select-input.js";
import TextInput from "./vendor/ink-text-input.js";
import { Box, Text, useInput } from "ink";
-import SelectInput from "ink-select-input";
import React, { useState } from "react";
export type TypeaheadItem = { label: string; value: string };
diff --git a/codex-cli/tests/typeahead-scroll.test.tsx b/codex-cli/tests/typeahead-scroll.test.tsx
index 49614a41..fab7c753 100644
--- a/codex-cli/tests/typeahead-scroll.test.tsx
+++ b/codex-cli/tests/typeahead-scroll.test.tsx
@@ -9,14 +9,13 @@ import * as React from "react";
import { describe, it, expect, vi } from "vitest";
// ---------------------------------------------------------------------------
-// Mock so we can capture the props that TypeaheadOverlay
+// Mock so we can capture the props that TypeaheadOverlay
// forwards without rendering the real component (which would require a full
// Ink TTY environment).
// ---------------------------------------------------------------------------
let receivedItems: Array<{ label: string; value: string }> | null = null;
-
-vi.mock("ink-select-input", () => {
+vi.mock("../src/components/select-input/select-input.js", () => {
return {
default: (props: any) => {
receivedItems = props.items;