Initial commit

Signed-off-by: Ilan Bigio <ilan@openai.com>
This commit is contained in:
Ilan Bigio
2025-04-16 12:56:08 -04:00
commit 59a180ddec
163 changed files with 30587 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
export * from "./select.js";

View File

@@ -0,0 +1,26 @@
export default class OptionMap extends Map {
first;
constructor(options) {
const items = [];
let firstItem;
let previous;
let index = 0;
for (const option of options) {
const item = {
...option,
previous,
next: undefined,
index,
};
if (previous) {
previous.next = item;
}
firstItem ||= item;
items.push([option.value, item]);
index++;
previous = item;
}
super(items);
this.first = firstItem;
}
}

View File

@@ -0,0 +1,27 @@
import React from "react";
import { Box, Text } from "ink";
import figures from "figures";
import { styles } from "./theme";
export function SelectOption({ isFocused, isSelected, children }) {
return React.createElement(
Box,
{ ...styles.option({ isFocused }) },
isFocused &&
React.createElement(
Text,
{ ...styles.focusIndicator() },
figures.pointer,
),
React.createElement(
Text,
{ ...styles.label({ isFocused, isSelected }) },
children,
),
isSelected &&
React.createElement(
Text,
{ ...styles.selectedIndicator() },
figures.tick,
),
);
}

View File

@@ -0,0 +1,53 @@
import React from "react";
import { Box, Text } from "ink";
import { styles } from "./theme";
import { SelectOption } from "./select-option";
import { useSelectState } from "./use-select-state";
import { useSelect } from "./use-select";
export function Select({
isDisabled = false,
visibleOptionCount = 5,
highlightText,
options,
defaultValue,
onChange,
}) {
const state = useSelectState({
visibleOptionCount,
options,
defaultValue,
onChange,
});
useSelect({ isDisabled, state });
return React.createElement(
Box,
{ ...styles.container() },
state.visibleOptions.map((option) => {
// eslint-disable-next-line prefer-destructuring
let label = option.label;
if (highlightText && option.label.includes(highlightText)) {
const index = option.label.indexOf(highlightText);
label = React.createElement(
React.Fragment,
null,
option.label.slice(0, index),
React.createElement(
Text,
{ ...styles.highlightedText() },
highlightText,
),
option.label.slice(index + highlightText.length),
);
}
return React.createElement(
SelectOption,
{
key: option.value,
isFocused: !isDisabled && state.focusedValue === option.value,
isSelected: state.value === option.value,
},
label,
);
}),
);
}

View File

@@ -0,0 +1,32 @@
const theme = {
styles: {
container: () => ({
flexDirection: "column",
}),
option: ({ isFocused }) => ({
gap: 1,
paddingLeft: isFocused ? 0 : 2,
}),
selectedIndicator: () => ({
color: "green",
}),
focusIndicator: () => ({
color: "blue",
}),
label({ isFocused, isSelected }) {
let color;
if (isSelected) {
color = "green";
}
if (isFocused) {
color = "blue";
}
return { color };
},
highlightedText: () => ({
bold: true,
}),
},
};
export const styles = theme.styles;
export default theme;

View File

@@ -0,0 +1,158 @@
import { isDeepStrictEqual } from "node:util";
import { useReducer, useCallback, useMemo, useState, useEffect } from "react";
import OptionMap from "./option-map";
const reducer = (state, action) => {
switch (action.type) {
case "focus-next-option": {
if (!state.focusedValue) {
return state;
}
const item = state.optionMap.get(state.focusedValue);
if (!item) {
return state;
}
// eslint-disable-next-line prefer-destructuring
const next = item.next;
if (!next) {
return state;
}
const needsToScroll = next.index >= state.visibleToIndex;
if (!needsToScroll) {
return {
...state,
focusedValue: next.value,
};
}
const nextVisibleToIndex = Math.min(
state.optionMap.size,
state.visibleToIndex + 1,
);
const nextVisibleFromIndex =
nextVisibleToIndex - state.visibleOptionCount;
return {
...state,
focusedValue: next.value,
visibleFromIndex: nextVisibleFromIndex,
visibleToIndex: nextVisibleToIndex,
};
}
case "focus-previous-option": {
if (!state.focusedValue) {
return state;
}
const item = state.optionMap.get(state.focusedValue);
if (!item) {
return state;
}
// eslint-disable-next-line prefer-destructuring
const previous = item.previous;
if (!previous) {
return state;
}
const needsToScroll = previous.index <= state.visibleFromIndex;
if (!needsToScroll) {
return {
...state,
focusedValue: previous.value,
};
}
const nextVisibleFromIndex = Math.max(0, state.visibleFromIndex - 1);
const nextVisibleToIndex =
nextVisibleFromIndex + state.visibleOptionCount;
return {
...state,
focusedValue: previous.value,
visibleFromIndex: nextVisibleFromIndex,
visibleToIndex: nextVisibleToIndex,
};
}
case "select-focused-option": {
return {
...state,
previousValue: state.value,
value: state.focusedValue,
};
}
case "reset": {
return action.state;
}
}
};
const createDefaultState = ({
visibleOptionCount: customVisibleOptionCount,
defaultValue,
options,
}) => {
const visibleOptionCount =
typeof customVisibleOptionCount === "number"
? Math.min(customVisibleOptionCount, options.length)
: options.length;
const optionMap = new OptionMap(options);
return {
optionMap,
visibleOptionCount,
focusedValue: optionMap.first?.value,
visibleFromIndex: 0,
visibleToIndex: visibleOptionCount,
previousValue: defaultValue,
value: defaultValue,
};
};
export const useSelectState = ({
visibleOptionCount = 5,
options,
defaultValue,
onChange,
}) => {
const [state, dispatch] = useReducer(
reducer,
{ visibleOptionCount, defaultValue, options },
createDefaultState,
);
const [lastOptions, setLastOptions] = useState(options);
if (options !== lastOptions && !isDeepStrictEqual(options, lastOptions)) {
dispatch({
type: "reset",
state: createDefaultState({ visibleOptionCount, defaultValue, options }),
});
setLastOptions(options);
}
const focusNextOption = useCallback(() => {
dispatch({
type: "focus-next-option",
});
}, []);
const focusPreviousOption = useCallback(() => {
dispatch({
type: "focus-previous-option",
});
}, []);
const selectFocusedOption = useCallback(() => {
dispatch({
type: "select-focused-option",
});
}, []);
const visibleOptions = useMemo(() => {
return options
.map((option, index) => ({
...option,
index,
}))
.slice(state.visibleFromIndex, state.visibleToIndex);
}, [options, state.visibleFromIndex, state.visibleToIndex]);
useEffect(() => {
if (state.value && state.previousValue !== state.value) {
onChange?.(state.value);
}
}, [state.previousValue, state.value, options, onChange]);
return {
focusedValue: state.focusedValue,
visibleFromIndex: state.visibleFromIndex,
visibleToIndex: state.visibleToIndex,
value: state.value,
visibleOptions,
focusNextOption,
focusPreviousOption,
selectFocusedOption,
};
};

View File

@@ -0,0 +1,17 @@
import { useInput } from "ink";
export const useSelect = ({ isDisabled = false, state }) => {
useInput(
(_input, key) => {
if (key.downArrow) {
state.focusNextOption();
}
if (key.upArrow) {
state.focusPreviousOption();
}
if (key.return) {
state.selectFocusedOption();
}
},
{ isActive: !isDisabled },
);
};

View File

@@ -0,0 +1,36 @@
import { Text } from "ink";
import React, { useState } from "react";
import { useInterval } from "use-interval";
const spinnerTypes: Record<string, string[]> = {
dots: ["⢎ ", "⠎⠁", "⠊⠑", "⠈⠱", " ⡱", "⢀⡰", "⢄⡠", "⢆⡀"],
ball: [
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"( ●)",
"( ● )",
"( ● )",
"( ● )",
"( ● )",
"(● )",
],
};
export default function Spinner({
type = "dots",
}: {
type?: string;
}): JSX.Element {
const frames = spinnerTypes[type || "dots"] || [];
const interval = 80;
const [frame, setFrame] = useState(0);
useInterval(() => {
setFrame((previousFrame) => {
const isLastFrame = previousFrame === frames.length - 1;
return isLastFrame ? 0 : previousFrame + 1;
});
}, interval);
return <Text>{frames[frame]}</Text>;
}

View File

@@ -0,0 +1,338 @@
import React, { useEffect, useState } from "react";
import { Text, useInput } from "ink";
import chalk from "chalk";
import type { Except } from "type-fest";
export type TextInputProps = {
/**
* Text to display when `value` is empty.
*/
readonly placeholder?: string;
/**
* 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.
*/
readonly focus?: boolean; // eslint-disable-line react/boolean-prop-naming
/**
* Replace all chars and mask the value. Useful for password inputs.
*/
readonly mask?: string;
/**
* Whether to show cursor and allow navigation inside text input with arrow keys.
*/
readonly showCursor?: boolean; // eslint-disable-line react/boolean-prop-naming
/**
* Highlight pasted text
*/
readonly highlightPastedText?: boolean; // eslint-disable-line react/boolean-prop-naming
/**
* Value to display in a text input.
*/
readonly value: string;
/**
* Function to call when value updates.
*/
readonly onChange: (value: string) => void;
/**
* Function to call when `Enter` is pressed, where first argument is a value of the input.
*/
readonly onSubmit?: (value: string) => void;
};
function findPrevWordJump(prompt: string, cursorOffset: number) {
const regex = /[\s,.;!?]+/g;
let lastMatch = 0;
let currentMatch: RegExpExecArray | null;
const stringToCursorOffset = prompt
.slice(0, cursorOffset)
.replace(/[\s,.;!?]+$/, "");
// Loop through all matches
while ((currentMatch = regex.exec(stringToCursorOffset)) !== null) {
lastMatch = currentMatch.index;
}
// Include the last match unless it is the first character
if (lastMatch != 0) {
lastMatch += 1;
}
return lastMatch;
}
function findNextWordJump(prompt: string, cursorOffset: number) {
const regex = /[\s,.;!?]+/g;
let currentMatch: RegExpExecArray | null;
// Loop through all matches
while ((currentMatch = regex.exec(prompt)) !== null) {
if (currentMatch.index > cursorOffset) {
return currentMatch.index + 1;
}
}
return prompt.length;
}
function TextInput({
value: originalValue,
placeholder = "",
focus = true,
mask,
highlightPastedText = false,
showCursor = true,
onChange,
onSubmit,
}: TextInputProps) {
const [state, setState] = useState({
cursorOffset: (originalValue || "").length,
cursorWidth: 0,
});
const { cursorOffset, cursorWidth } = state;
useEffect(() => {
setState((previousState) => {
if (!focus || !showCursor) {
return previousState;
}
const newValue = originalValue || "";
if (previousState.cursorOffset > newValue.length - 1) {
return {
cursorOffset: newValue.length,
cursorWidth: 0,
};
}
return previousState;
});
}, [originalValue, focus, showCursor]);
const cursorActualWidth = highlightPastedText ? cursorWidth : 0;
const value = mask ? mask.repeat(originalValue.length) : originalValue;
let renderedValue = value;
let renderedPlaceholder = placeholder ? chalk.grey(placeholder) : undefined;
// Fake mouse cursor, because it's too inconvenient to deal with actual cursor and ansi escapes.
if (showCursor && focus) {
renderedPlaceholder =
placeholder.length > 0
? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1))
: chalk.inverse(" ");
renderedValue = value.length > 0 ? "" : chalk.inverse(" ");
let i = 0;
for (const char of value) {
renderedValue +=
i >= cursorOffset - cursorActualWidth && i <= cursorOffset
? chalk.inverse(char)
: char;
i++;
}
if (value.length > 0 && cursorOffset === value.length) {
renderedValue += chalk.inverse(" ");
}
}
useInput(
(input, key) => {
if (
key.upArrow ||
key.downArrow ||
(key.ctrl && input === "c") ||
key.tab ||
(key.shift && key.tab)
) {
return;
}
let nextCursorOffset = cursorOffset;
let nextValue = originalValue;
let nextCursorWidth = 0;
// TODO: continue improving the cursor management to feel native
if (key.return) {
if (key.meta) {
// This does not work yet. We would like to have this behavior:
// Mac terminal: Settings → Profiles → Keyboard → Use Option as Meta key
// iTerm2: Open Settings → Profiles → Keys → General → Set Left/Right Option as Esc+
// And then when Option+ENTER is pressed, we want to insert a newline.
// However, even with the settings, the input="\n" and only key.shift is True.
// This is likely an artifact of how ink works.
nextValue =
originalValue.slice(0, cursorOffset) +
"\n" +
originalValue.slice(cursorOffset, originalValue.length);
nextCursorOffset++;
} else {
// Handle Enter key: support bash-style line continuation with backslash
// -- count consecutive backslashes immediately before cursor
// -- only a single trailing backslash at end indicates line continuation
const isAtEnd = cursorOffset === originalValue.length;
const trailingMatch = originalValue.match(/\\+$/);
const trailingCount = trailingMatch ? trailingMatch[0].length : 0;
if (isAtEnd && trailingCount === 1) {
nextValue += "\n";
nextCursorOffset = nextValue.length;
nextCursorWidth = 0;
} else if (onSubmit) {
onSubmit(originalValue);
return;
}
}
} else if ((key.ctrl && input === "a") || (key.meta && key.leftArrow)) {
nextCursorOffset = 0;
} else if ((key.ctrl && input === "e") || (key.meta && key.rightArrow)) {
// Move cursor to end of line
nextCursorOffset = originalValue.length;
// Emacs/readline-style navigation and editing shortcuts
} else if (key.ctrl && input === "b") {
// Move cursor backward by one
if (showCursor) {
nextCursorOffset = Math.max(cursorOffset - 1, 0);
}
} else if (key.ctrl && input === "f") {
// Move cursor forward by one
if (showCursor) {
nextCursorOffset = Math.min(cursorOffset + 1, originalValue.length);
}
} else if (key.ctrl && input === "d") {
// Delete character at cursor (forward delete)
if (cursorOffset < originalValue.length) {
nextValue =
originalValue.slice(0, cursorOffset) +
originalValue.slice(cursorOffset + 1);
}
} else if (key.ctrl && input === "k") {
// Kill text from cursor to end of line
nextValue = originalValue.slice(0, cursorOffset);
} else if (key.ctrl && input === "u") {
// Kill text from start to cursor
nextValue = originalValue.slice(cursorOffset);
nextCursorOffset = 0;
} else if (key.ctrl && input === "w") {
// Delete the word before cursor
{
const left = originalValue.slice(0, cursorOffset);
const match = left.match(/\s*\S+$/);
const cut = match ? match[0].length : cursorOffset;
nextValue =
originalValue.slice(0, cursorOffset - cut) +
originalValue.slice(cursorOffset);
nextCursorOffset = cursorOffset - cut;
}
} else if (key.meta && (key.backspace || key.delete)) {
const regex = /[\s,.;!?]+/g;
let lastMatch = 0;
let currentMatch: RegExpExecArray | null;
const stringToCursorOffset = originalValue
.slice(0, cursorOffset)
.replace(/[\s,.;!?]+$/, "");
// Loop through all matches
while ((currentMatch = regex.exec(stringToCursorOffset)) !== null) {
lastMatch = currentMatch.index;
}
// Include the last match unless it is the first character
if (lastMatch != 0) {
lastMatch += 1;
}
nextValue =
stringToCursorOffset.slice(0, lastMatch) +
originalValue.slice(cursorOffset, originalValue.length);
nextCursorOffset = lastMatch;
} else if (key.meta && (input === "b" || key.leftArrow)) {
nextCursorOffset = findPrevWordJump(originalValue, cursorOffset);
} else if (key.meta && (input === "f" || key.rightArrow)) {
nextCursorOffset = findNextWordJump(originalValue, cursorOffset);
} else if (key.leftArrow) {
if (showCursor) {
nextCursorOffset--;
}
} else if (key.rightArrow) {
if (showCursor) {
nextCursorOffset++;
}
} else if (key.backspace || key.delete) {
if (cursorOffset > 0) {
nextValue =
originalValue.slice(0, cursorOffset - 1) +
originalValue.slice(cursorOffset, originalValue.length);
nextCursorOffset--;
}
} else {
nextValue =
originalValue.slice(0, cursorOffset) +
input +
originalValue.slice(cursorOffset, originalValue.length);
nextCursorOffset += input.length;
if (input.length > 1) {
nextCursorWidth = input.length;
}
}
if (cursorOffset < 0) {
nextCursorOffset = 0;
}
if (cursorOffset > originalValue.length) {
nextCursorOffset = originalValue.length;
}
setState({
cursorOffset: nextCursorOffset,
cursorWidth: nextCursorWidth,
});
if (nextValue !== originalValue) {
onChange(nextValue);
}
},
{ isActive: focus },
);
return (
<Text>
{placeholder
? value.length > 0
? renderedValue
: renderedPlaceholder
: renderedValue}
</Text>
);
}
export default TextInput;
type UncontrolledProps = {
readonly initialValue?: string;
} & Except<TextInputProps, "value" | "onChange">;
export function UncontrolledTextInput({
initialValue = "",
...props
}: UncontrolledProps) {
const [value, setValue] = useState(initialValue);
return <TextInput {...props} value={value} onChange={setValue} />;
}