grokcodecli 0.1.2 → 0.1.4

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.
@@ -11,6 +11,7 @@ import { PermissionManager } from '../permissions/manager.js';
11
11
  import { HistoryManager, ConversationSession } from './history.js';
12
12
  import { ConfigManager } from '../config/manager.js';
13
13
  import { drawBox, randomTip, divider, formatCodeBlock, progressBar } from '../utils/ui.js';
14
+ import { interactiveSelect, SelectorOption } from '../utils/selector.js';
14
15
 
15
16
  // Get version from package.json
16
17
  const __filename = fileURLToPath(import.meta.url);
@@ -76,12 +77,51 @@ export class GrokChat {
76
77
  private sessionStartTime: Date = new Date();
77
78
  private apiKey: string;
78
79
 
80
+ // All slash commands for autocomplete
81
+ private static SLASH_COMMANDS = [
82
+ '/help', '/h',
83
+ '/clear', '/c',
84
+ '/save', '/s',
85
+ '/exit', '/q',
86
+ '/history',
87
+ '/resume',
88
+ '/rename',
89
+ '/export',
90
+ '/compact',
91
+ '/config',
92
+ '/model',
93
+ '/stream',
94
+ '/permissions',
95
+ '/status',
96
+ '/context',
97
+ '/cost',
98
+ '/usage',
99
+ '/doctor',
100
+ '/version',
101
+ '/init',
102
+ '/review',
103
+ '/terminal-setup',
104
+ '/add-dir',
105
+ '/pwd',
106
+ ];
107
+
79
108
  constructor(options: ChatOptions) {
80
109
  this.apiKey = options.apiKey;
81
- this.client = new GrokClient(options.apiKey, options.model || 'grok-4-0709');
110
+ this.client = new GrokClient(options.apiKey, options.model || 'grok-4-1-fast-reasoning');
111
+
112
+ // Autocomplete function for slash commands
113
+ const completer = (line: string): [string[], string] => {
114
+ if (line.startsWith('/')) {
115
+ const hits = GrokChat.SLASH_COMMANDS.filter(cmd => cmd.startsWith(line));
116
+ return [hits.length ? hits : GrokChat.SLASH_COMMANDS, line];
117
+ }
118
+ return [[], line];
119
+ };
120
+
82
121
  this.rl = readline.createInterface({
83
122
  input: process.stdin,
84
123
  output: process.stdout,
124
+ completer,
85
125
  });
86
126
  this.permissions = new PermissionManager();
87
127
  this.permissions.setReadlineInterface(this.rl);
@@ -90,28 +130,11 @@ export class GrokChat {
90
130
  }
91
131
 
92
132
  async start(): Promise<void> {
93
- // Beautiful welcome screen
94
- console.log(chalk.cyan(`
95
- ██████╗ ██████╗ ██████╗ ██╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗
96
- ██╔════╝ ██╔══██╗██╔═══██╗██║ ██╔╝ ██╔════╝██╔═══██╗██╔══██╗██╔════╝
97
- ██║ ███╗██████╔╝██║ ██║█████╔╝ ██║ ██║ ██║██║ ██║█████╗
98
- ██║ ██║██╔══██╗██║ ██║██╔═██╗ ██║ ██║ ██║██║ ██║██╔══╝
99
- ╚██████╔╝██║ ██║╚██████╔╝██║ ██╗ ╚██████╗╚██████╔╝██████╔╝███████╗
100
- ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝`));
101
-
102
- console.log();
103
- console.log(drawBox([
104
- `${chalk.bold('Grok Code CLI')} ${chalk.gray(`v${VERSION}`)}`,
105
- '',
106
- `${chalk.gray('Model:')} ${chalk.green(this.client.model)}`,
107
- `${chalk.gray('CWD:')} ${chalk.blue(process.cwd())}`,
108
- `${chalk.gray('Tools:')} ${chalk.cyan('8 available')} ${chalk.gray('(Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch)')}`,
109
- '',
110
- `${chalk.gray('Commands:')} ${chalk.cyan('/help')} ${chalk.gray('•')} ${chalk.cyan('/model')} ${chalk.gray('•')} ${chalk.cyan('/doctor')} ${chalk.gray('•')} ${chalk.yellow('exit')}`,
111
- ], { borderColor: chalk.cyan, padding: 0 }));
112
-
133
+ // Clean welcome like Claude Code
113
134
  console.log();
114
- console.log(randomTip());
135
+ console.log(chalk.bold.cyan(' Grok Code') + chalk.dim(` v${VERSION}`));
136
+ console.log(chalk.dim(` ${this.client.model} • ${process.cwd()}`));
137
+ console.log(chalk.dim(' Type /help for commands, Tab for autocomplete'));
115
138
  console.log();
116
139
 
117
140
  // Create new session
@@ -465,8 +488,8 @@ export class GrokChat {
465
488
  }
466
489
 
467
490
  private async handleModel(modelName?: string): Promise<void> {
468
- // Fetch latest models from xAI API dynamically
469
- console.log(chalk.gray('Fetching available models...\n'));
491
+ // Fetch latest models from xAI API
492
+ process.stdout.write(chalk.dim(' Fetching models...'));
470
493
 
471
494
  let availableModels: string[] = [];
472
495
  try {
@@ -478,175 +501,87 @@ export class GrokChat {
478
501
  const data = await response.json() as { data: { id: string }[] };
479
502
  availableModels = data.data.map(m => m.id).sort();
480
503
  } else {
481
- // Fallback to known models if API fails
482
504
  availableModels = [
483
- 'grok-4-0709', 'grok-4-fast-reasoning', 'grok-4-fast-non-reasoning',
484
505
  'grok-4-1-fast-reasoning', 'grok-4-1-fast-non-reasoning',
485
- 'grok-3', 'grok-3-mini', 'grok-code-fast-1',
486
- 'grok-2-vision-1212', 'grok-2-image-1212',
506
+ 'grok-4-0709', 'grok-4-fast-reasoning', 'grok-4-fast-non-reasoning',
507
+ 'grok-3', 'grok-3-mini',
487
508
  ];
488
509
  }
489
510
  } catch {
490
- // Fallback to known models
491
511
  availableModels = [
492
- 'grok-4-0709', 'grok-4-fast-reasoning', 'grok-4-fast-non-reasoning',
493
- 'grok-3', 'grok-3-mini', 'grok-code-fast-1',
512
+ 'grok-4-1-fast-reasoning', 'grok-4-1-fast-non-reasoning',
513
+ 'grok-4-0709', 'grok-3', 'grok-3-mini',
494
514
  ];
495
515
  }
496
516
 
497
- if (!modelName) {
498
- console.log();
499
- console.log(chalk.cyan('╭──────────────────────────────────────────────────────────────────────╮'));
500
- console.log(chalk.cyan('│') + chalk.bold.cyan(' 🤖 Model Selection ') + chalk.cyan('│'));
501
- console.log(chalk.cyan('╰──────────────────────────────────────────────────────────────────────╯'));
502
- console.log();
503
- console.log(` ${chalk.gray('Current Model:')} ${chalk.bold.green(this.client.model)}`);
504
- console.log();
517
+ process.stdout.write('\r\x1B[K'); // Clear the "Fetching" line
505
518
 
506
- // Better categorization
507
- const grok41Reasoning: string[] = [];
508
- const grok41NonReasoning: string[] = [];
509
- const grok4Reasoning: string[] = [];
510
- const grok4NonReasoning: string[] = [];
511
- const grok4Other: string[] = [];
512
- const grok3: string[] = [];
513
- const grok2: string[] = [];
514
- const specialized: string[] = [];
515
-
516
- for (const model of availableModels) {
517
- if (model.startsWith('grok-4-1')) {
518
- if (model.includes('non-reasoning')) {
519
- grok41NonReasoning.push(model);
520
- } else if (model.includes('reasoning')) {
521
- grok41Reasoning.push(model);
522
- }
523
- } else if (model.startsWith('grok-4')) {
524
- if (model.includes('non-reasoning')) {
525
- grok4NonReasoning.push(model);
526
- } else if (model.includes('reasoning')) {
527
- grok4Reasoning.push(model);
528
- } else {
529
- grok4Other.push(model);
530
- }
531
- } else if (model.startsWith('grok-3')) {
532
- grok3.push(model);
533
- } else if (model.startsWith('grok-2')) {
534
- grok2.push(model);
535
- } else if (model.includes('code') || model.includes('vision') || model.includes('image')) {
536
- specialized.push(model);
537
- }
538
- }
519
+ // If model name provided directly, switch to it
520
+ if (modelName) {
521
+ let matchedModel = modelName;
522
+ if (!availableModels.includes(modelName)) {
523
+ // Normalize: "grok41" "grok-4-1", "4.1" → "4-1"
524
+ const normalized = modelName.toLowerCase()
525
+ .replace(/grok\s*(\d)(\d)?/g, (_, d1, d2) => d2 ? `grok-${d1}-${d2}` : `grok-${d1}`)
526
+ .replace(/(\d+)\.(\d+)/g, '$1-$2')
527
+ .replace(/\s+/g, '-');
539
528
 
540
- // Display Grok 4.1 (Latest)
541
- if (grok41Reasoning.length > 0 || grok41NonReasoning.length > 0) {
542
- console.log(chalk.bold.magenta(' ⭐ Grok 4.1 (Latest)'));
543
- console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
544
- for (const model of grok41Reasoning) {
545
- const current = model === this.client.model ? chalk.green(' ← current') : '';
546
- console.log(` ${chalk.green('🧠')} ${chalk.green(model)}${current} ${chalk.yellow('[REASONING]')}`);
547
- }
548
- for (const model of grok41NonReasoning) {
549
- const current = model === this.client.model ? chalk.green(' ← current') : '';
550
- console.log(` ${chalk.cyan('⚡')} ${model}${current} ${chalk.gray('[FAST]')}`);
551
- }
552
- console.log();
553
- }
529
+ const partialMatch = availableModels.find(m => m.toLowerCase().includes(normalized)) ||
530
+ availableModels.find(m => m.toLowerCase().includes(modelName.toLowerCase()));
554
531
 
555
- // Display Grok 4
556
- if (grok4Reasoning.length > 0 || grok4NonReasoning.length > 0 || grok4Other.length > 0) {
557
- console.log(chalk.bold.cyan(' 🚀 Grok 4'));
558
- console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
559
- for (const model of grok4Other) {
560
- const current = model === this.client.model ? chalk.green(' ← current') : '';
561
- console.log(` ${chalk.cyan('•')} ${model}${current} ${chalk.gray('(recommended)')}`);
562
- }
563
- for (const model of grok4Reasoning) {
564
- const current = model === this.client.model ? chalk.green(' ← current') : '';
565
- console.log(` ${chalk.green('🧠')} ${chalk.green(model)}${current} ${chalk.yellow('[REASONING]')}`);
566
- }
567
- for (const model of grok4NonReasoning) {
568
- const current = model === this.client.model ? chalk.green(' ← current') : '';
569
- console.log(` ${chalk.cyan('⚡')} ${model}${current} ${chalk.gray('[FAST]')}`);
532
+ if (partialMatch) {
533
+ matchedModel = partialMatch;
534
+ } else {
535
+ console.log(chalk.red(` Unknown model: ${modelName}`));
536
+ return;
570
537
  }
571
- console.log();
572
538
  }
573
539
 
574
- // Display Grok 3
575
- if (grok3.length > 0) {
576
- console.log(chalk.bold.blue(' 📦 Grok 3'));
577
- console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
578
- for (const model of grok3) {
579
- const current = model === this.client.model ? chalk.green(' ← current') : '';
580
- console.log(` ${chalk.cyan('•')} ${model}${current}`);
581
- }
582
- console.log();
583
- }
540
+ this.client = new GrokClient(this.apiKey, matchedModel);
541
+ console.log(chalk.green(` ✓ Switched to ${matchedModel}`));
542
+ return;
543
+ }
584
544
 
585
- // Display Grok 2
586
- if (grok2.length > 0) {
587
- console.log(chalk.bold.gray(' 📷 Grok 2 (Vision/Image)'));
588
- console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
589
- for (const model of grok2) {
590
- const current = model === this.client.model ? chalk.green(' ← current') : '';
591
- console.log(` ${chalk.cyan('•')} ${model}${current}`);
592
- }
593
- console.log();
594
- }
545
+ // Build options for interactive selector - prioritize Grok 4.1
546
+ const options: SelectorOption[] = [];
595
547
 
596
- // Display Specialized
597
- if (specialized.length > 0) {
598
- console.log(chalk.bold.yellow(' 🔧 Specialized'));
599
- console.log(chalk.gray(' ─────────────────────────────────────────────────────────────────'));
600
- for (const model of specialized) {
601
- const current = model === this.client.model ? chalk.green(' ← current') : '';
602
- console.log(` ${chalk.cyan('•')} ${model}${current}`);
603
- }
604
- console.log();
605
- }
548
+ // Categorize models
549
+ const grok41 = availableModels.filter(m => m.startsWith('grok-4-1'));
550
+ const grok4 = availableModels.filter(m => m.startsWith('grok-4') && !m.startsWith('grok-4-1'));
551
+ const grok3 = availableModels.filter(m => m.startsWith('grok-3'));
552
+ const others = availableModels.filter(m => !m.startsWith('grok-4') && !m.startsWith('grok-3'));
606
553
 
607
- console.log(chalk.gray(` ${availableModels.length} models available Use /model <name> to switch`));
608
- console.log(chalk.gray(' 🧠 = Reasoning (best for complex tasks) • ⚡ = Fast (quick responses)'));
609
- console.log();
610
- return;
554
+ // Add Grok 4.1 first (latest)
555
+ for (const model of grok41) {
556
+ const desc = model.includes('non-reasoning') ? 'fast' : model.includes('reasoning') ? 'reasoning' : '';
557
+ options.push({ label: model, value: model, description: desc });
611
558
  }
612
559
 
613
- // Allow partial matching with normalization
614
- let matchedModel = modelName;
615
- if (!availableModels.includes(modelName)) {
616
- // Normalize input:
617
- // "grok41" → "grok-4-1", "grok4" → "grok-4", "grok 3" → "grok-3"
618
- // "4.1" → "4-1", "grok 4 1" → "grok-4-1"
619
- let normalized = modelName.toLowerCase()
620
- .replace(/grok\s*(\d)(\d)?(\d)?/g, (_, d1, d2, d3) => {
621
- if (d3) return `grok-${d1}-${d2}-${d3}`;
622
- if (d2) return `grok-${d1}-${d2}`;
623
- return `grok-${d1}`;
624
- })
625
- .replace(/(\d+)\.(\d+)/g, '$1-$2') // "4.1" → "4-1"
626
- .replace(/\s+/g, '-'); // spaces to hyphens
627
-
628
- // Try to find a match
629
- let partialMatch = availableModels.find(m => m.toLowerCase().includes(normalized));
630
-
631
- // If no match, try the original input
632
- if (!partialMatch) {
633
- partialMatch = availableModels.find(m =>
634
- m.toLowerCase().includes(modelName.toLowerCase())
635
- );
636
- }
560
+ // Add Grok 4
561
+ for (const model of grok4) {
562
+ const desc = model.includes('non-reasoning') ? 'fast' : model.includes('reasoning') ? 'reasoning' : '';
563
+ options.push({ label: model, value: model, description: desc });
564
+ }
637
565
 
638
- if (partialMatch) {
639
- matchedModel = partialMatch;
640
- console.log(chalk.gray(`Matched: ${matchedModel}`));
641
- } else {
642
- console.log(chalk.red(`Unknown model: ${modelName}\n`));
643
- console.log(chalk.gray('Use /model to see available models.\n'));
644
- return;
645
- }
566
+ // Add Grok 3
567
+ for (const model of grok3) {
568
+ options.push({ label: model, value: model });
646
569
  }
647
570
 
648
- this.client = new GrokClient(this.apiKey, matchedModel);
649
- console.log(chalk.green(`✓ Switched to model: ${matchedModel}\n`));
571
+ // Add others
572
+ for (const model of others) {
573
+ options.push({ label: model, value: model });
574
+ }
575
+
576
+ console.log();
577
+ const selected = await interactiveSelect('Select model:', options, this.client.model);
578
+
579
+ if (selected && selected !== this.client.model) {
580
+ this.client = new GrokClient(this.apiKey, selected);
581
+ console.log(chalk.green(` ✓ Switched to ${selected}`));
582
+ } else if (!selected) {
583
+ console.log(chalk.dim(' Cancelled'));
584
+ }
650
585
  }
651
586
 
652
587
  private handlePermissions(): void {
@@ -1101,9 +1036,8 @@ Start by checking git status and recent changes, then provide specific, actionab
1101
1036
  }
1102
1037
 
1103
1038
  private async getStreamingResponse(): Promise<void> {
1104
- // Show thinking indicator
1105
- process.stdout.write(chalk.cyan('\n╭─ ') + chalk.bold.cyan('Grok') + chalk.cyan(' ─────────────────────────────────────────────────────────────╮\n'));
1106
- process.stdout.write(chalk.cyan('│ ') + chalk.gray('⠋ thinking...'));
1039
+ // Simple thinking indicator like Claude Code
1040
+ process.stdout.write('\n' + chalk.dim(' Thinking...'));
1107
1041
 
1108
1042
  let fullContent = '';
1109
1043
  let toolCalls: ToolCall[] = [];
@@ -1116,8 +1050,8 @@ Start by checking git status and recent changes, then provide specific, actionab
1116
1050
 
1117
1051
  if (delta?.content) {
1118
1052
  if (firstChunk) {
1119
- // Clear thinking indicator and start content
1120
- process.stdout.write('\r' + chalk.cyan(' ') + ' '.repeat(50) + '\r' + chalk.cyan('│ '));
1053
+ // Clear thinking indicator
1054
+ process.stdout.write('\r' + ' '.repeat(20) + '\r\n');
1121
1055
  firstChunk = false;
1122
1056
  }
1123
1057
  process.stdout.write(delta.content);
@@ -1153,13 +1087,11 @@ Start by checking git status and recent changes, then provide specific, actionab
1153
1087
  toolCalls.push(currentToolCall as ToolCall);
1154
1088
  }
1155
1089
 
1156
- // Close the response box
1090
+ // End response
1157
1091
  if (fullContent) {
1158
- console.log();
1159
- console.log(chalk.cyan('╰──────────────────────────────────────────────────────────────────────╯'));
1160
- } else if (toolCalls.length > 0) {
1161
- process.stdout.write('\r' + chalk.cyan('│ ') + chalk.gray('Using tools...') + ' '.repeat(40) + '\n');
1162
- console.log(chalk.cyan('╰──────────────────────────────────────────────────────────────────────╯'));
1092
+ console.log('\n');
1093
+ } else if (toolCalls.length > 0 && firstChunk) {
1094
+ process.stdout.write('\r' + ' '.repeat(20) + '\r');
1163
1095
  }
1164
1096
 
1165
1097
  // Build the message for history
@@ -1223,59 +1155,35 @@ Start by checking git status and recent changes, then provide specific, actionab
1223
1155
  return;
1224
1156
  }
1225
1157
 
1226
- // Show execution with beautiful box
1227
- const toolIcons: Record<string, string> = {
1228
- Read: '📖', Write: '✏️', Edit: '🔧', Bash: '⚡',
1229
- Glob: '🔍', Grep: '🔎', WebFetch: '🌐', WebSearch: '🔍'
1230
- };
1231
- const toolColors: Record<string, typeof chalk> = {
1232
- Read: chalk.green, Write: chalk.yellow, Edit: chalk.yellow, Bash: chalk.red,
1233
- Glob: chalk.green, Grep: chalk.green, WebFetch: chalk.green, WebSearch: chalk.green
1234
- };
1235
-
1236
- const icon = toolIcons[name] || '🔧';
1237
- const color = toolColors[name] || chalk.gray;
1238
-
1239
- console.log();
1240
- console.log(color('┌─ ') + chalk.bold(`${icon} ${name}`) + color(' ─────────────────────────────────────────────────'));
1241
-
1242
- // Show details based on tool type
1158
+ // Simple tool display like Claude Code
1159
+ let toolInfo = '';
1243
1160
  if (name === 'Bash') {
1244
- console.log(color('│ ') + chalk.gray('$') + ' ' + chalk.white(params.command));
1245
- } else if (name === 'Read' || name === 'Write' || name === 'Edit') {
1246
- console.log(color('│ ') + chalk.gray('File:') + ' ' + chalk.cyan(params.file_path as string));
1247
- } else if (name === 'Glob' || name === 'Grep') {
1248
- console.log(color('│ ') + chalk.gray('Pattern:') + ' ' + chalk.cyan(params.pattern as string));
1161
+ toolInfo = chalk.dim('$ ') + (params.command as string).slice(0, 60);
1162
+ } else if (name === 'Read') {
1163
+ toolInfo = params.file_path as string;
1164
+ } else if (name === 'Write') {
1165
+ toolInfo = params.file_path as string;
1166
+ } else if (name === 'Edit') {
1167
+ toolInfo = params.file_path as string;
1168
+ } else if (name === 'Glob') {
1169
+ toolInfo = params.pattern as string;
1170
+ } else if (name === 'Grep') {
1171
+ toolInfo = params.pattern as string;
1249
1172
  } else if (name === 'WebFetch') {
1250
- console.log(color('│ ') + chalk.gray('URL:') + ' ' + chalk.cyan(params.url as string));
1173
+ toolInfo = (params.url as string).slice(0, 50);
1251
1174
  } else if (name === 'WebSearch') {
1252
- console.log(color('│ ') + chalk.gray('Query:') + ' ' + chalk.cyan(params.query as string));
1175
+ toolInfo = params.query as string;
1253
1176
  }
1254
1177
 
1178
+ console.log(chalk.dim(' ● ') + chalk.cyan(name) + chalk.dim(' ' + toolInfo));
1179
+
1255
1180
  // Execute
1256
1181
  const result = await executeTool(name, params);
1257
1182
 
1258
- if (result.success) {
1259
- console.log(color(''));
1260
- console.log(color('│ ') + chalk.green('✓ Success'));
1261
- if (result.output && result.output.length < 500) {
1262
- const lines = result.output.split('\n').slice(0, 10);
1263
- for (const line of lines) {
1264
- console.log(color('│ ') + chalk.gray(line.slice(0, 80)));
1265
- }
1266
- if (result.output.split('\n').length > 10) {
1267
- console.log(color('│ ') + chalk.gray('... (truncated)'));
1268
- }
1269
- } else if (result.output) {
1270
- console.log(color('│ ') + chalk.gray(result.output.slice(0, 200) + '... (truncated)'));
1271
- }
1272
- } else {
1273
- console.log(color('│'));
1274
- console.log(color('│ ') + chalk.red('✗ Failed: ') + chalk.red(result.error || 'Unknown error'));
1183
+ if (!result.success) {
1184
+ console.log(chalk.red('') + chalk.red(result.error || 'Failed'));
1275
1185
  }
1276
1186
 
1277
- console.log(color('└──────────────────────────────────────────────────────────────────────'));
1278
-
1279
1187
  this.messages.push({
1280
1188
  role: 'tool',
1281
1189
  tool_call_id: toolCall.id,
@@ -0,0 +1,165 @@
1
+ import chalk from 'chalk';
2
+ import * as readline from 'readline';
3
+
4
+ export interface SelectorOption {
5
+ label: string;
6
+ value: string;
7
+ description?: string;
8
+ }
9
+
10
+ export async function interactiveSelect(
11
+ title: string,
12
+ options: SelectorOption[],
13
+ currentValue?: string
14
+ ): Promise<string | null> {
15
+ return new Promise((resolve) => {
16
+ let selectedIndex = 0;
17
+
18
+ // Find current value index
19
+ if (currentValue) {
20
+ const idx = options.findIndex(o => o.value === currentValue);
21
+ if (idx >= 0) selectedIndex = idx;
22
+ }
23
+
24
+ const render = () => {
25
+ // Clear previous render
26
+ process.stdout.write('\x1B[?25l'); // Hide cursor
27
+
28
+ // Move up and clear lines if not first render
29
+ const totalLines = options.length + 2;
30
+ process.stdout.write(`\x1B[${totalLines}A`);
31
+
32
+ // Title
33
+ console.log(chalk.bold(title));
34
+ console.log(chalk.dim('↑↓/Tab to navigate, Enter to select, Esc to cancel'));
35
+
36
+ // Options
37
+ for (let i = 0; i < options.length; i++) {
38
+ const opt = options[i];
39
+ const isSelected = i === selectedIndex;
40
+ const isCurrent = opt.value === currentValue;
41
+
42
+ const pointer = isSelected ? chalk.cyan('❯') : ' ';
43
+ const label = isSelected ? chalk.cyan.bold(opt.label) : opt.label;
44
+ const current = isCurrent ? chalk.green(' (current)') : '';
45
+ const desc = opt.description ? chalk.dim(` - ${opt.description}`) : '';
46
+
47
+ console.log(`${pointer} ${label}${current}${desc}`);
48
+ }
49
+ };
50
+
51
+ // Initial render with padding
52
+ console.log(chalk.bold(title));
53
+ console.log(chalk.dim('↑↓/Tab to navigate, Enter to select, Esc to cancel'));
54
+ for (const opt of options) {
55
+ const isCurrent = opt.value === currentValue;
56
+ const current = isCurrent ? chalk.green(' (current)') : '';
57
+ const desc = opt.description ? chalk.dim(` - ${opt.description}`) : '';
58
+ console.log(` ${opt.label}${current}${desc}`);
59
+ }
60
+
61
+ // Now render with selection
62
+ render();
63
+
64
+ // Set up raw mode for key input
65
+ if (process.stdin.isTTY) {
66
+ process.stdin.setRawMode(true);
67
+ }
68
+ process.stdin.resume();
69
+
70
+ const onKeypress = (key: Buffer) => {
71
+ const char = key.toString();
72
+
73
+ // Arrow up or k
74
+ if (char === '\x1B[A' || char === 'k') {
75
+ selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : options.length - 1;
76
+ render();
77
+ }
78
+ // Arrow down or j
79
+ else if (char === '\x1B[B' || char === 'j') {
80
+ selectedIndex = selectedIndex < options.length - 1 ? selectedIndex + 1 : 0;
81
+ render();
82
+ }
83
+ // Tab - cycle forward
84
+ else if (char === '\t') {
85
+ selectedIndex = (selectedIndex + 1) % options.length;
86
+ render();
87
+ }
88
+ // Shift+Tab - cycle backward (usually \x1B[Z)
89
+ else if (char === '\x1B[Z') {
90
+ selectedIndex = selectedIndex > 0 ? selectedIndex - 1 : options.length - 1;
91
+ render();
92
+ }
93
+ // Enter
94
+ else if (char === '\r' || char === '\n') {
95
+ cleanup();
96
+ resolve(options[selectedIndex].value);
97
+ }
98
+ // Escape or q
99
+ else if (char === '\x1B' || char === 'q') {
100
+ cleanup();
101
+ resolve(null);
102
+ }
103
+ // Ctrl+C
104
+ else if (char === '\x03') {
105
+ cleanup();
106
+ resolve(null);
107
+ }
108
+ };
109
+
110
+ const cleanup = () => {
111
+ process.stdin.removeListener('data', onKeypress);
112
+ if (process.stdin.isTTY) {
113
+ process.stdin.setRawMode(false);
114
+ }
115
+ process.stdout.write('\x1B[?25h'); // Show cursor
116
+ console.log(); // New line after selection
117
+ };
118
+
119
+ process.stdin.on('data', onKeypress);
120
+ });
121
+ }
122
+
123
+ // Simple yes/no confirmation
124
+ export async function confirm(message: string, defaultYes = true): Promise<boolean> {
125
+ return new Promise((resolve) => {
126
+ const hint = defaultYes ? '[Y/n]' : '[y/N]';
127
+ process.stdout.write(`${message} ${chalk.dim(hint)} `);
128
+
129
+ if (process.stdin.isTTY) {
130
+ process.stdin.setRawMode(true);
131
+ }
132
+ process.stdin.resume();
133
+
134
+ const onKeypress = (key: Buffer) => {
135
+ const char = key.toString().toLowerCase();
136
+
137
+ if (char === 'y') {
138
+ cleanup();
139
+ console.log(chalk.green('Yes'));
140
+ resolve(true);
141
+ } else if (char === 'n') {
142
+ cleanup();
143
+ console.log(chalk.red('No'));
144
+ resolve(false);
145
+ } else if (char === '\r' || char === '\n') {
146
+ cleanup();
147
+ console.log(defaultYes ? chalk.green('Yes') : chalk.red('No'));
148
+ resolve(defaultYes);
149
+ } else if (char === '\x03' || char === '\x1B') {
150
+ cleanup();
151
+ console.log(chalk.red('Cancelled'));
152
+ resolve(false);
153
+ }
154
+ };
155
+
156
+ const cleanup = () => {
157
+ process.stdin.removeListener('data', onKeypress);
158
+ if (process.stdin.isTTY) {
159
+ process.stdin.setRawMode(false);
160
+ }
161
+ };
162
+
163
+ process.stdin.on('data', onKeypress);
164
+ });
165
+ }