@krishivpb60/aether-ai-cli 1.1.6 → 1.1.8
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 +1 -1
- package/src/agent.js +417 -0
- package/src/chat.js +412 -99
- package/src/cli.js +88 -0
- package/src/config.js +187 -10
- package/src/file-parser.js +48 -1
- package/src/git.js +41 -0
- package/src/ui/theme.js +202 -5
- package/test/agent.test.js +62 -0
- package/test/config.test.js +27 -0
package/src/cli.js
CHANGED
|
@@ -202,6 +202,14 @@ export function createCLI(argv) {
|
|
|
202
202
|
await handleSetup();
|
|
203
203
|
});
|
|
204
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
|
+
|
|
205
213
|
// ── Default: Show help ──────────────────────────────────
|
|
206
214
|
program.action(() => {
|
|
207
215
|
showMiniBanner();
|
|
@@ -569,6 +577,86 @@ async function handleStatus() {
|
|
|
569
577
|
console.log("");
|
|
570
578
|
}
|
|
571
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
|
+
|
|
572
660
|
async function handleSetup() {
|
|
573
661
|
const { createInterface } = await import("node:readline");
|
|
574
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
|
|
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
|
|
184
|
-
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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
|
-
|
|
385
|
+
const file = getSessionFile();
|
|
386
|
+
unlinkSync(file);
|
|
387
|
+
currentSessionFile = null;
|
|
211
388
|
} catch {
|
|
212
389
|
// File may not exist
|
|
213
390
|
}
|
package/src/file-parser.js
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// ═══════════════════════════════════════════════════════════
|
|
4
4
|
|
|
5
5
|
import { readFile, stat } from "node:fs/promises";
|
|
6
|
-
import {
|
|
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
|
@@ -269,11 +269,6 @@ export function getThemesList() {
|
|
|
269
269
|
return Object.keys(THEMES);
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
/**
|
|
273
|
-
* Strips markdown code block fences (```lang ... ```) from a string if present.
|
|
274
|
-
* @param {string} content - Raw content extracted from file write blocks
|
|
275
|
-
* @returns {string} Cleaned content
|
|
276
|
-
*/
|
|
277
272
|
export function stripCodeFences(content) {
|
|
278
273
|
let cleaned = content.trim();
|
|
279
274
|
if (cleaned.startsWith("```")) {
|
|
@@ -289,3 +284,205 @@ export function stripCodeFences(content) {
|
|
|
289
284
|
}
|
|
290
285
|
return cleaned.trim();
|
|
291
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
|
+
}
|