tui.ts
1 /** 2 * tui.ts — Zero-dependency interactive TUI components 3 * 4 * Uses raw stdin mode + ANSI escape codes for interactive prompts. 5 */ 6 import { styleText } from 'node:util'; 7 import { EXIT_CODES } from './errors.js'; 8 9 export interface CheckboxItem { 10 label: string; 11 value: string; 12 checked: boolean; 13 /** Optional status to display after the label */ 14 status?: string; 15 statusColor?: 'green' | 'yellow' | 'red' | 'dim'; 16 } 17 18 /** 19 * Interactive multi-select checkbox prompt. 20 * 21 * Controls: 22 * ↑/↓ or j/k — navigate 23 * Space — toggle selection 24 * a — toggle all 25 * Enter — confirm 26 * q/Esc — cancel (returns empty) 27 */ 28 export async function checkboxPrompt( 29 items: CheckboxItem[], 30 opts: { title?: string; hint?: string } = {}, 31 ): Promise<string[]> { 32 if (items.length === 0) return []; 33 34 const { stdin, stdout } = process; 35 if (!stdin.isTTY) { 36 // Non-interactive: return all checked items 37 return items.filter(i => i.checked).map(i => i.value); 38 } 39 40 let cursor = 0; 41 const state = items.map(i => ({ ...i })); 42 43 function colorStatus(status: string | undefined, color: CheckboxItem['statusColor']): string { 44 if (!status) return ''; 45 switch (color) { 46 case 'green': return styleText('green', status); 47 case 'yellow': return styleText('yellow', status); 48 case 'red': return styleText('red', status); 49 case 'dim': return styleText('dim', status); 50 default: return styleText('dim', status); 51 } 52 } 53 54 function render() { 55 // Move cursor to start and clear 56 let out = ''; 57 58 if (opts.title) { 59 out += `\n${styleText('bold', opts.title)}\n\n`; 60 } 61 62 for (let i = 0; i < state.length; i++) { 63 const item = state[i]; 64 const pointer = i === cursor ? styleText('cyan', '❯') : ' '; 65 const checkbox = item.checked ? styleText('green', '◉') : styleText('dim', '○'); 66 const label = i === cursor ? styleText('bold', item.label) : item.label; 67 const status = colorStatus(item.status, item.statusColor); 68 out += ` ${pointer} ${checkbox} ${label}${status ? ` ${status}` : ''}\n`; 69 } 70 71 out += `\n ${styleText('dim', '↑↓ navigate · Space toggle · a all · Enter confirm · q cancel')}\n`; 72 73 return out; 74 } 75 76 return new Promise<string[]>((resolve) => { 77 const wasRaw = stdin.isRaw; 78 stdin.setRawMode(true); 79 stdin.resume(); 80 stdout.write('\x1b[?25l'); // Hide cursor 81 82 let firstDraw = true; 83 84 function draw() { 85 // Clear previous render (skip on first draw) 86 if (!firstDraw) { 87 const lines = render().split('\n').length; 88 stdout.write(`\x1b[${lines}A\x1b[J`); 89 } 90 firstDraw = false; 91 stdout.write(render()); 92 } 93 94 function cleanup() { 95 stdin.setRawMode(wasRaw ?? false); 96 stdin.pause(); 97 stdin.removeListener('data', onData); 98 // Clear the TUI and restore cursor 99 const lines = render().split('\n').length; 100 stdout.write(`\x1b[${lines}A\x1b[J`); 101 stdout.write('\x1b[?25h'); // Show cursor 102 } 103 104 function onData(data: Buffer) { 105 const key = data.toString(); 106 107 // Arrow up / k 108 if (key === '\x1b[A' || key === 'k') { 109 cursor = (cursor - 1 + state.length) % state.length; 110 draw(); 111 return; 112 } 113 114 // Arrow down / j 115 if (key === '\x1b[B' || key === 'j') { 116 cursor = (cursor + 1) % state.length; 117 draw(); 118 return; 119 } 120 121 // Space — toggle 122 if (key === ' ') { 123 state[cursor].checked = !state[cursor].checked; 124 draw(); 125 return; 126 } 127 128 // Tab — toggle and move down 129 if (key === '\t') { 130 state[cursor].checked = !state[cursor].checked; 131 cursor = (cursor + 1) % state.length; 132 draw(); 133 return; 134 } 135 136 // 'a' — toggle all 137 if (key === 'a') { 138 const allChecked = state.every(i => i.checked); 139 for (const item of state) item.checked = !allChecked; 140 draw(); 141 return; 142 } 143 144 // Enter — confirm 145 if (key === '\r' || key === '\n') { 146 cleanup(); 147 const selected = state.filter(i => i.checked).map(i => i.value); 148 // Show summary 149 stdout.write(` ${styleText('green', '✓')} ${styleText('bold', `${selected.length} file(s) selected`)}\n\n`); 150 resolve(selected); 151 return; 152 } 153 154 // q / Esc — cancel 155 if (key === 'q' || key === '\x1b') { 156 cleanup(); 157 stdout.write(` ${styleText('yellow', '✗')} ${styleText('dim', 'Cancelled')}\n\n`); 158 resolve([]); 159 return; 160 } 161 162 // Ctrl+C — exit process 163 if (key === '\x03') { 164 cleanup(); 165 process.exit(EXIT_CODES.INTERRUPTED); 166 } 167 } 168 169 stdin.on('data', onData); 170 draw(); 171 }); 172 } 173 174 /** 175 * Simple yes/no confirmation prompt. 176 * 177 * In non-TTY environments, returns `defaultYes` (defaults to true) without blocking. 178 * In TTY, waits for a single keypress: y/Enter → true, n/Esc/q → false. 179 */ 180 export async function confirmPrompt( 181 message: string, 182 defaultYes: boolean = true, 183 ): Promise<boolean> { 184 const { stdin, stdout } = process; 185 if (!stdin.isTTY) return defaultYes; 186 187 const hint = defaultYes ? '[Y/n]' : '[y/N]'; 188 stdout.write(` ${message} ${styleText('dim', hint)} `); 189 190 return new Promise<boolean>((resolve) => { 191 const wasRaw = stdin.isRaw; 192 stdin.setRawMode(true); 193 stdin.resume(); 194 195 function cleanup() { 196 stdin.setRawMode(wasRaw ?? false); 197 stdin.pause(); 198 stdin.removeListener('data', onData); 199 stdout.write('\n'); 200 } 201 202 function onData(data: Buffer) { 203 const key = data.toString(); 204 205 // Ctrl+C 206 if (key === '\x03') { 207 cleanup(); 208 process.exit(EXIT_CODES.INTERRUPTED); 209 } 210 211 // Enter — use default 212 if (key === '\r' || key === '\n') { 213 cleanup(); 214 resolve(defaultYes); 215 return; 216 } 217 218 // y/Y — yes 219 if (key === 'y' || key === 'Y') { 220 cleanup(); 221 resolve(true); 222 return; 223 } 224 225 // n/N/q/Esc — no 226 if (key === 'n' || key === 'N' || key === 'q' || key === '\x1b') { 227 cleanup(); 228 resolve(false); 229 return; 230 } 231 232 // Ignore other keys 233 } 234 235 stdin.on('data', onData); 236 }); 237 }