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.
- package/LICENSE +21 -0
- package/README.md +246 -0
- package/banana.js +5464 -0
- package/lib/agenticRunner.js +1884 -0
- package/lib/borderRenderer.js +41 -0
- package/lib/commandRunner.js +205 -0
- package/lib/completer.js +286 -0
- package/lib/config.js +301 -0
- package/lib/contextBuilder.js +324 -0
- package/lib/diffViewer.js +295 -0
- package/lib/fileManager.js +224 -0
- package/lib/historyManager.js +124 -0
- package/lib/hookManager.js +1143 -0
- package/lib/imageHandler.js +268 -0
- package/lib/inlineComplete.js +192 -0
- package/lib/interactivePicker.js +254 -0
- package/lib/lmStudio.js +226 -0
- package/lib/markdownRenderer.js +423 -0
- package/lib/mcpClient.js +288 -0
- package/lib/modelRegistry.js +350 -0
- package/lib/monkeyModels.js +97 -0
- package/lib/oauthOpenAI.js +167 -0
- package/lib/parser.js +134 -0
- package/lib/promptManager.js +96 -0
- package/lib/providerClients.js +1014 -0
- package/lib/providerManager.js +130 -0
- package/lib/providerStore.js +413 -0
- package/lib/statusBar.js +283 -0
- package/lib/streamHandler.js +306 -0
- package/lib/subAgentManager.js +406 -0
- package/lib/tokenCounter.js +132 -0
- package/lib/visionAnalyzer.js +163 -0
- package/lib/watcher.js +138 -0
- package/models.json +57 -0
- package/package.json +42 -0
- package/prompts/base.md +23 -0
- package/prompts/code-agent-glm.md +16 -0
- package/prompts/code-agent-gptoss.md +25 -0
- package/prompts/code-agent-nemotron.md +17 -0
- package/prompts/code-agent-qwen.md +20 -0
- package/prompts/code-agent.md +70 -0
- 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 };
|