(fix) do not transitively rely on deprecated lodash deps (#175)

Signed-off-by: Thibault Sottiaux <tibo@openai.com>
This commit is contained in:
Thibault Sottiaux
2025-04-16 20:52:35 -07:00
committed by GitHub
parent 057f113c6d
commit f3f9e41a15
7 changed files with 231 additions and 32 deletions

View File

@@ -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",

View File

@@ -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": {

View File

@@ -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 (
<Box marginRight={1}>
{isSelected ? (
<Text color="blue">{figures.pointer}</Text>
) : (
<Text> </Text>
)}
</Box>
);
}
export default Indicator;

View File

@@ -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 <Text color={isSelected ? "blue" : undefined}>{label}</Text>;
}
export default Item;

View File

@@ -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<V> = {
/**
* 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<Item<V>>;
/**
* 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<IndicatorProps>;
/**
* Custom component to override the default item component.
*/
readonly itemComponent?: FC<ItemProps>;
/**
* Function to call when user selects an item. Item object is passed to that function as an argument.
*/
readonly onSelect?: (item: Item<V>) => void;
/**
* Function to call when user highlights an item. Item object is passed to that function as an argument.
*/
readonly onHighlight?: (item: Item<V>) => void;
};
export type Item<V> = {
key?: string;
label: string;
value: V;
};
function SelectInput<V>({
items = [],
isFocused = true,
initialIndex = 0,
indicatorComponent = Indicator,
itemComponent = ItemComponent,
limit: customLimit,
onSelect,
onHighlight,
}: Props<V>): 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<Array<Item<V>>>(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 (
<Box flexDirection="column">
{slicedItems.map((item, index) => {
const isSelected = index === selectedIndex;
return (
<Box key={item.key ?? String(item.value)}>
{React.createElement(indicatorComponent, { isSelected })}
{React.createElement(itemComponent, { ...item, isSelected })}
</Box>
);
})}
</Box>
);
}
export default SelectInput;

View File

@@ -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 };

View File

@@ -9,14 +9,13 @@ import * as React from "react";
import { describe, it, expect, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock <ink-select-input> so we can capture the props that TypeaheadOverlay
// Mock <select-input> 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;