/ src / tui.ts
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  }