Files
opencli/src/tui.ts
T
jackwener b308d5594a refactor(doctor/setup): polish UX and dedup code
- Doctor: chalk-colored output ([OK] green, [MISSING] red, etc.)
- Doctor: paths shortened with ~ and tool labels ([Codex], etc.)
- Doctor --fix: skip already-configured files
- TUI: hide cursor during interaction, proper Ctrl+C exit
- Setup: source hint after shell write, dedup shared helpers
- Tests: strip ANSI for assertions
2026-03-16 14:02:02 +08:00

172 lines
4.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* tui.ts — Zero-dependency interactive TUI components
*
* Uses raw stdin mode + ANSI escape codes for interactive prompts.
*/
import chalk from 'chalk';
export interface CheckboxItem {
label: string;
value: string;
checked: boolean;
/** Optional status to display after the label */
status?: string;
statusColor?: 'green' | 'yellow' | 'red' | 'dim';
}
/**
* Interactive multi-select checkbox prompt.
*
* Controls:
* ↑/↓ or j/k — navigate
* Space — toggle selection
* a — toggle all
* Enter — confirm
* q/Esc — cancel (returns empty)
*/
export async function checkboxPrompt(
items: CheckboxItem[],
opts: { title?: string; hint?: string } = {},
): Promise<string[]> {
if (items.length === 0) return [];
const { stdin, stdout } = process;
if (!stdin.isTTY) {
// Non-interactive: return all checked items
return items.filter(i => i.checked).map(i => i.value);
}
let cursor = 0;
const state = items.map(i => ({ ...i }));
function colorStatus(status: string | undefined, color: CheckboxItem['statusColor']): string {
if (!status) return '';
switch (color) {
case 'green': return chalk.green(status);
case 'yellow': return chalk.yellow(status);
case 'red': return chalk.red(status);
case 'dim': return chalk.dim(status);
default: return chalk.dim(status);
}
}
function render() {
// Move cursor to start and clear
let out = '';
if (opts.title) {
out += `\n${chalk.bold(opts.title)}\n\n`;
}
for (let i = 0; i < state.length; i++) {
const item = state[i];
const pointer = i === cursor ? chalk.cyan('❯') : ' ';
const checkbox = item.checked ? chalk.green('◉') : chalk.dim('○');
const label = i === cursor ? chalk.bold(item.label) : item.label;
const status = colorStatus(item.status, item.statusColor);
out += ` ${pointer} ${checkbox} ${label}${status ? ` ${status}` : ''}\n`;
}
out += `\n ${chalk.dim('↑↓ navigate · Space toggle · a all · Enter confirm · q cancel')}\n`;
return out;
}
return new Promise<string[]>((resolve) => {
const wasRaw = stdin.isRaw;
stdin.setRawMode(true);
stdin.resume();
stdout.write('\x1b[?25l'); // Hide cursor
let firstDraw = true;
function draw() {
// Clear previous render (skip on first draw)
if (!firstDraw) {
const lines = render().split('\n').length;
stdout.write(`\x1b[${lines}A\x1b[J`);
}
firstDraw = false;
stdout.write(render());
}
function cleanup() {
stdin.setRawMode(wasRaw ?? false);
stdin.pause();
stdin.removeListener('data', onData);
// Clear the TUI and restore cursor
const lines = render().split('\n').length;
stdout.write(`\x1b[${lines}A\x1b[J`);
stdout.write('\x1b[?25h'); // Show cursor
}
function onData(data: Buffer) {
const key = data.toString();
// Arrow up / k
if (key === '\x1b[A' || key === 'k') {
cursor = (cursor - 1 + state.length) % state.length;
draw();
return;
}
// Arrow down / j
if (key === '\x1b[B' || key === 'j') {
cursor = (cursor + 1) % state.length;
draw();
return;
}
// Space — toggle
if (key === ' ') {
state[cursor].checked = !state[cursor].checked;
draw();
return;
}
// Tab — toggle and move down
if (key === '\t') {
state[cursor].checked = !state[cursor].checked;
cursor = (cursor + 1) % state.length;
draw();
return;
}
// 'a' — toggle all
if (key === 'a') {
const allChecked = state.every(i => i.checked);
for (const item of state) item.checked = !allChecked;
draw();
return;
}
// Enter — confirm
if (key === '\r' || key === '\n') {
cleanup();
const selected = state.filter(i => i.checked).map(i => i.value);
// Show summary
stdout.write(` ${chalk.green('✓')} ${chalk.bold(`${selected.length} file(s) selected`)}\n\n`);
resolve(selected);
return;
}
// q / Esc — cancel
if (key === 'q' || key === '\x1b') {
cleanup();
stdout.write(` ${chalk.yellow('✗')} ${chalk.dim('Cancelled')}\n\n`);
resolve([]);
return;
}
// Ctrl+C — exit process
if (key === '\x03') {
cleanup();
process.exit(130);
}
}
stdin.on('data', onData);
draw();
});
}