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,283 @@
1
+ /**
2
+ * statusBar.js - Pinned status bar at the bottom of the terminal
3
+ *
4
+ * Uses a scroll region to constrain content above the bar, plus cursor
5
+ * save/restore to paint a 3-row bar (rule + content + pad) in the
6
+ * reserved bottom rows, with a blank spacer row above it so the input
7
+ * prompt is not visually cramped. The bar is rendered on demand (updates, prompt,
8
+ * resize) to avoid cursor races while typing.
9
+ */
10
+
11
+ const RESET = '\x1b[0m';
12
+ const DIM = '\x1b[2m';
13
+ const GREEN = '\x1b[32m';
14
+ const YELLOW = '\x1b[33m';
15
+ const RED = '\x1b[31m';
16
+ const BG_DARK = '\x1b[48;5;236m';
17
+ const FG_LIGHT = '\x1b[38;5;252m';
18
+ const BORDER_COLOR = '\x1b[38;5;240m';
19
+ const SEP = `${DIM} \u2502 ${RESET}`;
20
+
21
+ const BAR_HEIGHT = 4;
22
+ const GAP_ROWS = 1;
23
+ class StatusBar {
24
+ constructor() {
25
+ this._installed = false;
26
+ this._lastRendered = '';
27
+ this._timerStart = null;
28
+ this._responseTime = null;
29
+ this._inputHint = ''; // Dim text shown in gap row during active work
30
+ this._fields = {
31
+ modelName: '?',
32
+ modelId: '',
33
+ mode: 'work',
34
+ contextPct: 0,
35
+ contextTokens: 0,
36
+ contextLimit: 0,
37
+ mcpConnected: false,
38
+ sessionIn: 0,
39
+ sessionOut: 0,
40
+ };
41
+ }
42
+
43
+ /** Install: set scroll region, start repaint interval, listen for resize */
44
+ install() {
45
+ if (!process.stdout.isTTY) return;
46
+ if (this._installed) {
47
+ this._lastRendered = '';
48
+ this._applyScrollRegion();
49
+ this._renderBar();
50
+ return;
51
+ }
52
+ this._installed = true;
53
+ this._applyScrollRegion();
54
+ this._renderBar();
55
+ this._resizeTimer = null;
56
+ this._resizeHandler = () => {
57
+ // Debounce resize to avoid racing with readline's own resize handling.
58
+ // Without this, rapid resize events cause the scroll region, cursor
59
+ // position, and prompt to fight each other, producing garbled output.
60
+ if (this._resizeTimer) clearTimeout(this._resizeTimer);
61
+ this._resizeTimer = setTimeout(() => {
62
+ this._resizeTimer = null;
63
+ this._lastRendered = '';
64
+ this._applyScrollRegion();
65
+ this._renderBar();
66
+ this._paintGapRow();
67
+ }, 80);
68
+ };
69
+ process.stdout.on('resize', this._resizeHandler);
70
+ }
71
+
72
+ /** Uninstall: stop repaint, reset scroll region, clear bar */
73
+ uninstall() {
74
+ if (!process.stdout.isTTY) return;
75
+ this._installed = false;
76
+ if (this._resizeTimer) {
77
+ clearTimeout(this._resizeTimer);
78
+ this._resizeTimer = null;
79
+ }
80
+ if (this._resizeHandler) {
81
+ process.stdout.removeListener('resize', this._resizeHandler);
82
+ this._resizeHandler = null;
83
+ }
84
+ // Reset scroll region to full terminal
85
+ process.stdout.write('\x1b[r');
86
+ // Clear the reserved rows (gap + bar)
87
+ const rows = process.stdout.rows || 24;
88
+ const reserved = BAR_HEIGHT + GAP_ROWS;
89
+ const clearStart = Math.max(1, rows - reserved + 1);
90
+ for (let r = clearStart; r <= rows; r++) {
91
+ process.stdout.write(`\x1b[${r};1H\x1b[2K`);
92
+ }
93
+ }
94
+
95
+ /** Reinstall after console.clear() */
96
+ reinstall() {
97
+ if (!process.stdout.isTTY) return;
98
+ this._installed = true;
99
+ this._lastRendered = '';
100
+ this._applyScrollRegion();
101
+ this._renderBar();
102
+ }
103
+
104
+ /** Partial field update + re-render */
105
+ update(fields) {
106
+ Object.assign(this._fields, fields);
107
+ if (this._installed) this._renderBar();
108
+ }
109
+
110
+ /** Start wall-clock response timer */
111
+ startTiming() {
112
+ this._timerStart = Date.now();
113
+ this._responseTime = null;
114
+ }
115
+
116
+ /** Stop timer and re-render */
117
+ stopTiming() {
118
+ if (this._timerStart) {
119
+ this._responseTime = ((Date.now() - this._timerStart) / 1000).toFixed(1);
120
+ this._timerStart = null;
121
+ if (this._installed) this._renderBar();
122
+ }
123
+ }
124
+
125
+ /** Show or clear the input hint in the gap row above the bar */
126
+ setInputHint(text) {
127
+ this._inputHint = text || '';
128
+ if (this._installed) this._paintGapRow();
129
+ }
130
+
131
+ /** Force a render (called from showPrompt) */
132
+ render() {
133
+ if (this._installed) {
134
+ this._lastRendered = '';
135
+ this._renderBar();
136
+ this._paintGapRow();
137
+ }
138
+ }
139
+
140
+ /** Constrain scrollable area to rows above the bar.
141
+ * Uses cursor save/restore so the cursor stays wherever it was before. */
142
+ _applyScrollRegion() {
143
+ const rows = process.stdout.rows || 24;
144
+ const reserved = BAR_HEIGHT + GAP_ROWS;
145
+ const scrollEnd = Math.max(1, rows - reserved);
146
+ // Save cursor, set scroll region, restore cursor.
147
+ // This prevents the scroll region reset from moving the cursor.
148
+ process.stdout.write(`\x1b[s\x1b[1;${scrollEnd}r\x1b[u`);
149
+ }
150
+
151
+ /** Force-repaint the bar. Safe to call frequently (e.g. after every
152
+ * clearLine/cursorTo in spinner code). Does NOT reapply the scroll
153
+ * region since clearLine/cursorTo don't reset it. */
154
+ refresh() {
155
+ if (!process.stdout.isTTY || !this._installed) return;
156
+ this._lastRendered = '';
157
+ this._renderBar();
158
+ }
159
+
160
+ /** Paint the status bar rows at the bottom of the terminal */
161
+ _renderBar() {
162
+ if (!process.stdout.isTTY || !this._installed) return;
163
+
164
+ const cols = process.stdout.columns || 80;
165
+ const rows = process.stdout.rows || 24;
166
+ const f = this._fields;
167
+
168
+ // Build content segments
169
+ const segments = [];
170
+
171
+ const modelDisplay = f.modelId
172
+ ? `${f.modelName} (${f.modelId})`
173
+ : f.modelName;
174
+ segments.push(modelDisplay);
175
+
176
+ // Mode indicator
177
+ const modeColors = { work: GREEN, plan: '\x1b[36m', ask: '\x1b[35m' };
178
+ const modeColor = modeColors[f.mode] || GREEN;
179
+ segments.push(`${modeColor}${(f.mode || 'work').toUpperCase()}${RESET}${BG_DARK}${FG_LIGHT}`);
180
+
181
+ const ctxColor = f.contextPct >= 80 ? RED : f.contextPct >= 50 ? YELLOW : GREEN;
182
+ const ctxTokensStr = this._formatTokens(f.contextTokens);
183
+ const ctxLimitStr = this._formatTokens(f.contextLimit);
184
+ const ctxDisplay = f.contextLimit
185
+ ? `ctx: ${ctxColor}${f.contextPct}%${RESET}${BG_DARK}${FG_LIGHT} (${ctxTokensStr}/${ctxLimitStr})`
186
+ : `ctx: ${ctxColor}${f.contextPct}%${RESET}${BG_DARK}${FG_LIGHT}`;
187
+ segments.push(ctxDisplay);
188
+
189
+ const mcpDot = f.mcpConnected
190
+ ? `${GREEN}\u25CF${RESET}${BG_DARK}${FG_LIGHT}`
191
+ : `${RED}\u25CF${RESET}${BG_DARK}${FG_LIGHT}`;
192
+ segments.push(`MCP: ${mcpDot}`);
193
+
194
+ const inStr = this._formatTokens(f.sessionIn);
195
+ const outStr = this._formatTokens(f.sessionOut);
196
+ segments.push(`in:${inStr} out:${outStr}`);
197
+
198
+ if (this._responseTime) {
199
+ segments.push(`${this._responseTime}s`);
200
+ }
201
+
202
+ // Progressive collapse for narrow terminals
203
+ let bar = this._joinSegments(segments);
204
+ let barVisualLen = this._visualLen(bar);
205
+
206
+ if (barVisualLen > cols - 4 && this._responseTime) {
207
+ segments.pop();
208
+ bar = this._joinSegments(segments);
209
+ barVisualLen = this._visualLen(bar);
210
+ }
211
+ if (barVisualLen > cols - 4 && f.modelId) {
212
+ segments[0] = f.modelName;
213
+ bar = this._joinSegments(segments);
214
+ barVisualLen = this._visualLen(bar);
215
+ }
216
+ if (barVisualLen > cols - 4) {
217
+ // Remove MCP segment (index 3: model, mode, ctx, MCP, in/out)
218
+ const mcpIdx = segments.findIndex(s => s.startsWith('MCP:'));
219
+ if (mcpIdx !== -1) segments.splice(mcpIdx, 1);
220
+ bar = this._joinSegments(segments);
221
+ barVisualLen = this._visualLen(bar);
222
+ }
223
+
224
+ // Build the 4 rows: rule, top pad, content, bottom pad
225
+ const rule = `${BORDER_COLOR}${'\u2500'.repeat(cols)}${RESET}`;
226
+ const contentPad = Math.max(0, cols - barVisualLen - 1);
227
+ const content = `${BG_DARK}${FG_LIGHT} ${bar}${' '.repeat(contentPad)}${RESET}`;
228
+ const padRow = `${BG_DARK}${' '.repeat(cols)}${RESET}`;
229
+
230
+ const fullBar = rule + padRow + content + padRow;
231
+ if (fullBar === this._lastRendered) return;
232
+ this._lastRendered = fullBar;
233
+
234
+ const barStartRow = rows - BAR_HEIGHT + 1;
235
+
236
+ // Save cursor, draw bar rows, restore cursor.
237
+ let paint = '\x1b[s';
238
+ paint +=
239
+ `\x1b[${barStartRow};1H\x1b[2K` + rule + // row 1: rule
240
+ `\x1b[${barStartRow + 1};1H\x1b[2K` + padRow + // row 2: top pad
241
+ `\x1b[${barStartRow + 2};1H\x1b[2K` + content + // row 3: content
242
+ `\x1b[${barStartRow + 3};1H\x1b[2K` + padRow + // row 4: bottom pad
243
+ '\x1b[u';
244
+ process.stdout.write(paint);
245
+ }
246
+
247
+ /** Paint the gap row above the bar with the input hint (or blank) */
248
+ _paintGapRow() {
249
+ if (!process.stdout.isTTY || !this._installed) return;
250
+ const rows = process.stdout.rows || 24;
251
+ const cols = process.stdout.columns || 80;
252
+ const gapRow = rows - BAR_HEIGHT - GAP_ROWS + 1;
253
+ if (gapRow < 1) return;
254
+
255
+ let rowContent;
256
+ if (this._inputHint) {
257
+ const hint = this._inputHint.length > cols - 2
258
+ ? this._inputHint.slice(0, cols - 2)
259
+ : this._inputHint;
260
+ rowContent = `${DIM} ${hint}${RESET}`;
261
+ } else {
262
+ rowContent = '';
263
+ }
264
+ process.stdout.write(`\x1b[s\x1b[${gapRow};1H\x1b[2K${rowContent}\x1b[u`);
265
+ }
266
+
267
+ _joinSegments(segments) {
268
+ return segments.join(`${RESET}${BG_DARK}${FG_LIGHT}${SEP}${BG_DARK}${FG_LIGHT}`);
269
+ }
270
+
271
+ _formatTokens(n) {
272
+ if (!n || n === 0) return '0';
273
+ if (n < 1000) return String(n);
274
+ return (n / 1000).toFixed(1) + 'K';
275
+ }
276
+
277
+ _visualLen(str) {
278
+ // eslint-disable-next-line no-control-regex
279
+ return str.replace(/\x1b\[[0-9;]*m/g, '').length;
280
+ }
281
+ }
282
+
283
+ module.exports = StatusBar;
@@ -0,0 +1,306 @@
1
+ /**
2
+ * Streaming response handler for Banana Code
3
+ *
4
+ * Filters out <file_operation>, <run_command>, and <think> blocks during streaming
5
+ * so users only see the explanation text. The full response (with XML)
6
+ * is still captured for parsing after streaming completes.
7
+ *
8
+ * Also handles "orphan" </think> tags where the model outputs reasoning
9
+ * WITHOUT an opening <think> tag (common with Qwen models).
10
+ */
11
+
12
+ // Strip model control tokens like <|start|>, <|channel|>, <|constrain|>, <|end|>, etc.
13
+ // These leak from models like GPT-OSS, Llama, and others.
14
+ const CONTROL_TOKEN_RE = /<\|[^|]*\|>/g;
15
+ function stripControlTokens(text) {
16
+ const cleaned = text.replace(CONTROL_TOKEN_RE, '');
17
+ // If the line was entirely control tokens + whitespace, return empty
18
+ return cleaned.replace(/^\s+$/, '');
19
+ }
20
+
21
+ class StreamHandler {
22
+ constructor(options = {}) {
23
+ this.onToken = options.onToken || (() => {});
24
+ this.onComplete = options.onComplete || (() => {});
25
+ this.onError = options.onError || (() => {});
26
+ this.onBlockProgress = options.onBlockProgress || null; // (type, charsSoFar) - called while inside suppressed XML blocks
27
+ this.buffer = '';
28
+ this.fullResponse = '';
29
+
30
+ // For filtering XML blocks during display
31
+ this.displayBuffer = '';
32
+ this.insideXmlBlock = false;
33
+ this.xmlBlockType = null; // 'file_operation', 'run_command', or 'think'
34
+
35
+ // For detecting orphan </think> tags (model reasoning without opening tag)
36
+ // Buffer output until we confirm there's no orphan </think> coming
37
+ this.orphanBuffer = '';
38
+ this.orphanCheckComplete = false; // Once true, stop buffering for orphan check
39
+ }
40
+
41
+ /**
42
+ * Process content for display, filtering out XML blocks
43
+ * Returns only the text that should be shown to the user
44
+ *
45
+ * Handles two cases:
46
+ * 1. Normal XML blocks: <think>...</think>, <file_operation>...</file_operation>, etc.
47
+ * 2. Orphan closing tags: Model outputs reasoning without <think> tag, only </think> at end
48
+ */
49
+ filterForDisplay(content) {
50
+ this.displayBuffer += content;
51
+
52
+ // Regex patterns
53
+ const openTagRegex = /<\s*(file_operation|run_command|think|thinking)\s*>[\r\n]*/i;
54
+ const closeFileOp = /<\s*\/\s*file_operation\s*>/i;
55
+ const closeRunCmd = /<\s*\/\s*run_command\s*>/i;
56
+ const closeThink = /<\s*\/\s*think(?:ing)?\s*>/i;
57
+
58
+ let output = '';
59
+
60
+ while (this.displayBuffer.length > 0) {
61
+ if (this.insideXmlBlock) {
62
+ // Look for closing tag based on block type
63
+ let closeRegex;
64
+ if (this.xmlBlockType === 'file_operation') {
65
+ closeRegex = closeFileOp;
66
+ } else if (this.xmlBlockType === 'run_command') {
67
+ closeRegex = closeRunCmd;
68
+ } else {
69
+ closeRegex = closeThink;
70
+ }
71
+ const closeMatch = this.displayBuffer.match(closeRegex);
72
+
73
+ if (closeMatch) {
74
+ // Found closing tag, skip everything up to and including it
75
+ // Use displayBuffer length as the final count (displayBuffer has accumulated content since block open)
76
+ if (this.onBlockProgress) this.onBlockProgress(this.xmlBlockType, this.displayBuffer.length, true);
77
+ const closeEnd = closeMatch.index + closeMatch[0].length;
78
+ this.displayBuffer = this.displayBuffer.slice(closeEnd);
79
+ this.insideXmlBlock = false;
80
+ this.xmlBlockType = null;
81
+ } else {
82
+ // Still inside block, keep buffering
83
+ // displayBuffer contains all content since block opened, so use its length directly
84
+ if (this.onBlockProgress) this.onBlockProgress(this.xmlBlockType, this.displayBuffer.length, false);
85
+ break;
86
+ }
87
+ } else {
88
+ // Not inside a block - check for orphan </think> first (before checking opening tags)
89
+ if (!this.orphanCheckComplete) {
90
+ // Check if we have an orphan </think> (closing tag without opening)
91
+ const orphanCloseMatch = this.displayBuffer.match(closeThink);
92
+ const openThinkMatch = this.displayBuffer.match(/<\s*(think|thinking)\s*>/i);
93
+
94
+ if (orphanCloseMatch) {
95
+ // Found </think> - check if there's an opening tag before it
96
+ if (!openThinkMatch || openThinkMatch.index > orphanCloseMatch.index) {
97
+ // Orphan! Discard everything up to and including </think>
98
+ const closeEnd = orphanCloseMatch.index + orphanCloseMatch[0].length;
99
+ let remaining = this.displayBuffer.slice(closeEnd);
100
+ // Also strip any leading whitespace/newlines after </think>
101
+ remaining = remaining.replace(/^[\s\r\n]+/, '');
102
+ this.displayBuffer = remaining;
103
+ this.orphanCheckComplete = true;
104
+ // Also discard anything we had buffered
105
+ this.orphanBuffer = '';
106
+ continue; // Re-process remaining buffer
107
+ }
108
+ }
109
+
110
+ // Check if buffer might still receive </think>
111
+ // If we see content that looks like actual response (not reasoning), stop checking
112
+ // Heuristic: If buffer is > 500 chars with no </think>, assume no orphan
113
+ if (this.displayBuffer.length > 500) {
114
+ this.orphanCheckComplete = true;
115
+ // Flush orphan buffer
116
+ output += this.orphanBuffer;
117
+ this.orphanBuffer = '';
118
+ }
119
+ }
120
+
121
+ // Look for opening tag
122
+ const openMatch = this.displayBuffer.match(openTagRegex);
123
+
124
+ if (openMatch) {
125
+ // Found opening tag - mark orphan check complete if it's a think tag
126
+ const blockType = openMatch[1].toLowerCase();
127
+ if (blockType === 'think' || blockType === 'thinking') {
128
+ this.orphanCheckComplete = true;
129
+ output += this.orphanBuffer;
130
+ this.orphanBuffer = '';
131
+ }
132
+
133
+ // Output everything before the tag
134
+ const beforeTag = this.displayBuffer.slice(0, openMatch.index);
135
+ if (this.orphanCheckComplete) {
136
+ output += beforeTag;
137
+ } else {
138
+ this.orphanBuffer += beforeTag;
139
+ }
140
+
141
+ // Skip past the opening tag
142
+ this.displayBuffer = this.displayBuffer.slice(openMatch.index + openMatch[0].length);
143
+ this.insideXmlBlock = true;
144
+ this.xmlBlockType = blockType === 'thinking' ? 'think' : blockType;
145
+ } else {
146
+ // No complete opening tag found
147
+ // Check if buffer ends with a partial tag
148
+ const partialMatch = this.displayBuffer.match(/<[^>]{0,20}$/);
149
+
150
+ if (partialMatch) {
151
+ // Output everything before the potential partial tag
152
+ const beforePartial = this.displayBuffer.slice(0, partialMatch.index);
153
+ if (this.orphanCheckComplete) {
154
+ output += beforePartial;
155
+ } else {
156
+ this.orphanBuffer += beforePartial;
157
+ }
158
+ this.displayBuffer = this.displayBuffer.slice(partialMatch.index);
159
+ break;
160
+ } else {
161
+ // Safe to output everything
162
+ if (this.orphanCheckComplete) {
163
+ output += this.displayBuffer;
164
+ } else {
165
+ this.orphanBuffer += this.displayBuffer;
166
+ }
167
+ this.displayBuffer = '';
168
+ }
169
+ }
170
+ }
171
+ }
172
+
173
+ return stripControlTokens(output);
174
+ }
175
+
176
+ async handleStream(response) {
177
+ const reader = response.body.getReader();
178
+ const decoder = new TextDecoder();
179
+ const IDLE_TIMEOUT = 30000; // 30 seconds with no data = assume done (LM Studio needs time after prompt processing)
180
+
181
+ try {
182
+ while (true) {
183
+ // Race between read and timeout
184
+ const readPromise = reader.read();
185
+ const timeoutPromise = new Promise((_, reject) =>
186
+ setTimeout(() => reject(new Error('IDLE_TIMEOUT')), IDLE_TIMEOUT)
187
+ );
188
+
189
+ let done, value;
190
+ try {
191
+ const result = await Promise.race([readPromise, timeoutPromise]);
192
+ done = result.done;
193
+ value = result.value;
194
+ } catch (e) {
195
+ if (e.message === 'IDLE_TIMEOUT') {
196
+ // No data for 5 seconds, assume stream is complete
197
+ break;
198
+ }
199
+ throw e;
200
+ }
201
+
202
+ if (done) {
203
+ break;
204
+ }
205
+
206
+ const chunk = decoder.decode(value, { stream: true });
207
+ this.buffer += chunk;
208
+
209
+ // Process SSE data
210
+ const lines = this.buffer.split('\n');
211
+ this.buffer = lines.pop() || ''; // Keep incomplete line in buffer
212
+
213
+ for (const line of lines) {
214
+ let content = '';
215
+
216
+ if (line.startsWith('data: ')) {
217
+ const data = line.slice(6);
218
+
219
+ if (data === '[DONE]') {
220
+ // Stream complete signal - exit the loop
221
+ this.onComplete(this.fullResponse);
222
+ return this.fullResponse;
223
+ }
224
+
225
+ try {
226
+ const parsed = JSON.parse(data);
227
+ // Try multiple possible response formats
228
+ content = parsed.choices?.[0]?.delta?.content
229
+ || parsed.choices?.[0]?.text
230
+ || parsed.content
231
+ || parsed.delta?.content
232
+ || parsed.text
233
+ || '';
234
+ } catch {
235
+ // Not valid JSON, treat as raw text
236
+ content = data;
237
+ }
238
+ } else if (line.trim() && !line.startsWith(':')) {
239
+ // Raw text line (not SSE comment)
240
+ content = line;
241
+ }
242
+
243
+ if (content) {
244
+ this.fullResponse += content;
245
+ // Filter out XML blocks for display
246
+ const displayContent = this.filterForDisplay(content);
247
+ if (displayContent) {
248
+ this.onToken(displayContent);
249
+ }
250
+ }
251
+ }
252
+ }
253
+
254
+ // Process any remaining buffer
255
+ if (this.buffer) {
256
+ const lines = this.buffer.split('\n');
257
+ for (const line of lines) {
258
+ if (line.startsWith('data: ') && line.slice(6) !== '[DONE]') {
259
+ try {
260
+ const parsed = JSON.parse(line.slice(6));
261
+ // Try multiple possible response formats
262
+ const content = parsed.choices?.[0]?.delta?.content
263
+ || parsed.choices?.[0]?.text
264
+ || parsed.content
265
+ || parsed.delta?.content
266
+ || parsed.text
267
+ || '';
268
+ if (content) {
269
+ this.fullResponse += content;
270
+ // Filter out XML blocks for display
271
+ const displayContent = this.filterForDisplay(content);
272
+ if (displayContent) {
273
+ this.onToken(displayContent);
274
+ }
275
+ }
276
+ } catch {
277
+ // Ignore
278
+ }
279
+ }
280
+ }
281
+ }
282
+
283
+ this.onComplete(this.fullResponse);
284
+ return this.fullResponse;
285
+
286
+ } catch (error) {
287
+ this.onError(error);
288
+ throw error;
289
+ }
290
+ }
291
+
292
+ getFullResponse() {
293
+ return this.fullResponse;
294
+ }
295
+ }
296
+
297
+ // Non-streaming fallback with simulated streaming effect
298
+ async function simulateStreaming(text, onToken, delay = 5) {
299
+ const words = text.split(/(\s+)/);
300
+ for (const word of words) {
301
+ onToken(word);
302
+ await new Promise(resolve => setTimeout(resolve, delay));
303
+ }
304
+ }
305
+
306
+ module.exports = { StreamHandler, simulateStreaming };