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
package/lib/lmStudio.js
ADDED
|
@@ -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 };
|