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;