@jackwener/opencli 0.5.2 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/tui.ts ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * tui.ts — Zero-dependency interactive TUI components
3
+ *
4
+ * Uses raw stdin mode + ANSI escape codes for interactive prompts.
5
+ */
6
+ import chalk from 'chalk';
7
+
8
+ export interface CheckboxItem {
9
+ label: string;
10
+ value: string;
11
+ checked: boolean;
12
+ /** Optional status to display after the label */
13
+ status?: string;
14
+ statusColor?: 'green' | 'yellow' | 'red' | 'dim';
15
+ }
16
+
17
+ /**
18
+ * Interactive multi-select checkbox prompt.
19
+ *
20
+ * Controls:
21
+ * ↑/↓ or j/k — navigate
22
+ * Space — toggle selection
23
+ * a — toggle all
24
+ * Enter — confirm
25
+ * q/Esc — cancel (returns empty)
26
+ */
27
+ export async function checkboxPrompt(
28
+ items: CheckboxItem[],
29
+ opts: { title?: string; hint?: string } = {},
30
+ ): Promise<string[]> {
31
+ if (items.length === 0) return [];
32
+
33
+ const { stdin, stdout } = process;
34
+ if (!stdin.isTTY) {
35
+ // Non-interactive: return all checked items
36
+ return items.filter(i => i.checked).map(i => i.value);
37
+ }
38
+
39
+ let cursor = 0;
40
+ const state = items.map(i => ({ ...i }));
41
+
42
+ function colorStatus(status: string | undefined, color: CheckboxItem['statusColor']): string {
43
+ if (!status) return '';
44
+ switch (color) {
45
+ case 'green': return chalk.green(status);
46
+ case 'yellow': return chalk.yellow(status);
47
+ case 'red': return chalk.red(status);
48
+ case 'dim': return chalk.dim(status);
49
+ default: return chalk.dim(status);
50
+ }
51
+ }
52
+
53
+ function render() {
54
+ // Move cursor to start and clear
55
+ let out = '';
56
+
57
+ if (opts.title) {
58
+ out += `\n${chalk.bold(opts.title)}\n\n`;
59
+ }
60
+
61
+ for (let i = 0; i < state.length; i++) {
62
+ const item = state[i];
63
+ const pointer = i === cursor ? chalk.cyan('❯') : ' ';
64
+ const checkbox = item.checked ? chalk.green('◉') : chalk.dim('○');
65
+ const label = i === cursor ? chalk.bold(item.label) : item.label;
66
+ const status = colorStatus(item.status, item.statusColor);
67
+ out += ` ${pointer} ${checkbox} ${label}${status ? ` ${status}` : ''}\n`;
68
+ }
69
+
70
+ out += `\n ${chalk.dim('↑↓ navigate · Space toggle · a all · Enter confirm · q cancel')}\n`;
71
+
72
+ return out;
73
+ }
74
+
75
+ return new Promise<string[]>((resolve) => {
76
+ const wasRaw = stdin.isRaw;
77
+ stdin.setRawMode(true);
78
+ stdin.resume();
79
+ stdout.write('\x1b[?25l'); // Hide cursor
80
+
81
+ let firstDraw = true;
82
+
83
+ function draw() {
84
+ // Clear previous render (skip on first draw)
85
+ if (!firstDraw) {
86
+ const lines = render().split('\n').length;
87
+ stdout.write(`\x1b[${lines}A\x1b[J`);
88
+ }
89
+ firstDraw = false;
90
+ stdout.write(render());
91
+ }
92
+
93
+ function cleanup() {
94
+ stdin.setRawMode(wasRaw ?? false);
95
+ stdin.pause();
96
+ stdin.removeListener('data', onData);
97
+ // Clear the TUI and restore cursor
98
+ const lines = render().split('\n').length;
99
+ stdout.write(`\x1b[${lines}A\x1b[J`);
100
+ stdout.write('\x1b[?25h'); // Show cursor
101
+ }
102
+
103
+ function onData(data: Buffer) {
104
+ const key = data.toString();
105
+
106
+ // Arrow up / k
107
+ if (key === '\x1b[A' || key === 'k') {
108
+ cursor = (cursor - 1 + state.length) % state.length;
109
+ draw();
110
+ return;
111
+ }
112
+
113
+ // Arrow down / j
114
+ if (key === '\x1b[B' || key === 'j') {
115
+ cursor = (cursor + 1) % state.length;
116
+ draw();
117
+ return;
118
+ }
119
+
120
+ // Space — toggle
121
+ if (key === ' ') {
122
+ state[cursor].checked = !state[cursor].checked;
123
+ draw();
124
+ return;
125
+ }
126
+
127
+ // Tab — toggle and move down
128
+ if (key === '\t') {
129
+ state[cursor].checked = !state[cursor].checked;
130
+ cursor = (cursor + 1) % state.length;
131
+ draw();
132
+ return;
133
+ }
134
+
135
+ // 'a' — toggle all
136
+ if (key === 'a') {
137
+ const allChecked = state.every(i => i.checked);
138
+ for (const item of state) item.checked = !allChecked;
139
+ draw();
140
+ return;
141
+ }
142
+
143
+ // Enter — confirm
144
+ if (key === '\r' || key === '\n') {
145
+ cleanup();
146
+ const selected = state.filter(i => i.checked).map(i => i.value);
147
+ // Show summary
148
+ stdout.write(` ${chalk.green('✓')} ${chalk.bold(`${selected.length} file(s) selected`)}\n\n`);
149
+ resolve(selected);
150
+ return;
151
+ }
152
+
153
+ // q / Esc — cancel
154
+ if (key === 'q' || key === '\x1b') {
155
+ cleanup();
156
+ stdout.write(` ${chalk.yellow('✗')} ${chalk.dim('Cancelled')}\n\n`);
157
+ resolve([]);
158
+ return;
159
+ }
160
+
161
+ // Ctrl+C — exit process
162
+ if (key === '\x03') {
163
+ cleanup();
164
+ process.exit(130);
165
+ }
166
+ }
167
+
168
+ stdin.on('data', onData);
169
+ draw();
170
+ });
171
+ }