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,226 @@
1
+ /**
2
+ * LM Studio API Client for Banana Code
3
+ * Direct connection to LM Studio - no middleware needed.
4
+ */
5
+
6
+ const DEFAULT_URL = 'http://localhost:1234';
7
+
8
+ class LmStudio {
9
+ constructor(options = {}) {
10
+ this.baseUrl = options.baseUrl || DEFAULT_URL;
11
+ this.completionsUrl = `${this.baseUrl}/v1/chat/completions`;
12
+ this.modelsUrl = `${this.baseUrl}/v1/models`;
13
+ }
14
+
15
+ /**
16
+ * Non-streaming chat completion
17
+ */
18
+ async chat(messages, options = {}) {
19
+ const body = {
20
+ messages,
21
+ temperature: options.temperature ?? 0.7,
22
+ max_tokens: options.maxTokens ?? 10000,
23
+ stream: false
24
+ };
25
+ if (options.topP !== undefined) body.top_p = options.topP;
26
+ if (options.repeatPenalty !== undefined) body.repeat_penalty = options.repeatPenalty;
27
+ if (options.model) body.model = options.model;
28
+ if (options.tools) {
29
+ body.tools = options.tools;
30
+ body.tool_choice = options.toolChoice ?? 'auto';
31
+ }
32
+ if (typeof options.thinking === 'number' && options.thinking > 0) {
33
+ body.thinking = { type: 'enabled', budget_tokens: options.thinking };
34
+ } else if (options.thinking === true) {
35
+ body.thinking = { type: 'enabled', budget_tokens: 1024 };
36
+ } else if (options.thinking === false || options.thinking === 0) {
37
+ body.thinking = { type: 'disabled' };
38
+ }
39
+
40
+ const timeoutSignal = AbortSignal.timeout(options.timeout ?? 300000);
41
+ const signals = [timeoutSignal];
42
+ if (options.signal) signals.push(options.signal);
43
+ const composedSignal = signals.length > 1 ? AbortSignal.any(signals) : timeoutSignal;
44
+
45
+ const fetchOptions = {
46
+ method: 'POST',
47
+ headers: { 'Content-Type': 'application/json' },
48
+ body: JSON.stringify(body),
49
+ signal: composedSignal
50
+ };
51
+
52
+ let response;
53
+ try {
54
+ response = await fetch(this.completionsUrl, fetchOptions);
55
+ } catch (err) {
56
+ if (err?.name === 'TimeoutError' || (err?.name === 'AbortError' && timeoutSignal.aborted)) {
57
+ throw new Error('LM Studio did not respond within 5 minutes. Check if the model is loaded and not stuck.');
58
+ }
59
+ throw err;
60
+ }
61
+
62
+ if (!response.ok) {
63
+ const error = await response.text();
64
+ throw new Error(`LM Studio error (${response.status}): ${error}`);
65
+ }
66
+
67
+ const data = await response.json();
68
+ return data;
69
+ }
70
+
71
+ /**
72
+ * Streaming chat completion - returns the raw Response for SSE processing
73
+ */
74
+ async chatStream(messages, options = {}) {
75
+ const body = {
76
+ messages,
77
+ temperature: options.temperature ?? 0.7,
78
+ max_tokens: options.maxTokens ?? 10000,
79
+ stream: true
80
+ };
81
+ if (options.topP !== undefined) body.top_p = options.topP;
82
+ if (options.repeatPenalty !== undefined) body.repeat_penalty = options.repeatPenalty;
83
+ if (options.model) body.model = options.model;
84
+ if (typeof options.thinking === 'number' && options.thinking > 0) {
85
+ body.thinking = { type: 'enabled', budget_tokens: options.thinking };
86
+ } else if (options.thinking === true) {
87
+ body.thinking = { type: 'enabled', budget_tokens: 1024 };
88
+ } else if (options.thinking === false || options.thinking === 0) {
89
+ body.thinking = { type: 'disabled' };
90
+ }
91
+
92
+ const timeoutSignal = AbortSignal.timeout(options.timeout ?? 300000);
93
+ const signals = [timeoutSignal];
94
+ if (options.signal) signals.push(options.signal);
95
+ const composedSignal = signals.length > 1 ? AbortSignal.any(signals) : timeoutSignal;
96
+
97
+ const fetchOptions = {
98
+ method: 'POST',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify(body),
101
+ signal: composedSignal
102
+ };
103
+
104
+ let response;
105
+ try {
106
+ response = await fetch(this.completionsUrl, fetchOptions);
107
+ } catch (err) {
108
+ if (err?.name === 'TimeoutError' || (err?.name === 'AbortError' && timeoutSignal.aborted)) {
109
+ throw new Error('LM Studio did not respond within 5 minutes. Check if the model is loaded and not stuck.');
110
+ }
111
+ throw err;
112
+ }
113
+
114
+ if (!response.ok) {
115
+ const error = await response.text();
116
+ throw new Error(`LM Studio error (${response.status}): ${error}`);
117
+ }
118
+
119
+ return response;
120
+ }
121
+
122
+ /**
123
+ * List available models from LM Studio
124
+ */
125
+ async listModels() {
126
+ try {
127
+ const response = await fetch(this.modelsUrl);
128
+ if (!response.ok) return [];
129
+ const data = await response.json();
130
+ return data.data || [];
131
+ } catch {
132
+ return [];
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Get the currently loaded model
138
+ */
139
+ async getLoadedModel() {
140
+ const models = await this.listModels();
141
+ return models.length > 0 ? models[0] : null;
142
+ }
143
+
144
+ /**
145
+ * Get all loaded model instances from LM Studio REST API.
146
+ * Returns array of { key, instanceId, displayName }
147
+ */
148
+ async getLoadedInstances() {
149
+ try {
150
+ const response = await fetch(`${this.baseUrl}/api/v1/models`, {
151
+ signal: AbortSignal.timeout(5000)
152
+ });
153
+ if (!response.ok) return [];
154
+ const data = await response.json();
155
+ const instances = [];
156
+ for (const model of data.models || []) {
157
+ for (const inst of model.loaded_instances || []) {
158
+ instances.push({ key: model.key, instanceId: inst.id, displayName: model.display_name });
159
+ }
160
+ }
161
+ return instances;
162
+ } catch {
163
+ return [];
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Load a model into LM Studio.
169
+ * @param {string} modelId - Model identifier (e.g., "qwen/qwen3-coder-30b")
170
+ * @param {Object} options - Optional load config (context_length, flash_attention, etc.)
171
+ * @returns {Object} - { instance_id, load_time_seconds, status }
172
+ */
173
+ async loadModel(modelId, options = {}) {
174
+ const body = { model: modelId };
175
+ if (options.contextLength) body.context_length = options.contextLength;
176
+ if (options.flashAttention !== undefined) body.flash_attention = options.flashAttention;
177
+
178
+ const response = await fetch(`${this.baseUrl}/api/v1/models/load`, {
179
+ method: 'POST',
180
+ headers: { 'Content-Type': 'application/json' },
181
+ body: JSON.stringify(body)
182
+ });
183
+
184
+ if (!response.ok) {
185
+ const error = await response.text();
186
+ throw new Error(`Failed to load model (${response.status}): ${error}`);
187
+ }
188
+
189
+ return await response.json();
190
+ }
191
+
192
+ /**
193
+ * Unload a model from LM Studio.
194
+ * @param {string} instanceId - The instance_id of the loaded model
195
+ */
196
+ async unloadModel(instanceId) {
197
+ const response = await fetch(`${this.baseUrl}/api/v1/models/unload`, {
198
+ method: 'POST',
199
+ headers: { 'Content-Type': 'application/json' },
200
+ body: JSON.stringify({ instance_id: instanceId })
201
+ });
202
+
203
+ if (!response.ok) {
204
+ const error = await response.text();
205
+ throw new Error(`Failed to unload model (${response.status}): ${error}`);
206
+ }
207
+
208
+ return await response.json();
209
+ }
210
+
211
+ /**
212
+ * Health check - can we reach LM Studio?
213
+ */
214
+ async isConnected() {
215
+ try {
216
+ const response = await fetch(this.modelsUrl, {
217
+ signal: AbortSignal.timeout(3000)
218
+ });
219
+ return response.ok;
220
+ } catch {
221
+ return false;
222
+ }
223
+ }
224
+ }
225
+
226
+ module.exports = LmStudio;
@@ -0,0 +1,423 @@
1
+ /**
2
+ * Markdown to Terminal renderer for Banana Code
3
+ *
4
+ * Renders Markdown syntax to ANSI-styled terminal output.
5
+ * Works with streaming by buffering incomplete patterns.
6
+ */
7
+
8
+ // ANSI escape codes
9
+ const ansi = {
10
+ reset: '\x1b[0m',
11
+ bold: '\x1b[1m',
12
+ dim: '\x1b[2m',
13
+ italic: '\x1b[3m',
14
+ underline: '\x1b[4m',
15
+ // Colors
16
+ cyan: '\x1b[36m',
17
+ yellow: '\x1b[33m',
18
+ banana: '\x1b[38;5;220m',
19
+ green: '\x1b[32m',
20
+ magenta: '\x1b[35m',
21
+ blue: '\x1b[34m',
22
+ gray: '\x1b[90m',
23
+ white: '\x1b[37m',
24
+ // Backgrounds
25
+ bgGray: '\x1b[48;5;236m',
26
+ bgDarkGray: '\x1b[48;5;234m',
27
+ };
28
+
29
+ // OSC 8 hyperlink helpers (Ctrl+Click in modern terminals)
30
+ const oscLink = (url, text) => `\x1b]8;;${url}\x07${ansi.underline}${ansi.blue}${text}${ansi.reset}\x1b]8;;\x07`;
31
+ const oscFileLink = (filePath, text) => {
32
+ // Convert to file:// URI for local file Ctrl+Click
33
+ const normalized = filePath.replace(/\\/g, '/');
34
+ const fileUrl = normalized.startsWith('/') ? `file://${normalized}` : `file:///${normalized}`;
35
+ return `\x1b]8;;${fileUrl}\x07${ansi.underline}${ansi.cyan}${text}${ansi.reset}\x1b]8;;\x07`;
36
+ };
37
+
38
+ // Patterns for detecting URLs and file paths
39
+ const URL_RE = /https?:\/\/[^\s)\]>]+/;
40
+ const FILE_PATH_RE = /(?:[A-Z]:\\|\.\/|\.\.\/|\/)[^\s:*?"<>|)]+\.[a-zA-Z0-9]+/;
41
+
42
+ class MarkdownRenderer {
43
+ constructor() {
44
+ this.buffer = '';
45
+ this.inCodeBlock = false;
46
+ this.codeBlockLang = '';
47
+ this.inInlineCode = false;
48
+ this.lineStart = true;
49
+ this.tableRows = [];
50
+ }
51
+
52
+ /**
53
+ * Process streaming text and return rendered output
54
+ * Buffers incomplete Markdown patterns
55
+ */
56
+ render(text) {
57
+ this.buffer += text;
58
+ let output = '';
59
+ let processed = 0;
60
+
61
+ while (processed < this.buffer.length) {
62
+ const remaining = this.buffer.slice(processed);
63
+
64
+ // Handle code blocks (```)
65
+ if (remaining.startsWith('```')) {
66
+ // Look for end of opening line or closing ```
67
+ if (this.inCodeBlock) {
68
+ // Closing code block
69
+ output += ansi.reset;
70
+ output += `\n${ansi.dim}└${'─'.repeat(39)}${ansi.reset}\n`;
71
+ this.inCodeBlock = false;
72
+ this.codeBlockLang = '';
73
+ processed += 3;
74
+ // Skip newline after closing ```
75
+ if (this.buffer[processed] === '\n') processed++;
76
+ this.lineStart = true;
77
+ continue;
78
+ } else {
79
+ // Opening code block - find the language and newline
80
+ const newlineIdx = remaining.indexOf('\n');
81
+ if (newlineIdx === -1) {
82
+ // Wait for more input
83
+ break;
84
+ }
85
+ this.codeBlockLang = remaining.slice(3, newlineIdx).trim();
86
+ this.inCodeBlock = true;
87
+ output += `${ansi.dim}┌─${this.codeBlockLang ? ` ${this.codeBlockLang} ` : ''}${'─'.repeat(Math.max(0, 36 - (this.codeBlockLang?.length || 0)))}${ansi.reset}\n`;
88
+ output += ansi.cyan;
89
+ processed += newlineIdx + 1;
90
+ this.lineStart = true;
91
+ continue;
92
+ }
93
+ }
94
+
95
+ // Inside code block - output directly with styling
96
+ if (this.inCodeBlock) {
97
+ const char = this.buffer[processed];
98
+ if (char === '\n') {
99
+ output += ansi.reset + '\n' + ansi.cyan;
100
+ this.lineStart = true;
101
+ } else {
102
+ if (this.lineStart) {
103
+ output += ansi.dim + '│ ' + ansi.reset + ansi.cyan;
104
+ this.lineStart = false;
105
+ }
106
+ output += char;
107
+ }
108
+ processed++;
109
+ continue;
110
+ }
111
+
112
+ // Check for potential incomplete patterns at end of buffer
113
+ if (processed === this.buffer.length - 1 || processed === this.buffer.length - 2) {
114
+ // Could be start of ``` or ** or __ or `
115
+ const endChars = remaining;
116
+ if (endChars === '`' || endChars === '``' ||
117
+ endChars === '*' || endChars === '**' ||
118
+ endChars === '_' || endChars === '__' ||
119
+ endChars === '#') {
120
+ // Wait for more input
121
+ break;
122
+ }
123
+ }
124
+
125
+ // Inline code (`)
126
+ if (remaining[0] === '`' && !remaining.startsWith('```')) {
127
+ const endIdx = remaining.indexOf('`', 1);
128
+ const newlineIdx = remaining.indexOf('\n', 1);
129
+ // Inline code should not span across newlines. If a newline appears
130
+ // before a closing backtick, treat this as a literal backtick.
131
+ if (newlineIdx !== -1 && (endIdx === -1 || newlineIdx < endIdx)) {
132
+ output += '`';
133
+ processed++;
134
+ this.lineStart = false;
135
+ continue;
136
+ }
137
+ if (endIdx === -1) {
138
+ // Don't stall rendering forever on a stray/unbalanced backtick.
139
+ if (remaining.length > 120) {
140
+ output += '`';
141
+ processed++;
142
+ this.lineStart = false;
143
+ continue;
144
+ }
145
+ // Wait for closing backtick
146
+ break;
147
+ }
148
+ const code = remaining.slice(1, endIdx);
149
+ output += `${ansi.banana}${code}${ansi.reset}`;
150
+ processed += endIdx + 1;
151
+ this.lineStart = false;
152
+ continue;
153
+ }
154
+
155
+ // Markdown links [text](url)
156
+ if (remaining[0] === '[') {
157
+ const linkMatch = remaining.match(/^\[([^\]]+)\]\(([^)]+)\)/);
158
+ if (linkMatch) {
159
+ const linkText = linkMatch[1];
160
+ const linkUrl = linkMatch[2];
161
+ if (linkUrl.startsWith('http://') || linkUrl.startsWith('https://')) {
162
+ output += oscLink(linkUrl, linkText);
163
+ } else {
164
+ // Assume file path
165
+ output += oscFileLink(linkUrl, linkText);
166
+ }
167
+ processed += linkMatch[0].length;
168
+ this.lineStart = false;
169
+ continue;
170
+ }
171
+ }
172
+
173
+ // Bare URLs (https://... or http://...)
174
+ if ((remaining.startsWith('https://') || remaining.startsWith('http://')) && !this.inCodeBlock) {
175
+ const urlMatch = remaining.match(URL_RE);
176
+ if (urlMatch) {
177
+ const url = urlMatch[0];
178
+ output += oscLink(url, url);
179
+ processed += url.length;
180
+ this.lineStart = false;
181
+ continue;
182
+ }
183
+ }
184
+
185
+ // File paths (C:\..., ./..., ../..., /...) outside code blocks
186
+ if (!this.inCodeBlock) {
187
+ const fileMatch = remaining.match(FILE_PATH_RE);
188
+ if (fileMatch && fileMatch.index === 0) {
189
+ const filePath = fileMatch[0];
190
+ output += oscFileLink(filePath, filePath);
191
+ processed += filePath.length;
192
+ this.lineStart = false;
193
+ continue;
194
+ }
195
+ }
196
+
197
+ // Bold (**text** or __text__)
198
+ if (remaining.startsWith('**') || remaining.startsWith('__')) {
199
+ const marker = remaining.slice(0, 2);
200
+ const endIdx = remaining.indexOf(marker, 2);
201
+ if (endIdx === -1) {
202
+ // Wait for closing marker
203
+ break;
204
+ }
205
+ const boldText = remaining.slice(2, endIdx);
206
+ output += `${ansi.bold}${ansi.banana}${boldText}${ansi.reset}`;
207
+ processed += endIdx + 2;
208
+ this.lineStart = false;
209
+ continue;
210
+ }
211
+
212
+ // Italic (*text* or _text_) - but not ** or __
213
+ if ((remaining[0] === '*' || remaining[0] === '_') &&
214
+ remaining[1] !== '*' && remaining[1] !== '_' && remaining[1] !== ' ') {
215
+ const marker = remaining[0];
216
+ const endIdx = remaining.indexOf(marker, 1);
217
+ if (endIdx === -1 || endIdx === 1) {
218
+ // Not italic, just output the character
219
+ output += remaining[0];
220
+ processed++;
221
+ this.lineStart = false;
222
+ continue;
223
+ }
224
+ const italicText = remaining.slice(1, endIdx);
225
+ output += `${ansi.italic}${italicText}${ansi.reset}`;
226
+ processed += endIdx + 1;
227
+ this.lineStart = false;
228
+ continue;
229
+ }
230
+
231
+ // Headers at line start (# ## ### etc.)
232
+ if (this.lineStart && remaining[0] === '#') {
233
+ // Wait for complete line before parsing header
234
+ if (!remaining.includes('\n') && remaining.length < 100) {
235
+ break;
236
+ }
237
+ const match = remaining.match(/^(#{1,6})\s+(.*)\n/);
238
+ if (match) {
239
+ const level = match[1].length;
240
+ const headerText = match[2];
241
+ const colors = [ansi.banana, ansi.banana, ansi.yellow, ansi.magenta, ansi.blue, ansi.white];
242
+ output += `${ansi.bold}${colors[level - 1] || ansi.white}${headerText}${ansi.reset}\n\n`;
243
+ processed += match[0].length;
244
+ this.lineStart = true;
245
+ continue;
246
+ }
247
+ }
248
+
249
+ // Bullet points at line start
250
+ if (this.lineStart && (remaining.startsWith('- ') || remaining.startsWith('* ') || remaining.startsWith('• '))) {
251
+ output += `${ansi.cyan}•${ansi.reset} `;
252
+ processed += 2;
253
+ this.lineStart = false;
254
+ continue;
255
+ }
256
+
257
+ // Numbered lists at line start
258
+ if (this.lineStart) {
259
+ const numMatch = remaining.match(/^(\d+)\.\s/);
260
+ if (numMatch) {
261
+ output += `${ansi.cyan}${numMatch[1]}.${ansi.reset} `;
262
+ processed += numMatch[0].length;
263
+ this.lineStart = false;
264
+ continue;
265
+ }
266
+ }
267
+
268
+ // Horizontal rules (---, ***, ___)
269
+ if (this.lineStart && remaining.length >= 3) {
270
+ const hrMatch = remaining.match(/^([-*_])\1{2,}\s*\n/);
271
+ if (hrMatch) {
272
+ output += `${ansi.dim}${'─'.repeat(44)}${ansi.reset}\n`;
273
+ processed += hrMatch[0].length;
274
+ this.lineStart = true;
275
+ continue;
276
+ }
277
+ // Buffer if potential HR but no newline yet
278
+ if (/^([-*_])\1{2,}\s*$/.test(remaining)) {
279
+ break;
280
+ }
281
+ }
282
+
283
+ // Table rows (|...|)
284
+ if (this.lineStart && remaining[0] === '|') {
285
+ const newlineIdx = remaining.indexOf('\n');
286
+ if (newlineIdx === -1) {
287
+ // Wait for complete line
288
+ break;
289
+ }
290
+ const line = remaining.slice(0, newlineIdx);
291
+ if (line.endsWith('|')) {
292
+ this.tableRows.push(line);
293
+ processed += newlineIdx + 1;
294
+ this.lineStart = true;
295
+ continue;
296
+ }
297
+ }
298
+
299
+ // If we had buffered table rows but the current line isn't a table row, flush them
300
+ if (this.tableRows.length > 0) {
301
+ output += this._renderTable(this.tableRows);
302
+ this.tableRows = [];
303
+ }
304
+
305
+ // Regular character
306
+ const char = this.buffer[processed];
307
+ output += char;
308
+ processed++;
309
+ if (char === '\n') {
310
+ this.lineStart = true;
311
+ // Check for paragraph break (double newline) - add extra spacing
312
+ if (this.buffer[processed] === '\n') {
313
+ output += '\n'; // Extra line for paragraph spacing
314
+ processed++;
315
+ }
316
+ } else {
317
+ this.lineStart = false;
318
+ }
319
+ }
320
+
321
+ // Keep unprocessed buffer for next call
322
+ this.buffer = this.buffer.slice(processed);
323
+ return output;
324
+ }
325
+
326
+ /**
327
+ * Render buffered table rows into aligned columns
328
+ */
329
+ _renderTable(rows) {
330
+ if (rows.length === 0) return '';
331
+
332
+ // Parse rows into cells
333
+ const parsed = [];
334
+ const separatorIndices = [];
335
+ for (let i = 0; i < rows.length; i++) {
336
+ const cells = rows[i].split('|').slice(1, -1).map(c => c.trim());
337
+ // Detect separator rows (|---|---|)
338
+ if (cells.every(c => /^[-:]+$/.test(c))) {
339
+ separatorIndices.push(i);
340
+ } else {
341
+ parsed.push(cells);
342
+ }
343
+ }
344
+
345
+ if (parsed.length === 0) return '';
346
+
347
+ // Calculate column widths (capped at 30)
348
+ const colCount = Math.max(...parsed.map(r => r.length));
349
+ const widths = [];
350
+ for (let col = 0; col < colCount; col++) {
351
+ let max = 0;
352
+ for (const row of parsed) {
353
+ const cell = row[col] || '';
354
+ max = Math.max(max, cell.length);
355
+ }
356
+ widths.push(Math.min(max, 30));
357
+ }
358
+
359
+ let output = '';
360
+ for (let i = 0; i < parsed.length; i++) {
361
+ const row = parsed[i];
362
+ const isHeader = (i === 0 && separatorIndices.includes(1));
363
+ let line = ' ';
364
+ for (let col = 0; col < colCount; col++) {
365
+ let cell = (row[col] || '').slice(0, 30);
366
+ cell = cell.padEnd(widths[col]);
367
+ if (col > 0) line += ' ';
368
+ if (isHeader) {
369
+ line += `${ansi.bold}${ansi.banana}${cell}${ansi.reset}`;
370
+ } else {
371
+ line += cell;
372
+ }
373
+ }
374
+ output += line + '\n';
375
+
376
+ // Add separator line after header
377
+ if (isHeader) {
378
+ let sep = ' ';
379
+ for (let col = 0; col < colCount; col++) {
380
+ if (col > 0) sep += ' ';
381
+ sep += `${ansi.dim}${'─'.repeat(widths[col])}${ansi.reset}`;
382
+ }
383
+ output += sep + '\n';
384
+ }
385
+ }
386
+ return output;
387
+ }
388
+
389
+ /**
390
+ * Flush remaining buffer (call at end of stream)
391
+ */
392
+ flush() {
393
+ let output = '';
394
+
395
+ // Flush any buffered table
396
+ if (this.tableRows.length > 0) {
397
+ output += this._renderTable(this.tableRows);
398
+ this.tableRows = [];
399
+ }
400
+
401
+ output += this.buffer;
402
+
403
+ // Close any open code block
404
+ if (this.inCodeBlock) {
405
+ output += ansi.reset;
406
+ }
407
+
408
+ this.buffer = '';
409
+ this.inCodeBlock = false;
410
+ this.lineStart = true;
411
+ return output;
412
+ }
413
+ }
414
+
415
+ /**
416
+ * Render a complete Markdown string (non-streaming)
417
+ */
418
+ function renderMarkdown(text) {
419
+ const renderer = new MarkdownRenderer();
420
+ return renderer.render(text) + renderer.flush();
421
+ }
422
+
423
+ module.exports = { MarkdownRenderer, renderMarkdown };