banana-code 1.3.1 → 1.4.1

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/banana.js CHANGED
@@ -81,7 +81,7 @@ let pendingHumanQuestion = null; // { resolve, question }
81
81
  // CONFIGURATION
82
82
  // =============================================================================
83
83
 
84
- const VERSION = '1.3.1';
84
+ const VERSION = '1.4.0';
85
85
  const { PAD } = require('./lib/borderRenderer'); // Single source of truth for left padding
86
86
  const DEBUG_DISABLED_VALUES = new Set(['0', 'false', 'off', 'no']);
87
87
  const NEXT_TURN_RESERVE_TOKENS = 1200;
@@ -389,7 +389,7 @@ ${P}${c.yellow}/hooks${c.reset} Manage lifecycle hooks (add, edit,
389
389
  ${P}${c.yellow}/steer <text>${c.reset} Steer next turn (or interrupt + redirect current turn)
390
390
  ${P}${c.yellow}/model [name]${c.reset} Show/switch model
391
391
  ${P}${c.yellow}/model search <query>${c.reset} Search OpenRouter models and add one
392
- ${P}${c.yellow}/connect [provider]${c.reset} Connect provider (Anthropic, OpenAI OAuth, OpenRouter)
392
+ ${P}${c.yellow}/connect [provider]${c.reset} Connect/disconnect providers (Anthropic, OpenAI, OpenRouter, Claude Code)
393
393
  ${P}${c.yellow}/prompt [name]${c.reset} Show/switch prompt (base, code-agent, or any .md)
394
394
 
395
395
  ${P}${c.banana}${c.dim}Config Commands:${c.reset}
@@ -442,6 +442,15 @@ function initProject() {
442
442
  tokenCounter = new TokenCounter(config);
443
443
  imageHandler = new ImageHandler(projectDir);
444
444
 
445
+ const lastRunSnapshot = config.getRunSnapshot();
446
+ if (lastRunSnapshot && lastRunSnapshot.completed === false) {
447
+ const when = lastRunSnapshot.savedAt ? new Date(lastRunSnapshot.savedAt).toLocaleString() : 'recently';
448
+ console.log(`${PAD}${c.yellow}⚠ Previous run appears to have ended unexpectedly.${c.reset} ${c.dim}(${when})${c.reset}`);
449
+ if (lastRunSnapshot.userMessage) {
450
+ console.log(`${PAD}${c.dim} Last request: ${String(lastRunSnapshot.userMessage).slice(0, 120)}${c.reset}`);
451
+ }
452
+ }
453
+
445
454
  // Initialize LM Studio + provider manager
446
455
  const lmStudioUrl = config.get('lmStudioUrl') || 'http://localhost:1234';
447
456
  lmStudio = new LmStudio({ baseUrl: lmStudioUrl });
@@ -913,6 +922,7 @@ function normalizeProviderKey(raw) {
913
922
  const key = String(raw || '').trim().toLowerCase();
914
923
  if (!key) return null;
915
924
  if (key === 'lmstudio' || key === 'local') return 'local';
925
+ if (key === 'claude' || key === 'claudecode' || key === 'claude-code') return 'claude-code';
916
926
  if (PROVIDERS.includes(key)) return key;
917
927
  return null;
918
928
  }
@@ -958,7 +968,11 @@ function buildFullSystemPrompt(promptMode = resolveActivePromptMode()) {
958
968
  // Build cache key from prompt mode + instruction file modification times
959
969
  const globalInstructions = globalConfig ? globalConfig.getInstructions() : null;
960
970
  const instructions = config ? config.getInstructions() : null;
961
- const cacheKey = `${promptMode}|${globalInstructions?.source || ''}|${instructions?.source || ''}`;
971
+ // Include file mtimes so edits mid-session invalidate the cache
972
+ function safeMtime(filePath) {
973
+ try { return filePath ? fs.statSync(filePath).mtimeMs : 0; } catch { return 0; }
974
+ }
975
+ const cacheKey = `${promptMode}|${globalInstructions?.source || ''}:${safeMtime(globalInstructions?.source)}|${instructions?.source || ''}:${safeMtime(instructions?.source)}`;
962
976
 
963
977
  if (_systemPromptCache.key === cacheKey) {
964
978
  return _systemPromptCache.value;
@@ -966,11 +980,23 @@ function buildFullSystemPrompt(promptMode = resolveActivePromptMode()) {
966
980
 
967
981
  const systemPrompt = promptManager ? promptManager.get(promptMode) : '';
968
982
  let fullSystemPrompt = systemPrompt || '';
983
+
984
+ // Strip HTML comments and collapse blank lines from instruction files to avoid wasting tokens
985
+ // on placeholder templates that users haven't edited yet
986
+ function cleanInstructions(raw) {
987
+ const stripped = raw.replace(/<!--[\s\S]*?-->/g, '').replace(/\n{3,}/g, '\n\n').trim();
988
+ // If only headings and whitespace remain, treat as empty (unedited template)
989
+ const withoutHeadings = stripped.replace(/^#+\s+.*$/gm, '').trim();
990
+ return withoutHeadings ? stripped : '';
991
+ }
992
+
969
993
  if (globalInstructions) {
970
- fullSystemPrompt += `\n\n## Global Instructions\n\n${globalInstructions.content}`;
994
+ const cleaned = cleanInstructions(globalInstructions.content);
995
+ if (cleaned) fullSystemPrompt += `\n\n## Global Instructions\n\n${cleaned}`;
971
996
  }
972
997
  if (instructions) {
973
- fullSystemPrompt += `\n\n## Project Instructions\n\n${instructions.content}`;
998
+ const cleaned = cleanInstructions(instructions.content);
999
+ if (cleaned) fullSystemPrompt += `\n\n## Project Instructions\n\n${cleaned}`;
974
1000
  }
975
1001
 
976
1002
  _systemPromptCache = { key: cacheKey, value: fullSystemPrompt };
@@ -1301,24 +1327,85 @@ function listProviderStatus() {
1301
1327
 
1302
1328
  async function connectProviderInteractive(provider) {
1303
1329
  if (!provider) {
1304
- const providerItems = PROVIDERS.map(p => ({
1305
- key: p,
1306
- label: PROVIDER_LABELS[p] || p,
1307
- description: p === 'openai'
1308
- ? 'OAuth device login for Codex subscription'
1309
- : 'Connect with API key',
1310
- tags: ['provider'],
1311
- active: providerStore.isConnected(p)
1312
- }));
1313
- const selected = await pick(providerItems, { title: 'Connect Provider' });
1330
+ const providerItems = PROVIDERS.filter(p => p !== 'monkey').map(p => {
1331
+ const connected = providerStore.isConnected(p);
1332
+ let description;
1333
+ if (connected) {
1334
+ description = 'Connected';
1335
+ } else if (p === 'openai') {
1336
+ description = 'OAuth device login for Codex subscription';
1337
+ } else if (p === 'claude-code') {
1338
+ description = 'Use your Claude Code CLI subscription (no API key needed)';
1339
+ } else {
1340
+ description = 'Connect with API key';
1341
+ }
1342
+ return {
1343
+ key: p,
1344
+ label: PROVIDER_LABELS[p] || p,
1345
+ description,
1346
+ tags: [],
1347
+ active: connected
1348
+ };
1349
+ });
1350
+ const selected = await pick(providerItems, { title: 'Manage Providers' });
1314
1351
  if (!selected) {
1315
1352
  console.log(`${PAD}${c.dim}Cancelled${c.reset}\n`);
1316
1353
  return;
1317
1354
  }
1318
1355
  provider = selected.key;
1356
+
1357
+ // If already connected, offer to disconnect
1358
+ if (providerStore.isConnected(provider)) {
1359
+ const providerLabel = PROVIDER_LABELS[provider] || provider;
1360
+ const answer = await askQuestion(`${PAD}${c.yellow}Disconnect ${providerLabel}? (y/N): ${c.reset}`);
1361
+ if (answer && answer.trim().toLowerCase() === 'y') {
1362
+ const wasActiveProvider = (modelRegistry.getCurrentModel()?.provider || 'local') === provider;
1363
+ providerStore.disconnect(provider);
1364
+ modelRegistry.refreshRemoteModels();
1365
+ if (wasActiveProvider) {
1366
+ const fallback = modelRegistry.getDefault();
1367
+ if (fallback) {
1368
+ await switchModel(fallback);
1369
+ } else {
1370
+ console.log(`${PAD}${c.yellow}No fallback model available.${c.reset}`);
1371
+ }
1372
+ }
1373
+ console.log(`${PAD}${c.green}✓ Disconnected ${providerLabel}${c.reset}\n`);
1374
+ } else {
1375
+ console.log(`${PAD}${c.dim}Cancelled${c.reset}\n`);
1376
+ }
1377
+ return;
1378
+ }
1319
1379
  }
1320
1380
  const providerLabel = PROVIDER_LABELS[provider] || provider;
1321
1381
 
1382
+ // If called with an explicit provider arg and it's already connected, offer disconnect or reconnect
1383
+ if (providerStore.isConnected(provider)) {
1384
+ console.log(`\n${PAD}${c.cyan}${providerLabel} is already connected.${c.reset}`);
1385
+ const answer = await askQuestion(`${PAD}${c.yellow}Disconnect (d), reconnect with new key (r), or cancel (Enter): ${c.reset}`);
1386
+ const choice = (answer || '').trim().toLowerCase();
1387
+ if (choice === 'd') {
1388
+ const wasActiveProvider = (modelRegistry.getCurrentModel()?.provider || 'local') === provider;
1389
+ providerStore.disconnect(provider);
1390
+ modelRegistry.refreshRemoteModels();
1391
+ if (wasActiveProvider) {
1392
+ const fallback = modelRegistry.getDefault();
1393
+ if (fallback) {
1394
+ await switchModel(fallback);
1395
+ } else {
1396
+ console.log(`${PAD}${c.yellow}No fallback model available.${c.reset}`);
1397
+ }
1398
+ }
1399
+ console.log(`${PAD}${c.green}✓ Disconnected ${providerLabel}${c.reset}\n`);
1400
+ return;
1401
+ } else if (choice === 'r') {
1402
+ // Fall through to the connect flow below to re-enter credentials
1403
+ } else {
1404
+ console.log(`${PAD}${c.dim}Cancelled${c.reset}\n`);
1405
+ return;
1406
+ }
1407
+ }
1408
+
1322
1409
  if (provider === 'anthropic' || provider === 'openrouter') {
1323
1410
  const label = provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENROUTER_API_KEY';
1324
1411
  console.log(`\n${PAD}${c.cyan}${providerLabel} Connection${c.reset}`);
@@ -1351,6 +1438,29 @@ async function connectProviderInteractive(provider) {
1351
1438
  return;
1352
1439
  }
1353
1440
 
1441
+ if (provider === 'claude-code') {
1442
+ console.log(`\n${PAD}${c.cyan}Claude Code CLI Connection${c.reset}`);
1443
+ console.log(`${PAD}${c.dim}Checking for Claude Code CLI...${c.reset}`);
1444
+
1445
+ const { ClaudeCodeClient } = require('./lib/claudeCodeProvider');
1446
+ const claudeClient = new ClaudeCodeClient();
1447
+ const connected = await claudeClient.isConnected();
1448
+
1449
+ if (!connected) {
1450
+ console.log(`${PAD}${c.red}✗ Claude Code CLI not found.${c.reset}`);
1451
+ console.log(`${PAD}${c.dim}Install it: npm install -g @anthropic-ai/claude-code${c.reset}`);
1452
+ console.log(`${PAD}${c.dim}Then run: claude login${c.reset}\n`);
1453
+ return;
1454
+ }
1455
+
1456
+ providerStore.connectClaudeCode();
1457
+ modelRegistry.refreshRemoteModels();
1458
+ console.log(`${PAD}${c.green}✓ Connected Claude Code CLI${c.reset}`);
1459
+ console.log(`${PAD}${c.dim}Uses your existing Claude subscription (no API key needed).${c.reset}`);
1460
+ console.log(`${PAD}${c.dim}Use /model to switch to Claude Code models.${c.reset}\n`);
1461
+ return;
1462
+ }
1463
+
1354
1464
  throw new Error(`Unsupported provider: ${provider}`);
1355
1465
  }
1356
1466
 
@@ -1589,9 +1699,15 @@ async function handleCommand(input) {
1589
1699
  tokenCounter.resetSession();
1590
1700
  imageHandler.clearPending();
1591
1701
  setContextEstimate(0);
1702
+ promptDuringWork = false;
1703
+ renderWorkingPrompt = null;
1704
+ if (rl) {
1705
+ rl.write(null, { ctrl: true, name: 'u' });
1706
+ }
1592
1707
  if (statusBar) {
1708
+ statusBar.setInputHint('');
1593
1709
  statusBar.update({ sessionIn: 0, sessionOut: 0 });
1594
- statusBar.reinstall();
1710
+ statusBar.uninstall();
1595
1711
  }
1596
1712
  console.clear();
1597
1713
  // Push cursor to bottom of scroll region so prompt isn't stranded at the top
@@ -1603,6 +1719,9 @@ async function handleCommand(input) {
1603
1719
  if (padding > 0) process.stdout.write('\n'.repeat(padding));
1604
1720
  }
1605
1721
  refreshIdleContextEstimate();
1722
+ if (statusBar) {
1723
+ statusBar.reinstall();
1724
+ }
1606
1725
  if (rl) {
1607
1726
  rl.setPrompt(buildPromptPrefix());
1608
1727
  rl.prompt(false);
@@ -2009,13 +2128,52 @@ async function handleCommand(input) {
2009
2128
  await modelRegistry.refreshLmStudio();
2010
2129
  const models = modelRegistry.list();
2011
2130
  const current = modelRegistry.getCurrent();
2012
- const pickerItems = models.map(m => ({
2013
- key: m.key,
2014
- label: m.name,
2015
- description: m.description || '',
2016
- tags: m.tags || [],
2017
- active: m.key === current
2018
- }));
2131
+
2132
+ // Group models by provider for clarity
2133
+ const providerOrder = ['monkey', 'claude-code', 'anthropic', 'openai', 'openrouter', 'local'];
2134
+ const providerHints = {
2135
+ monkey: 'Banana Cloud',
2136
+ 'claude-code': 'Claude Code CLI',
2137
+ anthropic: 'your API key',
2138
+ openai: 'your API key',
2139
+ openrouter: 'your API key',
2140
+ local: 'local'
2141
+ };
2142
+
2143
+ // Sort models by provider group order, then by name within each group
2144
+ const sorted = [...models].sort((a, b) => {
2145
+ const aIdx = providerOrder.indexOf(a.provider || 'local');
2146
+ const bIdx = providerOrder.indexOf(b.provider || 'local');
2147
+ const aOrder = aIdx >= 0 ? aIdx : providerOrder.length;
2148
+ const bOrder = bIdx >= 0 ? bIdx : providerOrder.length;
2149
+ if (aOrder !== bOrder) return aOrder - bOrder;
2150
+ return (a.name || '').localeCompare(b.name || '');
2151
+ });
2152
+
2153
+ // Build picker items with provider context in description
2154
+ const pickerItems = sorted.map(m => {
2155
+ const provider = m.provider || 'local';
2156
+ const providerLabel = PROVIDER_LABELS[provider] || provider;
2157
+ const hint = providerHints[provider] || provider;
2158
+ const via = `${providerLabel} (${hint})`;
2159
+ const hasProviderInfo = m.description && m.description.includes(' via ');
2160
+ let baseDesc = (m.description && m.description !== m.name) ? m.description : '';
2161
+ if (baseDesc.length > 30) baseDesc = baseDesc.slice(0, 28) + '..';
2162
+ const desc = hasProviderInfo
2163
+ ? `${m.description} (${hint})`
2164
+ : baseDesc
2165
+ ? `${baseDesc} [${hint}]`
2166
+ : `[${hint}]`;
2167
+ // Pass vision tag through for the V indicator column, but drop the rest to keep lines short
2168
+ const visionTags = (m.tags || []).includes('vision') ? ['vision'] : [];
2169
+ return {
2170
+ key: m.key,
2171
+ label: m.name,
2172
+ description: desc,
2173
+ tags: visionTags,
2174
+ active: m.key === current
2175
+ };
2176
+ });
2019
2177
 
2020
2178
  const selected = await pick(pickerItems, {
2021
2179
  title: 'Switch Model',
@@ -2117,8 +2275,8 @@ async function handleCommand(input) {
2117
2275
 
2118
2276
  if (normalizedSub === 'disconnect') {
2119
2277
  const provider = normalizeProviderKey(secondArg);
2120
- if (!provider || provider === 'local') {
2121
- console.log(`\n${PAD}${c.yellow}Usage: /connect disconnect <anthropic|openai|openrouter>${c.reset}\n`);
2278
+ if (!provider || provider === 'local' || provider === 'monkey') {
2279
+ console.log(`\n${PAD}${c.yellow}Usage: /connect disconnect <anthropic|openai|openrouter|claude-code>${c.reset}\n`);
2122
2280
  return true;
2123
2281
  }
2124
2282
  const wasActiveProvider = (modelRegistry.getCurrentModel()?.provider || 'local') === provider;
@@ -2135,7 +2293,7 @@ async function handleCommand(input) {
2135
2293
  if (normalizedSub === 'use') {
2136
2294
  const provider = normalizeProviderKey(secondArg);
2137
2295
  if (!provider) {
2138
- console.log(`\n${PAD}${c.yellow}Usage: /connect use <local|anthropic|openai|openrouter>${c.reset}\n`);
2296
+ console.log(`\n${PAD}${c.yellow}Usage: /connect use <local|anthropic|openai|openrouter|claude-code>${c.reset}\n`);
2139
2297
  return true;
2140
2298
  }
2141
2299
 
@@ -2160,7 +2318,7 @@ async function handleCommand(input) {
2160
2318
  if (!provider || provider === 'local') {
2161
2319
  console.log(`\n${PAD}${c.yellow}Usage:${c.reset}`);
2162
2320
  console.log(`${PAD}${c.dim} /connect${c.reset}`);
2163
- console.log(`${PAD}${c.dim} /connect <anthropic|openai|openrouter>${c.reset}`);
2321
+ console.log(`${PAD}${c.dim} /connect <anthropic|openai|openrouter|claude-code>${c.reset}`);
2164
2322
  console.log(`${PAD}${c.dim} /connect status${c.reset}`);
2165
2323
  console.log(`${PAD}${c.dim} /connect disconnect <provider>${c.reset}`);
2166
2324
  console.log(`${PAD}${c.dim} /connect use <local|provider>${c.reset}\n`);
@@ -2684,9 +2842,19 @@ async function sendMessage(message) {
2684
2842
  fullMessage += '\n\n[Image analysis above is primary source of truth. Focus on image content, not file listing.]';
2685
2843
  }
2686
2844
 
2845
+ config.saveRunSnapshot({
2846
+ projectDir,
2847
+ activeModel: modelRegistry.getCurrent(),
2848
+ userMessage: message,
2849
+ fullMessagePreview: fullMessage.slice(0, 2000),
2850
+ conversationLength: conversationHistory.length
2851
+ });
2852
+
2687
2853
  try {
2688
2854
  await sendAgenticMessage(fullMessage, pendingImages, message);
2855
+ config.completeRunSnapshot({ status: 'completed' });
2689
2856
  } catch (error) {
2857
+ config.completeRunSnapshot({ status: 'failed', error: error.message });
2690
2858
  const provider = activeProviderKey();
2691
2859
  const providerLabel = providerManager.getProviderLabel(provider);
2692
2860
  console.log(`\n${PAD}${c.red}✗ Error: ${error.message}${c.reset}`);
@@ -2985,6 +3153,11 @@ async function sendStreamingMessage(message, images = [], rawMessage = '') {
2985
3153
 
2986
3154
  try {
2987
3155
  await streamHandler.handleStream(response);
3156
+ const streamResult = streamHandler.getResult();
3157
+ if (!streamResult.completed && streamResult.warning) {
3158
+ fullResponse = `${streamResult.warning}\n\n${fullResponse}`.trim();
3159
+ console.log(`\n${PAD}${c.yellow}⚠ ${streamResult.warning}${c.reset}`);
3160
+ }
2988
3161
  } catch (error) {
2989
3162
  stopStatus();
2990
3163
  // Check if this was an abort
@@ -5172,10 +5345,12 @@ Examples:
5172
5345
  // then subsequent lines arrive as new 'line' events. We detect paste by
5173
5346
  // buffering lines that arrive within PASTE_DELAY_MS of each other.
5174
5347
  const PASTE_DELAY_MS = 400; // 400ms to handle large pastes and Windows Terminal dialog latency
5348
+ const PASTE_STRAGGLER_WINDOW_MS = 1200; // Late lines can arrive after submit on Windows Terminal
5175
5349
  let pasteBuffer = [];
5176
5350
  let pasteTimer = null;
5177
5351
  let waitingForInput = false;
5178
5352
  let lastFlushTime = 0; // Track when paste buffer last flushed (to catch stragglers)
5353
+ let lastPasteStragglerWarningAt = 0;
5179
5354
 
5180
5355
  showGeminiKeyPrompt = (callback) => {
5181
5356
  awaitingGeminiKey = true;
@@ -5342,13 +5517,17 @@ Examples:
5342
5517
  return;
5343
5518
  }
5344
5519
 
5345
- // Straggler paste lines: arrived after flush but within 2s and before AI started.
5346
- // This happens on Windows when the paste confirmation dialog adds latency between lines.
5347
- if (!currentAbortController && lastFlushTime && (Date.now() - lastFlushTime) < 2000) {
5520
+ // Straggler paste lines: arrived after flush but before the paste has fully settled.
5521
+ // On Windows Terminal, delayed lines can arrive after the first chunk was submitted,
5522
+ // and without this guard they'd be misread as mid-turn steering.
5523
+ if (lastFlushTime && (Date.now() - lastFlushTime) < PASTE_STRAGGLER_WINDOW_MS) {
5348
5524
  const trimmed = String(input || '').trim();
5349
5525
  if (trimmed) {
5350
5526
  appendDebugLog(`[paste-straggler] Dropped line arrived ${Date.now() - lastFlushTime}ms after flush: ${trimmed.slice(0, 60)}\n`);
5351
- console.log(`${PAD}${c.yellow}Lines were dropped from your paste.${c.reset} ${c.dim}Try pasting again, or disable the paste warning in Windows Terminal settings.${c.reset}`);
5527
+ if (lastPasteStragglerWarningAt !== lastFlushTime) {
5528
+ lastPasteStragglerWarningAt = lastFlushTime;
5529
+ console.log(`${PAD}${c.yellow}Ignored delayed paste lines from the previous submission.${c.reset} ${c.dim}If this keeps happening, disable the Windows Terminal paste warning or paste again after the prompt settles.${c.reset}`);
5530
+ }
5352
5531
  }
5353
5532
  return;
5354
5533
  }
@@ -5476,6 +5655,9 @@ process.on('SIGINT', () => {
5476
5655
  if (config && config.get('autoSaveHistory') && conversationHistory.length > 0) {
5477
5656
  config.saveConversation('autosave', conversationHistory);
5478
5657
  }
5658
+ if (config) {
5659
+ config.completeRunSnapshot({ status: 'cancelled' });
5660
+ }
5479
5661
  if (watcher) watcher.stop();
5480
5662
  console.log(`\n${PAD}${c.cyan}👋 See you later!${c.reset}\n`);
5481
5663
  process.exit(0);
@@ -5484,6 +5666,12 @@ process.on('SIGINT', () => {
5484
5666
  main().catch(error => {
5485
5667
  logSessionEnd('crash', ` error=${error.message}`);
5486
5668
  if (statusBar) statusBar.uninstall();
5669
+ if (config && config.get('autoSaveHistory') && conversationHistory.length > 0) {
5670
+ config.saveConversation('autosave-crash', conversationHistory);
5671
+ }
5672
+ if (config) {
5673
+ config.completeRunSnapshot({ status: 'crashed', error: error.message });
5674
+ }
5487
5675
  console.error(`${c.red}Fatal error: ${error.message}${c.reset}`);
5488
5676
  if (watcher) watcher.stop();
5489
5677
  process.exit(1);