Create propmptsmith lib with prototype select-option

This commit is contained in:
Eric Rumsey 2025-05-16 17:36:12 -05:00
parent 825fe82168
commit 4fd0118ac1
3 changed files with 221 additions and 0 deletions

View File

@ -0,0 +1,44 @@
## Basic Inputs
- [ ] **Text Input** User types a free-form response.
- [ ] **Password Input** Masked input for sensitive information.
- [ ] **Multi-line Input** Allows extended text responses.
- [ ] **Autocomplete** Suggests completions based on partial input.
## Selection & Choice
- [ ] **Single Choice (Radio List)** User selects one option from a list.
- [ ] **Multiple Choice (Checkbox List)** User selects multiple options.
- [ ] **Hierarchical Menus** Options lead to submenus for deeper selection.
- [ ] **Searchable Select** Allows filtering of choices dynamically.
## Confirmation & Boolean Responses
- [ ] **Yes/No Confirmation** Quick validation prompt.
- [ ] **Custom Boolean Toggle** Enables/disables a setting interactively.
## Numeric & Range Inputs
- [ ] **Direct Number Input** Accepts numeric values.
- [ ] **Slider/Range Selector** Interactive range selection (e.g., choose a value between 110).
- [ ] **Incremental Stepper** Adjusts numeric values up/down.
## Progress & Status Indicators
- [ ] **Loading Indicators** Displays progress for background tasks.
- [ ] **Spinners & Animations** Adds visual feedback while waiting.
- [ ] **Progress Bars** Tracks completion percentage.
## Navigation & Actions
- [ ] **Interactive Tables** Allows selection from tabular data.
- [ ] **File Picker** User selects files interactively.
- [ ] **Date/Time Picker** User selects a timestamp interactively.
- [ ] **Shortcut Actions** Fast execution based on predefined keys.
- [ ] **Undo/Redo Prompts** Allows correction of previous inputs.
## Advanced & Custom Interactions
- [ ] **Drag-and-Drop Items (in CLI format)** Reorder items interactively.
- [ ] **Text-Based Dialogs** Simulates step-by-step conversational input.
- [ ] **Inline Editing** Lets users modify text inline.
- [ ] **Command Suggestions** Auto-suggests commands based on context.

View File

@ -0,0 +1,128 @@
#!/usr/bin/env bun run
import * as readline from 'node:readline';
import { TerminalUtils, type CursorPosition } from '../util/terminal';
import { assertArrayOfStrings } from '../util/typeCheck';
export type PromptsmithConfig = {
format?: {
selected?: string,
selectedText?: string,
defualt?: string,
defualtText?: string,
},
selector?: {
selected?: string,
default?: string,
}
}
// A pure function to render options without side effects
const formatText = (text: string, format: string): string => `${format}${text}\x1B[0m`;
const renderOptions = (options: string[], selectedIndex: number, pos: CursorPosition, config: PromptsmithConfig): string[] => {
TerminalUtils.cursor.restorePosition(pos);
TerminalUtils.clear.below();
return [
formatText('Use the ↑ and ↓ keys to select an option. Press ENTER to confirm.\n', '\x1B[1m'), // Bold
...options.map((option, index) => {
const isSelected = index === selectedIndex;
const selector = formatText(
isSelected ? config.selector.selected as string : config.selector.default as string,
isSelected ? config.format.selected as string : config.format.defualt as string
)
const optionText = formatText(
option,
isSelected ? config.format.selectedText as string : config.format.defualtText as string
)
return selector + optionText
}),
];
};
// A pure function to update selected index based on input key
const updateSelection = (selectedIndex: number, keyName: string, options: string[]): number =>
keyName === 'up' ? Math.max(selectedIndex - 1, 0) :
keyName === 'down' ? Math.min(selectedIndex + 1, options.length - 1) :
selectedIndex;
// Handles keypress events as pure transformations
const handleKeyPress = async (options: string[], selectedIndex: number, pos: CursorPosition, config: PromptsmithConfig): Promise<string> => {
return new Promise((resolve) => {
if (!assertArrayOfStrings(options)) throw new TypeError("'options' should be Array<string>.")
process.stdin.resume();
readline.emitKeypressEvents(process.stdin);
if (process.stdin.isTTY) process.stdin.setRawMode(true);
const onKeyPress = async (_: string, key: readline.Key) => {
const newIndex = updateSelection(selectedIndex, key.name as string, options);
if (key.name === 'return') {
cleanup();
resolve(options[newIndex] as string);
return;
} else if (key.name === 'c' && key.ctrl) {
cleanup();
process.exit();
}
renderOptions(options, newIndex, pos, config).forEach(line => console.log(line));
selectedIndex = newIndex;
};
process.stdin.on('keypress', onKeyPress);
const cleanup = () => {
process.stdin.removeListener('keypress', onKeyPress);
if (process.stdin.isTTY) process.stdin.setRawMode(false);
TerminalUtils.cursor.restorePosition(pos)
TerminalUtils.clear.below()
process.stdin.pause();
};
});
};
const setConfig = (config: PromptsmithConfig) => {
if (!config.format) config.format = {}
if (!config.format.selected) config.format.selected = '\x1B[32m\x1B[1m'
if (!config.format.selectedText) config.format.selectedText = config.format.selected
if (!config.format.defualt) config.format.defualt = ''
if (!config.format.defualtText) config.format.defualtText = config.format.defualt
if (!config.selector) config.selector = {}
if (!config.selector.default) config.selector.default = ' '
if (!config.selector.selected) config.selector.selected = '> '
return config
}
// Main function using pure composition
const defaultConfig: PromptsmithConfig = {
format: {
selected: '\x1B[32m\x1B[1m',
},
selector: {
selected: '> ',
default: ' '
}
}
const interactiveSelect = async (options: string[], config: PromptsmithConfig = {}): Promise<string> => {
const newConfig = setConfig(config)
const pos = await TerminalUtils.cursor.getCursorPosition();
renderOptions(options, 0, pos, newConfig).forEach(line => console.log(line));
return handleKeyPress(options, 0, pos, newConfig);
};
// Execution
(async () => {
const options = ['Option A', 'Option B', 'Option C'];
const selectedOption = await interactiveSelect(options, { selector: { selected: '* ' } });
console.log(`You selected: ${selectedOption}`);
process.stdin.pause(); // Ensures stdin closes AFTER selection
})();

49
src/lib/util/terminal.ts Normal file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env bun
export type CursorPosition = { row: number; col: number };
export const TerminalUtils = {
clear: {
screen: () => process.stdout.write('\x1Bc'),
content: () => process.stdout.write('\x1B[2J'),
cursorHome: () => process.stdout.write('\x1B[H'),
line: () => process.stdout.write('\x1B[K'),
below: () => process.stdout.write('\x1B[J'),
above: () => process.stdout.write('\x1B[1J'),
clear: () => console.clear(),
},
cursor: { /** Move cursor to a specific row & column */
moveCursor: (row: number, col: number) => process.stdout.write(`\x1B[${row};${col}H`),
/** Queries the terminal for the current cursor position */
async getCursorPosition(): Promise<CursorPosition> {
return new Promise((resolve) => {
const stdin = process.stdin;
stdin.setRawMode(true);
stdin.resume();
stdin.setEncoding('utf8');
stdin.once('data', (data) => {
const match = /\[(\d+);(\d+)R/.exec(data.toString());
resolve({
row: parseInt(match?.[1] ?? '0', 10),
col: parseInt(match?.[2] ?? '0', 10),
});
stdin.setRawMode(false);
stdin.pause();
});
process.stdout.write('\x1B[6n'); // Ask terminal for cursor position
});
},
/** Stores detected cursor position in a constant */
async storePosition(): Promise<CursorPosition> {
return TerminalUtils.cursor.getCursorPosition();
},
/** Restores the passed cursor position */
restorePosition: (pos: CursorPosition) => TerminalUtils.cursor.moveCursor(pos.row, pos.col),
}
};