@rlabs-inc/create-tui 0.1.0

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/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "@rlabs-inc/create-tui",
3
+ "version": "0.1.0",
4
+ "description": "Create TUI Framework applications - The Terminal UI Framework for TypeScript/Bun",
5
+ "type": "module",
6
+ "bin": {
7
+ "create-tui": "./src/index.ts"
8
+ },
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "scripts": {
13
+ "dev": "bun run src/index.ts"
14
+ },
15
+ "keywords": [
16
+ "tui",
17
+ "terminal",
18
+ "ui",
19
+ "cli",
20
+ "create",
21
+ "scaffold",
22
+ "bun",
23
+ "typescript",
24
+ "reactive"
25
+ ],
26
+ "author": "Rusty & Watson",
27
+ "license": "MIT",
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "https://github.com/rlabs-inc/tui.git",
31
+ "directory": "packages/tui-cli"
32
+ },
33
+ "homepage": "https://github.com/rlabs-inc/tui",
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }
@@ -0,0 +1,298 @@
1
+ /**
2
+ * Create command - scaffold a new TUI Framework project
3
+ */
4
+
5
+ import { mkdir, exists } from 'fs/promises'
6
+ import { join, resolve, basename } from 'path'
7
+ import { $ } from 'bun'
8
+ import { c, symbols, box } from '../utils/colors'
9
+ import { text, confirm, spinner } from '../utils/prompts'
10
+
11
+ interface CreateOptions {
12
+ projectName?: string
13
+ template?: string
14
+ }
15
+
16
+ export async function create(options: CreateOptions) {
17
+ console.log()
18
+ console.log(box([
19
+ `${c.brightCyan(symbols.sparkle)} ${c.bold('Create TUI Project')} ${c.brightCyan(symbols.sparkle)}`,
20
+ '',
21
+ c.dim('Scaffold a new TUI Framework application'),
22
+ ], { padding: 1 }))
23
+ console.log()
24
+
25
+ // Get project name
26
+ let projectName = options.projectName
27
+
28
+ if (!projectName) {
29
+ projectName = await text({
30
+ message: 'Project name',
31
+ placeholder: 'my-tui-app',
32
+ defaultValue: 'my-tui-app',
33
+ validate: (value) => {
34
+ if (!value) return 'Project name is required'
35
+ if (!/^[a-z0-9-_]+$/i.test(value)) {
36
+ return 'Project name can only contain letters, numbers, dashes, and underscores'
37
+ }
38
+ return true
39
+ }
40
+ })
41
+ }
42
+
43
+ const projectPath = resolve(process.cwd(), projectName)
44
+ const displayName = basename(projectPath)
45
+
46
+ // Check if directory exists
47
+ if (await exists(projectPath)) {
48
+ const overwrite = await confirm({
49
+ message: `Directory ${c.bold(displayName)} already exists. Overwrite?`,
50
+ defaultValue: false
51
+ })
52
+
53
+ if (!overwrite) {
54
+ console.log(`${c.muted(symbols.cross)} Cancelled`)
55
+ return
56
+ }
57
+ }
58
+
59
+ console.log()
60
+
61
+ // Create project
62
+ const spin = spinner('Creating project structure...')
63
+
64
+ try {
65
+ // Create directories
66
+ await mkdir(join(projectPath, 'src', 'components'), { recursive: true })
67
+
68
+ // Write files using Bun.write (faster than fs.writeFile)
69
+ await Promise.all([
70
+ Bun.write(join(projectPath, 'package.json'), PACKAGE_JSON(displayName)),
71
+ Bun.write(join(projectPath, 'bunfig.toml'), BUNFIG_TOML),
72
+ Bun.write(join(projectPath, 'tsconfig.json'), TSCONFIG_JSON),
73
+ Bun.write(join(projectPath, '.gitignore'), GITIGNORE),
74
+ Bun.write(join(projectPath, 'README.md'), README(displayName)),
75
+ Bun.write(join(projectPath, 'src', 'main.ts'), MAIN_TS),
76
+ Bun.write(join(projectPath, 'src', 'App.tui'), APP_TUI),
77
+ Bun.write(join(projectPath, 'src', 'components', 'Counter.tui'), COUNTER_TUI),
78
+ ])
79
+
80
+ spin.stop('Project structure created')
81
+
82
+ // Install dependencies using Bun shell
83
+ const installSpin = spinner('Installing dependencies...')
84
+
85
+ try {
86
+ await $`cd ${projectPath} && bun install`.quiet()
87
+ installSpin.stop('Dependencies installed')
88
+ } catch {
89
+ installSpin.stop()
90
+ console.log(`${c.warning(symbols.bullet)} Run ${c.info('bun install')} manually to install dependencies`)
91
+ }
92
+
93
+ // Success!
94
+ console.log()
95
+ console.log(box([
96
+ `${c.success(symbols.check)} ${c.bold('Project created!')}`,
97
+ '',
98
+ `${c.dim('Next steps:')}`,
99
+ '',
100
+ ` ${c.muted('$')} ${c.info(`cd ${displayName}`)}`,
101
+ ` ${c.muted('$')} ${c.info('bun run dev')}`,
102
+ '',
103
+ c.dim('Happy hacking!'),
104
+ ], { title: ` ${symbols.star} Success `, padding: 1 }))
105
+ console.log()
106
+
107
+ } catch (error) {
108
+ spin.stop()
109
+ throw error
110
+ }
111
+ }
112
+
113
+ // Template files
114
+
115
+ const PACKAGE_JSON = (name: string) => `{
116
+ "name": "${name}",
117
+ "version": "0.1.0",
118
+ "type": "module",
119
+ "scripts": {
120
+ "dev": "bun run src/main.ts",
121
+ "build": "bun build src/main.ts --outfile dist/main.js --target bun"
122
+ },
123
+ "dependencies": {
124
+ "@rlabs-inc/tui": "latest",
125
+ "@rlabs-inc/signals": "latest"
126
+ },
127
+ "devDependencies": {
128
+ "@rlabs-inc/tui-compiler": "latest",
129
+ "@types/bun": "latest",
130
+ "typescript": "^5.0.0"
131
+ }
132
+ }
133
+ `
134
+
135
+ const BUNFIG_TOML = `# TUI Framework configuration
136
+ # The compiler plugin enables .tui file imports
137
+
138
+ preload = ["@rlabs-inc/tui-compiler/register"]
139
+ `
140
+
141
+ const TSCONFIG_JSON = `{
142
+ "compilerOptions": {
143
+ "target": "ESNext",
144
+ "module": "ESNext",
145
+ "moduleResolution": "bundler",
146
+ "strict": true,
147
+ "esModuleInterop": true,
148
+ "skipLibCheck": true,
149
+ "forceConsistentCasingInFileNames": true,
150
+ "resolveJsonModule": true,
151
+ "declaration": true,
152
+ "declarationMap": true,
153
+ "noEmit": true,
154
+ "types": ["bun-types"]
155
+ },
156
+ "include": ["src/**/*"],
157
+ "exclude": ["node_modules", "dist"]
158
+ }
159
+ `
160
+
161
+ const GITIGNORE = `# Dependencies
162
+ node_modules/
163
+
164
+ # Build output
165
+ dist/
166
+
167
+ # Environment
168
+ .env
169
+ .env.local
170
+
171
+ # IDE
172
+ .vscode/
173
+ .idea/
174
+ *.swp
175
+ *.swo
176
+
177
+ # OS
178
+ .DS_Store
179
+ Thumbs.db
180
+
181
+ # Debug
182
+ *.log
183
+ `
184
+
185
+ const README = (name: string) => `# ${name}
186
+
187
+ A TUI Framework application.
188
+
189
+ ## Getting Started
190
+
191
+ \`\`\`bash
192
+ # Run in development
193
+ bun run dev
194
+
195
+ # Build for production
196
+ bun run build
197
+ \`\`\`
198
+
199
+ ## Project Structure
200
+
201
+ \`\`\`
202
+ ${name}/
203
+ ├── src/
204
+ │ ├── main.ts # Entry point
205
+ │ ├── App.tui # Root component
206
+ │ └── components/ # Reusable components
207
+ │ └── Counter.tui
208
+ ├── bunfig.toml # Bun configuration (enables .tui compiler)
209
+ ├── package.json
210
+ └── tsconfig.json
211
+ \`\`\`
212
+
213
+ ## Learn More
214
+
215
+ - [TUI Framework Documentation](https://github.com/rlabs-inc/tui)
216
+ - [Signals Library](https://github.com/rlabs-inc/signals)
217
+ `
218
+
219
+ const MAIN_TS = `/**
220
+ * TUI Application Entry Point
221
+ */
222
+
223
+ import '@rlabs-inc/tui-compiler/register'
224
+ import { mount, keyboard } from '@rlabs-inc/tui'
225
+ import App from './App.tui'
226
+
227
+ async function main() {
228
+ // Mount the application
229
+ const cleanup = await mount(() => {
230
+ App()
231
+ })
232
+
233
+ // Handle exit
234
+ keyboard.onKey((event) => {
235
+ if (event.key === 'q' || (event.modifiers.ctrl && event.key === 'c')) {
236
+ cleanup().then(() => process.exit(0))
237
+ }
238
+ })
239
+ }
240
+
241
+ main().catch(console.error)
242
+ `
243
+
244
+ const APP_TUI = `<script lang="ts">
245
+ /**
246
+ * Root Application Component
247
+ */
248
+ import Counter from './components/Counter.tui'
249
+ </script>
250
+
251
+ <box
252
+ width="100%"
253
+ height="100%"
254
+ flexDirection="column"
255
+ justifyContent="center"
256
+ alignItems="center"
257
+ gap={2}
258
+ >
259
+ <text variant="accent">Welcome to TUI Framework</text>
260
+ <Counter initialCount={0} />
261
+ <text variant="muted">Press Q to quit</text>
262
+ </box>
263
+ `
264
+
265
+ const COUNTER_TUI = `<script lang="ts">
266
+ /**
267
+ * Counter Component
268
+ *
269
+ * A simple reactive counter to demonstrate TUI Framework basics.
270
+ */
271
+ import { keyboard } from '@rlabs-inc/tui'
272
+
273
+ export let initialCount: number = 0
274
+
275
+ const count = signal(initialCount)
276
+
277
+ // Handle keyboard input
278
+ keyboard.onKey((event) => {
279
+ if (event.key === 'ArrowUp' || event.key === '+') {
280
+ count.value++
281
+ }
282
+ if (event.key === 'ArrowDown' || event.key === '-') {
283
+ count.value--
284
+ }
285
+ })
286
+ </script>
287
+
288
+ <box
289
+ border={1}
290
+ padding={1}
291
+ flexDirection="column"
292
+ alignItems="center"
293
+ gap={1}
294
+ >
295
+ <text>Count: {count}</text>
296
+ <text variant="muted">Use +/- or arrow keys</text>
297
+ </box>
298
+ `
package/src/index.ts ADDED
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * create-tui - Create TUI Framework applications
4
+ *
5
+ * Usage:
6
+ * bun create tui my-app
7
+ * bunx create-tui my-app
8
+ * npx create-tui my-app
9
+ */
10
+
11
+ import { parseArgs } from 'util'
12
+ import { c, symbols } from './utils/colors'
13
+ import { create } from './commands/create'
14
+
15
+ const VERSION = '0.1.0'
16
+
17
+ const HELP = `
18
+ ${c.bold('@rlabs-inc/create-tui')} ${c.muted(`v${VERSION}`)}
19
+
20
+ ${c.dim('Create TUI Framework applications')}
21
+ ${c.dim('The Terminal UI Framework for TypeScript/Bun')}
22
+
23
+ ${c.bold('Usage:')}
24
+ ${c.info('bunx @rlabs-inc/create-tui')} ${c.muted('<project-name>')}
25
+ ${c.info('npx @rlabs-inc/create-tui')} ${c.muted('<project-name>')}
26
+
27
+ ${c.bold('Options:')}
28
+ ${c.muted('-h, --help')} Show this help message
29
+ ${c.muted('-v, --version')} Show version
30
+
31
+ ${c.bold('Examples:')}
32
+ ${c.dim('$')} ${c.info('bunx @rlabs-inc/create-tui')} my-app
33
+ ${c.dim('$')} ${c.info('bunx @rlabs-inc/create-tui')} dashboard
34
+
35
+ ${c.muted('Documentation: https://github.com/rlabs-inc/tui')}
36
+ `
37
+
38
+ async function main() {
39
+ try {
40
+ const { values, positionals } = parseArgs({
41
+ args: Bun.argv.slice(2),
42
+ options: {
43
+ help: { type: 'boolean', short: 'h' },
44
+ version: { type: 'boolean', short: 'v' },
45
+ template: { type: 'string', short: 't' },
46
+ },
47
+ allowPositionals: true,
48
+ strict: false,
49
+ })
50
+
51
+ // Help flag
52
+ if (values.help) {
53
+ console.log(HELP)
54
+ return
55
+ }
56
+
57
+ // Version flag
58
+ if (values.version) {
59
+ console.log(`${c.bold('@rlabs-inc/create-tui')} ${c.muted(`v${VERSION}`)}`)
60
+ return
61
+ }
62
+
63
+ // Get project name from positional args
64
+ const projectName = positionals[0]
65
+
66
+ // Run create command (will prompt for name if not provided)
67
+ await create({ projectName, template: values.template as string | undefined })
68
+
69
+ } catch (error: any) {
70
+ console.error(`${c.error(symbols.cross)} ${error.message}`)
71
+ process.exit(1)
72
+ }
73
+ }
74
+
75
+ main()
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Terminal colors using Bun's built-in color API
3
+ *
4
+ * - Automatically respects NO_COLOR environment variable
5
+ * - Automatically detects terminal color support (16, 256, or 16m colors)
6
+ * - Uses semantic colors that work with any terminal theme
7
+ */
8
+
9
+ const RESET = '\x1b[0m'
10
+
11
+ // Helper to wrap text with ANSI color
12
+ function colorize(text: string, color: string): string {
13
+ const ansi = Bun.color(color, 'ansi')
14
+ if (!ansi) return text
15
+ return `${ansi}${text}${RESET}`
16
+ }
17
+
18
+ // Style helpers
19
+ function bold(text: string): string {
20
+ return `\x1b[1m${text}${RESET}`
21
+ }
22
+
23
+ function dim(text: string): string {
24
+ return `\x1b[2m${text}${RESET}`
25
+ }
26
+
27
+ function italic(text: string): string {
28
+ return `\x1b[3m${text}${RESET}`
29
+ }
30
+
31
+ function underline(text: string): string {
32
+ return `\x1b[4m${text}${RESET}`
33
+ }
34
+
35
+ // Semantic color helpers that work with any terminal theme
36
+ export const c = {
37
+ // Emphasis
38
+ bold,
39
+ dim,
40
+ italic,
41
+ underline,
42
+
43
+ // Semantic colors using Bun.color for automatic terminal detection
44
+ success: (s: string) => colorize(s, '#22c55e'), // Green
45
+ error: (s: string) => colorize(s, '#ef4444'), // Red
46
+ warning: (s: string) => colorize(s, '#eab308'), // Yellow
47
+ info: (s: string) => colorize(s, '#06b6d4'), // Cyan
48
+ accent: (s: string) => colorize(s, '#a855f7'), // Purple
49
+
50
+ // Muted text (gray)
51
+ muted: (s: string) => colorize(s, '#6b7280'),
52
+
53
+ // Bright variants
54
+ brightGreen: (s: string) => colorize(s, '#4ade80'),
55
+ brightCyan: (s: string) => colorize(s, '#22d3ee'),
56
+ brightYellow: (s: string) => colorize(s, '#facc15'),
57
+ brightMagenta: (s: string) => colorize(s, '#c084fc'),
58
+
59
+ // Custom color
60
+ color: (s: string, color: string) => colorize(s, color),
61
+ }
62
+
63
+ // Symbols that work in most terminals
64
+ export const symbols = {
65
+ check: '\u2714', // ✔
66
+ cross: '\u2718', // ✘
67
+ arrow: '\u276F', // ❯
68
+ bullet: '\u25CF', // ●
69
+ line: '\u2500', // ─
70
+ corner: '\u2514', // └
71
+ tee: '\u251C', // ├
72
+ vertical: '\u2502', // │
73
+ star: '\u2605', // ★
74
+ sparkle: '\u2728', // ✨
75
+ }
76
+
77
+ // Strip ANSI codes for length calculation
78
+ function stripAnsi(s: string): string {
79
+ return s.replace(/\x1b\[[0-9;]*m/g, '')
80
+ }
81
+
82
+ // Box drawing for beautiful output
83
+ export function box(content: string[], options: { title?: string; padding?: number } = {}): string {
84
+ const padding = options.padding ?? 1
85
+ const maxWidth = Math.max(...content.map(l => stripAnsi(l).length), stripAnsi(options.title ?? '').length)
86
+ const width = maxWidth + padding * 2
87
+
88
+ const horizontal = symbols.line.repeat(width + 2)
89
+ const empty = `${symbols.vertical}${' '.repeat(width + 2)}${symbols.vertical}`
90
+ const pad = ' '.repeat(padding)
91
+
92
+ const lines: string[] = []
93
+
94
+ // Top border with optional title
95
+ if (options.title) {
96
+ const titleText = options.title
97
+ const strippedTitle = stripAnsi(titleText)
98
+ const leftPad = Math.floor((width + 2 - strippedTitle.length) / 2)
99
+ const rightPad = width + 2 - strippedTitle.length - leftPad
100
+ lines.push(`\u256D${symbols.line.repeat(leftPad)}${titleText}${symbols.line.repeat(rightPad)}\u256E`)
101
+ } else {
102
+ lines.push(`\u256D${horizontal}\u256E`)
103
+ }
104
+
105
+ // Top padding
106
+ for (let i = 0; i < padding; i++) lines.push(empty)
107
+
108
+ // Content
109
+ for (const line of content) {
110
+ const stripped = stripAnsi(line)
111
+ const rightPad = width - stripped.length - padding
112
+ lines.push(`${symbols.vertical}${pad}${line}${' '.repeat(Math.max(0, rightPad) + padding)}${symbols.vertical}`)
113
+ }
114
+
115
+ // Bottom padding
116
+ for (let i = 0; i < padding; i++) lines.push(empty)
117
+
118
+ // Bottom border
119
+ lines.push(`\u2570${horizontal}\u256F`)
120
+
121
+ return lines.join('\n')
122
+ }
123
+
124
+ // Spinner frames
125
+ export const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
126
+
127
+ // Progress bar
128
+ export function progressBar(percent: number, width = 20): string {
129
+ const filled = Math.round(width * percent)
130
+ const empty = width - filled
131
+ return `${c.success('\u2588'.repeat(filled))}${c.muted('\u2591'.repeat(empty))}`
132
+ }
@@ -0,0 +1,273 @@
1
+ /**
2
+ * Simple interactive prompts without external dependencies
3
+ *
4
+ * Uses raw mode to capture key presses for a smooth experience
5
+ */
6
+
7
+ import { c, symbols } from './colors'
8
+
9
+ const stdin = process.stdin
10
+ const stdout = process.stdout
11
+
12
+ // Read a line of input
13
+ export async function text(options: {
14
+ message: string
15
+ placeholder?: string
16
+ defaultValue?: string
17
+ validate?: (value: string) => string | true
18
+ }): Promise<string> {
19
+ const { message, placeholder, defaultValue, validate } = options
20
+
21
+ // Print prompt
22
+ stdout.write(`${c.success(symbols.arrow)} ${c.bold(message)}`)
23
+ if (defaultValue) {
24
+ stdout.write(` ${c.muted(`(${defaultValue})`)}`)
25
+ }
26
+ stdout.write(': ')
27
+
28
+ if (placeholder && !defaultValue) {
29
+ stdout.write(c.muted(placeholder))
30
+ stdout.write('\x1b[' + placeholder.length + 'D') // Move cursor back
31
+ }
32
+
33
+ return new Promise((resolve) => {
34
+ let buffer = ''
35
+ let placeholderCleared = false
36
+
37
+ const cleanup = () => {
38
+ stdin.setRawMode(false)
39
+ stdin.removeListener('data', onData)
40
+ stdin.pause()
41
+ }
42
+
43
+ const onData = (data: Buffer) => {
44
+ const char = data.toString()
45
+ const code = data[0]
46
+
47
+ // Clear placeholder on first input
48
+ if (!placeholderCleared && placeholder && buffer === '') {
49
+ stdout.write('\x1b[' + placeholder.length + 'D')
50
+ stdout.write(' '.repeat(placeholder.length))
51
+ stdout.write('\x1b[' + placeholder.length + 'D')
52
+ placeholderCleared = true
53
+ }
54
+
55
+ // Enter
56
+ if (code === 13) {
57
+ stdout.write('\n')
58
+ const result = buffer || defaultValue || ''
59
+
60
+ // Validate
61
+ if (validate) {
62
+ const validationResult = validate(result)
63
+ if (validationResult !== true) {
64
+ stdout.write(`${c.error(symbols.cross)} ${validationResult}\n`)
65
+ stdout.write(`${c.success(symbols.arrow)} ${c.bold(message)}: `)
66
+ buffer = ''
67
+ return
68
+ }
69
+ }
70
+
71
+ cleanup()
72
+ resolve(result)
73
+ return
74
+ }
75
+
76
+ // Ctrl+C
77
+ if (code === 3) {
78
+ stdout.write('\n')
79
+ cleanup()
80
+ process.exit(1)
81
+ }
82
+
83
+ // Backspace
84
+ if (code === 127) {
85
+ if (buffer.length > 0) {
86
+ buffer = buffer.slice(0, -1)
87
+ stdout.write('\b \b')
88
+ }
89
+ return
90
+ }
91
+
92
+ // Printable characters
93
+ if (code >= 32 && code < 127) {
94
+ buffer += char
95
+ stdout.write(char)
96
+ }
97
+ }
98
+
99
+ stdin.resume()
100
+ stdin.setRawMode(true)
101
+ stdin.on('data', onData)
102
+ })
103
+ }
104
+
105
+ // Select from options
106
+ export async function select<T extends string>(options: {
107
+ message: string
108
+ options: { value: T; label: string; hint?: string }[]
109
+ }): Promise<T> {
110
+ const { message, options: choices } = options
111
+ let selectedIndex = 0
112
+
113
+ const render = () => {
114
+ // Clear previous render
115
+ stdout.write('\x1b[?25l') // Hide cursor
116
+
117
+ for (let i = 0; i < choices.length; i++) {
118
+ const choice = choices[i]
119
+ const isSelected = i === selectedIndex
120
+ const prefix = isSelected ? c.success(symbols.arrow) : ' '
121
+ const label = isSelected ? c.bold(choice.label) : choice.label
122
+ const hint = choice.hint ? c.muted(` ${choice.hint}`) : ''
123
+
124
+ stdout.write(` ${prefix} ${label}${hint}\n`)
125
+ }
126
+ }
127
+
128
+ stdout.write(`${c.success(symbols.arrow)} ${c.bold(message)}\n`)
129
+ render()
130
+
131
+ return new Promise((resolve) => {
132
+ const cleanup = () => {
133
+ stdin.setRawMode(false)
134
+ stdin.removeListener('data', onData)
135
+ stdin.pause()
136
+ stdout.write('\x1b[?25h') // Show cursor
137
+ }
138
+
139
+ const onData = (data: Buffer) => {
140
+ const code = data[0]
141
+
142
+ // Ctrl+C
143
+ if (code === 3) {
144
+ stdout.write('\n')
145
+ cleanup()
146
+ process.exit(1)
147
+ }
148
+
149
+ // Up arrow or k
150
+ if ((data[0] === 27 && data[1] === 91 && data[2] === 65) || code === 107) {
151
+ selectedIndex = (selectedIndex - 1 + choices.length) % choices.length
152
+ // Move cursor up and re-render
153
+ stdout.write(`\x1b[${choices.length}A`)
154
+ render()
155
+ return
156
+ }
157
+
158
+ // Down arrow or j
159
+ if ((data[0] === 27 && data[1] === 91 && data[2] === 66) || code === 106) {
160
+ selectedIndex = (selectedIndex + 1) % choices.length
161
+ // Move cursor up and re-render
162
+ stdout.write(`\x1b[${choices.length}A`)
163
+ render()
164
+ return
165
+ }
166
+
167
+ // Enter
168
+ if (code === 13) {
169
+ cleanup()
170
+ // Clear the options
171
+ stdout.write(`\x1b[${choices.length}A`)
172
+ for (let i = 0; i < choices.length; i++) {
173
+ stdout.write('\x1b[2K\n')
174
+ }
175
+ stdout.write(`\x1b[${choices.length}A`)
176
+ stdout.write(` ${c.muted(symbols.check)} ${choices[selectedIndex].label}\n`)
177
+ resolve(choices[selectedIndex].value)
178
+ return
179
+ }
180
+ }
181
+
182
+ stdin.resume()
183
+ stdin.setRawMode(true)
184
+ stdin.on('data', onData)
185
+ })
186
+ }
187
+
188
+ // Confirm yes/no
189
+ export async function confirm(options: {
190
+ message: string
191
+ defaultValue?: boolean
192
+ }): Promise<boolean> {
193
+ const { message, defaultValue = true } = options
194
+ const hint = defaultValue ? c.muted('[Y/n]') : c.muted('[y/N]')
195
+
196
+ stdout.write(`${c.success(symbols.arrow)} ${c.bold(message)} ${hint} `)
197
+
198
+ return new Promise((resolve) => {
199
+ const cleanup = () => {
200
+ stdin.setRawMode(false)
201
+ stdin.removeListener('data', onData)
202
+ stdin.pause()
203
+ }
204
+
205
+ const onData = (data: Buffer) => {
206
+ const char = data.toString().toLowerCase()
207
+ const code = data[0]
208
+
209
+ // Ctrl+C
210
+ if (code === 3) {
211
+ stdout.write('\n')
212
+ cleanup()
213
+ process.exit(1)
214
+ }
215
+
216
+ // Enter (use default)
217
+ if (code === 13) {
218
+ stdout.write(defaultValue ? 'Yes' : 'No')
219
+ stdout.write('\n')
220
+ cleanup()
221
+ resolve(defaultValue)
222
+ return
223
+ }
224
+
225
+ // Y
226
+ if (char === 'y') {
227
+ stdout.write('Yes\n')
228
+ cleanup()
229
+ resolve(true)
230
+ return
231
+ }
232
+
233
+ // N
234
+ if (char === 'n') {
235
+ stdout.write('No\n')
236
+ cleanup()
237
+ resolve(false)
238
+ return
239
+ }
240
+ }
241
+
242
+ stdin.resume()
243
+ stdin.setRawMode(true)
244
+ stdin.on('data', onData)
245
+ })
246
+ }
247
+
248
+ // Spinner
249
+ export function spinner(message: string): { stop: (finalMessage?: string) => void } {
250
+ const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
251
+ let i = 0
252
+ let stopped = false
253
+
254
+ stdout.write('\x1b[?25l') // Hide cursor
255
+
256
+ const interval = setInterval(() => {
257
+ if (stopped) return
258
+ stdout.write(`\r${c.info(frames[i])} ${message}`)
259
+ i = (i + 1) % frames.length
260
+ }, 80)
261
+
262
+ return {
263
+ stop: (finalMessage?: string) => {
264
+ stopped = true
265
+ clearInterval(interval)
266
+ stdout.write('\r\x1b[K') // Clear line
267
+ if (finalMessage) {
268
+ stdout.write(`${c.success(symbols.check)} ${finalMessage}\n`)
269
+ }
270
+ stdout.write('\x1b[?25h') // Show cursor
271
+ }
272
+ }
273
+ }