diff --git a/packages/inquirerer/dev/demo-chat.ts b/packages/inquirerer/dev/demo-chat.ts new file mode 100644 index 0000000..27a93e5 --- /dev/null +++ b/packages/inquirerer/dev/demo-chat.ts @@ -0,0 +1,80 @@ +#!/usr/bin/env node +/** + * Demo: Chat AI Prompt Box with Streaming + * + * Run with: pnpm dev:chat + * Or: npx ts-node dev/demo-chat.ts + */ + +import { createStream, createSpinner } from '../src/ui'; +import { cyan, dim, green, white } from 'yanse'; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +// Simulated AI responses +const AI_RESPONSES = [ + "Hello! I'm an AI assistant. I can help you with coding questions, explain concepts, or assist with various tasks. What would you like to know?", + "TypeScript is a strongly typed programming language that builds on JavaScript. It adds optional static typing and class-based object-oriented programming to the language. Here are some key benefits:\n\n1. **Type Safety**: Catch errors at compile time rather than runtime\n2. **Better IDE Support**: Enhanced autocomplete and refactoring\n3. **Improved Readability**: Types serve as documentation\n4. **Modern Features**: Access to latest ECMAScript features", + "Here's a simple example of a TypeScript function:\n\n```typescript\nfunction greet(name: string): string {\n return `Hello, ${name}!`;\n}\n\nconst message = greet('World');\nconsole.log(message); // Output: Hello, World!\n```\n\nThe `: string` after the parameter and function declaration specifies the types.", +]; + +/** + * Simulate streaming text character by character + */ +async function streamText(stream: ReturnType, text: string) { + const words = text.split(' '); + + for (let i = 0; i < words.length; i++) { + const word = words[i]; + + // Add word character by character for realistic effect + for (const char of word) { + stream.append(char); + await sleep(15 + Math.random() * 25); // Variable typing speed + } + + // Add space after word (except last) + if (i < words.length - 1) { + stream.append(' '); + await sleep(10); + } + } +} + +async function main() { + console.log('\n' + white('═'.repeat(60))); + console.log(white(' 🤖 AI Chat Demo - Streaming Response Simulation')); + console.log(white('═'.repeat(60)) + '\n'); + + for (let i = 0; i < AI_RESPONSES.length; i++) { + const response = AI_RESPONSES[i]; + + // Show user prompt + console.log(cyan('You: ') + dim(`[Question ${i + 1}]`)); + console.log(''); + + // Show thinking spinner + const thinking = createSpinner('Thinking...', { interval: 80 }); + thinking.start(); + await sleep(800 + Math.random() * 500); + thinking.stop('info', 'Generating response...'); + + // Stream the response + console.log(''); + const stream = createStream({ prefix: green('AI: ') }); + stream.start(); + + await streamText(stream, response); + + stream.done(); + console.log('\n' + dim('─'.repeat(60)) + '\n'); + + await sleep(500); + } + + console.log(white('═'.repeat(60))); + console.log(white(' ✨ Demo complete!')); + console.log(white('═'.repeat(60)) + '\n'); +} + +main().catch(console.error); diff --git a/packages/inquirerer/dev/demo-spinner.ts b/packages/inquirerer/dev/demo-spinner.ts new file mode 100644 index 0000000..399f3ad --- /dev/null +++ b/packages/inquirerer/dev/demo-spinner.ts @@ -0,0 +1,85 @@ +#!/usr/bin/env node +/** + * Demo: Spinners and Loaders + * + * Run with: pnpm dev:spinner + * Or: npx ts-node dev/demo-spinner.ts + */ + +import { createSpinner, SPINNER_STYLES } from '../src/ui'; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +async function main() { + console.log('\n🎨 Spinner Demo\n'); + console.log('This demo shows various spinner styles and states.\n'); + + // Basic spinner + const spinner1 = createSpinner('Loading packages...'); + spinner1.start(); + await sleep(2000); + spinner1.succeed('Packages loaded successfully'); + + await sleep(500); + + // Spinner with text updates + const spinner2 = createSpinner('Connecting to server...'); + spinner2.start(); + await sleep(1000); + spinner2.text('Authenticating...'); + await sleep(1000); + spinner2.text('Fetching data...'); + await sleep(1000); + spinner2.succeed('Data fetched'); + + await sleep(500); + + // Error state + const spinner3 = createSpinner('Installing dependencies...'); + spinner3.start(); + await sleep(1500); + spinner3.fail('Failed to install: network error'); + + await sleep(500); + + // Warning state + const spinner4 = createSpinner('Checking for updates...'); + spinner4.start(); + await sleep(1500); + spinner4.warn('Updates available but not critical'); + + await sleep(500); + + // Info state + const spinner5 = createSpinner('Scanning project...'); + spinner5.start(); + await sleep(1500); + spinner5.info('Found 42 files'); + + await sleep(500); + + // Different spinner styles + console.log('\n📊 Spinner Styles:\n'); + + const styles: Array<[string, string[]]> = [ + ['dots', SPINNER_STYLES.dots], + ['line', SPINNER_STYLES.line], + ['arc', SPINNER_STYLES.arc], + ['circle', SPINNER_STYLES.circle], + ['bounce', SPINNER_STYLES.bounce], + ['arrow', SPINNER_STYLES.arrow], + ['dots2', SPINNER_STYLES.dots2], + ]; + + for (const [name, frames] of styles) { + const spinner = createSpinner(`Style: ${name}`, { frames, interval: 100 }); + spinner.start(); + await sleep(1500); + spinner.succeed(`${name} complete`); + await sleep(200); + } + + console.log('\n✨ Demo complete!\n'); +} + +main().catch(console.error); diff --git a/packages/inquirerer/dev/demo-upgrade.ts b/packages/inquirerer/dev/demo-upgrade.ts new file mode 100644 index 0000000..d4faa85 --- /dev/null +++ b/packages/inquirerer/dev/demo-upgrade.ts @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * Demo: Interactive Dependency Upgrade UI + * + * Run with: pnpm dev:upgrade + * Or: npx ts-node dev/demo-upgrade.ts + */ + +import { upgradePrompt, PackageInfo, createSpinner } from '../src/ui'; +import { cyan, green, yellow, dim, white } from 'yanse'; + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); + +// Simulated package data (like pnpm outdated would return) +const MOCK_PACKAGES: PackageInfo[] = [ + { name: 'typescript', current: '5.2.2', latest: '5.7.2', type: 'devDependencies' }, + { name: 'react', current: '18.2.0', latest: '19.0.0', type: 'dependencies' }, + { name: 'react-dom', current: '18.2.0', latest: '19.0.0', type: 'dependencies' }, + { name: '@types/node', current: '20.8.0', latest: '22.10.2', type: 'devDependencies' }, + { name: 'eslint', current: '8.50.0', latest: '9.17.0', type: 'devDependencies' }, + { name: 'prettier', current: '3.0.3', latest: '3.4.2', type: 'devDependencies' }, + { name: 'jest', current: '29.6.4', latest: '29.7.0', type: 'devDependencies' }, + { name: 'lodash', current: '4.17.20', latest: '4.17.21', type: 'dependencies' }, + { name: 'axios', current: '1.5.0', latest: '1.7.9', type: 'dependencies' }, + { name: 'zod', current: '3.22.2', latest: '3.24.1', type: 'dependencies' }, + { name: 'vitest', current: '0.34.4', latest: '2.1.8', type: 'devDependencies' }, + { name: '@tanstack/react-query', current: '4.35.3', latest: '5.62.8', type: 'dependencies' }, + { name: 'tailwindcss', current: '3.3.3', latest: '3.4.17', type: 'devDependencies' }, + { name: 'next', current: '13.5.2', latest: '15.1.3', type: 'dependencies' }, + { name: 'prisma', current: '5.3.1', latest: '6.1.0', type: 'devDependencies' }, +]; + +async function main() { + console.log('\n' + white('═'.repeat(70))); + console.log(white(' 📦 Interactive Dependency Upgrade Demo')); + console.log(white('═'.repeat(70)) + '\n'); + + // Show loading spinner first + const spinner = createSpinner('Checking for outdated packages...'); + spinner.start(); + await sleep(1500); + spinner.succeed(`Found ${MOCK_PACKAGES.length} packages with updates available`); + + console.log(''); + console.log(dim('Controls:')); + console.log(dim(' ↑/↓ Navigate packages')); + console.log(dim(' SPACE Toggle selection')); + console.log(dim(' → Change target version')); + console.log(dim(' ENTER Confirm selection')); + console.log(dim(' ESC Cancel')); + console.log(dim(' Type Filter packages')); + console.log(''); + + try { + const result = await upgradePrompt(MOCK_PACKAGES, 10); + + console.log(''); + + if (result.updates.length === 0) { + console.log(yellow('No packages selected for upgrade.')); + } else { + console.log(green(`\n✔ Selected ${result.updates.length} packages for upgrade:\n`)); + + for (const update of result.updates) { + console.log(` ${cyan(update.name.padEnd(30))} ${dim(update.from)} ${dim('→')} ${green(update.to)}`); + } + + console.log(''); + console.log(dim('In a real scenario, this would run:')); + console.log(dim(` pnpm update ${result.updates.map(u => `${u.name}@${u.to}`).join(' ')}`)); + } + } catch (error) { + console.error('Error:', error); + } + + console.log('\n' + white('═'.repeat(70))); + console.log(white(' ✨ Demo complete!')); + console.log(white('═'.repeat(70)) + '\n'); +} + +main().catch(console.error); diff --git a/packages/inquirerer/package.json b/packages/inquirerer/package.json index 34bb2c5..98749a7 100644 --- a/packages/inquirerer/package.json +++ b/packages/inquirerer/package.json @@ -19,15 +19,18 @@ "bugs": { "url": "https://github.com/constructive-io/dev-utils/issues" }, - "scripts": { - "copy": "makage assets", - "clean": "makage clean", - "prepublishOnly": "npm run build", - "build": "makage build", - "dev": "ts-node dev/index", - "test": "jest", - "test:watch": "jest --watch" - }, + "scripts": { + "copy": "makage assets", + "clean": "makage clean", + "prepublishOnly": "npm run build", + "build": "makage build", + "dev": "ts-node dev/index", + "dev:spinner": "ts-node dev/demo-spinner", + "dev:chat": "ts-node dev/demo-chat", + "dev:upgrade": "ts-node dev/demo-upgrade", + "test": "jest", + "test:watch": "jest --watch" + }, "dependencies": { "deepmerge": "^4.3.1", "find-and-require-package-json": "workspace:*", diff --git a/packages/inquirerer/src/index.ts b/packages/inquirerer/src/index.ts index 0d5d424..08eb8db 100644 --- a/packages/inquirerer/src/index.ts +++ b/packages/inquirerer/src/index.ts @@ -1,4 +1,5 @@ export * from './commander'; export * from './prompt'; export * from './question'; -export * from './resolvers'; \ No newline at end of file +export * from './resolvers'; +export * from './ui'; diff --git a/packages/inquirerer/src/ui/engine.ts b/packages/inquirerer/src/ui/engine.ts new file mode 100644 index 0000000..f623fa3 --- /dev/null +++ b/packages/inquirerer/src/ui/engine.ts @@ -0,0 +1,255 @@ +/** + * Event-driven UI Engine + * + * A flexible engine for building custom interactive terminal UIs + * with support for key events, timers, and async updates. + */ + +import { Readable, Writable } from 'stream'; +import { KEY_CODES, TerminalKeypress } from '../keypress'; +import { Key, UIEvent, UIScreenConfig, EventResult } from './types'; + +/** + * Maps raw key codes to normalized Key enum + */ +const KEY_MAP: Record = { + [KEY_CODES.UP_ARROW]: Key.UP, + [KEY_CODES.DOWN_ARROW]: Key.DOWN, + [KEY_CODES.LEFT_ARROW]: Key.LEFT, + [KEY_CODES.RIGHT_ARROW]: Key.RIGHT, + [KEY_CODES.ENTER]: Key.ENTER, + [KEY_CODES.SPACE]: Key.SPACE, + [KEY_CODES.BACKSPACE]: Key.BACKSPACE, + [KEY_CODES.BACKSPACE_LEGACY]: Key.BACKSPACE, + [KEY_CODES.CTRL_C]: Key.CTRL_C, + '\u001b': Key.ESCAPE, + '\t': Key.TAB, +}; + +/** + * ANSI escape codes for terminal control + */ +const ANSI = { + clearScreen: '\x1Bc', + hideCursor: '\x1B[?25l', + showCursor: '\x1B[?25h', + cursorUp: (n: number) => `\x1B[${n}A`, + cursorDown: (n: number) => `\x1B[${n}B`, + cursorTo: (x: number, y: number) => `\x1B[${y};${x}H`, + clearLine: '\x1B[2K', + clearToEnd: '\x1B[0J', + saveCursor: '\x1B[s', + restoreCursor: '\x1B[u', +}; + +export interface UIEngineOptions { + input?: Readable; + output?: Writable; + noTty?: boolean; +} + +export class UIEngine { + private input: Readable; + private output: Writable; + private noTty: boolean; + private keypress: TerminalKeypress | null = null; + private tickTimer: NodeJS.Timeout | null = null; + private lastLineCount: number = 0; + + constructor(options: UIEngineOptions = {}) { + this.input = options.input ?? process.stdin; + this.output = options.output ?? process.stdout; + this.noTty = options.noTty ?? false; + } + + /** + * Write to output + */ + private write(text: string): void { + this.output.write(text); + } + + /** + * Clear the screen + */ + private clearScreen(): void { + this.write(ANSI.clearScreen); + } + + /** + * Hide the cursor + */ + private hideCursor(): void { + this.write(ANSI.hideCursor); + } + + /** + * Show the cursor + */ + private showCursor(): void { + this.write(ANSI.showCursor); + } + + /** + * Render lines to the terminal, clearing previous output + */ + private render(lines: string[]): void { + // Move cursor up to overwrite previous output + if (this.lastLineCount > 0) { + this.write(ANSI.cursorUp(this.lastLineCount)); + } + + // Clear and write each line + for (const line of lines) { + this.write(ANSI.clearLine + line + '\n'); + } + + // Clear any remaining lines from previous render + if (lines.length < this.lastLineCount) { + for (let i = 0; i < this.lastLineCount - lines.length; i++) { + this.write(ANSI.clearLine + '\n'); + } + // Move cursor back up + this.write(ANSI.cursorUp(this.lastLineCount - lines.length)); + } + + this.lastLineCount = lines.length; + } + + /** + * Run a custom UI screen + */ + async run( + config: UIScreenConfig + ): Promise { + if (this.noTty) { + // In non-TTY mode, just return undefined + return undefined; + } + + let state = config.initialState; + let resolved = false; + let result: V | undefined; + + // Setup keypress handler + this.keypress = new TerminalKeypress(this.noTty, this.input); + this.keypress.resume(); + + // Hide cursor if requested + if (config.hideCursor) { + this.hideCursor(); + } + + // Call onStart hook + config.onStart?.(state); + + // Initial render + const lines = config.render(state); + this.render(lines); + + // Setup tick timer if interval specified + if (config.tickInterval && config.tickInterval > 0) { + this.tickTimer = setInterval(() => { + if (resolved) return; + const event: UIEvent = { type: 'tick' }; + const eventResult = config.onEvent(event, state); + state = eventResult.state; + this.render(config.render(state)); + + if (eventResult.done) { + resolved = true; + result = eventResult.value; + } + }, config.tickInterval); + } + + return new Promise((resolve) => { + const handleEvent = (event: UIEvent) => { + if (resolved) return; + + const eventResult = config.onEvent(event, state); + state = eventResult.state; + this.render(config.render(state)); + + if (eventResult.done) { + resolved = true; + result = eventResult.value; + cleanup(); + resolve(result); + } + }; + + const cleanup = () => { + // Clear tick timer + if (this.tickTimer) { + clearInterval(this.tickTimer); + this.tickTimer = null; + } + + // Cleanup keypress + if (this.keypress) { + this.keypress.pause(); + } + + // Show cursor + if (config.hideCursor) { + this.showCursor(); + } + + // Call onExit hook + config.onExit?.(state, result); + }; + + // Register key handlers + // Handle special keys + Object.entries(KEY_MAP).forEach(([code, key]) => { + this.keypress!.on(code, () => { + if (key === Key.CTRL_C) { + cleanup(); + process.exit(0); + } + handleEvent({ type: 'key', key }); + }); + }); + + // Handle alphanumeric characters + 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'.split('').forEach(char => { + this.keypress!.on(char, () => { + handleEvent({ type: 'char', char }); + }); + }); + + // Handle punctuation and special characters + '!@#$%^&*()_+-=[]{}|;:\'",.<>?/`~'.split('').forEach(char => { + this.keypress!.on(char, () => { + handleEvent({ type: 'char', char }); + }); + }); + }); + } + + /** + * Dispatch an external event (for async updates) + */ + dispatch(event: UIEvent): void { + // This would be used for external event dispatch + // Implementation depends on how we want to handle async updates + } + + /** + * Cleanup resources + */ + destroy(): void { + if (this.tickTimer) { + clearInterval(this.tickTimer); + this.tickTimer = null; + } + if (this.keypress) { + this.keypress.destroy(); + this.keypress = null; + } + this.showCursor(); + } +} + +export { ANSI }; diff --git a/packages/inquirerer/src/ui/index.ts b/packages/inquirerer/src/ui/index.ts new file mode 100644 index 0000000..a090511 --- /dev/null +++ b/packages/inquirerer/src/ui/index.ts @@ -0,0 +1,27 @@ +/** + * UI Module + * + * Event-driven UI components for building rich terminal interfaces + */ + +// Core engine +export { UIEngine, UIEngineOptions, ANSI } from './engine'; + +// Types +export { + Key, + UIEvent, + EventResult, + UIScreenConfig, + SpinnerConfig, + ProgressConfig, + StreamConfig, + PackageInfo, + UpgradeSelection, +} from './types'; + +// Components +export { Spinner, createSpinner, SPINNER_STYLES } from './spinner'; +export { ProgressBar, createProgress } from './progress'; +export { StreamingText, createStream } from './stream'; +export { interactiveUpgrade, upgradePrompt } from './upgrade'; diff --git a/packages/inquirerer/src/ui/progress.ts b/packages/inquirerer/src/ui/progress.ts new file mode 100644 index 0000000..705e78b --- /dev/null +++ b/packages/inquirerer/src/ui/progress.ts @@ -0,0 +1,153 @@ +/** + * Progress Bar Component + * + * Visual progress indicator for operations with known completion + */ + +import { Writable } from 'stream'; +import { green, cyan, dim, white } from 'yanse'; +import { ProgressConfig } from './types'; + +interface ProgressState { + value: number; + text: string; + status: 'active' | 'complete' | 'error'; +} + +/** + * Progress bar that can be controlled externally + */ +export class ProgressBar { + private width: number; + private showPercentage: boolean; + private state: ProgressState; + private output: Writable; + private isRunning: boolean = false; + + constructor(config: ProgressConfig, output: Writable = process.stdout) { + this.width = config.width ?? 40; + this.showPercentage = config.showPercentage ?? true; + this.output = output; + this.state = { + value: 0, + text: config.text, + status: 'active', + }; + } + + /** + * Start the progress bar + */ + start(): this { + if (this.isRunning) return this; + this.isRunning = true; + + // Hide cursor + this.output.write('\x1B[?25l'); + + // Initial render + this.render(); + + return this; + } + + /** + * Update progress (0-1) + */ + update(value: number, text?: string): this { + this.state.value = Math.max(0, Math.min(1, value)); + if (text) { + this.state.text = text; + } + if (this.isRunning) { + this.render(); + } + return this; + } + + /** + * Increment progress + */ + increment(amount: number = 0.1): this { + return this.update(this.state.value + amount); + } + + /** + * Complete the progress bar + */ + complete(text?: string): this { + this.state.value = 1; + this.state.status = 'complete'; + if (text) { + this.state.text = text; + } + this.render(); + + // Show cursor and newline + this.output.write('\x1B[?25h\n'); + this.isRunning = false; + + return this; + } + + /** + * Mark as error + */ + error(text?: string): this { + this.state.status = 'error'; + if (text) { + this.state.text = text; + } + this.render(); + + // Show cursor and newline + this.output.write('\x1B[?25h\n'); + this.isRunning = false; + + return this; + } + + /** + * Render the current state + */ + private render(): void { + const { value, text, status } = this.state; + + const filled = Math.round(value * this.width); + const empty = this.width - filled; + + let bar: string; + let icon: string; + + switch (status) { + case 'complete': + bar = green('█'.repeat(this.width)); + icon = green('✔'); + break; + case 'error': + bar = dim('█'.repeat(filled) + '░'.repeat(empty)); + icon = '\x1B[31m✖\x1B[0m'; // red + break; + default: + bar = cyan('█'.repeat(filled)) + dim('░'.repeat(empty)); + icon = cyan('◐'); + } + + let line = `${icon} ${text} [${bar}]`; + + if (this.showPercentage) { + const percent = Math.round(value * 100); + line += ` ${white(percent.toString().padStart(3))}%`; + } + + // Clear line and write + this.output.write('\r\x1B[2K' + line); + } +} + +/** + * Create and start a progress bar + */ +export function createProgress(text: string, options?: Partial): ProgressBar { + return new ProgressBar({ text, ...options }); +} diff --git a/packages/inquirerer/src/ui/spinner.ts b/packages/inquirerer/src/ui/spinner.ts new file mode 100644 index 0000000..a68da37 --- /dev/null +++ b/packages/inquirerer/src/ui/spinner.ts @@ -0,0 +1,193 @@ +/** + * Spinner Component + * + * Animated loading spinner for async operations + */ + +import { Writable } from 'stream'; +import { UIEngine } from './engine'; +import { SpinnerConfig } from './types'; +import { green, red, yellow, cyan } from 'yanse'; + +/** + * Default spinner frames (dots style) + */ +const DEFAULT_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +/** + * Alternative spinner styles + */ +export const SPINNER_STYLES = { + dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + line: ['-', '\\', '|', '/'], + arc: ['◜', '◠', '◝', '◞', '◡', '◟'], + circle: ['◐', '◓', '◑', '◒'], + square: ['◰', '◳', '◲', '◱'], + bounce: ['⠁', '⠂', '⠄', '⠂'], + arrow: ['←', '↖', '↑', '↗', '→', '↘', '↓', '↙'], + clock: ['🕐', '🕑', '🕒', '🕓', '🕔', '🕕', '🕖', '🕗', '🕘', '🕙', '🕚', '🕛'], + moon: ['🌑', '🌒', '🌓', '🌔', '🌕', '🌖', '🌗', '🌘'], + dots2: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'], +}; + +interface SpinnerState { + frame: number; + text: string; + status: 'spinning' | 'success' | 'error' | 'warning' | 'info'; + finalText?: string; +} + +/** + * Create a spinner that can be controlled externally + */ +export class Spinner { + private engine: UIEngine; + private frames: string[]; + private interval: number; + private state: SpinnerState; + private tickTimer: NodeJS.Timeout | null = null; + private output: Writable; + private lastLineCount: number = 0; + private isRunning: boolean = false; + + constructor(config: SpinnerConfig, output: Writable = process.stdout) { + this.engine = new UIEngine({ output }); + this.frames = config.frames ?? DEFAULT_FRAMES; + this.interval = config.interval ?? 80; + this.output = output; + this.state = { + frame: 0, + text: config.text, + status: 'spinning', + }; + } + + /** + * Start the spinner + */ + start(): this { + if (this.isRunning) return this; + this.isRunning = true; + + // Hide cursor + this.output.write('\x1B[?25l'); + + // Initial render + this.render(); + + // Start animation + this.tickTimer = setInterval(() => { + this.state.frame = (this.state.frame + 1) % this.frames.length; + this.render(); + }, this.interval); + + return this; + } + + /** + * Update the spinner text + */ + text(text: string): this { + this.state.text = text; + if (this.isRunning) { + this.render(); + } + return this; + } + + /** + * Stop with success + */ + succeed(text?: string): this { + return this.stop('success', text); + } + + /** + * Stop with error + */ + fail(text?: string): this { + return this.stop('error', text); + } + + /** + * Stop with warning + */ + warn(text?: string): this { + return this.stop('warning', text); + } + + /** + * Stop with info + */ + info(text?: string): this { + return this.stop('info', text); + } + + /** + * Stop the spinner + */ + stop(status: 'success' | 'error' | 'warning' | 'info' = 'success', text?: string): this { + if (!this.isRunning) return this; + + // Clear timer + if (this.tickTimer) { + clearInterval(this.tickTimer); + this.tickTimer = null; + } + + this.state.status = status; + if (text) { + this.state.finalText = text; + } + + // Final render + this.render(); + + // Show cursor and newline + this.output.write('\x1B[?25h\n'); + + this.isRunning = false; + return this; + } + + /** + * Render the current state + */ + private render(): void { + const { frame, text, status, finalText } = this.state; + + let icon: string; + let displayText = finalText ?? text; + + switch (status) { + case 'success': + icon = green('✔'); + displayText = green(displayText); + break; + case 'error': + icon = red('✖'); + displayText = red(displayText); + break; + case 'warning': + icon = yellow('⚠'); + displayText = yellow(displayText); + break; + case 'info': + icon = cyan('ℹ'); + displayText = cyan(displayText); + break; + default: + icon = cyan(this.frames[frame]); + } + + // Clear line and write + this.output.write('\r\x1B[2K' + icon + ' ' + displayText); + } +} + +/** + * Create and start a spinner + */ +export function createSpinner(text: string, options?: Partial): Spinner { + return new Spinner({ text, ...options }); +} diff --git a/packages/inquirerer/src/ui/stream.ts b/packages/inquirerer/src/ui/stream.ts new file mode 100644 index 0000000..e91815b --- /dev/null +++ b/packages/inquirerer/src/ui/stream.ts @@ -0,0 +1,210 @@ +/** + * Streaming Text Component + * + * For displaying streaming output like AI chat responses + */ + +import { Writable } from 'stream'; +import { cyan } from 'yanse'; +import { StreamConfig } from './types'; + +interface StreamState { + lines: string[]; + currentLine: string; + showCursor: boolean; + isComplete: boolean; +} + +/** + * Streaming text display for AI-style output + */ +export class StreamingText { + private prefix: string; + private showCursor: boolean; + private state: StreamState; + private output: Writable; + private isRunning: boolean = false; + private cursorTimer: NodeJS.Timeout | null = null; + private cursorVisible: boolean = true; + + constructor(config: StreamConfig = {}, output: Writable = process.stdout) { + this.prefix = config.prefix ?? ''; + this.showCursor = config.showCursor ?? true; + this.output = output; + this.state = { + lines: [], + currentLine: '', + showCursor: this.showCursor, + isComplete: false, + }; + } + + /** + * Start the stream display + */ + start(): this { + if (this.isRunning) return this; + this.isRunning = true; + + // Hide terminal cursor + this.output.write('\x1B[?25l'); + + // Start cursor blink if enabled + if (this.showCursor) { + this.cursorTimer = setInterval(() => { + this.cursorVisible = !this.cursorVisible; + this.render(); + }, 530); + } + + // Initial render + this.render(); + + return this; + } + + /** + * Append text to the stream + */ + append(text: string): this { + for (const char of text) { + if (char === '\n') { + this.state.lines.push(this.state.currentLine); + this.state.currentLine = ''; + } else { + this.state.currentLine += char; + } + } + + if (this.isRunning) { + this.render(); + } + + return this; + } + + /** + * Append a complete line + */ + appendLine(line: string): this { + if (this.state.currentLine) { + this.state.lines.push(this.state.currentLine + line); + this.state.currentLine = ''; + } else { + this.state.lines.push(line); + } + + if (this.isRunning) { + this.render(); + } + + return this; + } + + /** + * Clear all content + */ + clear(): this { + this.state.lines = []; + this.state.currentLine = ''; + + if (this.isRunning) { + this.render(); + } + + return this; + } + + /** + * Mark stream as complete + */ + done(): this { + this.state.isComplete = true; + + // Stop cursor blink + if (this.cursorTimer) { + clearInterval(this.cursorTimer); + this.cursorTimer = null; + } + + // Final render without cursor + this.cursorVisible = false; + this.render(); + + // Show terminal cursor and newline + this.output.write('\x1B[?25h\n'); + this.isRunning = false; + + return this; + } + + /** + * Get the current content + */ + getContent(): string { + const allLines = [...this.state.lines]; + if (this.state.currentLine) { + allLines.push(this.state.currentLine); + } + return allLines.join('\n'); + } + + /** + * Render the current state + */ + private render(): void { + const { lines, currentLine, isComplete } = this.state; + + // Build output + const allLines = [...lines]; + + // Add current line with optional cursor + let lastLine = currentLine; + if (this.showCursor && !isComplete && this.cursorVisible) { + lastLine += cyan('▋'); + } else if (this.showCursor && !isComplete) { + lastLine += ' '; + } + + if (lastLine || allLines.length === 0) { + allLines.push(lastLine); + } + + // Add prefix to each line + const prefixedLines = allLines.map((line, i) => { + if (i === 0 && this.prefix) { + return this.prefix + line; + } + return (this.prefix ? ' '.repeat(this.prefix.length) : '') + line; + }); + + // Clear previous output and write new + // Move cursor to start of output area + const totalLines = prefixedLines.length; + + // For simplicity, just clear and rewrite + // In a more sophisticated implementation, we'd track line count + this.output.write('\r\x1B[2K'); + + // Write all lines + for (let i = 0; i < prefixedLines.length; i++) { + if (i > 0) { + this.output.write('\n\x1B[2K'); + } + this.output.write(prefixedLines[i]); + } + + // Move cursor back to end of last line + if (prefixedLines.length > 1) { + this.output.write(`\x1B[${prefixedLines.length - 1}A`); + this.output.write(`\x1B[${prefixedLines[0].length}G`); + } + } +} + +/** + * Create a streaming text display + */ +export function createStream(options?: StreamConfig): StreamingText { + return new StreamingText(options); +} diff --git a/packages/inquirerer/src/ui/types.ts b/packages/inquirerer/src/ui/types.ts new file mode 100644 index 0000000..04deb4e --- /dev/null +++ b/packages/inquirerer/src/ui/types.ts @@ -0,0 +1,125 @@ +/** + * Event-driven UI Engine Types + * + * This module provides types for building custom interactive terminal UIs + * with support for key events, timers, and async updates. + */ + +/** + * Normalized key events - abstracts raw escape sequences + */ +export enum Key { + UP = 'UP', + DOWN = 'DOWN', + LEFT = 'LEFT', + RIGHT = 'RIGHT', + ENTER = 'ENTER', + SPACE = 'SPACE', + BACKSPACE = 'BACKSPACE', + ESCAPE = 'ESCAPE', + TAB = 'TAB', + CTRL_C = 'CTRL_C', +} + +/** + * Event types that can trigger state updates and re-renders + */ +export type UIEvent = + | { type: 'key'; key: Key | string } + | { type: 'char'; char: string } + | { type: 'tick' } + | { type: 'progress'; value: number } + | { type: 'data'; data: T } + | { type: 'resize'; width: number; height: number }; + +/** + * Result of handling an event + */ +export interface EventResult { + state: S; + done?: boolean; + value?: V; +} + +/** + * Configuration for a custom UI screen + */ +export interface UIScreenConfig { + /** Initial state */ + initialState: S; + + /** Render function - returns lines to display */ + render: (state: S) => string[]; + + /** Event handler - reducer pattern */ + onEvent: (event: UIEvent, state: S) => EventResult; + + /** Called when the screen starts */ + onStart?: (state: S) => void; + + /** Called when the screen exits (cleanup) */ + onExit?: (state: S, value?: V) => void; + + /** Whether to hide the cursor */ + hideCursor?: boolean; + + /** Interval for tick events (ms) - enables animation */ + tickInterval?: number; +} + +/** + * Spinner configuration + */ +export interface SpinnerConfig { + /** Text to display next to spinner */ + text: string; + + /** Spinner frames (defaults to dots) */ + frames?: string[]; + + /** Frame interval in ms (default: 80) */ + interval?: number; +} + +/** + * Progress bar configuration + */ +export interface ProgressConfig { + /** Text to display */ + text: string; + + /** Total width of progress bar (default: 40) */ + width?: number; + + /** Show percentage (default: true) */ + showPercentage?: boolean; +} + +/** + * Streaming text configuration + */ +export interface StreamConfig { + /** Optional prefix for each line */ + prefix?: string; + + /** Whether to show a cursor at the end */ + showCursor?: boolean; +} + +/** + * Package info for upgrade UI + */ +export interface PackageInfo { + name: string; + current: string; + latest: string; + type: 'dependencies' | 'devDependencies' | 'peerDependencies'; +} + +/** + * Upgrade selection state + */ +export interface UpgradeSelection { + selected: boolean; + targetVersion: string; +} diff --git a/packages/inquirerer/src/ui/upgrade.ts b/packages/inquirerer/src/ui/upgrade.ts new file mode 100644 index 0000000..be35a03 --- /dev/null +++ b/packages/inquirerer/src/ui/upgrade.ts @@ -0,0 +1,379 @@ +/** + * Interactive Upgrade UI Component + * + * pnpm-style interactive dependency upgrade interface + */ + +import { UIEngine } from './engine'; +import { Key, UIEvent, EventResult, PackageInfo, UpgradeSelection } from './types'; +import { cyan, green, yellow, red, dim, white, blue, gray } from 'yanse'; + +interface UpgradeState { + packages: PackageInfo[]; + selections: Map; + selectedIndex: number; + startIndex: number; + maxLines: number; + filter: string; + mode: 'select' | 'version'; + versionOptions: string[]; + versionIndex: number; +} + +interface UpgradeConfig { + packages: PackageInfo[]; + maxLines?: number; +} + +interface UpgradeResult { + updates: Array<{ + name: string; + from: string; + to: string; + }>; +} + +/** + * Render a single package row + */ +function renderPackageRow( + pkg: PackageInfo, + selection: UpgradeSelection, + isSelected: boolean, + isVersionMode: boolean, + versionIndex: number, + versionOptions: string[] +): string { + const checkbox = selection.selected ? green('◉') : dim('○'); + const cursor = isSelected ? cyan('❯') : ' '; + + const name = pkg.name.padEnd(30); + const current = dim(pkg.current.padEnd(12)); + const arrow = dim('→'); + + let target: string; + if (isSelected && isVersionMode) { + // Show version selector + const versions = versionOptions.map((v, i) => + i === versionIndex ? cyan(`[${v}]`) : dim(v) + ).join(' '); + target = versions; + } else { + target = selection.selected + ? green(selection.targetVersion.padEnd(12)) + : dim(selection.targetVersion.padEnd(12)); + } + + const type = dim(`(${pkg.type.replace('Dependencies', '')})`); + + return `${cursor} ${checkbox} ${name} ${current} ${arrow} ${target} ${type}`; +} + +/** + * Create the upgrade UI + */ +export async function interactiveUpgrade( + engine: UIEngine, + config: UpgradeConfig +): Promise { + const { packages, maxLines = 10 } = config; + + // Initialize selections + const selections = new Map(); + packages.forEach(pkg => { + selections.set(pkg.name, { + selected: false, + targetVersion: pkg.latest, + }); + }); + + const initialState: UpgradeState = { + packages, + selections, + selectedIndex: 0, + startIndex: 0, + maxLines, + filter: '', + mode: 'select', + versionOptions: [], + versionIndex: 0, + }; + + const result = await engine.run({ + initialState, + hideCursor: true, + + render: (state) => { + const lines: string[] = []; + + // Header + lines.push(white('Interactive Dependency Upgrade')); + lines.push(dim('Use ↑↓ to navigate, SPACE to select, → to change version, ENTER to confirm')); + lines.push(''); + + // Column headers + const header = ` ${dim(' Package'.padEnd(32))} ${dim('Current'.padEnd(12))} ${dim('Target'.padEnd(12))} ${dim('Type')}`; + lines.push(header); + lines.push(dim('─'.repeat(80))); + + // Filter packages if filter is set + let filteredPackages = state.packages; + if (state.filter) { + filteredPackages = state.packages.filter(p => + p.name.toLowerCase().includes(state.filter.toLowerCase()) + ); + } + + // Calculate visible range + const endIndex = Math.min(state.startIndex + state.maxLines, filteredPackages.length); + + // Render visible packages + for (let i = state.startIndex; i < endIndex; i++) { + const pkg = filteredPackages[i]; + const selection = state.selections.get(pkg.name)!; + const isSelected = i === state.selectedIndex; + const isVersionMode = state.mode === 'version' && isSelected; + + lines.push(renderPackageRow( + pkg, + selection, + isSelected, + isVersionMode, + state.versionIndex, + state.versionOptions + )); + } + + // Padding if fewer packages than maxLines + for (let i = filteredPackages.length; i < state.maxLines; i++) { + lines.push(''); + } + + lines.push(dim('─'.repeat(80))); + + // Summary + const selectedCount = Array.from(state.selections.values()).filter(s => s.selected).length; + lines.push(`${cyan(selectedCount.toString())} packages selected for upgrade`); + + // Filter indicator + if (state.filter) { + lines.push(dim(`Filter: ${state.filter}`)); + } + + return lines; + }, + + onEvent: (event, state): EventResult => { + // Get filtered packages for navigation + let filteredPackages = state.packages; + if (state.filter) { + filteredPackages = state.packages.filter(p => + p.name.toLowerCase().includes(state.filter.toLowerCase()) + ); + } + + if (event.type === 'key') { + switch (event.key) { + case Key.UP: { + if (filteredPackages.length === 0) return { state }; + + let newIndex = state.selectedIndex - 1; + let newStartIndex = state.startIndex; + + if (newIndex < 0) { + newIndex = filteredPackages.length - 1; + newStartIndex = Math.max(0, filteredPackages.length - state.maxLines); + } else if (newIndex < state.startIndex) { + newStartIndex = newIndex; + } + + return { + state: { + ...state, + selectedIndex: newIndex, + startIndex: newStartIndex, + mode: 'select', + } + }; + } + + case Key.DOWN: { + if (filteredPackages.length === 0) return { state }; + + let newIndex = (state.selectedIndex + 1) % filteredPackages.length; + let newStartIndex = state.startIndex; + + if (newIndex === 0) { + newStartIndex = 0; + } else if (newIndex >= state.startIndex + state.maxLines) { + newStartIndex = newIndex - state.maxLines + 1; + } + + return { + state: { + ...state, + selectedIndex: newIndex, + startIndex: newStartIndex, + mode: 'select', + } + }; + } + + case Key.SPACE: { + if (filteredPackages.length === 0) return { state }; + + const pkg = filteredPackages[state.selectedIndex]; + const newSelections = new Map(state.selections); + const current = newSelections.get(pkg.name)!; + newSelections.set(pkg.name, { + ...current, + selected: !current.selected, + }); + + return { + state: { + ...state, + selections: newSelections, + } + }; + } + + case Key.RIGHT: { + if (filteredPackages.length === 0) return { state }; + + const pkg = filteredPackages[state.selectedIndex]; + // Generate version options (simplified - in real use, these would come from npm) + const versionOptions = [ + pkg.latest, + `^${pkg.latest}`, + `~${pkg.latest}`, + pkg.current, + ]; + + return { + state: { + ...state, + mode: 'version', + versionOptions, + versionIndex: 0, + } + }; + } + + case Key.LEFT: { + if (state.mode === 'version') { + const newIndex = (state.versionIndex - 1 + state.versionOptions.length) % state.versionOptions.length; + return { + state: { + ...state, + versionIndex: newIndex, + } + }; + } + return { state }; + } + + case Key.ENTER: { + if (state.mode === 'version') { + // Apply selected version + const pkg = filteredPackages[state.selectedIndex]; + const newSelections = new Map(state.selections); + const current = newSelections.get(pkg.name)!; + newSelections.set(pkg.name, { + ...current, + selected: true, + targetVersion: state.versionOptions[state.versionIndex], + }); + + return { + state: { + ...state, + selections: newSelections, + mode: 'select', + } + }; + } + + // Confirm and return results + const updates: UpgradeResult['updates'] = []; + state.selections.forEach((selection, name) => { + if (selection.selected) { + const pkg = state.packages.find(p => p.name === name)!; + updates.push({ + name, + from: pkg.current, + to: selection.targetVersion, + }); + } + }); + + return { + state, + done: true, + value: { updates }, + }; + } + + case Key.ESCAPE: { + if (state.mode === 'version') { + return { + state: { + ...state, + mode: 'select', + } + }; + } + // Cancel + return { + state, + done: true, + value: { updates: [] }, + }; + } + + case Key.BACKSPACE: { + if (state.filter.length > 0) { + return { + state: { + ...state, + filter: state.filter.slice(0, -1), + selectedIndex: 0, + startIndex: 0, + } + }; + } + return { state }; + } + } + } + + // Handle character input for filtering + if (event.type === 'char') { + return { + state: { + ...state, + filter: state.filter + event.char, + selectedIndex: 0, + startIndex: 0, + } + }; + } + + return { state }; + }, + }); + + return result ?? { updates: [] }; +} + +/** + * Standalone upgrade prompt + */ +export async function upgradePrompt(packages: PackageInfo[], maxLines?: number): Promise { + const engine = new UIEngine(); + try { + return await interactiveUpgrade(engine, { packages, maxLines }); + } finally { + engine.destroy(); + } +}