erosolar-cli 1.7.116 → 1.7.118

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.
@@ -1,390 +1,298 @@
1
1
  /**
2
- * PersistentChatBox - Enhanced version of PinnedChatBox that remains visible during AI streaming
2
+ * Persistent Chat Box - Stays at bottom during AI streaming
3
3
  *
4
- * Key improvements:
5
- * - Always visible at bottom, even during AI processing/streaming
6
- * - Graceful handling of long pastes with summarization
7
- * - Only submits to AI when user explicitly hits Enter
8
- * - Enhanced paste detection and management
4
+ * Features:
5
+ * - Always visible at bottom of terminal
6
+ * - Supports typing while AI is streaming
7
+ * - Gracefully handles long pastes without errors
8
+ * - Only submits to AI when user hits enter key
9
+ * - Integrates with existing multiline paste handler
9
10
  */
11
+ import readline from 'node:readline';
12
+ import { stdin as input, stdout as output } from 'node:process';
10
13
  import { theme } from './theme.js';
11
- import { ANSI } from './ansi.js';
14
+ import { processPaste, isMultilinePaste } from '../core/multilinePasteHandler.js';
15
+ import { display } from './display.js';
12
16
  export class PersistentChatBox {
13
- writeStream;
17
+ rl;
18
+ config;
14
19
  state;
15
- reservedLines = 3; // Lines reserved at bottom for chat box
16
- _lastRenderedHeight = 0;
17
- inputBuffer = '';
18
- cursorPosition = 0;
19
- commandIdCounter = 0;
20
- onCommandQueued;
21
- onInputSubmit;
22
- renderScheduled = false;
23
- isEnabled = true;
24
- isDisposed = false;
25
- lastRenderTime = 0;
26
- renderThrottleMs = 16; // ~60fps max
27
- maxInputLength = 10000; // Prevent memory issues
28
- maxQueueSize = 100; // Prevent queue overflow
29
- ansiPattern = /[\u001B\u009B][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
30
- oscPattern = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g;
31
- maxStatusMessageLength = 200;
32
- // Scroll region management for persistent bottom input
33
- scrollRegionActive = false;
34
- // Input history for up/down navigation
35
- inputHistory = [];
36
- historyIndex = -1;
37
- tempCurrentInput = ''; // Stores current input when navigating history
38
- maxHistorySize = 100;
39
- // Enhanced paste tracking for summary display
40
- isPastedBlock = false;
41
- pastedFullContent = '';
42
- pasteStartTime = 0;
43
- pasteLineCount = 0;
44
- maxPasteLines = 50;
45
- /** Cleanup function for output interceptor registration */
46
- outputInterceptorCleanup;
47
- // Track if we're currently in streaming mode
48
- isStreaming = false;
49
- constructor(writeStream, options = {}) {
50
- this.writeStream = writeStream;
51
- this.onCommandQueued = options.onCommandQueued;
52
- this.onInputSubmit = options.onInputSubmit;
53
- this.maxInputLength = options.maxInputLength ?? this.maxInputLength;
54
- this.maxQueueSize = options.maxQueueSize ?? this.maxQueueSize;
20
+ onSubmit;
21
+ onCancel;
22
+ isActive = false;
23
+ originalPrompt = '';
24
+ pasteBuffer = '';
25
+ pasteMode = false;
26
+ constructor(onSubmit, onCancel, config = {}) {
27
+ this.onSubmit = onSubmit;
28
+ this.onCancel = onCancel;
29
+ this.config = {
30
+ enabled: true,
31
+ position: 1,
32
+ maxLines: 3,
33
+ showPasteSummaries: true,
34
+ autoScroll: true,
35
+ ...config,
36
+ };
55
37
  this.state = {
56
- isProcessing: false,
57
- queuedCommands: [],
58
- currentInput: '',
59
- contextUsage: 0,
60
- statusMessage: null,
61
- isVisible: true,
38
+ input: '',
39
+ isStreaming: false,
40
+ isTyping: false,
41
+ isPasteMode: false,
42
+ cursorPosition: 0,
43
+ history: [],
44
+ historyIndex: -1,
62
45
  };
46
+ // Create readline interface with custom prompt handling
47
+ this.rl = readline.createInterface({
48
+ input,
49
+ output,
50
+ prompt: '',
51
+ terminal: true,
52
+ historySize: 100,
53
+ });
54
+ this.setupEventHandlers();
63
55
  }
64
56
  /**
65
- * Set streaming state - controls whether chat box remains visible during AI streaming
57
+ * Setup readline event handlers
66
58
  */
67
- setStreaming(isStreaming) {
68
- if (this.isDisposed)
69
- return;
70
- this.isStreaming = isStreaming;
71
- this.scheduleRender();
59
+ setupEventHandlers() {
60
+ this.rl.on('line', (line) => {
61
+ this.handleLineInput(line);
62
+ });
63
+ this.rl.on('close', () => {
64
+ this.handleClose();
65
+ });
66
+ // Handle paste detection
67
+ this.rl.input.on('data', (data) => {
68
+ this.handleDataInput(data.toString());
69
+ });
70
+ // Handle keyboard shortcuts
71
+ this.rl.input.on('keypress', (str, key) => {
72
+ if (key.ctrl && key.name === 'c') {
73
+ this.handleCancel();
74
+ }
75
+ else if (key.name === 'up') {
76
+ this.handleHistoryUp();
77
+ }
78
+ else if (key.name === 'down') {
79
+ this.handleHistoryDown();
80
+ }
81
+ });
72
82
  }
73
83
  /**
74
- * Enhanced paste detection - detects large pastes and provides summary
84
+ * Handle line input (when user presses enter)
75
85
  */
76
- handlePaste(content) {
77
- if (this.isDisposed)
86
+ handleLineInput(line) {
87
+ if (!this.isActive)
78
88
  return;
79
- const lines = content.split('\n');
80
- const lineCount = lines.length;
81
- const charCount = content.length;
82
- // Track paste metrics
83
- this.pasteStartTime = Date.now();
84
- this.pasteLineCount = lineCount;
85
- // For large pastes, show summary instead of full content
86
- if (lineCount > this.maxPasteLines || charCount > 2000) {
87
- this.isPastedBlock = true;
88
- this.pastedFullContent = content;
89
- // Create summary for display
90
- const summaryLines = lines.slice(0, 5); // Show first 5 lines
91
- const remainingLines = lineCount - 5;
92
- const summary = summaryLines.join('\n') +
93
- (remainingLines > 0 ? `\n... (${remainingLines} more lines)` : '');
94
- this.inputBuffer = summary;
95
- this.cursorPosition = summary.length;
96
- this.state.currentInput = summary;
97
- // Show paste status
98
- this.setStatusMessage(`📋 Pasted ${lineCount} lines, ${charCount} chars - hit Enter to submit`);
89
+ const trimmed = line.trim();
90
+ if (this.pasteMode) {
91
+ // Handle paste completion
92
+ this.handlePasteComplete(trimmed);
93
+ }
94
+ else if (trimmed.length > 0) {
95
+ // Handle normal input
96
+ this.handleNormalInput(trimmed);
99
97
  }
100
98
  else {
101
- // Small paste - handle normally
102
- this.isPastedBlock = false;
103
- this.pastedFullContent = '';
104
- this.inputBuffer = content;
105
- this.cursorPosition = content.length;
106
- this.state.currentInput = content;
99
+ // Empty input - just clear and reset
100
+ this.clearInput();
107
101
  }
108
- this.scheduleRender();
109
102
  }
110
103
  /**
111
- * Clear paste state
104
+ * Handle normal text input
112
105
  */
113
- clearPastedBlock() {
114
- this.isPastedBlock = false;
115
- this.pastedFullContent = '';
116
- this.pasteStartTime = 0;
117
- this.pasteLineCount = 0;
106
+ handleNormalInput(text) {
107
+ // Add to history
108
+ if (this.state.history[0] !== text) {
109
+ this.state.history.unshift(text);
110
+ if (this.state.history.length > 100) {
111
+ this.state.history.pop();
112
+ }
113
+ }
114
+ // Submit to AI
115
+ this.onSubmit(text);
116
+ // Clear input
117
+ this.clearInput();
118
+ this.state.historyIndex = -1;
118
119
  }
119
120
  /**
120
- * Handle character input with paste detection
121
+ * Handle paste completion
121
122
  */
122
- handleCharacter(char) {
123
- if (this.isDisposed)
124
- return;
125
- // Check for rapid input that might indicate paste
126
- const now = Date.now();
127
- if (this.pasteStartTime > 0 && now - this.pasteStartTime < 100) {
128
- // Rapid input detected - treat as paste continuation
129
- this.inputBuffer = this.inputBuffer.slice(0, this.cursorPosition) +
130
- char +
131
- this.inputBuffer.slice(this.cursorPosition);
132
- this.cursorPosition += char.length;
133
- this.state.currentInput = this.inputBuffer;
134
- this.scheduleRender();
135
- return;
123
+ handlePasteComplete(text) {
124
+ const fullPaste = this.pasteBuffer + text;
125
+ if (this.config.showPasteSummaries && isMultilinePaste(fullPaste)) {
126
+ const processed = processPaste(fullPaste);
127
+ display.showSystemMessage(processed.displaySummary);
136
128
  }
137
- // Normal character input
138
- this.inputBuffer = this.inputBuffer.slice(0, this.cursorPosition) +
139
- char +
140
- this.inputBuffer.slice(this.cursorPosition);
141
- this.cursorPosition += char.length;
142
- this.state.currentInput = this.inputBuffer;
143
- // Clear any paste state for normal typing
144
- this.clearPastedBlock();
145
- this.scheduleRender();
129
+ // Submit the full paste content
130
+ this.onSubmit(fullPaste);
131
+ // Reset paste state
132
+ this.pasteMode = false;
133
+ this.pasteBuffer = '';
134
+ this.clearInput();
146
135
  }
147
136
  /**
148
- * Handle Enter key - only submit when explicitly pressed
137
+ * Handle raw data input for paste detection
149
138
  */
150
- handleEnter() {
151
- if (this.isDisposed)
152
- return null;
153
- let input;
154
- // If we have a pasted block, use the full content
155
- if (this.isPastedBlock && this.pastedFullContent) {
156
- input = this.sanitizeCommandText(this.pastedFullContent);
157
- }
158
- else {
159
- input = this.sanitizeCommandText(this.inputBuffer);
139
+ handleDataInput(data) {
140
+ if (!this.isActive)
141
+ return;
142
+ // Detect paste by checking for multiple lines or large content
143
+ if (data.includes('\n') && data.length > 100) {
144
+ this.pasteMode = true;
145
+ this.pasteBuffer = data;
146
+ // Show paste indicator
147
+ this.updatePrompt('📋 Paste mode - Press Enter to submit or Ctrl+C to cancel');
160
148
  }
161
- if (!input)
162
- return null;
163
- // Add to history before processing
164
- const historyEntry = this.isPastedBlock
165
- ? `[Pasted ${this.pasteLineCount} lines] ${input.slice(0, 100)}...`
166
- : input;
167
- this.addToHistory(historyEntry);
168
- // If processing/streaming, queue the command
169
- if (this.state.isProcessing || this.isStreaming) {
170
- const type = input.startsWith('/') ? 'slash' : 'request';
171
- const queued = this.queueCommand(input, type);
172
- if (!queued) {
173
- this.setStatusMessage(`Queue is full (${this.maxQueueSize}). Submit after current task or clear queued items.`);
174
- return null;
175
- }
149
+ }
150
+ /**
151
+ * Handle cancel (Ctrl+C)
152
+ */
153
+ handleCancel() {
154
+ if (this.pasteMode) {
155
+ // Cancel paste mode
156
+ this.pasteMode = false;
157
+ this.pasteBuffer = '';
176
158
  this.clearInput();
177
- return null;
159
+ display.showSystemMessage('Paste cancelled');
178
160
  }
179
- // Clear input and paste state, then notify
180
- this.clearInput();
181
- if (this.onInputSubmit) {
182
- this.onInputSubmit(input);
161
+ else {
162
+ // Call cancel callback
163
+ this.onCancel();
183
164
  }
184
- return input;
185
165
  }
186
166
  /**
187
- * Enhanced render method that works during streaming
167
+ * Handle close
168
+ */
169
+ handleClose() {
170
+ this.isActive = false;
171
+ }
172
+ /**
173
+ * Handle history navigation - up
188
174
  */
189
- render() {
190
- if (!this.state.isVisible || !this.supportsRendering())
175
+ handleHistoryUp() {
176
+ if (this.state.history.length === 0)
191
177
  return;
192
- // ALWAYS render - even during processing/streaming
193
- this.lastRenderTime = Date.now();
194
- try {
195
- const cols = Math.max(this.writeStream.columns || 80, 40);
196
- const separatorWidth = Math.min(cols - 2, 72);
197
- // Build status message
198
- const statusParts = [];
199
- // Show streaming status if active
200
- if (this.isStreaming) {
201
- statusParts.push(theme.ui.info('🔄 AI is streaming...'));
202
- }
203
- // Processing status
204
- if (this.state.statusMessage) {
205
- const maxLen = Math.max(20, cols - 30);
206
- const msg = this.state.statusMessage.length > maxLen
207
- ? `${this.state.statusMessage.slice(0, maxLen - 3)}...`
208
- : this.state.statusMessage;
209
- statusParts.push(theme.ui.muted(msg));
210
- }
211
- // Queue status
212
- if (this.state.queuedCommands.length > 0) {
213
- statusParts.push(theme.ui.warning(`📋 ${this.state.queuedCommands.length} queued`));
214
- }
215
- const statusLine = statusParts.join(' │ ');
216
- const separator = theme.ui.border('─'.repeat(separatorWidth));
217
- // Write the persistent chat box
218
- this.safeWrite(`\r${ANSI.CLEAR_LINE}${separator}\n`);
219
- this.safeWrite(`${ANSI.CLEAR_LINE}${statusLine}\n`);
220
- // Show input line with cursor
221
- const displayInput = this.state.currentInput || '';
222
- const prompt = theme.ui.muted('> ');
223
- this.safeWrite(`${ANSI.CLEAR_LINE}${prompt}${displayInput}`);
224
- // Position cursor if needed
225
- if (this.cursorPosition >= 0) {
226
- const cursorPos = prompt.length + this.cursorPosition;
227
- this.safeWrite(`\r${ANSI.CURSOR_FORWARD(cursorPos)}`);
228
- }
229
- this._lastRenderedHeight = 3;
230
- }
231
- catch {
232
- // Silently handle render errors
178
+ if (this.state.historyIndex < this.state.history.length - 1) {
179
+ this.state.historyIndex++;
180
+ const historyItem = this.state.history[this.state.historyIndex];
181
+ this.setInput(historyItem);
233
182
  }
234
183
  }
235
- supportsRendering() {
236
- return this.isEnabled &&
237
- !this.isDisposed &&
238
- this.writeStream.writable &&
239
- this.writeStream.isTTY;
240
- }
241
- safeWrite(content) {
242
- try {
243
- if (this.writeStream.writable) {
244
- this.writeStream.write(content);
245
- }
184
+ /**
185
+ * Handle history navigation - down
186
+ */
187
+ handleHistoryDown() {
188
+ if (this.state.historyIndex > 0) {
189
+ this.state.historyIndex--;
190
+ const historyItem = this.state.history[this.state.historyIndex];
191
+ this.setInput(historyItem);
246
192
  }
247
- catch {
248
- // Swallow write errors (e.g., stream closed) to avoid crashing the app
193
+ else if (this.state.historyIndex === 0) {
194
+ this.state.historyIndex = -1;
195
+ this.clearInput();
249
196
  }
250
197
  }
251
- sanitizeCommandText(text) {
252
- return text
253
- .replace(this.ansiPattern, '')
254
- .replace(this.oscPattern, '')
255
- .trim();
198
+ /**
199
+ * Update the readline prompt
200
+ */
201
+ updatePrompt(prompt) {
202
+ this.rl.setPrompt(prompt);
203
+ this.rl.prompt();
256
204
  }
257
- sanitizeStatusMessage(message) {
258
- if (!message)
259
- return null;
260
- return message
261
- .replace(this.ansiPattern, '')
262
- .replace(this.oscPattern, '')
263
- .slice(0, this.maxStatusMessageLength);
205
+ /**
206
+ * Set input text
207
+ */
208
+ setInput(text) {
209
+ this.state.input = text;
210
+ this.state.cursorPosition = text.length;
211
+ // Update the readline line
212
+ this.rl.write('\x1B[2K\r'); // Clear line
213
+ this.rl.write(text);
264
214
  }
265
- addToHistory(entry) {
266
- if (!entry.trim())
267
- return;
268
- // Avoid duplicates
269
- const existingIndex = this.inputHistory.indexOf(entry);
270
- if (existingIndex >= 0) {
271
- this.inputHistory.splice(existingIndex, 1);
272
- }
273
- this.inputHistory.push(entry);
274
- // Trim history if needed
275
- if (this.inputHistory.length > this.maxHistorySize) {
276
- this.inputHistory = this.inputHistory.slice(-this.maxHistorySize);
277
- }
215
+ /**
216
+ * Clear input
217
+ */
218
+ clearInput() {
219
+ this.state.input = '';
220
+ this.state.cursorPosition = 0;
221
+ this.updatePrompt(this.getDefaultPrompt());
278
222
  }
279
- setEnabled(enabled) {
280
- if (this.isDisposed)
281
- return;
282
- if (!enabled && this.isEnabled) {
283
- this.clear();
223
+ /**
224
+ * Get default prompt based on streaming state
225
+ */
226
+ getDefaultPrompt() {
227
+ if (this.state.isStreaming) {
228
+ return `${theme.info('◉')} ${theme.ui.muted('Type while AI streams (Enter to send)')}: `;
284
229
  }
285
- this.isEnabled = enabled;
286
- if (enabled) {
287
- this.scheduleRender();
230
+ else {
231
+ return `${theme.success('>')} `;
288
232
  }
289
233
  }
290
- setProcessing(isProcessing) {
291
- if (this.isDisposed)
234
+ /**
235
+ * Activate the chat box
236
+ */
237
+ activate() {
238
+ if (this.isActive)
292
239
  return;
293
- this.state.isProcessing = isProcessing;
294
- this.scheduleRender();
240
+ this.isActive = true;
241
+ this.clearInput();
242
+ // Show initial prompt
243
+ display.showSystemMessage('Persistent chat box activated - Type while AI streams, press Enter to send');
295
244
  }
296
- setContextUsage(percentage) {
297
- if (this.isDisposed)
298
- return;
299
- const value = Number.isFinite(percentage) ? percentage : 0;
300
- this.state.contextUsage = Math.max(0, Math.min(100, value));
245
+ /**
246
+ * Deactivate the chat box
247
+ */
248
+ deactivate() {
249
+ this.isActive = false;
250
+ this.rl.pause();
301
251
  }
302
- setStatusMessage(message) {
303
- if (this.isDisposed)
304
- return;
305
- this.state.statusMessage = this.sanitizeStatusMessage(message);
306
- this.scheduleRender();
252
+ /**
253
+ * Set streaming state
254
+ */
255
+ setStreaming(isStreaming) {
256
+ this.state.isStreaming = isStreaming;
257
+ if (this.isActive) {
258
+ this.updatePrompt(this.getDefaultPrompt());
259
+ }
307
260
  }
308
- queueCommand(text, type = 'request') {
309
- if (this.isDisposed)
310
- return null;
311
- // Validate input
312
- if (typeof text !== 'string')
313
- return null;
314
- const sanitizedText = this.sanitizeCommandText(text);
315
- if (!sanitizedText)
316
- return null;
317
- // Check queue size limit
318
- if (this.state.queuedCommands.length >= this.maxQueueSize) {
319
- // Remove oldest non-slash commands to make room
320
- const idx = this.state.queuedCommands.findIndex(c => c.type !== 'slash');
321
- if (idx >= 0) {
322
- this.state.queuedCommands.splice(idx, 1);
323
- }
324
- else {
325
- // Queue is full of slash commands, reject
326
- return null;
261
+ /**
262
+ * Add message to history
263
+ */
264
+ addToHistory(message) {
265
+ if (message.trim() && this.state.history[0] !== message.trim()) {
266
+ this.state.history.unshift(message.trim());
267
+ if (this.state.history.length > 100) {
268
+ this.state.history.pop();
327
269
  }
328
270
  }
329
- // Sanitize and truncate command text
330
- const truncated = sanitizedText.slice(0, this.maxInputLength);
331
- const preview = truncated.length > 60 ? `${truncated.slice(0, 57)}...` : truncated;
332
- const cmd = {
333
- id: `cmd-${++this.commandIdCounter}`,
334
- text: truncated,
335
- type,
336
- timestamp: Date.now(),
337
- preview,
338
- };
339
- this.state.queuedCommands.push(cmd);
340
- this.scheduleRender();
341
- if (this.onCommandQueued) {
342
- this.onCommandQueued(cmd);
343
- }
344
- return cmd;
345
271
  }
346
- clearQueue() {
347
- if (this.isDisposed)
348
- return;
349
- this.state.queuedCommands = [];
350
- this.scheduleRender();
272
+ /**
273
+ * Get current state
274
+ */
275
+ getState() {
276
+ return { ...this.state };
351
277
  }
352
- clearInput() {
353
- if (this.isDisposed)
354
- return;
355
- this.inputBuffer = '';
356
- this.cursorPosition = 0;
357
- this.state.currentInput = '';
358
- // Reset history navigation
359
- this.historyIndex = -1;
360
- this.tempCurrentInput = '';
361
- // Clear paste state
362
- this.clearPastedBlock();
363
- this.scheduleRender();
278
+ /**
279
+ * Get config
280
+ */
281
+ getConfig() {
282
+ return { ...this.config };
364
283
  }
365
- clear() {
366
- if (!this.supportsRendering())
367
- return;
368
- // If we rendered a multi-line box, move up and clear it
369
- if (this._lastRenderedHeight > 1) {
370
- this.safeWrite(ANSI.MOVE_UP(this._lastRenderedHeight - 1));
371
- }
372
- this.safeWrite(`\r${ANSI.CLEAR_TO_END}`);
373
- this._lastRenderedHeight = 0;
284
+ /**
285
+ * Update config
286
+ */
287
+ updateConfig(config) {
288
+ this.config = { ...this.config, ...config };
374
289
  }
375
- scheduleRender() {
376
- if (this.isDisposed || this.renderScheduled)
377
- return;
378
- const now = Date.now();
379
- const timeSinceLastRender = now - this.lastRenderTime;
380
- if (timeSinceLastRender < this.renderThrottleMs) {
381
- this.renderScheduled = true;
382
- setTimeout(() => {
383
- this.renderScheduled = false;
384
- if (!)
385
- ;
386
- });
387
- }
290
+ /**
291
+ * Dispose of resources
292
+ */
293
+ dispose() {
294
+ this.deactivate();
295
+ this.rl.close();
388
296
  }
389
297
  }
390
- //# sourceMappingURL=persistentChatBox.js.map
298
+ //# sourceMappingURL=PersistentChatBox.js.map