@krishivpb60/aether-ai-cli 1.1.5 → 1.1.7

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/cli.js CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  bullet,
20
20
  clearStreamedText,
21
21
  StreamFilter,
22
+ stripCodeFences,
22
23
  getActiveTheme,
23
24
  setTheme,
24
25
  getThemesList,
@@ -201,6 +202,14 @@ export function createCLI(argv) {
201
202
  await handleSetup();
202
203
  });
203
204
 
205
+ // ── Commit Command ──────────────────────────────────────
206
+ program
207
+ .command("commit")
208
+ .description("Generate conventional commit message from git diff and commit changes")
209
+ .action(async () => {
210
+ await handleCommit();
211
+ });
212
+
204
213
  // ── Default: Show help ──────────────────────────────────
205
214
  program.action(() => {
206
215
  showMiniBanner();
@@ -328,7 +337,7 @@ async function handleAsk(prompt, opts) {
328
337
  let match;
329
338
  const fileWrites = [];
330
339
  while ((match = writeRegex.exec(result.text)) !== null) {
331
- fileWrites.push({ path: match[1].trim(), content: match[2] });
340
+ fileWrites.push({ path: match[1].trim(), content: stripCodeFences(match[2]) });
332
341
  }
333
342
 
334
343
  if (fileWrites.length > 0) {
@@ -568,6 +577,86 @@ async function handleStatus() {
568
577
  console.log("");
569
578
  }
570
579
 
580
+ async function handleCommit() {
581
+ const { getGitDiff, runGitCommit } = await import("./git.js");
582
+ const { createInterface } = await import("node:readline/promises");
583
+
584
+ try {
585
+ const { diff, isStaged } = await getGitDiff();
586
+ if (!diff) {
587
+ console.log("\n" + label.system + " " + colors.warning("No staged or unstaged changes detected. Stage your files using 'git add' first.\n"));
588
+ return;
589
+ }
590
+
591
+ if (!isStaged) {
592
+ const rlInit = createInterface({
593
+ input: process.stdin,
594
+ output: process.stdout,
595
+ });
596
+ const stageAnswer = await rlInit.question("\n" + label.system + " " + colors.warning("No staged changes found. Do you want to stage all changes automatically? [y/N]: "));
597
+ rlInit.close();
598
+
599
+ if (stageAnswer.toLowerCase().trim() === "y" || stageAnswer.toLowerCase().trim() === "yes") {
600
+ const { exec } = await import("node:child_process");
601
+ const { promisify } = await import("node:util");
602
+ const execAsync = promisify(exec);
603
+ await execAsync("git add .");
604
+ console.log(label.system + " " + colors.success("Staged all changes successfully."));
605
+ } else {
606
+ console.log("\n" + label.system + " " + colors.muted("Aborted. Please stage files using 'git add' first.\n"));
607
+ return;
608
+ }
609
+ }
610
+
611
+ const aiConfig = await getAIConfig();
612
+ const mode = MODES[DEFAULT_MODE];
613
+
614
+ console.log("");
615
+ console.log(label.system + " " + colors.brand("Reading git diff and generating conventional commit message..."));
616
+ console.log("");
617
+
618
+ const systemPrompt = "You are an expert developer assistant. Generate a concise, clear, and professional conventional commit message (e.g., 'feat: add login page', 'fix: resolve buffer overflow') based on the provided git diff. Output ONLY the commit message itself on a single line, with absolutely no backticks, markdown, explanations, prefix, or introductory text.";
619
+ const userPrompt = `Here is the git diff:\n\n${diff}`;
620
+
621
+ let firstToken = true;
622
+ let commitMessage = "";
623
+ const onToken = (token) => {
624
+ if (firstToken) {
625
+ firstToken = false;
626
+ process.stdout.write(label.aether + " Suggested Commit Message: " + colors.success(token));
627
+ } else {
628
+ process.stdout.write(colors.success(token));
629
+ }
630
+ commitMessage += token;
631
+ };
632
+
633
+ const result = await routePrompt(userPrompt, mode.systemPrompt, aiConfig, onToken);
634
+ console.log("\n");
635
+
636
+ const cleanMessage = result.text.trim().replace(/^`+|`+$/g, ""); // strip quotes/backticks
637
+
638
+ const rl = createInterface({
639
+ input: process.stdin,
640
+ output: process.stdout,
641
+ });
642
+
643
+ const answer = await rl.question(colors.muted("Commit with this message? [Y/n]: "));
644
+ rl.close();
645
+
646
+ if (answer.toLowerCase().trim() === "n" || answer.toLowerCase().trim() === "no") {
647
+ console.log("\n" + label.system + " " + colors.muted("Commit aborted.\n"));
648
+ return;
649
+ }
650
+
651
+ console.log("\n" + label.system + " " + colors.brand("Executing git commit..."));
652
+ const output = await runGitCommit(cleanMessage);
653
+ console.log("\n" + colors.success(output) + "\n");
654
+
655
+ } catch (err) {
656
+ console.log("\n" + label.error + " " + colors.danger(err.message) + "\n");
657
+ }
658
+ }
659
+
571
660
  async function handleSetup() {
572
661
  const { createInterface } = await import("node:readline");
573
662
 
package/src/config.js CHANGED
@@ -5,6 +5,15 @@
5
5
  // ═══════════════════════════════════════════════════════════
6
6
 
7
7
  import { readFile, writeFile, mkdir, unlink, access } from "node:fs/promises";
8
+ import {
9
+ existsSync,
10
+ readdirSync,
11
+ statSync,
12
+ mkdirSync,
13
+ readFileSync,
14
+ writeFileSync,
15
+ unlinkSync
16
+ } from "node:fs";
8
17
  import { join } from "node:path";
9
18
  import { homedir } from "node:os";
10
19
  import { getAllConfigKeys } from "./ai/providers.js";
@@ -164,7 +173,7 @@ export async function configExists() {
164
173
  export function isValidConfigKey(key) {
165
174
  const upper = key.toUpperCase();
166
175
  // Accept any API key or model override
167
- if (upper.endsWith("_API_KEY") || upper.endsWith("_API_KEYS") || upper.endsWith("_MODEL") || upper === "THEME" || upper === "CUSTOM_COMMANDS") {
176
+ if (upper.endsWith("_API_KEY") || upper.endsWith("_API_KEYS") || upper.endsWith("_MODEL") || upper === "THEME" || upper === "CUSTOM_COMMANDS" || upper === "AUTOPILOT") {
168
177
  return true;
169
178
  }
170
179
  // Accept known config keys
@@ -172,7 +181,143 @@ export function isValidConfigKey(key) {
172
181
  return knownKeys.includes(upper);
173
182
  }
174
183
 
175
- const HISTORY_FILE = join(CONFIG_DIR, "history.json");
184
+ const HISTORY_DIR = join(CONFIG_DIR, "history");
185
+ const LEGACY_HISTORY_FILE = join(CONFIG_DIR, "history.json");
186
+
187
+ let currentSessionFile = null;
188
+
189
+ /**
190
+ * Gets the current active session file path, initializing it if necessary.
191
+ * @returns {string}
192
+ */
193
+ export function getSessionFile() {
194
+ if (currentSessionFile) {
195
+ return currentSessionFile;
196
+ }
197
+
198
+ try {
199
+ if (!existsSync(HISTORY_DIR)) {
200
+ mkdirSync(HISTORY_DIR, { recursive: true });
201
+ }
202
+
203
+ const files = readdirSync(HISTORY_DIR).filter(
204
+ (f) => f.startsWith("session_") && f.endsWith(".json")
205
+ );
206
+
207
+ if (files.length > 0) {
208
+ // Sort files descending by modification time
209
+ files.sort((a, b) => {
210
+ return statSync(join(HISTORY_DIR, b)).mtimeMs - statSync(join(HISTORY_DIR, a)).mtimeMs;
211
+ });
212
+ currentSessionFile = join(HISTORY_DIR, files[0]);
213
+ } else {
214
+ // If legacy history file exists, migrate it
215
+ if (existsSync(LEGACY_HISTORY_FILE)) {
216
+ const timestamp = statSync(LEGACY_HISTORY_FILE).mtimeMs || Date.now();
217
+ currentSessionFile = join(HISTORY_DIR, `session_${timestamp}.json`);
218
+ try {
219
+ const raw = readFileSync(LEGACY_HISTORY_FILE, "utf-8");
220
+ const legacyData = JSON.parse(raw);
221
+ const sessionData = {
222
+ mode: "titan",
223
+ timestamp,
224
+ messages: Array.isArray(legacyData) ? legacyData : (legacyData.messages || []),
225
+ };
226
+ writeFileSync(currentSessionFile, JSON.stringify(sessionData, null, 2), "utf-8");
227
+ try {
228
+ unlinkSync(LEGACY_HISTORY_FILE);
229
+ } catch {
230
+ // ignore unlink error
231
+ }
232
+ } catch {
233
+ // ignore parsing error, start new
234
+ startNewSession();
235
+ }
236
+ } else {
237
+ startNewSession();
238
+ }
239
+ }
240
+ } catch {
241
+ startNewSession();
242
+ }
243
+
244
+ return currentSessionFile;
245
+ }
246
+
247
+ /**
248
+ * Starts a new chat session.
249
+ * @returns {string} Path to the new session file
250
+ */
251
+ export function startNewSession() {
252
+ const timestamp = Date.now();
253
+ currentSessionFile = join(HISTORY_DIR, `session_${timestamp}.json`);
254
+ try {
255
+ if (!existsSync(HISTORY_DIR)) {
256
+ mkdirSync(HISTORY_DIR, { recursive: true });
257
+ }
258
+ const sessionData = {
259
+ mode: "titan",
260
+ timestamp,
261
+ messages: [],
262
+ };
263
+ writeFileSync(currentSessionFile, JSON.stringify(sessionData, null, 2), "utf-8");
264
+ } catch {
265
+ // Fail silently
266
+ }
267
+ return currentSessionFile;
268
+ }
269
+
270
+ /**
271
+ * Lists all session logs.
272
+ * @returns {Array} List of sessions with metadata
273
+ */
274
+ export function listSessions() {
275
+ try {
276
+ if (!existsSync(HISTORY_DIR)) {
277
+ mkdirSync(HISTORY_DIR, { recursive: true });
278
+ }
279
+ const files = readdirSync(HISTORY_DIR).filter(
280
+ (f) => f.startsWith("session_") && f.endsWith(".json")
281
+ );
282
+ const sessions = [];
283
+
284
+ for (const file of files) {
285
+ const fullPath = join(HISTORY_DIR, file);
286
+ try {
287
+ const raw = readFileSync(fullPath, "utf-8");
288
+ const data = JSON.parse(raw);
289
+ const messages = Array.isArray(data) ? data : (data.messages || []);
290
+ const mode = Array.isArray(data) ? "titan" : (data.mode || "titan");
291
+ const timestamp = Array.isArray(data)
292
+ ? (statSync(fullPath).mtimeMs || Date.now())
293
+ : (data.timestamp || statSync(fullPath).mtimeMs || Date.now());
294
+
295
+ sessions.push({
296
+ file: fullPath,
297
+ filename: file,
298
+ timestamp,
299
+ mode,
300
+ messages,
301
+ });
302
+ } catch {
303
+ // ignore corrupt files
304
+ }
305
+ }
306
+
307
+ sessions.sort((a, b) => b.timestamp - a.timestamp);
308
+ return sessions;
309
+ } catch {
310
+ return [];
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Switches the active session file.
316
+ * @param {string} sessionFile
317
+ */
318
+ export function switchSession(sessionFile) {
319
+ currentSessionFile = sessionFile;
320
+ }
176
321
 
177
322
  /**
178
323
  * Loads chat history from disk.
@@ -180,8 +325,10 @@ const HISTORY_FILE = join(CONFIG_DIR, "history.json");
180
325
  */
181
326
  export async function loadHistory() {
182
327
  try {
183
- const raw = await readFile(HISTORY_FILE, "utf-8");
184
- return JSON.parse(raw);
328
+ const file = getSessionFile();
329
+ const raw = readFileSync(file, "utf-8");
330
+ const data = JSON.parse(raw);
331
+ return Array.isArray(data) ? data : (data.messages || []);
185
332
  } catch {
186
333
  return [];
187
334
  }
@@ -190,24 +337,54 @@ export async function loadHistory() {
190
337
  /**
191
338
  * Saves chat history to disk.
192
339
  * @param {Array} history - List of chat exchanges to save
340
+ * @param {string} [mode] - Current mode name
193
341
  */
194
- export async function saveHistory(history) {
342
+ export async function saveHistory(history, mode) {
195
343
  try {
196
- await mkdir(CONFIG_DIR, { recursive: true });
197
- // Limit saved history to last 50 entries to keep it light
344
+ const file = getSessionFile();
345
+ let timestamp = Date.now();
346
+ const match = file.match(/session_(\d+)\.json$/);
347
+ if (match) {
348
+ timestamp = parseInt(match[1], 10);
349
+ }
198
350
  const trimmed = history.slice(-50);
199
- await writeFile(HISTORY_FILE, JSON.stringify(trimmed, null, 2), "utf-8");
351
+
352
+ let finalMode = mode;
353
+ if (!finalMode) {
354
+ try {
355
+ const raw = readFileSync(file, "utf-8");
356
+ const data = JSON.parse(raw);
357
+ if (!Array.isArray(data)) {
358
+ finalMode = data.mode;
359
+ }
360
+ } catch {
361
+ // file might not exist yet
362
+ }
363
+ }
364
+ if (!finalMode) {
365
+ finalMode = "titan";
366
+ }
367
+
368
+ const sessionData = {
369
+ mode: finalMode,
370
+ timestamp,
371
+ messages: trimmed,
372
+ };
373
+
374
+ writeFileSync(file, JSON.stringify(sessionData, null, 2), "utf-8");
200
375
  } catch {
201
376
  // Fail silently to not block chat
202
377
  }
203
378
  }
204
379
 
205
380
  /**
206
- * Deletes the chat history file.
381
+ * Deletes the current chat history file.
207
382
  */
208
383
  export async function clearHistory() {
209
384
  try {
210
- await unlink(HISTORY_FILE);
385
+ const file = getSessionFile();
386
+ unlinkSync(file);
387
+ currentSessionFile = null;
211
388
  } catch {
212
389
  // File may not exist
213
390
  }
@@ -3,7 +3,8 @@
3
3
  // ═══════════════════════════════════════════════════════════
4
4
 
5
5
  import { readFile, stat } from "node:fs/promises";
6
- import { resolve, extname, basename } from "node:path";
6
+ import { readdirSync, statSync } from "node:fs";
7
+ import { resolve, extname, basename, join, relative } from "node:path";
7
8
 
8
9
  const MAX_CONTENT_LENGTH = 30000;
9
10
 
@@ -92,3 +93,49 @@ function formatBytes(bytes) {
92
93
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
93
94
  return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
94
95
  }
96
+
97
+ const EXCLUDE_DIRS = new Set([
98
+ "node_modules", ".git", ".agents", "build", "dist", ".github",
99
+ "aether_pip", "aether_ai_agent_cli.egg-info", "aether_ai_cli.egg-info"
100
+ ]);
101
+
102
+ /**
103
+ * Recursively scans baseDir and returns a list of supported files.
104
+ * @param {string} baseDir - Directory to scan
105
+ * @returns {string[]} List of relative file paths
106
+ */
107
+ export function scanWorkspaceFiles(baseDir) {
108
+ const files = [];
109
+
110
+ function recurse(dir) {
111
+ let entries;
112
+ try {
113
+ entries = readdirSync(dir);
114
+ } catch {
115
+ return;
116
+ }
117
+
118
+ for (const entry of entries) {
119
+ if (EXCLUDE_DIRS.has(entry)) continue;
120
+ const fullPath = join(dir, entry);
121
+ let stats;
122
+ try {
123
+ stats = statSync(fullPath);
124
+ } catch {
125
+ continue;
126
+ }
127
+
128
+ if (stats.isDirectory()) {
129
+ recurse(fullPath);
130
+ } else if (stats.isFile()) {
131
+ const ext = extname(entry).toLowerCase();
132
+ if (SUPPORTED_EXTENSIONS.has(ext)) {
133
+ files.push(relative(baseDir, fullPath));
134
+ }
135
+ }
136
+ }
137
+ }
138
+
139
+ recurse(baseDir);
140
+ return files.sort();
141
+ }
package/src/git.js ADDED
@@ -0,0 +1,41 @@
1
+ import { exec } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ const execAsync = promisify(exec);
5
+
6
+ /**
7
+ * Checks if inside a git repository and returns the staged or unstaged diff.
8
+ * @returns {Promise<{ diff: string, isStaged: boolean }>}
9
+ */
10
+ export async function getGitDiff() {
11
+ try {
12
+ await execAsync("git rev-parse --is-inside-work-tree");
13
+ } catch {
14
+ throw new Error("Not a git repository (or git is not installed).");
15
+ }
16
+
17
+ // Try staged changes first
18
+ const { stdout: staged } = await execAsync("git diff --cached");
19
+ if (staged.trim()) {
20
+ return { diff: staged.trim(), isStaged: true };
21
+ }
22
+
23
+ // Fallback to unstaged changes
24
+ const { stdout: unstaged } = await execAsync("git diff");
25
+ if (unstaged.trim()) {
26
+ return { diff: unstaged.trim(), isStaged: false };
27
+ }
28
+
29
+ return { diff: "", isStaged: false };
30
+ }
31
+
32
+ /**
33
+ * Executes a git commit with the specified message.
34
+ * @param {string} message - The commit message
35
+ * @returns {Promise<string>} stdout output of the git commit command
36
+ */
37
+ export async function runGitCommit(message) {
38
+ const escaped = message.replace(/"/g, '\\"');
39
+ const { stdout } = await execAsync(`git commit -m "${escaped}"`);
40
+ return stdout.trim();
41
+ }
package/src/ui/theme.js CHANGED
@@ -268,3 +268,221 @@ export function setTheme(themeName) {
268
268
  export function getThemesList() {
269
269
  return Object.keys(THEMES);
270
270
  }
271
+
272
+ export function stripCodeFences(content) {
273
+ let cleaned = content.trim();
274
+ if (cleaned.startsWith("```")) {
275
+ const firstNewline = cleaned.indexOf("\n");
276
+ if (firstNewline !== -1) {
277
+ cleaned = cleaned.slice(firstNewline + 1);
278
+ } else {
279
+ cleaned = cleaned.slice(3);
280
+ }
281
+ if (cleaned.endsWith("```")) {
282
+ cleaned = cleaned.slice(0, -3);
283
+ }
284
+ }
285
+ return cleaned.trim();
286
+ }
287
+
288
+ /**
289
+ * Interactive checkbox selector inside terminal using raw stdin. Renders scrollable pagination.
290
+ * Arrow Up/Down to navigate, Space to toggle, Enter to confirm, Esc/q to abort.
291
+ */
292
+ export async function interactiveCheckbox(headerText, items, preselected = []) {
293
+ if (items.length === 0) return [];
294
+
295
+ const stdin = process.stdin;
296
+ const stdout = process.stdout;
297
+
298
+ const wasRaw = stdin.isRaw;
299
+ stdin.setRawMode(true);
300
+ stdin.resume();
301
+ stdin.setEncoding("utf8");
302
+
303
+ stdout.write("\x1b[?25l"); // Hide cursor
304
+
305
+ let activeIndex = 0;
306
+ const selected = new Set(preselected.map(item => items.indexOf(item)).filter(i => i !== -1));
307
+
308
+ const PAGE_SIZE = 10;
309
+ let startRow = 0;
310
+ let renderedLines = 0;
311
+
312
+ function render() {
313
+ if (renderedLines > 0) {
314
+ stdout.write(`\x1b[${renderedLines}A\x1b[J`);
315
+ }
316
+
317
+ let lines = [];
318
+ lines.push(colors.brand(headerText));
319
+
320
+ if (activeIndex < startRow) {
321
+ startRow = activeIndex;
322
+ } else if (activeIndex >= startRow + PAGE_SIZE) {
323
+ startRow = activeIndex - PAGE_SIZE + 1;
324
+ }
325
+
326
+ const visibleEnd = Math.min(items.length, startRow + PAGE_SIZE);
327
+ for (let i = startRow; i < visibleEnd; i++) {
328
+ const isActive = i === activeIndex;
329
+ const isSelected = selected.has(i);
330
+
331
+ const pointer = isActive ? colors.accent("❯ ") : " ";
332
+ const checkbox = isSelected
333
+ ? colors.success("[⬢] ")
334
+ : colors.muted("[⬡] ");
335
+
336
+ const itemText = isActive
337
+ ? colors.brand(items[i])
338
+ : colors.text(items[i]);
339
+
340
+ lines.push(pointer + checkbox + itemText);
341
+ }
342
+
343
+ if (items.length > PAGE_SIZE) {
344
+ lines.push(colors.dim(` (Arrow Keys, Page ${Math.floor(startRow/PAGE_SIZE) + 1}/${Math.ceil(items.length/PAGE_SIZE)})`));
345
+ }
346
+
347
+ const outputStr = lines.join("\n") + "\n";
348
+ stdout.write(outputStr);
349
+ renderedLines = lines.length;
350
+ }
351
+
352
+ render();
353
+
354
+ return new Promise((resolve) => {
355
+ function handleKey(key) {
356
+ if (key === "\u0003" || key === "\u001b" || key === "q") { // Ctrl+C, Esc, q
357
+ cleanup();
358
+ resolve(null);
359
+ return;
360
+ }
361
+
362
+ if (key === "\r" || key === "\n") { // Enter
363
+ cleanup();
364
+ resolve([...selected].map(i => items[i]));
365
+ return;
366
+ }
367
+
368
+ if (key === " ") { // Spacebar
369
+ if (selected.has(activeIndex)) {
370
+ selected.delete(activeIndex);
371
+ } else {
372
+ selected.add(activeIndex);
373
+ }
374
+ render();
375
+ return;
376
+ }
377
+
378
+ if (key === "\u001b[A") { // Up Arrow
379
+ activeIndex = (activeIndex - 1 + items.length) % items.length;
380
+ render();
381
+ } else if (key === "\u001b[B") { // Down Arrow
382
+ activeIndex = (activeIndex + 1) % items.length;
383
+ render();
384
+ }
385
+ }
386
+
387
+ function cleanup() {
388
+ stdin.removeListener("data", handleKey);
389
+ stdin.setRawMode(wasRaw);
390
+ stdin.pause();
391
+ stdout.write("\x1b[?25h"); // Show cursor
392
+ }
393
+
394
+ stdin.on("data", handleKey);
395
+ });
396
+ }
397
+
398
+ /**
399
+ * Interactive single-select menu selector inside terminal. Renders scrollable pagination.
400
+ * Arrow Up/Down to navigate, Enter to select, Esc/q to abort.
401
+ */
402
+ export async function interactiveMenu(headerText, items) {
403
+ if (items.length === 0) return null;
404
+
405
+ const stdin = process.stdin;
406
+ const stdout = process.stdout;
407
+
408
+ const wasRaw = stdin.isRaw;
409
+ stdin.setRawMode(true);
410
+ stdin.resume();
411
+ stdin.setEncoding("utf8");
412
+
413
+ stdout.write("\x1b[?25l"); // Hide cursor
414
+
415
+ let activeIndex = 0;
416
+ const PAGE_SIZE = 10;
417
+ let startRow = 0;
418
+ let renderedLines = 0;
419
+
420
+ function render() {
421
+ if (renderedLines > 0) {
422
+ stdout.write(`\x1b[${renderedLines}A\x1b[J`);
423
+ }
424
+
425
+ let lines = [];
426
+ lines.push(colors.brand(headerText));
427
+
428
+ if (activeIndex < startRow) {
429
+ startRow = activeIndex;
430
+ } else if (activeIndex >= startRow + PAGE_SIZE) {
431
+ startRow = activeIndex - PAGE_SIZE + 1;
432
+ }
433
+
434
+ const visibleEnd = Math.min(items.length, startRow + PAGE_SIZE);
435
+ for (let i = startRow; i < visibleEnd; i++) {
436
+ const isActive = i === activeIndex;
437
+ const pointer = isActive ? colors.accent("❯ ") : " ";
438
+ const itemText = isActive
439
+ ? colors.success(items[i])
440
+ : colors.text(items[i]);
441
+
442
+ lines.push(pointer + itemText);
443
+ }
444
+
445
+ if (items.length > PAGE_SIZE) {
446
+ lines.push(colors.dim(` (Page ${Math.floor(startRow/PAGE_SIZE) + 1}/${Math.ceil(items.length/PAGE_SIZE)})`));
447
+ }
448
+
449
+ const outputStr = lines.join("\n") + "\n";
450
+ stdout.write(outputStr);
451
+ renderedLines = lines.length;
452
+ }
453
+
454
+ render();
455
+
456
+ return new Promise((resolve) => {
457
+ function handleKey(key) {
458
+ if (key === "\u0003" || key === "\u001b" || key === "q") { // Ctrl+C, Esc, q
459
+ cleanup();
460
+ resolve(null);
461
+ return;
462
+ }
463
+
464
+ if (key === "\r" || key === "\n") { // Enter
465
+ cleanup();
466
+ resolve(activeIndex);
467
+ return;
468
+ }
469
+
470
+ if (key === "\u001b[A") { // Up Arrow
471
+ activeIndex = (activeIndex - 1 + items.length) % items.length;
472
+ render();
473
+ } else if (key === "\u001b[B") { // Down Arrow
474
+ activeIndex = (activeIndex + 1) % items.length;
475
+ render();
476
+ }
477
+ }
478
+
479
+ function cleanup() {
480
+ stdin.removeListener("data", handleKey);
481
+ stdin.setRawMode(wasRaw);
482
+ stdin.pause();
483
+ stdout.write("\x1b[?25h"); // Show cursor
484
+ }
485
+
486
+ stdin.on("data", handleKey);
487
+ });
488
+ }