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,41 @@
1
+ /**
2
+ * borderRenderer.js - Colored left-border prefix strings for message types
3
+ */
4
+
5
+ const RESET = '\x1b[0m';
6
+
7
+ const BORDERS = {
8
+ user: { char: '│', color: '\x1b[38;5;220m' }, // banana yellow
9
+ ai: { char: '│', color: '\x1b[38;5;75m' }, // blue/cyan
10
+ thinking: { char: '│', color: '\x1b[38;5;240m' }, // dim gray
11
+ tool: { char: '│', color: '\x1b[38;5;245m' }, // gray
12
+ };
13
+
14
+ const PAD = ' ';
15
+
16
+ /**
17
+ * Returns a colored border prefix string for the given speaker type.
18
+ * e.g. " \x1b[38;5;220m│\x1b[0m "
19
+ */
20
+ function prefix(speaker) {
21
+ const b = BORDERS[speaker];
22
+ if (!b) return PAD;
23
+ return `${PAD}${b.color}${b.char}${RESET} `;
24
+ }
25
+
26
+ /**
27
+ * Strip ANSI escape codes from a string for visual width calculation.
28
+ */
29
+ function stripAnsi(str) {
30
+ // eslint-disable-next-line no-control-regex
31
+ return str.replace(/\x1b\[[0-9;]*m/g, '');
32
+ }
33
+
34
+ /**
35
+ * Get the visual (display) width of the prefix for a speaker type.
36
+ */
37
+ function prefixWidth(speaker) {
38
+ return stripAnsi(prefix(speaker)).length;
39
+ }
40
+
41
+ module.exports = { prefix, stripAnsi, prefixWidth, BORDERS, PAD };
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Command Runner - Execute shell commands safely
3
+ */
4
+
5
+ const { spawn } = require('child_process');
6
+ const path = require('path');
7
+
8
+ // Commands that are always blocked
9
+ const BLOCKED_COMMANDS = [
10
+ 'rm -rf /', 'rm -rf /*', 'del /s /q c:',
11
+ 'format c:', 'mkfs', ':(){:|:&};:'
12
+ ];
13
+
14
+ // Commands that require confirmation
15
+ const DANGEROUS_PATTERNS = [
16
+ /^rm\s+-rf/i,
17
+ /^del\s+\/s/i,
18
+ /^rmdir\s+\/s/i,
19
+ /drop\s+database/i,
20
+ /drop\s+table/i,
21
+ /truncate\s+table/i
22
+ ];
23
+
24
+ class CommandRunner {
25
+ constructor(projectDir) {
26
+ this.projectDir = projectDir;
27
+ this.isWindows = process.platform === 'win32';
28
+ }
29
+
30
+ /**
31
+ * Check if a command is blocked
32
+ */
33
+ isBlocked(command) {
34
+ const lowerCmd = command.toLowerCase().trim();
35
+
36
+ // Check absolute blocks
37
+ for (const blocked of BLOCKED_COMMANDS) {
38
+ if (lowerCmd.includes(blocked)) {
39
+ return { blocked: true, reason: 'This command is blocked for safety' };
40
+ }
41
+ }
42
+
43
+ return { blocked: false };
44
+ }
45
+
46
+ /**
47
+ * Check if a command needs extra confirmation
48
+ */
49
+ isDangerous(command) {
50
+ for (const pattern of DANGEROUS_PATTERNS) {
51
+ if (pattern.test(command)) {
52
+ return true;
53
+ }
54
+ }
55
+ return false;
56
+ }
57
+
58
+ /**
59
+ * Run a command and return a promise with the result
60
+ */
61
+ run(command, options = {}) {
62
+ return new Promise((resolve, reject) => {
63
+ const blockCheck = this.isBlocked(command);
64
+ if (blockCheck.blocked) {
65
+ reject(new Error(blockCheck.reason));
66
+ return;
67
+ }
68
+
69
+ const shell = this.isWindows ? 'cmd.exe' : '/bin/sh';
70
+ const shellArg = this.isWindows ? '/c' : '-c';
71
+
72
+ const child = spawn(shell, [shellArg, command], {
73
+ cwd: options.cwd || this.projectDir,
74
+ env: { ...process.env, ...options.env },
75
+ stdio: options.stream ? 'inherit' : 'pipe'
76
+ });
77
+
78
+ let stdout = '';
79
+ let stderr = '';
80
+
81
+ if (!options.stream) {
82
+ child.stdout.on('data', (data) => {
83
+ stdout += data.toString();
84
+ if (options.onStdout) options.onStdout(data.toString());
85
+ });
86
+
87
+ child.stderr.on('data', (data) => {
88
+ stderr += data.toString();
89
+ if (options.onStderr) options.onStderr(data.toString());
90
+ });
91
+ }
92
+
93
+ child.on('close', (code) => {
94
+ resolve({
95
+ code,
96
+ stdout: stdout.trim(),
97
+ stderr: stderr.trim(),
98
+ success: code === 0
99
+ });
100
+ });
101
+
102
+ child.on('error', (error) => {
103
+ reject(error);
104
+ });
105
+
106
+ // Handle timeout
107
+ if (options.timeout) {
108
+ setTimeout(() => {
109
+ child.kill('SIGTERM');
110
+ reject(new Error(`Command timed out after ${options.timeout}ms`));
111
+ }, options.timeout);
112
+ }
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Run a command with streaming output
118
+ */
119
+ runWithStream(command, options = {}) {
120
+ return this.run(command, { ...options, stream: true });
121
+ }
122
+
123
+ /**
124
+ * Run npm/pnpm/yarn command
125
+ */
126
+ async runPackageManager(args, options = {}) {
127
+ // Detect package manager
128
+ const fs = require('fs');
129
+
130
+ let pm = 'npm';
131
+ if (fs.existsSync(path.join(this.projectDir, 'pnpm-lock.yaml'))) {
132
+ pm = 'pnpm';
133
+ } else if (fs.existsSync(path.join(this.projectDir, 'yarn.lock'))) {
134
+ pm = 'yarn';
135
+ } else if (fs.existsSync(path.join(this.projectDir, 'bun.lockb'))) {
136
+ pm = 'bun';
137
+ }
138
+
139
+ return this.run(`${pm} ${args}`, options);
140
+ }
141
+
142
+ /**
143
+ * Install dependencies
144
+ */
145
+ async install(packages = [], options = {}) {
146
+ if (packages.length === 0) {
147
+ return this.runPackageManager('install', options);
148
+ }
149
+ return this.runPackageManager(`install ${packages.join(' ')}`, options);
150
+ }
151
+
152
+ /**
153
+ * Run a dev server
154
+ */
155
+ async runDev(options = {}) {
156
+ return this.runPackageManager('run dev', { ...options, stream: true });
157
+ }
158
+
159
+ /**
160
+ * Run build
161
+ */
162
+ async build(options = {}) {
163
+ return this.runPackageManager('run build', options);
164
+ }
165
+
166
+ /**
167
+ * Run tests
168
+ */
169
+ async test(options = {}) {
170
+ return this.runPackageManager('test', options);
171
+ }
172
+
173
+ /**
174
+ * Git operations
175
+ */
176
+ async git(args, options = {}) {
177
+ return this.run(`git ${args}`, options);
178
+ }
179
+
180
+ /**
181
+ * Get current git status
182
+ */
183
+ async gitStatus() {
184
+ try {
185
+ const result = await this.git('status --porcelain');
186
+ if (!result.success) {
187
+ return { isRepo: false, changes: [] };
188
+ }
189
+
190
+ const changes = result.stdout
191
+ .split('\n')
192
+ .filter(line => line.trim())
193
+ .map(line => ({
194
+ status: line.substring(0, 2).trim(),
195
+ file: line.substring(3)
196
+ }));
197
+
198
+ return { isRepo: true, changes };
199
+ } catch {
200
+ return { isRepo: false, changes: [] };
201
+ }
202
+ }
203
+ }
204
+
205
+ module.exports = CommandRunner;
@@ -0,0 +1,286 @@
1
+ /**
2
+ * Tab completion for Banana Code
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ class Completer {
9
+ constructor(projectDir, contextBuilder) {
10
+ this.projectDir = projectDir;
11
+ this.contextBuilder = contextBuilder;
12
+
13
+ // All built-in slash commands from handleCommand() switch
14
+ this.builtinCommands = [
15
+ '/help', '/?', '/update', '/version', '/v',
16
+ '/files', '/ls', '/read', '/add', '/unread', '/remove',
17
+ '/tree', '/find', '/grep', '/search',
18
+ '/image',
19
+ '/git', '/status', '/diff', '/log',
20
+ '/clear', '/clearhistory',
21
+ '/save', '/load', '/sessions',
22
+ '/context', '/tokens',
23
+ '/think', '/steer', '/steering',
24
+ '/ctx', '/compact', '/stream',
25
+ '/watch', '/yolo', '/agent',
26
+ '/hooks',
27
+ '/work', '/code', '/plan', '/implement', '/ask', '/mode',
28
+ '/prompt', '/model', '/models', '/connect', '/mcp',
29
+ '/config', '/set', '/instructions',
30
+ '/run', '/exec', '/$',
31
+ '/undo', '/backups', '/restore',
32
+ '/exit', '/quit', '/q',
33
+ '/commands'
34
+ ];
35
+
36
+ // Load custom commands from all locations, deduplicated
37
+ this.commands = [...new Set([
38
+ ...this.builtinCommands,
39
+ ...this._loadGlobalCommandNames(),
40
+ ...this._loadProjectCommandNames()
41
+ ])];
42
+
43
+ this.configKeys = [
44
+ 'compactMode', 'streamingEnabled', 'maxTokens',
45
+ 'tokenWarningThreshold', 'autoSaveHistory', 'geminiApiKey', 'steeringEnabled'
46
+ ];
47
+ }
48
+
49
+ complete(line) {
50
+ const trimmed = line.trim();
51
+
52
+ // Command completion
53
+ if (trimmed.startsWith('/')) {
54
+ return this.completeCommand(trimmed);
55
+ }
56
+
57
+ // @ file mention completion
58
+ if (trimmed.includes('@')) {
59
+ return this.completeFileMention(trimmed);
60
+ }
61
+
62
+ // Default: no completions
63
+ return [[], line];
64
+ }
65
+
66
+ completeCommand(line) {
67
+ const parts = line.split(/\s+/);
68
+ const cmd = parts[0].toLowerCase();
69
+
70
+ // If just "/" show all commands
71
+ if (cmd === '/') {
72
+ return [this.commands, '/'];
73
+ }
74
+
75
+ // If just the command, complete command names
76
+ if (parts.length === 1) {
77
+ const matches = this.commands.filter(c => c.startsWith(cmd));
78
+ return [matches, cmd];
79
+ }
80
+
81
+ // Command-specific completions
82
+ const arg = parts.slice(1).join(' ');
83
+
84
+ switch (cmd) {
85
+ case '/read':
86
+ case '/unread':
87
+ case '/restore':
88
+ return this.completeFilePath(arg, cmd + ' ');
89
+
90
+ case '/set':
91
+ if (parts.length === 2) {
92
+ const matches = this.configKeys.filter(k =>
93
+ k.toLowerCase().startsWith(arg.toLowerCase())
94
+ );
95
+ return [matches.map(m => `${cmd} ${m}`), line];
96
+ }
97
+ break;
98
+
99
+ case '/connect':
100
+ if (parts.length === 2) {
101
+ const options = ['anthropic', 'openai', 'openrouter', 'status', 'list', 'disconnect', 'use'];
102
+ const matches = options.filter(o => o.startsWith(arg.toLowerCase()));
103
+ return [matches.map(m => `${cmd} ${m}`), line];
104
+ }
105
+ if (parts.length === 3 && ['disconnect', 'use'].includes(parts[1].toLowerCase())) {
106
+ const providers = ['local', 'anthropic', 'openai', 'openrouter'];
107
+ const providerArg = parts[2].toLowerCase();
108
+ const matches = providers.filter(p => p.startsWith(providerArg));
109
+ return [matches.map(m => `${cmd} ${parts[1]} ${m}`), line];
110
+ }
111
+ break;
112
+
113
+ case '/model':
114
+ case '/models':
115
+ if (parts.length === 2) {
116
+ const options = ['search'];
117
+ const matches = options.filter(o => o.startsWith(arg.toLowerCase()));
118
+ return [matches.map(m => `${cmd} ${m}`), line];
119
+ }
120
+ break;
121
+
122
+ case '/think':
123
+ if (parts.length === 2) {
124
+ const options = ['off', 'low', 'medium', 'high'];
125
+ const matches = options.filter(o => o.startsWith(arg.toLowerCase()));
126
+ return [matches.map(m => `${cmd} ${m}`), line];
127
+ }
128
+ break;
129
+
130
+ case '/steer':
131
+ case '/steering':
132
+ if (parts.length === 2) {
133
+ const options = ['status', 'show', 'clear', 'on', 'off'];
134
+ const matches = options.filter(o => o.startsWith(arg.toLowerCase()));
135
+ return [matches.map(m => `${cmd} ${m}`), line];
136
+ }
137
+ break;
138
+
139
+ case '/hooks':
140
+ if (parts.length === 2) {
141
+ const options = ['list', 'add', 'remove', 'toggle', 'test'];
142
+ const matches = options.filter(o => o.startsWith(arg.toLowerCase()));
143
+ return [matches.map(m => `${cmd} ${m}`), line];
144
+ }
145
+ break;
146
+
147
+ case '/load':
148
+ return this.completeSessionName(arg, cmd + ' ');
149
+
150
+ case '/find':
151
+ case '/grep':
152
+ // No completion for search patterns
153
+ break;
154
+
155
+ case '/run':
156
+ return this.completeShellCommand(arg, cmd + ' ');
157
+ }
158
+
159
+ return [[], line];
160
+ }
161
+
162
+ completeFilePath(partial, prefix = '') {
163
+ try {
164
+ const searchDir = partial.includes('/')
165
+ ? path.dirname(partial)
166
+ : '.';
167
+
168
+ const searchBase = partial.includes('/')
169
+ ? path.basename(partial)
170
+ : partial;
171
+
172
+ const fullSearchDir = path.join(this.projectDir, searchDir);
173
+
174
+ if (!fs.existsSync(fullSearchDir)) {
175
+ return [[], prefix + partial];
176
+ }
177
+
178
+ const entries = fs.readdirSync(fullSearchDir, { withFileTypes: true });
179
+
180
+ const matches = entries
181
+ .filter(e => {
182
+ // Skip hidden and common ignores
183
+ if (e.name.startsWith('.')) return false;
184
+ if (e.name === 'node_modules') return false;
185
+ return e.name.toLowerCase().startsWith(searchBase.toLowerCase());
186
+ })
187
+ .map(e => {
188
+ const relativePath = searchDir === '.'
189
+ ? e.name
190
+ : path.join(searchDir, e.name).replace(/\\/g, '/');
191
+ return prefix + relativePath + (e.isDirectory() ? '/' : '');
192
+ })
193
+ .slice(0, 20); // Limit suggestions
194
+
195
+ return [matches, prefix + partial];
196
+ } catch {
197
+ return [[], prefix + partial];
198
+ }
199
+ }
200
+
201
+ completeFileMention(line) {
202
+ // Find the @ mention being typed
203
+ const atIndex = line.lastIndexOf('@');
204
+ if (atIndex === -1) return [[], line];
205
+
206
+ const beforeAt = line.slice(0, atIndex);
207
+ const afterAt = line.slice(atIndex + 1);
208
+
209
+ // Get file completions
210
+ const [completions] = this.completeFilePath(afterAt);
211
+
212
+ // Format as @ mentions
213
+ const matches = completions.map(c => beforeAt + '@' + c);
214
+
215
+ return [matches, line];
216
+ }
217
+
218
+ completeSessionName(partial, prefix = '') {
219
+ try {
220
+ const historyDir = path.join(this.projectDir, '.banana', 'history');
221
+ if (!fs.existsSync(historyDir)) return [[], prefix + partial];
222
+
223
+ const files = fs.readdirSync(historyDir)
224
+ .filter(f => f.endsWith('.json'))
225
+ .filter(f => f.toLowerCase().includes(partial.toLowerCase()))
226
+ .slice(0, 10);
227
+
228
+ return [files.map(f => prefix + f), prefix + partial];
229
+ } catch {
230
+ return [[], prefix + partial];
231
+ }
232
+ }
233
+
234
+ _loadGlobalCommandNames() {
235
+ const names = [];
236
+ const dirs = [
237
+ path.join(require('os').homedir(), '.banana', 'commands'),
238
+ path.join(require('os').homedir(), '.ripley', 'commands') // legacy
239
+ ];
240
+ for (const dir of dirs) {
241
+ try {
242
+ if (!fs.existsSync(dir)) continue;
243
+ for (const f of fs.readdirSync(dir)) {
244
+ if (f.endsWith('.md')) {
245
+ const cmd = '/' + f.replace('.md', '').toLowerCase();
246
+ if (!names.includes(cmd)) names.push(cmd);
247
+ }
248
+ }
249
+ } catch {}
250
+ }
251
+ return names;
252
+ }
253
+
254
+ _loadProjectCommandNames() {
255
+ try {
256
+ const commandsDir = path.join(this.projectDir, '.banana', 'commands');
257
+ if (!fs.existsSync(commandsDir)) return [];
258
+ return fs.readdirSync(commandsDir)
259
+ .filter(f => f.endsWith('.md'))
260
+ .map(f => '/' + f.replace('.md', '').toLowerCase())
261
+ .filter(cmd => !this.builtinCommands.includes(cmd)); // avoid duplicates
262
+ } catch {
263
+ return [];
264
+ }
265
+ }
266
+
267
+ completeShellCommand(partial, prefix = '') {
268
+ // Common commands
269
+ const commonCommands = [
270
+ 'npm install', 'npm run', 'npm test', 'npm start', 'npm run dev', 'npm run build',
271
+ 'pnpm install', 'pnpm run', 'pnpm test', 'pnpm dev', 'pnpm build',
272
+ 'yarn install', 'yarn', 'yarn test', 'yarn dev', 'yarn build',
273
+ 'git status', 'git add', 'git commit', 'git push', 'git pull', 'git log',
274
+ 'ls', 'dir', 'cd', 'mkdir', 'rm', 'cat', 'echo',
275
+ 'node', 'npx', 'tsx', 'ts-node'
276
+ ];
277
+
278
+ const matches = commonCommands
279
+ .filter(c => c.toLowerCase().startsWith(partial.toLowerCase()))
280
+ .map(c => prefix + c);
281
+
282
+ return [matches, prefix + partial];
283
+ }
284
+ }
285
+
286
+ module.exports = Completer;