From 4fd0118ac14ca0ea4f2ee992b17a375c75d29174 Mon Sep 17 00:00:00 2001 From: Eric Rumsey Date: Fri, 16 May 2025 17:36:12 -0500 Subject: [PATCH] Create propmptsmith lib with prototype select-option --- src/lib/promptsmith/README.md | 44 +++++++++ src/lib/promptsmith/select-options.ts | 128 ++++++++++++++++++++++++++ src/lib/util/terminal.ts | 49 ++++++++++ 3 files changed, 221 insertions(+) create mode 100644 src/lib/promptsmith/README.md create mode 100644 src/lib/promptsmith/select-options.ts create mode 100644 src/lib/util/terminal.ts diff --git a/src/lib/promptsmith/README.md b/src/lib/promptsmith/README.md new file mode 100644 index 0000000..2721a33 --- /dev/null +++ b/src/lib/promptsmith/README.md @@ -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 1–10). +- [ ] **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. diff --git a/src/lib/promptsmith/select-options.ts b/src/lib/promptsmith/select-options.ts new file mode 100644 index 0000000..b15dfb0 --- /dev/null +++ b/src/lib/promptsmith/select-options.ts @@ -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 => { + return new Promise((resolve) => { + if (!assertArrayOfStrings(options)) throw new TypeError("'options' should be Array.") + + 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 => { + 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 +})(); + diff --git a/src/lib/util/terminal.ts b/src/lib/util/terminal.ts new file mode 100644 index 0000000..40d2477 --- /dev/null +++ b/src/lib/util/terminal.ts @@ -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 { + 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 { + return TerminalUtils.cursor.getCursorPosition(); + }, + + /** Restores the passed cursor position */ + restorePosition: (pos: CursorPosition) => TerminalUtils.cursor.moveCursor(pos.row, pos.col), + } +};