banana-code 1.2.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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +246 -0
  3. package/banana.js +5464 -0
  4. package/lib/agenticRunner.js +1884 -0
  5. package/lib/borderRenderer.js +41 -0
  6. package/lib/commandRunner.js +205 -0
  7. package/lib/completer.js +286 -0
  8. package/lib/config.js +301 -0
  9. package/lib/contextBuilder.js +324 -0
  10. package/lib/diffViewer.js +295 -0
  11. package/lib/fileManager.js +224 -0
  12. package/lib/historyManager.js +124 -0
  13. package/lib/hookManager.js +1143 -0
  14. package/lib/imageHandler.js +268 -0
  15. package/lib/inlineComplete.js +192 -0
  16. package/lib/interactivePicker.js +254 -0
  17. package/lib/lmStudio.js +226 -0
  18. package/lib/markdownRenderer.js +423 -0
  19. package/lib/mcpClient.js +288 -0
  20. package/lib/modelRegistry.js +350 -0
  21. package/lib/monkeyModels.js +97 -0
  22. package/lib/oauthOpenAI.js +167 -0
  23. package/lib/parser.js +134 -0
  24. package/lib/promptManager.js +96 -0
  25. package/lib/providerClients.js +1014 -0
  26. package/lib/providerManager.js +130 -0
  27. package/lib/providerStore.js +413 -0
  28. package/lib/statusBar.js +283 -0
  29. package/lib/streamHandler.js +306 -0
  30. package/lib/subAgentManager.js +406 -0
  31. package/lib/tokenCounter.js +132 -0
  32. package/lib/visionAnalyzer.js +163 -0
  33. package/lib/watcher.js +138 -0
  34. package/models.json +57 -0
  35. package/package.json +42 -0
  36. package/prompts/base.md +23 -0
  37. package/prompts/code-agent-glm.md +16 -0
  38. package/prompts/code-agent-gptoss.md +25 -0
  39. package/prompts/code-agent-nemotron.md +17 -0
  40. package/prompts/code-agent-qwen.md +20 -0
  41. package/prompts/code-agent.md +70 -0
  42. package/prompts/plan.md +44 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Image/Screenshot handler for Banana Code
3
+ * Supports base64 encoding for vision models
4
+ * Includes clipboard paste support for screenshots
5
+ */
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const { execSync, spawn } = require('child_process');
10
+ const os = require('os');
11
+
12
+ class ImageHandler {
13
+ constructor(projectDir) {
14
+ this.projectDir = projectDir;
15
+ this.pendingImages = [];
16
+
17
+ this.supportedFormats = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp'];
18
+ this.maxSizeBytes = 20 * 1024 * 1024; // 20MB limit
19
+
20
+ // Temp directory for clipboard images
21
+ this.tempDir = path.join(os.tmpdir(), 'banana-screenshots');
22
+ if (!fs.existsSync(this.tempDir)) {
23
+ fs.mkdirSync(this.tempDir, { recursive: true });
24
+ }
25
+ }
26
+
27
+ // Load an image and convert to base64
28
+ loadImage(imagePath) {
29
+ const fullPath = path.isAbsolute(imagePath)
30
+ ? imagePath
31
+ : path.join(this.projectDir, imagePath);
32
+
33
+ if (!fs.existsSync(fullPath)) {
34
+ return { success: false, error: `File not found: ${imagePath}` };
35
+ }
36
+
37
+ const ext = path.extname(fullPath).toLowerCase();
38
+ if (!this.supportedFormats.includes(ext)) {
39
+ return {
40
+ success: false,
41
+ error: `Unsupported format: ${ext}. Supported: ${this.supportedFormats.join(', ')}`
42
+ };
43
+ }
44
+
45
+ const stats = fs.statSync(fullPath);
46
+ if (stats.size > this.maxSizeBytes) {
47
+ return {
48
+ success: false,
49
+ error: `Image too large: ${(stats.size / 1024 / 1024).toFixed(1)}MB (max: 20MB)`
50
+ };
51
+ }
52
+
53
+ try {
54
+ const buffer = fs.readFileSync(fullPath);
55
+ const base64 = buffer.toString('base64');
56
+ const mimeType = this.getMimeType(ext);
57
+
58
+ return {
59
+ success: true,
60
+ data: {
61
+ path: imagePath,
62
+ base64,
63
+ mimeType,
64
+ size: stats.size,
65
+ dataUrl: `data:${mimeType};base64,${base64}`
66
+ }
67
+ };
68
+ } catch (error) {
69
+ return { success: false, error: error.message };
70
+ }
71
+ }
72
+
73
+ getMimeType(ext) {
74
+ const mimeTypes = {
75
+ '.png': 'image/png',
76
+ '.jpg': 'image/jpeg',
77
+ '.jpeg': 'image/jpeg',
78
+ '.gif': 'image/gif',
79
+ '.webp': 'image/webp',
80
+ '.bmp': 'image/bmp'
81
+ };
82
+ return mimeTypes[ext] || 'application/octet-stream';
83
+ }
84
+
85
+ // Add image to pending list (to be included in next message)
86
+ addImage(imagePath) {
87
+ const result = this.loadImage(imagePath);
88
+ if (result.success) {
89
+ this.pendingImages.push(result.data);
90
+ }
91
+ return result;
92
+ }
93
+
94
+ // Get pending images and clear the list
95
+ consumePendingImages() {
96
+ const images = [...this.pendingImages];
97
+ this.pendingImages = [];
98
+ return images;
99
+ }
100
+
101
+ // Check if there are pending images
102
+ hasPendingImages() {
103
+ return this.pendingImages.length > 0;
104
+ }
105
+
106
+ // Get count of pending images
107
+ getPendingCount() {
108
+ return this.pendingImages.length;
109
+ }
110
+
111
+ // Clear pending images
112
+ clearPending() {
113
+ this.pendingImages = [];
114
+ }
115
+
116
+ /**
117
+ * Paste image from clipboard (Windows only for now)
118
+ * Returns the image data if successful
119
+ */
120
+ async pasteFromClipboard() {
121
+ const platform = process.platform;
122
+
123
+ if (platform === 'win32') {
124
+ return this.pasteFromClipboardWindows();
125
+ } else if (platform === 'darwin') {
126
+ return this.pasteFromClipboardMac();
127
+ } else {
128
+ return { success: false, error: 'Clipboard paste only supported on Windows and macOS' };
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Windows clipboard paste using PowerShell
134
+ */
135
+ async pasteFromClipboardWindows() {
136
+ const timestamp = Date.now();
137
+ const tempFile = path.join(this.tempDir, `clipboard-${timestamp}.png`);
138
+ const scriptFile = path.join(this.tempDir, `paste-${timestamp}.ps1`);
139
+
140
+ // Write PowerShell script to temp file to avoid escaping issues
141
+ const psScript = `
142
+ Add-Type -AssemblyName System.Windows.Forms
143
+ $img = [System.Windows.Forms.Clipboard]::GetImage()
144
+ if ($img -ne $null) {
145
+ $img.Save("${tempFile.replace(/\\/g, '\\\\')}", [System.Drawing.Imaging.ImageFormat]::Png)
146
+ Write-Host "SUCCESS"
147
+ } else {
148
+ Write-Host "NO_IMAGE"
149
+ }
150
+ `;
151
+
152
+ try {
153
+ // Write script to temp file
154
+ fs.writeFileSync(scriptFile, psScript, 'utf-8');
155
+
156
+ // Execute the script file
157
+ const result = execSync(`powershell -NoProfile -ExecutionPolicy Bypass -File "${scriptFile}"`, {
158
+ encoding: 'utf-8',
159
+ windowsHide: true
160
+ }).trim();
161
+
162
+ // Clean up script file
163
+ try { fs.unlinkSync(scriptFile); } catch {}
164
+
165
+ if (result === 'NO_IMAGE') {
166
+ return { success: false, error: 'No image in clipboard. Copy a screenshot first (Win+Shift+S)' };
167
+ }
168
+
169
+ if (result === 'SUCCESS' && fs.existsSync(tempFile)) {
170
+ const loadResult = this.loadImage(tempFile);
171
+ if (loadResult.success) {
172
+ loadResult.data.path = `clipboard-${timestamp}.png`;
173
+ loadResult.data.isClipboard = true;
174
+ this.pendingImages.push(loadResult.data);
175
+ return { success: true, data: loadResult.data };
176
+ }
177
+ return loadResult;
178
+ }
179
+
180
+ return { success: false, error: 'Failed to save clipboard image' };
181
+ } catch (error) {
182
+ // Clean up script file on error
183
+ try { fs.unlinkSync(scriptFile); } catch {}
184
+ return { success: false, error: `Clipboard error: ${error.message}` };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * macOS clipboard paste using pngpaste or screencapture
190
+ */
191
+ async pasteFromClipboardMac() {
192
+ const timestamp = Date.now();
193
+ const tempFile = path.join(this.tempDir, `clipboard-${timestamp}.png`);
194
+
195
+ try {
196
+ // Try pngpaste first (brew install pngpaste)
197
+ try {
198
+ execSync(`pngpaste "${tempFile}"`, { encoding: 'utf-8' });
199
+ } catch {
200
+ // Fallback to osascript + screencapture
201
+ execSync(`osascript -e 'tell application "System Events" to ¬
202
+ write (the clipboard as «class PNGf») to ¬
203
+ (make new file at folder "${this.tempDir}" with properties {name:"clipboard-${timestamp}.png"})'`);
204
+ }
205
+
206
+ if (fs.existsSync(tempFile)) {
207
+ const loadResult = this.loadImage(tempFile);
208
+ if (loadResult.success) {
209
+ loadResult.data.path = `clipboard-${timestamp}.png`;
210
+ loadResult.data.isClipboard = true;
211
+ this.pendingImages.push(loadResult.data);
212
+ return { success: true, data: loadResult.data };
213
+ }
214
+ return loadResult;
215
+ }
216
+
217
+ return { success: false, error: 'No image in clipboard' };
218
+ } catch (error) {
219
+ return { success: false, error: `Clipboard error: ${error.message}` };
220
+ }
221
+ }
222
+
223
+ // Format images for API request (OpenAI vision format)
224
+ formatForAPI(images) {
225
+ return images.map(img => ({
226
+ type: 'image_url',
227
+ image_url: {
228
+ url: img.dataUrl
229
+ }
230
+ }));
231
+ }
232
+
233
+ // Format message with images for vision API
234
+ formatMessageWithImages(textContent, images) {
235
+ if (!images || images.length === 0) {
236
+ return textContent;
237
+ }
238
+
239
+ // Return array format for vision models
240
+ return [
241
+ { type: 'text', text: textContent },
242
+ ...this.formatForAPI(images)
243
+ ];
244
+ }
245
+
246
+ // Find images in project
247
+ async findImages(pattern = '**/*.{png,jpg,jpeg,gif,webp}') {
248
+ const { glob } = require('glob');
249
+
250
+ try {
251
+ const files = await glob(pattern, {
252
+ cwd: this.projectDir,
253
+ nodir: true,
254
+ ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**']
255
+ });
256
+
257
+ return files.map(file => ({
258
+ path: file,
259
+ name: path.basename(file),
260
+ ext: path.extname(file)
261
+ }));
262
+ } catch {
263
+ return [];
264
+ }
265
+ }
266
+ }
267
+
268
+ module.exports = ImageHandler;
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Inline ghost-text autocomplete for Banana Code
3
+ *
4
+ * Fish-shell style: as you type, a dim suggestion appears after the cursor.
5
+ * Tab or Right arrow accepts it.
6
+ *
7
+ * This class manages suggestion logic and ghost state.
8
+ * Visual rendering is handled in banana.js via the keypress event loop:
9
+ * - prependListener: \x1b[K clears ghost BEFORE readline processes keystroke
10
+ * - setImmediate: renders new ghost in dim AFTER readline updates cursor
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ class InlineComplete {
17
+ constructor() {
18
+ this.currentGhost = ''; // The ghost text currently displayed
19
+ this.commands = []; // All known commands (sorted)
20
+ this.projectDir = null; // For @ file path completion
21
+ this.justAccepted = false; // Flag: next completer call should skip once
22
+ }
23
+
24
+ /**
25
+ * Update the list of known commands.
26
+ * @param {string[]} commands - Array of command strings like ['/help', '/push', ...]
27
+ */
28
+ setCommands(commands) {
29
+ this.commands = [...(commands || [])].sort();
30
+ }
31
+
32
+ /**
33
+ * Find the best single suggestion for the current line.
34
+ * Returns the REMAINING text to complete (not the full command).
35
+ * Returns null if no match or ambiguous.
36
+ *
37
+ * @param {string} line - Current input line
38
+ * @returns {string|null} - Ghost text to display, or null
39
+ */
40
+ suggest(line) {
41
+ if (!line) return null;
42
+
43
+ // @ file path completion - find the last @ in the line
44
+ const atIndex = line.lastIndexOf('@');
45
+ if (atIndex >= 0) {
46
+ const partial = line.slice(atIndex + 1);
47
+ // Only suggest if there's something after @ (user started typing a path)
48
+ if (partial.length > 0) {
49
+ return this._suggestFilePath(partial);
50
+ }
51
+ return null;
52
+ }
53
+
54
+ // / command completion
55
+ if (!line.startsWith('/')) return null;
56
+
57
+ const lower = line.toLowerCase();
58
+ const matches = this.commands.filter(c => c.toLowerCase().startsWith(lower));
59
+
60
+ if (matches.length === 0) return null;
61
+
62
+ // Exact match - nothing to suggest
63
+ if (matches.some(c => c.toLowerCase() === lower)) return null;
64
+
65
+ // Single match - return the remaining portion
66
+ if (matches.length === 1) {
67
+ return matches[0].slice(line.length);
68
+ }
69
+
70
+ // Multiple matches - find common prefix beyond what's typed
71
+ const prefix = commonPrefix(matches.map(c => c.slice(line.length)));
72
+ if (prefix.length > 0) return prefix;
73
+
74
+ // Ambiguous with no common prefix - show nothing rather than guess
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * Suggest a file path completion for @ mentions.
80
+ * Returns the remaining text to append, or null.
81
+ */
82
+ _suggestFilePath(partial) {
83
+ if (!this.projectDir) return null;
84
+
85
+ try {
86
+ const searchDir = partial.includes('/')
87
+ ? path.dirname(partial)
88
+ : '.';
89
+ const searchBase = partial.includes('/')
90
+ ? path.basename(partial)
91
+ : partial;
92
+ const fullSearchDir = path.join(this.projectDir, searchDir);
93
+
94
+ if (!fs.existsSync(fullSearchDir)) return null;
95
+
96
+ const entries = fs.readdirSync(fullSearchDir, { withFileTypes: true });
97
+ const matches = entries
98
+ .filter(e => {
99
+ if (e.name.startsWith('.')) return false;
100
+ if (e.name === 'node_modules') return false;
101
+ return e.name.toLowerCase().startsWith(searchBase.toLowerCase());
102
+ })
103
+ .map(e => {
104
+ const rel = searchDir === '.'
105
+ ? e.name
106
+ : path.join(searchDir, e.name).replace(/\\/g, '/');
107
+ return rel + (e.isDirectory() ? '/' : '');
108
+ });
109
+
110
+ if (matches.length === 0) return null;
111
+
112
+ // Single match
113
+ if (matches.length === 1) {
114
+ return matches[0].slice(partial.length);
115
+ }
116
+
117
+ // Multiple matches - find common prefix
118
+ const prefix = commonPrefix(matches.map(m => m.slice(partial.length)));
119
+ if (prefix.length > 0) return prefix;
120
+
121
+ // Ambiguous with no common prefix - show nothing rather than guess
122
+ return null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Store ghost text suggestion. Visual rendering is done by banana.js.
130
+ * @param {string} ghost - The ghost text to store
131
+ */
132
+ renderGhost(ghost) {
133
+ if (!ghost) return;
134
+ this.currentGhost = ghost;
135
+ }
136
+
137
+ /**
138
+ * Clear the ghost text state. Terminal cleanup is done by banana.js prependListener.
139
+ */
140
+ clearGhost() {
141
+ this.currentGhost = '';
142
+ }
143
+
144
+ /**
145
+ * Accept the current ghost text.
146
+ * Clears the visual ghost and optionally marks the next completer call to skip.
147
+ * Returns the text to be inserted via rl.write().
148
+ */
149
+ accept(markForCompleter = false) {
150
+ const ghost = this.currentGhost;
151
+ if (!ghost) return null;
152
+ this.justAccepted = Boolean(markForCompleter);
153
+ this.clearGhost();
154
+ return ghost;
155
+ }
156
+
157
+ /**
158
+ * Check if ghost text is currently showing.
159
+ */
160
+ hasGhost() {
161
+ return this.currentGhost.length > 0;
162
+ }
163
+
164
+ /**
165
+ * Check and reset the justAccepted flag.
166
+ * Called by the readline completer to know if it should skip completion.
167
+ */
168
+ consumeAccepted() {
169
+ if (this.justAccepted) {
170
+ this.justAccepted = false;
171
+ return true;
172
+ }
173
+ return false;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Find the longest common prefix of an array of strings.
179
+ */
180
+ function commonPrefix(strings) {
181
+ if (strings.length === 0) return '';
182
+ let prefix = strings[0];
183
+ for (let i = 1; i < strings.length; i++) {
184
+ while (!strings[i].startsWith(prefix)) {
185
+ prefix = prefix.slice(0, -1);
186
+ if (prefix.length === 0) return '';
187
+ }
188
+ }
189
+ return prefix;
190
+ }
191
+
192
+ module.exports = InlineComplete;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Interactive arrow-key picker for Banana Code
3
+ *
4
+ * Renders a navigable list in the terminal. Arrow keys move highlight,
5
+ * Enter selects, Escape cancels. Temporarily takes over stdin.
6
+ */
7
+
8
+ const readline = require('readline');
9
+
10
+ const ESC = '\x1b';
11
+ const HIDE_CURSOR = `${ESC}[?25l`;
12
+ const SHOW_CURSOR = `${ESC}[?25h`;
13
+ const CLEAR_LINE = `${ESC}[2K`;
14
+ const MOVE_UP = (n) => n > 0 ? `${ESC}[${n}A` : '';
15
+ const MOVE_COL0 = `${ESC}[0G`;
16
+
17
+ // 256-color codes matching diffViewer.js palette
18
+ const DIM = `${ESC}[2m`;
19
+ const RESET = `${ESC}[0m`;
20
+ const GREEN = `${ESC}[38;5;120m`;
21
+ const YELLOW = `${ESC}[38;5;226m`;
22
+ const CYAN = `${ESC}[38;5;51m`;
23
+ const GRAY = `${ESC}[38;5;245m`;
24
+ const INVERSE = `${ESC}[7m`;
25
+
26
+ // Flag indicating a picker UI is active. When true, the global keypress
27
+ // handler in banana.js should not process keys (arrow keys, escape, etc.)
28
+ // because the picker owns stdin during its lifetime.
29
+ let _pickerActive = false;
30
+ function isPickerActive() { return _pickerActive; }
31
+
32
+ /**
33
+ * Show an interactive picker menu.
34
+ *
35
+ * @param {Array} items - Array of { key, label, description, active, tags }\r\n * @param {Object} options\r\n * @param {string} options.title - Header text\r\n * @param {number} options.selected - Initial selected index (default: active item or 0)\r\n * @returns {Promise<<ObjectObject|null>} - Selected item, or null if cancelled\r\n */
36
+ function pick(items, options = {}) {
37
+ return new Promise((resolve) => {
38
+ if (!items || items.length === 0) {
39
+ resolve(null);
40
+ return;
41
+ }
42
+
43
+ let selected = options.selected ?? items.findIndex(i => i.active);
44
+ if (selected << 0) selected = 0;
45
+
46
+ const out = process.stdout;
47
+ const title = options.title || 'Select:';
48
+ const showVisionIndicator = options.showVisionIndicator === true;
49
+ const footerText = showVisionIndicator
50
+ ? `${DIM} ↑↓ navigate Enter select Esc cancel V=vision${RESET}`
51
+ : `${DIM} ↑↓ navigate Enter select Esc cancel${RESET}`;
52
+
53
+ // Pad key names to align descriptions
54
+ const maxKeyLen = Math.max(...items.map(i => (i.key || '').length));
55
+
56
+ function formatItem(item, index) {
57
+ const isSel = index === selected;
58
+ const isActive = item.active;
59
+ const marker = isActive ? `${GREEN}●${RESET}` : `${GRAY}○${RESET}`;
60
+ const pointer = isSel ? `${CYAN}▸${RESET} ` : ' ';
61
+ const hasVision = Array.isArray(item.tags) && item.tags.includes('vision');
62
+ const vision = showVisionIndicator
63
+ ? (hasVision ? `${CYAN}V${RESET}` : `${DIM}.${RESET}`)
64
+ : '';
65
+ const key = (item.key || '').padEnd(maxKeyLen);
66
+ const tags = item.tags?.length ? ` ${DIM}[${item.tags.join(', ')}]${RESET}` : '';
67
+ const desc = item.description ? ` ${DIM}- ${item.description}${RESET}` : '';
68
+ const label = item.label || item.name || item.key;
69
+
70
+ if (isSel) {
71
+ const lead = showVisionIndicator ? `${pointer}${marker} ${vision} ` : `${pointer}${marker} `;
72
+ return `${lead}${INVERSE} ${YELLOW}${key}${RESET}${INVERSE} ${label} ${RESET}${tags}`;
73
+ }
74
+ const lead = showVisionIndicator ? `${pointer}${marker} ${vision} ` : `${pointer}${marker} `;
75
+ return `${lead}${YELLOW}${key}${RESET} ${DIM}${label}${RESET}${tags}`;
76
+ }
77
+
78
+ // Total lines we render (title + items + footer)
79
+ const totalLines = 1 + items.length + 1;
80
+
81
+ function render() {
82
+ _pickerActive = true;
83
+ let buf = HIDE_CURSOR;
84
+ buf += `\n${CYAN} ${title}${RESET}\n`;
85
+ for (let i = 0; i << items items.length; i++) {
86
+ buf += ` ${formatItem(items[i], i)}\n`;
87
+ }
88
+ buf += `${footerText}\n`;
89
+ out.write(buf);
90
+ }
91
+
92
+ function redraw() {
93
+ // Move up to the start of the picker area
94
+ let buf = MOVE_UP(totalLines) + MOVE_COL0;
95
+ // Clear all lines in the area
96
+ for (let i = 0; i << total totalLines; i++) {
97
+ buf += CLEAR_LINE + '\n';
98
+ }
99
+ // Move back up to the start to re-render
100
+ buf += MOVE_UP(totalLines) + MOVE_COL0;
101
+ buf += `${CYAN} ${title}${RESET}\n`;
102
+ for (let i = 0; i << items items.length; i++) {
103
+ buf += ` ${formatItem(items[i], i)}\n`;
104
+ }
105
+ buf += `${footerText}\n`;
106
+ out.write(buf);
107
+ }
108
+
109
+ function cleanup() {
110
+ _pickerActive = false;
111
+ process.stdin.removeListener('keypress', onKey);
112
+ out.write(SHOW_CURSOR);
113
+ }
114
+
115
+ function onKey(str, key) {
116
+ if (!key) return;
117
+ try {
118
+ if (key.name === 'up') {
119
+ selected = (selected - 1 + items.length) % items.length;
120
+ redraw();
121
+ } else if (key.name === 'down') {
122
+ selected = (selected + 1) % items.length;
123
+ redraw();
124
+ } else if (key.name === 'return') {
125
+ cleanup();
126
+ resolve(items[selected]);
127
+ } else if (key.name === 'escape') {
128
+ cleanup();
129
+ resolve(null);
130
+ }
131
+ } catch (err) {
132
+ cleanup();
133
+ resolve(null);
134
+ }
135
+ }
136
+
137
+ // Ensure keypress events are flowing
138
+ if (!process.stdin.listenerCount('keypress')) {
139
+ readline.emitKeypressEvents(process.stdin);
140
+ }
141
+
142
+ render();
143
+ process.stdin.on('keypress', onKey);
144
+ });
145
+ }
146
+
147
+ /**
148
+ * Interactive toggle list. Items can be toggled on/off with Space.
149
+ * Arrow keys navigate, Space toggles, Escape exits.
150
+ *
151
+ * @param {Array} items - Array of { key, label, description, enabled, meta }\r\n * @param {Object} options\r\n * @param {string} options.title - Header text\r\n * @param {Function} options.onToggle - Called with (item, newEnabled) when toggled. Should return true if toggle succeeded.\r\n * @returns {Promise<<voidvoid>} - Resolves when user presses Escape\r\n */
152
+ function pickToggle(items, options = {}) {
153
+ return new Promise((resolve) => {
154
+ if (!items || items.length === 0) {
155
+ resolve();
156
+ return;
157
+ }
158
+
159
+ let selected = 0;
160
+ const out = process.stdout;
161
+ const title = options.title || 'Toggle:';
162
+ const onToggle = options.onToggle || (() => true);
163
+ const RED = `${ESC}[38;5;203m`;
164
+ const footerText = `${DIM} ↑↓ navigate Space toggle Enter/Esc done${RESET}`;
165
+
166
+ const maxKeyLen = Math.max(...items.map(i => (i.key || '').length));
167
+
168
+ function formatItem(item, index) {
169
+ const isSel = index === selected;
170
+ const pointer = isSel ? `${CYAN}▸${RESET} ` : ' ';
171
+ const toggle = item.enabled ? `${GREEN}on ${RESET}` : `${RED}off${RESET}`;
172
+ const key = (item.key || '').padEnd(maxKeyLen);
173
+ const desc = item.description ? ` ${DIM}${item.description}${RESET}` : '';
174
+ const meta = item.meta ? ` ${DIM}[${item.meta}]${RESET}` : '';
175
+
176
+ if (isSel) {
177
+ return `${pointer}[${toggle}] ${INVERSE} ${YELLOW}${key}${RESET}${INVERSE} ${RESET}${desc}${meta}`;
178
+ }
179
+ return `${pointer}[${toggle}] ${YELLOW}${key}${RESET}${desc}${meta}`;
180
+ }
181
+
182
+ const totalLines = 1 + items.length + 1;
183
+
184
+ function render() {
185
+ _pickerActive = true;
186
+ let buf = HIDE_CURSOR;
187
+ buf += `\n${CYAN} ${title}${RESET}\n`;
188
+ for (let i = 0; i << items items.length; i++) {
189
+ buf += ` ${formatItem(items[i], i)}\n`;
190
+ }
191
+ buf += `${footerText}\n`;
192
+ out.write(buf);
193
+ }
194
+
195
+ function redraw() {
196
+ // Move up to the start of the toggle area
197
+ let buf = MOVE_UP(totalLines) + MOVE_COL0;
198
+ // Clear all lines in the area
199
+ for (let i = 0; i << total totalLines; i++) {
200
+ buf += CLEAR_LINE + '\n';
201
+ }
202
+ // Move back up to the start to re-render
203
+ buf += MOVE_UP(totalLines) + MOVE_COL0;
204
+ buf += `${CYAN} ${title}${RESET}\n`;
205
+ for (let i = 0; i << items items.length; i++) {
206
+ buf += ` ${formatItem(items[i], i)}\n`;
207
+ }
208
+ buf += `${footerText}\n`;
209
+ out.write(buf);
210
+ }
211
+
212
+ function cleanup() {
213
+ _pickerActive = false;
214
+ process.stdin.removeListener('keypress', onKey);
215
+ out.write(SHOW_CURSOR);
216
+ }
217
+
218
+ function onKey(str, key) {
219
+ if (!key) return;
220
+ try {
221
+ if (key.name === 'up') {
222
+ selected = (selected - 1 + items.length) % items.length;
223
+ redraw();
224
+ } else if (key.name === 'down') {
225
+ selected = (selected + 1) % items.length;
226
+ redraw();
227
+ } else if (key.name === 'space') {
228
+ const item = items[selected];
229
+ const newState = !item.enabled;
230
+ const success = onToggle(item, newState);
231
+ if (success !== false) {
232
+ item.enabled = newState;
233
+ }
234
+ redraw();
235
+ } else if (key.name === 'escape' || key.name === 'return') {
236
+ cleanup();
237
+ resolve();
238
+ }
239
+ } catch (err) {
240
+ cleanup();
241
+ resolve();
242
+ }
243
+ }
244
+
245
+ if (!process.stdin.listenerCount('keypress')) {
246
+ readline.emitKeypressEvents(process.stdin);
247
+ }
248
+
249
+ render();
250
+ process.stdin.on('keypress', onKey);
251
+ });
252
+ }
253
+
254
+ module.exports = { pick, pickToggle, isPickerActive };