erosolar-cli 1.7.194 → 1.7.196

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 (68) hide show
  1. package/dist/core/agent.d.ts +6 -0
  2. package/dist/core/agent.d.ts.map +1 -1
  3. package/dist/core/agent.js +10 -1
  4. package/dist/core/agent.js.map +1 -1
  5. package/dist/core/errors/errorUtils.d.ts +87 -0
  6. package/dist/core/errors/errorUtils.d.ts.map +1 -0
  7. package/dist/core/errors/errorUtils.js +158 -0
  8. package/dist/core/errors/errorUtils.js.map +1 -0
  9. package/dist/core/resultVerification.js.map +1 -1
  10. package/dist/core/toolValidation.d.ts +117 -0
  11. package/dist/core/toolValidation.d.ts.map +1 -0
  12. package/dist/core/toolValidation.js +282 -0
  13. package/dist/core/toolValidation.js.map +1 -0
  14. package/dist/core/types/utilityTypes.d.ts +192 -0
  15. package/dist/core/types/utilityTypes.d.ts.map +1 -0
  16. package/dist/core/types/utilityTypes.js +272 -0
  17. package/dist/core/types/utilityTypes.js.map +1 -0
  18. package/dist/shell/interactiveShell.d.ts +9 -0
  19. package/dist/shell/interactiveShell.d.ts.map +1 -1
  20. package/dist/shell/interactiveShell.js +69 -1
  21. package/dist/shell/interactiveShell.js.map +1 -1
  22. package/dist/shell/systemPrompt.d.ts.map +1 -1
  23. package/dist/shell/systemPrompt.js +5 -0
  24. package/dist/shell/systemPrompt.js.map +1 -1
  25. package/dist/shell/terminalInput.d.ts +1 -0
  26. package/dist/shell/terminalInput.d.ts.map +1 -1
  27. package/dist/shell/terminalInput.js +9 -2
  28. package/dist/shell/terminalInput.js.map +1 -1
  29. package/dist/shell/terminalInputAdapter.d.ts +4 -0
  30. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  31. package/dist/shell/terminalInputAdapter.js +6 -0
  32. package/dist/shell/terminalInputAdapter.js.map +1 -1
  33. package/dist/tools/planningTools.d.ts +1 -0
  34. package/dist/tools/planningTools.d.ts.map +1 -1
  35. package/dist/tools/planningTools.js +48 -0
  36. package/dist/tools/planningTools.js.map +1 -1
  37. package/dist/ui/display.d.ts +5 -49
  38. package/dist/ui/display.d.ts.map +1 -1
  39. package/dist/ui/display.js +36 -335
  40. package/dist/ui/display.js.map +1 -1
  41. package/dist/ui/toolDisplay.d.ts.map +1 -1
  42. package/dist/ui/toolDisplay.js +17 -0
  43. package/dist/ui/toolDisplay.js.map +1 -1
  44. package/dist/utils/planFormatter.d.ts +34 -0
  45. package/dist/utils/planFormatter.d.ts.map +1 -0
  46. package/dist/utils/planFormatter.js +140 -0
  47. package/dist/utils/planFormatter.js.map +1 -0
  48. package/package.json +2 -2
  49. package/dist/shell/bracketedPasteManager.d.ts +0 -128
  50. package/dist/shell/bracketedPasteManager.d.ts.map +0 -1
  51. package/dist/shell/bracketedPasteManager.enhanced.d.ts +0 -2
  52. package/dist/shell/bracketedPasteManager.enhanced.d.ts.map +0 -1
  53. package/dist/shell/bracketedPasteManager.enhanced.js +0 -4
  54. package/dist/shell/bracketedPasteManager.enhanced.js.map +0 -1
  55. package/dist/shell/bracketedPasteManager.js +0 -372
  56. package/dist/shell/bracketedPasteManager.js.map +0 -1
  57. package/dist/shell/chatBox.d.ts +0 -228
  58. package/dist/shell/chatBox.d.ts.map +0 -1
  59. package/dist/shell/chatBox.js +0 -811
  60. package/dist/shell/chatBox.js.map +0 -1
  61. package/dist/shell/unifiedChatBox.d.ts +0 -194
  62. package/dist/shell/unifiedChatBox.d.ts.map +0 -1
  63. package/dist/shell/unifiedChatBox.js +0 -585
  64. package/dist/shell/unifiedChatBox.js.map +0 -1
  65. package/dist/ui/persistentPrompt.d.ts +0 -545
  66. package/dist/ui/persistentPrompt.d.ts.map +0 -1
  67. package/dist/ui/persistentPrompt.js +0 -1529
  68. package/dist/ui/persistentPrompt.js.map +0 -1
@@ -1,1529 +0,0 @@
1
- /**
2
- * PersistentPrompt - Minimal, stable chat input at bottom of terminal
3
- *
4
- * Design principles:
5
- * - Simple: No complex cursor manipulation or multi-line rendering
6
- * - Stable: Uses standard readline without fighting it
7
- * - Reliable: Clear separation between output area and input area
8
- */
9
- import * as readline from 'node:readline';
10
- import { theme } from './theme.js';
11
- /**
12
- * Minimal prompt manager that doesn't fight with readline
13
- */
14
- export class PersistentPrompt {
15
- writeStream;
16
- promptText;
17
- statusBarState;
18
- isEnabled = true;
19
- promptState;
20
- constructor(writeStream, promptText = '> ') {
21
- this.writeStream = writeStream;
22
- this.promptText = promptText;
23
- this.statusBarState = {};
24
- this.promptState = {
25
- userInput: '',
26
- cursorPosition: 0,
27
- isVisible: true,
28
- };
29
- }
30
- /**
31
- * Update the prompt text
32
- */
33
- setPromptText(text) {
34
- this.promptText = text;
35
- }
36
- /**
37
- * Get the formatted prompt string
38
- */
39
- getPrompt() {
40
- return this.promptText;
41
- }
42
- /**
43
- * Update user input state (for tracking only - readline handles display)
44
- */
45
- updateInput(input, cursorPos) {
46
- this.promptState.userInput = input;
47
- this.promptState.cursorPosition = cursorPos;
48
- }
49
- /**
50
- * Update status bar information
51
- */
52
- updateStatusBar(state) {
53
- this.statusBarState = { ...this.statusBarState, ...state };
54
- }
55
- /**
56
- * Show a status line above the prompt (call before readline.prompt())
57
- */
58
- showStatus() {
59
- if (!this.isEnabled)
60
- return;
61
- const status = this.buildStatusLine();
62
- if (status) {
63
- this.writeStream.write(`\n${status}\n`);
64
- }
65
- }
66
- /**
67
- * Show the prompt
68
- */
69
- show() {
70
- this.promptState.isVisible = true;
71
- }
72
- /**
73
- * Hide the prompt
74
- */
75
- hide() {
76
- this.promptState.isVisible = false;
77
- }
78
- /**
79
- * Enable or disable
80
- */
81
- setEnabled(enabled) {
82
- this.isEnabled = enabled;
83
- }
84
- /**
85
- * Clear - no-op for minimal implementation
86
- */
87
- clear() {
88
- // Intentionally minimal - let readline handle clearing
89
- }
90
- /**
91
- * Build status line string
92
- * NOTE: Context usage is NOT shown here - it's already displayed by
93
- * display.showStatusLine() which shows "Session Xm • Context Y% used • Ready for prompts"
94
- */
95
- buildStatusLine() {
96
- const parts = [];
97
- if (this.statusBarState.fileChanges) {
98
- parts.push(this.statusBarState.fileChanges);
99
- }
100
- // Context usage removed - display.showStatusLine() already shows it
101
- if (this.statusBarState.message?.trim()) {
102
- parts.push(this.statusBarState.message.trim());
103
- }
104
- if (!parts.length) {
105
- return null;
106
- }
107
- return theme.ui.muted(parts.join(' • '));
108
- }
109
- /**
110
- * Handle terminal resize - no-op for minimal implementation
111
- */
112
- handleResize() {
113
- // Readline handles resize
114
- }
115
- /**
116
- * Dispose
117
- */
118
- dispose() {
119
- // Nothing to clean up in minimal implementation
120
- }
121
- }
122
- /**
123
- * Simple input box that wraps readline with stable behavior
124
- */
125
- export class SimpleInputBox {
126
- rl = null;
127
- promptText;
128
- statusText = '';
129
- constructor(promptText = '> ') {
130
- this.promptText = promptText;
131
- }
132
- /**
133
- * Initialize readline interface
134
- */
135
- init(input, output) {
136
- this.rl = readline.createInterface({
137
- input,
138
- output,
139
- prompt: this.promptText,
140
- terminal: true,
141
- });
142
- return this.rl;
143
- }
144
- /**
145
- * Set prompt text
146
- */
147
- setPrompt(text) {
148
- this.promptText = text;
149
- if (this.rl) {
150
- this.rl.setPrompt(text);
151
- }
152
- }
153
- /**
154
- * Set status text (shown above prompt on next prompt())
155
- */
156
- setStatus(text) {
157
- this.statusText = text;
158
- }
159
- /**
160
- * Show prompt with optional status line
161
- */
162
- prompt(preserveCursor) {
163
- if (!this.rl)
164
- return;
165
- // Show status line if set
166
- if (this.statusText) {
167
- process.stdout.write(`\n${theme.ui.muted(this.statusText)}\n`);
168
- this.statusText = '';
169
- }
170
- this.rl.prompt(preserveCursor);
171
- }
172
- /**
173
- * Get readline interface
174
- */
175
- getInterface() {
176
- return this.rl;
177
- }
178
- /**
179
- * Close and cleanup
180
- */
181
- close() {
182
- if (this.rl) {
183
- this.rl.close();
184
- this.rl = null;
185
- }
186
- }
187
- }
188
- /**
189
- * ANSI escape codes for terminal control
190
- */
191
- const ANSI = {
192
- SAVE_CURSOR: '\u001b7',
193
- RESTORE_CURSOR: '\u001b8',
194
- CURSOR_TO_BOTTOM: (rows) => `\u001b[${Math.max(1, rows)};1H`,
195
- CLEAR_LINE: '\u001b[2K',
196
- CLEAR_TO_END: '\u001b[0J',
197
- MOVE_UP: (n) => `\u001b[${Math.max(0, n)}A`,
198
- MOVE_DOWN: (n) => `\u001b[${Math.max(0, n)}B`,
199
- MOVE_TO_COL: (col) => `\u001b[${Math.max(1, col)}G`,
200
- HIDE_CURSOR: '\u001b[?25l',
201
- SHOW_CURSOR: '\u001b[?25h',
202
- // Scroll region
203
- SET_SCROLL_REGION: (top, bottom) => `\u001b[${Math.max(1, top)};${Math.max(1, bottom)}r`,
204
- RESET_SCROLL_REGION: '\u001b[r',
205
- };
206
- /**
207
- * PinnedChatBox - Persistent input box pinned to bottom of terminal
208
- *
209
- * Features:
210
- * - Always visible at bottom of terminal
211
- * - Accepts input while AI is processing (queues commands)
212
- * - Shows queue status and context usage
213
- * - Non-blocking - doesn't interfere with output scroll
214
- */
215
- export class PinnedChatBox {
216
- writeStream;
217
- state;
218
- baseReservedLines = 2; // Base lines reserved (separator + at least 1 input line)
219
- reservedLines = 2; // Actual reserved lines (dynamically adjusted for multi-line)
220
- _lastRenderedHeight = 0;
221
- inputBuffer = '';
222
- cursorPosition = 0;
223
- maxDisplayLines = 15; // Maximum lines to show in the input area
224
- commandIdCounter = 0;
225
- onCommandQueued;
226
- onInputSubmit;
227
- renderScheduled = false;
228
- isEnabled = true;
229
- isDisposed = false;
230
- lastRenderTime = 0;
231
- renderThrottleMs = 16; // ~60fps max
232
- maxInputLength = 10000; // Prevent memory issues
233
- maxQueueSize = 100; // Prevent queue overflow
234
- ansiPattern = /[\u001B\u009B][[\]()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g;
235
- oscPattern = /\u001b\][^\u0007]*(?:\u0007|\u001b\\)/g;
236
- maxStatusMessageLength = 200;
237
- // Scroll region management for persistent bottom input
238
- scrollRegionActive = false;
239
- // Input history for up/down navigation
240
- inputHistory = [];
241
- historyIndex = -1;
242
- tempCurrentInput = ''; // Stores current input when navigating history
243
- maxHistorySize = 100;
244
- // Multiline paste tracking for summary display
245
- isPastedBlock = false;
246
- pastedFullContent = '';
247
- // Prevent accidental submit right after paste (trailing newline issue)
248
- lastPasteTime = 0;
249
- pasteSubmitGuardMs = 150; // Ignore submit within 150ms of paste
250
- /** Cleanup function for output interceptor registration */
251
- outputInterceptorCleanup;
252
- // Render deduplication - prevent duplicate displays
253
- lastRenderedContent = '';
254
- lastRenderedCursor = -1;
255
- lastRenderedRows = 0;
256
- lastRenderedCols = 0;
257
- isRendering = false; // Render lock to prevent concurrent renders
258
- constructor(writeStream, _promptText = '> ', // Unused - readline handles input display
259
- options = {}) {
260
- this.writeStream = writeStream;
261
- this.onCommandQueued = options.onCommandQueued;
262
- this.onInputSubmit = options.onInputSubmit;
263
- this.maxInputLength = options.maxInputLength ?? 10000;
264
- this.maxQueueSize = options.maxQueueSize ?? 100;
265
- this.state = {
266
- isProcessing: false,
267
- queuedCommands: [],
268
- currentInput: '',
269
- contextUsage: 0,
270
- statusMessage: null,
271
- isVisible: true,
272
- };
273
- }
274
- /** Pending render timeout for debounced afterWrite */
275
- pendingAfterWriteRender = null;
276
- /**
277
- * Register with the display's output interceptor system.
278
- *
279
- * With true scroll regions, output flows naturally in the scrollable area
280
- * and the bottom is PROTECTED automatically - no re-rendering needed.
281
- *
282
- * This is a no-op now because scroll regions handle everything.
283
- */
284
- registerOutputInterceptor(display) {
285
- if (this.outputInterceptorCleanup) {
286
- this.outputInterceptorCleanup();
287
- }
288
- // With true scroll regions, the bottom area is protected automatically.
289
- // Output goes to the scroll region, input stays in the protected area.
290
- // No afterWrite re-rendering needed - that was causing flicker.
291
- this.outputInterceptorCleanup = display.registerOutputInterceptor({
292
- beforeWrite: () => {
293
- // Ensure cursor is in scroll region before output
294
- if (this.scrollRegionActive) {
295
- const rows = this.writeStream.rows || 24;
296
- const scrollBottom = rows - this.reservedLines;
297
- // Move cursor to scroll region if it's in the protected area
298
- this.safeWrite(ANSI.SAVE_CURSOR);
299
- this.safeWrite(ANSI.CURSOR_TO_BOTTOM(scrollBottom));
300
- }
301
- },
302
- afterWrite: () => {
303
- // Restore cursor to prompt area after output
304
- if (this.scrollRegionActive) {
305
- this.safeWrite(ANSI.RESTORE_CURSOR);
306
- }
307
- },
308
- });
309
- }
310
- /**
311
- * Enable scroll region to keep bottom lines reserved for input.
312
- * Sets terminal scroll region to exclude the bottom 2 lines (separator + prompt).
313
- *
314
- * TRUE SCROLL REGION approach:
315
- * 1. Set scroll region (rows 1 to N-2) - protects bottom 2 lines
316
- * 2. Render the prompt in protected area (rows N-1 and N)
317
- * 3. Leave cursor at prompt - user can type immediately
318
- * 4. Output writes will go to scroll region via beforeWrite/afterWrite hooks
319
- */
320
- enableScrollRegion() {
321
- if (this.scrollRegionActive || !this.supportsRendering())
322
- return;
323
- // Make sure reserved height matches current content/terminal width
324
- this.updateReservedLinesForContent();
325
- const rows = this.writeStream.rows || 24;
326
- const scrollBottom = rows - this.reservedLines;
327
- // Step 1: Set scroll region (excludes bottom reserved lines)
328
- this.safeWrite(ANSI.SET_SCROLL_REGION(1, scrollBottom));
329
- // Invalidate render state to ensure fresh render in the new scroll region
330
- this.invalidateRenderedState();
331
- // Step 2: Render the prompt in the protected bottom area
332
- this.renderPersistentInput();
333
- // Mark scroll region as active AFTER rendering
334
- this.scrollRegionActive = true;
335
- // Cursor is now at the prompt (from renderPersistentInput)
336
- // User can start typing immediately
337
- }
338
- /**
339
- * Disable scroll region and restore normal terminal behavior.
340
- *
341
- * 1. Reset scroll region to full terminal
342
- * 2. Clear the reserved bottom area (will be replaced by readline prompt)
343
- * 3. Move cursor to the end
344
- */
345
- disableScrollRegion() {
346
- if (!this.scrollRegionActive || !this.supportsRendering())
347
- return;
348
- this.scrollRegionActive = false;
349
- // Reset scroll region to full terminal
350
- this.safeWrite(ANSI.RESET_SCROLL_REGION);
351
- // Clear the bottom reserved lines (they'll be replaced by readline)
352
- const rows = this.writeStream.rows || 24;
353
- for (let i = 0; i < this.reservedLines; i++) {
354
- this.safeWrite(ANSI.CURSOR_TO_BOTTOM(rows - i));
355
- this.safeWrite(ANSI.CLEAR_LINE);
356
- }
357
- // Move cursor to the end of the terminal
358
- this.safeWrite(ANSI.CURSOR_TO_BOTTOM(rows));
359
- }
360
- /**
361
- * Check if render is needed by comparing with last rendered state.
362
- * Returns true if content, cursor, or terminal size has changed.
363
- */
364
- needsRender() {
365
- const rows = this.writeStream.rows || 24;
366
- const cols = this.writeStream.columns || 80;
367
- // Check if anything changed that requires a re-render
368
- if (this.inputBuffer !== this.lastRenderedContent)
369
- return true;
370
- if (this.cursorPosition !== this.lastRenderedCursor)
371
- return true;
372
- if (rows !== this.lastRenderedRows)
373
- return true;
374
- if (cols !== this.lastRenderedCols)
375
- return true;
376
- return false;
377
- }
378
- /**
379
- * Update last rendered state after successful render.
380
- */
381
- updateRenderedState() {
382
- this.lastRenderedContent = this.inputBuffer;
383
- this.lastRenderedCursor = this.cursorPosition;
384
- this.lastRenderedRows = this.writeStream.rows || 24;
385
- this.lastRenderedCols = this.writeStream.columns || 80;
386
- }
387
- /**
388
- * Reset rendered state to force next render.
389
- */
390
- invalidateRenderedState() {
391
- this.lastRenderedContent = '';
392
- this.lastRenderedCursor = -1;
393
- this.lastRenderedRows = 0;
394
- this.lastRenderedCols = 0;
395
- }
396
- /**
397
- * Render the persistent input area at the bottom of the terminal.
398
- *
399
- * CLEAN MULTI-LINE CHAT BOX:
400
- * - Line 1: Simple separator with line count hint
401
- * - Line 2+: Input lines with dark background, block cursor highlight
402
- *
403
- * Key features:
404
- * - Dark background (ANSI 48;5;236) for entire input area
405
- * - Block cursor shown via reverse video
406
- * - Clean vertical layout for multi-line input
407
- * - Proper cursor tracking through wrapped lines
408
- */
409
- renderPersistentInput() {
410
- if (!this.supportsRendering())
411
- return;
412
- // Render lock - prevent concurrent renders that cause flicker
413
- if (this.isRendering)
414
- return;
415
- // Deduplication - skip if nothing changed
416
- if (!this.needsRender())
417
- return;
418
- this.isRendering = true;
419
- try {
420
- const { rows, cols, maxInputWidth } = this.getRenderDimensions();
421
- // ANSI codes
422
- const HIDE_CURSOR = '\x1b[?25l';
423
- const SHOW_CURSOR = '\x1b[?25h';
424
- const RESET = '\x1b[0m';
425
- const DIM = '\x1b[2m';
426
- // Dark gray background for input area (256-color: 236)
427
- const INPUT_BG = '\x1b[48;5;236m';
428
- // Cursor: bright white on dark background with reverse for visibility
429
- const CURSOR_STYLE = '\x1b[7;1m';
430
- // Hide cursor during render
431
- this.safeWrite(HIDE_CURSOR);
432
- this.safeWrite(RESET);
433
- // Wrap input into terminal-safe lines
434
- const { lines: wrappedLines, cursorLine: cursorLineIndex, cursorCol: cursorColInLine } = this.wrapInputBuffer(maxInputWidth);
435
- const totalInputLines = Math.max(1, wrappedLines.length);
436
- const displayLineCount = Math.min(totalInputLines, this.maxDisplayLines);
437
- // Keep reserved height in sync
438
- this.updateReservedLinesForContent(maxInputWidth, totalInputLines);
439
- // Calculate which lines to display (keep cursor visible)
440
- let displayStartLine = 0;
441
- if (totalInputLines > displayLineCount) {
442
- displayStartLine = Math.max(0, cursorLineIndex - displayLineCount + 1);
443
- displayStartLine = Math.min(displayStartLine, totalInputLines - displayLineCount);
444
- }
445
- const linesToShow = wrappedLines.slice(displayStartLine, displayStartLine + displayLineCount);
446
- // Move to reserved area start
447
- const reservedStart = rows - this.reservedLines + 1;
448
- this.safeWrite(ANSI.CURSOR_TO_BOTTOM(reservedStart));
449
- this.safeWrite(ANSI.CLEAR_LINE);
450
- // Separator line with status
451
- const separatorWidth = Math.max(1, Math.min(cols - 2, 55));
452
- this.safeWrite(DIM);
453
- this.safeWrite('─'.repeat(separatorWidth));
454
- // Status hints on same line as separator
455
- const hints = [];
456
- if (totalInputLines > 1) {
457
- hints.push(`${totalInputLines} lines`);
458
- }
459
- const queueCount = this.state.queuedCommands.length;
460
- if (this.state.isProcessing && queueCount > 0) {
461
- hints.push(`${queueCount} queued`);
462
- }
463
- if (hints.length > 0) {
464
- this.safeWrite(' ' + hints.join(' • '));
465
- }
466
- this.safeWrite(RESET);
467
- let finalCursorRow = reservedStart + 1;
468
- let finalCursorCol = 3;
469
- // Render each input line with background
470
- for (let lineIdx = 0; lineIdx < linesToShow.length; lineIdx++) {
471
- this.safeWrite('\n');
472
- this.safeWrite(ANSI.CLEAR_LINE);
473
- const line = linesToShow[lineIdx] ?? '';
474
- const absoluteLineIdx = displayStartLine + lineIdx;
475
- const isFirstLine = absoluteLineIdx === 0;
476
- const isCursorLine = absoluteLineIdx === cursorLineIndex;
477
- // Start background for this line
478
- this.safeWrite(INPUT_BG);
479
- // Prompt prefix
480
- this.safeWrite(DIM);
481
- this.safeWrite(isFirstLine ? '> ' : '│ ');
482
- this.safeWrite(RESET);
483
- this.safeWrite(INPUT_BG);
484
- if (isCursorLine) {
485
- // Render line with block cursor
486
- const cursorPos = Math.max(0, Math.min(cursorColInLine, line.length));
487
- const beforeCursor = line.slice(0, cursorPos);
488
- const atCursor = cursorPos < line.length ? (line[cursorPos] ?? ' ') : ' ';
489
- const afterCursor = cursorPos < line.length ? line.slice(cursorPos + 1) : '';
490
- this.safeWrite(beforeCursor);
491
- this.safeWrite(CURSOR_STYLE);
492
- this.safeWrite(atCursor);
493
- this.safeWrite(RESET);
494
- this.safeWrite(INPUT_BG);
495
- this.safeWrite(afterCursor);
496
- // Track cursor position for terminal cursor
497
- finalCursorRow = reservedStart + 1 + lineIdx;
498
- finalCursorCol = 3 + cursorPos;
499
- }
500
- else {
501
- this.safeWrite(line);
502
- }
503
- // Pad to fill background to edge, then reset
504
- const lineLen = 2 + line.length + (isCursorLine && cursorColInLine >= line.length ? 1 : 0);
505
- const padding = Math.max(0, cols - lineLen - 1);
506
- if (padding > 0) {
507
- this.safeWrite(' '.repeat(padding));
508
- }
509
- this.safeWrite(RESET);
510
- }
511
- // Position terminal cursor and show it
512
- this.safeWrite(ANSI.CURSOR_TO_BOTTOM(finalCursorRow));
513
- this.safeWrite(ANSI.MOVE_TO_COL(Math.max(1, Math.min(finalCursorCol, cols))));
514
- this.safeWrite(SHOW_CURSOR);
515
- // Update rendered state
516
- this.updateRenderedState();
517
- }
518
- finally {
519
- this.isRendering = false;
520
- }
521
- }
522
- /**
523
- * Update the persistent input display.
524
- *
525
- * Always renders the input area with proper cursor tracking and background.
526
- * During processing, scroll region protects the bottom area.
527
- * During normal mode, this provides custom styling over readline.
528
- */
529
- updatePersistentInput() {
530
- // Always render to show input with proper cursor tracking and background
531
- this.renderPersistentInput();
532
- }
533
- /**
534
- * Determine if we can safely render to the terminal.
535
- * Protects against non-TTY streams and disposed instances.
536
- */
537
- supportsRendering() {
538
- return (!this.isDisposed &&
539
- this.isEnabled &&
540
- Boolean(this.writeStream.isTTY) &&
541
- typeof this.writeStream.write === 'function' &&
542
- this.writeStream.writable !== false);
543
- }
544
- /**
545
- * Strip ANSI escape codes
546
- */
547
- stripAnsi(text) {
548
- this.ansiPattern.lastIndex = 0;
549
- this.oscPattern.lastIndex = 0;
550
- return text
551
- .replace(this.oscPattern, '')
552
- .replace(this.ansiPattern, '');
553
- }
554
- /**
555
- * Sanitize inline text to a single safe line (no control/ANSI codes)
556
- */
557
- sanitizeInlineText(text) {
558
- if (!text)
559
- return '';
560
- const withoutAnsi = this.stripAnsi(text);
561
- return withoutAnsi
562
- .replace(/[\r\n\u2028\u2029]/g, ' ')
563
- .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
564
- }
565
- /**
566
- * Sanitize multiline content for safe display (preserves newlines for counting)
567
- */
568
- sanitizeMultilineForDisplay(text) {
569
- if (!text)
570
- return '';
571
- const withoutAnsi = this.stripAnsi(text.replace(/\r/g, ''));
572
- return withoutAnsi.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
573
- }
574
- /**
575
- * Sanitize queued command text (trimmed, preserves newlines)
576
- */
577
- sanitizeCommandText(text) {
578
- if (!text)
579
- return '';
580
- const clean = this.sanitizeMultilineForDisplay(text);
581
- return clean.slice(0, this.maxInputLength).trim();
582
- }
583
- /**
584
- * Sanitize status message (single line, bounded length)
585
- */
586
- sanitizeStatusMessage(message) {
587
- if (!message)
588
- return null;
589
- const clean = this.sanitizeInlineText(message).trim();
590
- if (!clean)
591
- return null;
592
- return clean.slice(0, this.maxStatusMessageLength);
593
- }
594
- /**
595
- * Clear paste-specific state when the user edits the buffer manually.
596
- * Prevents us from sending stale paste content after edits.
597
- */
598
- startManualEdit() {
599
- if (this.isPastedBlock) {
600
- this.clearPastedBlock();
601
- }
602
- }
603
- /**
604
- * Apply a new buffer/cursor position and trigger downstream updates.
605
- */
606
- applyBufferChange(buffer, cursorPos) {
607
- this.inputBuffer = buffer;
608
- const nextCursor = typeof cursorPos === 'number' ? cursorPos : buffer.length;
609
- this.cursorPosition = Math.max(0, Math.min(nextCursor, this.inputBuffer.length));
610
- this.state.currentInput = this.inputBuffer;
611
- this.updateReservedLinesForContent();
612
- this.scheduleRender();
613
- }
614
- /**
615
- * Treat spaces, tabs, and newlines as whitespace for navigation helpers.
616
- */
617
- isWhitespace(char) {
618
- return char === ' ' || char === '\t' || char === '\n';
619
- }
620
- /**
621
- * Update cursor position while keeping it in bounds and re-rendering.
622
- */
623
- setCursorPosition(position) {
624
- this.cursorPosition = Math.max(0, Math.min(position, this.inputBuffer.length));
625
- this.scheduleRender();
626
- }
627
- /**
628
- * Insert multi-line content directly into the buffer (manual typing).
629
- */
630
- insertMultilineText(text) {
631
- const sanitized = this.sanitizeMultilineForDisplay(text);
632
- if (!sanitized)
633
- return;
634
- // Respect max input length
635
- const availableSpace = this.maxInputLength - this.inputBuffer.length;
636
- if (availableSpace <= 0)
637
- return;
638
- const chunk = sanitized.slice(0, availableSpace);
639
- this.startManualEdit();
640
- this.applyBufferChange(this.inputBuffer.slice(0, this.cursorPosition) +
641
- chunk +
642
- this.inputBuffer.slice(this.cursorPosition), this.cursorPosition + chunk.length);
643
- }
644
- /**
645
- * Safely write to the output stream, swallowing non-fatal errors
646
- */
647
- safeWrite(content) {
648
- try {
649
- if (this.writeStream.writable) {
650
- this.writeStream.write(content);
651
- }
652
- }
653
- catch {
654
- // Swallow write errors (e.g., stream closed) to avoid crashing the app
655
- }
656
- }
657
- /**
658
- * Enable or disable the pinned chat box
659
- */
660
- setEnabled(enabled) {
661
- if (this.isDisposed)
662
- return;
663
- if (!enabled && this.isEnabled) {
664
- this.clear();
665
- }
666
- this.isEnabled = enabled;
667
- if (enabled) {
668
- this.scheduleRender();
669
- }
670
- }
671
- /**
672
- * Set processing state
673
- *
674
- * TRUE SCROLL REGION approach (like Claude Code):
675
- * - Enable scroll region at start (protects bottom 2-3 lines)
676
- * - Output flows naturally in scroll region (cursor stays there)
677
- * - Bottom area is PROTECTED - no re-rendering needed after each chunk
678
- * - User types directly into protected bottom area
679
- * - Disable scroll region when done
680
- */
681
- setProcessing(isProcessing) {
682
- if (this.isDisposed)
683
- return;
684
- const wasProcessing = this.state.isProcessing;
685
- this.state.isProcessing = isProcessing;
686
- if (isProcessing && !wasProcessing) {
687
- // Starting processing - enable scroll region to protect bottom
688
- this.enableScrollRegion();
689
- }
690
- else if (!isProcessing && wasProcessing) {
691
- // Ending processing - disable scroll region, restore normal mode
692
- this.disableScrollRegion();
693
- }
694
- }
695
- /**
696
- * Update context usage percentage
697
- * NOTE: This only tracks the value - it no longer triggers render since
698
- * context display is handled by display.showStatusLine() instead
699
- */
700
- setContextUsage(percentage) {
701
- if (this.isDisposed)
702
- return;
703
- const value = Number.isFinite(percentage) ? percentage : 0;
704
- this.state.contextUsage = Math.max(0, Math.min(100, value));
705
- // Don't schedule render - context display is handled by display.showStatusLine()
706
- }
707
- /**
708
- * Set status message
709
- */
710
- setStatusMessage(message) {
711
- if (this.isDisposed)
712
- return;
713
- this.state.statusMessage = this.sanitizeStatusMessage(message);
714
- this.scheduleRender();
715
- }
716
- /**
717
- * Queue a command for execution with overflow protection
718
- */
719
- queueCommand(text, type = 'request') {
720
- if (this.isDisposed)
721
- return null;
722
- // Validate input
723
- if (typeof text !== 'string')
724
- return null;
725
- const sanitizedText = this.sanitizeCommandText(text);
726
- if (!sanitizedText)
727
- return null;
728
- // Check queue size limit
729
- if (this.state.queuedCommands.length >= this.maxQueueSize) {
730
- // Remove oldest non-slash commands to make room
731
- const idx = this.state.queuedCommands.findIndex(c => c.type !== 'slash');
732
- if (idx >= 0) {
733
- this.state.queuedCommands.splice(idx, 1);
734
- }
735
- else {
736
- // Queue is full of slash commands, reject
737
- return null;
738
- }
739
- }
740
- // Sanitize and truncate command text
741
- const truncated = sanitizedText.slice(0, this.maxInputLength);
742
- const previewSource = this.sanitizeInlineText(truncated);
743
- const preview = previewSource.length > 60 ? `${previewSource.slice(0, 57)}...` : previewSource;
744
- const cmd = {
745
- id: `cmd-${++this.commandIdCounter}`,
746
- text: truncated,
747
- type,
748
- timestamp: Date.now(),
749
- preview,
750
- };
751
- this.state.queuedCommands.push(cmd);
752
- this.scheduleRender();
753
- if (this.onCommandQueued) {
754
- try {
755
- this.onCommandQueued(cmd);
756
- }
757
- catch (err) {
758
- // Don't let callback errors break the queue
759
- console.error('Error in onCommandQueued callback:', err);
760
- }
761
- }
762
- return cmd;
763
- }
764
- /**
765
- * Remove a command from the queue
766
- */
767
- dequeueCommand() {
768
- if (this.isDisposed)
769
- return undefined;
770
- const cmd = this.state.queuedCommands.shift();
771
- this.scheduleRender();
772
- return cmd;
773
- }
774
- /**
775
- * Clear the entire queue
776
- */
777
- clearQueue() {
778
- if (this.isDisposed)
779
- return;
780
- this.state.queuedCommands = [];
781
- this.scheduleRender();
782
- }
783
- /**
784
- * Get current queue
785
- */
786
- getQueue() {
787
- return [...this.state.queuedCommands];
788
- }
789
- /**
790
- * Get queue length
791
- */
792
- getQueueLength() {
793
- return this.state.queuedCommands.length;
794
- }
795
- /**
796
- * Handle character input with validation
797
- * Detects multiline paste and stores full content while showing summary
798
- */
799
- handleInput(char, options = {}) {
800
- if (!this.isEnabled || this.isDisposed)
801
- return;
802
- if (typeof char !== 'string')
803
- return;
804
- const allowNewlines = options.allowNewlines ?? options.isPaste ?? false;
805
- const hasNewline = char.includes('\n') || char.includes('\r');
806
- const shouldHandleAsPaste = options.isPaste || (allowNewlines && hasNewline);
807
- // Detect paste explicitly or any newline-containing input when allowed
808
- if (shouldHandleAsPaste) {
809
- this.lastPasteTime = Date.now(); // Guard against trailing newline triggering submit
810
- if (options.isPaste) {
811
- this.handleMultilinePaste(char);
812
- return;
813
- }
814
- this.insertMultilineText(char);
815
- return;
816
- }
817
- // Ignore unexpected newlines unless explicitly allowed
818
- if (hasNewline) {
819
- return;
820
- }
821
- const sanitized = this.sanitizeInlineText(char);
822
- if (!sanitized)
823
- return;
824
- // Respect max input length
825
- const availableSpace = this.maxInputLength - this.inputBuffer.length;
826
- if (availableSpace <= 0)
827
- return;
828
- const chunk = sanitized.slice(0, availableSpace);
829
- if (!chunk)
830
- return;
831
- // Insert character at cursor position
832
- this.startManualEdit();
833
- this.applyBufferChange(this.inputBuffer.slice(0, this.cursorPosition) +
834
- chunk +
835
- this.inputBuffer.slice(this.cursorPosition), this.cursorPosition + chunk.length);
836
- }
837
- /**
838
- * Handle multiline paste - store full content and display all lines
839
- */
840
- handleMultilinePaste(content) {
841
- // Keep the original content for submission
842
- const displaySafeContent = this.sanitizeMultilineForDisplay(content);
843
- if (!displaySafeContent)
844
- return;
845
- const truncatedDisplay = displaySafeContent.slice(0, this.maxInputLength);
846
- const truncatedOriginal = content.slice(0, this.maxInputLength);
847
- this.pastedFullContent = truncatedOriginal;
848
- this.isPastedBlock = true;
849
- this.applyBufferChange(truncatedDisplay, truncatedDisplay.length);
850
- }
851
- /**
852
- * Public helper for routing external multi-line pastes directly into the chat box
853
- */
854
- handlePaste(content) {
855
- if (!content || !this.isEnabled || this.isDisposed)
856
- return;
857
- this.lastPasteTime = Date.now(); // Guard against trailing newline triggering submit
858
- this.handleMultilinePaste(content);
859
- }
860
- /**
861
- * Check if current input is a pasted block
862
- */
863
- hasPastedBlock() {
864
- return this.isPastedBlock;
865
- }
866
- /**
867
- * Get the full pasted content (for submission)
868
- */
869
- getPastedContent() {
870
- return this.pastedFullContent;
871
- }
872
- /**
873
- * Clear pasted block state
874
- */
875
- clearPastedBlock() {
876
- this.isPastedBlock = false;
877
- this.pastedFullContent = '';
878
- }
879
- /**
880
- * Public method to clear pasted block state (for Ctrl+C clearing)
881
- */
882
- clearPastedBlockState() {
883
- this.clearPastedBlock();
884
- this.applyBufferChange('', 0);
885
- }
886
- /**
887
- * Get terminal dimensions and a safe width for rendering input content.
888
- */
889
- getRenderDimensions() {
890
- const rows = this.writeStream.rows || 24;
891
- const cols = Math.max(10, this.writeStream.columns ?? 80);
892
- // Leave a small gutter for prefix + padding so we never wrap unexpectedly,
893
- // but respect the actual terminal width instead of forcing a wide minimum.
894
- const maxInputWidth = Math.max(8, cols - 5);
895
- return { rows, cols, maxInputWidth };
896
- }
897
- /**
898
- * Wrap the current input buffer to the provided width and locate the cursor.
899
- *
900
- * ROBUST CURSOR TRACKING:
901
- * - Uses cursorFound flag to ensure cursor is explicitly located
902
- * - Falls back to direct calculation if normal tracking fails
903
- * - Handles edge cases: empty input, cursor at end, cursor at newlines
904
- */
905
- wrapInputBuffer(maxInputWidth) {
906
- const width = Math.max(1, maxInputWidth);
907
- const wrappedLines = [];
908
- const lineStarts = [];
909
- const targetCursor = Math.max(0, Math.min(this.cursorPosition, this.inputBuffer.length));
910
- // Handle empty buffer case explicitly
911
- if (this.inputBuffer.length === 0) {
912
- return { lines: [''], cursorLine: 0, cursorCol: 0, lineStarts: [0] };
913
- }
914
- let cursorLine = 0;
915
- let cursorCol = 0;
916
- let cursorFound = false;
917
- let absoluteIndex = 0;
918
- const rawLines = this.inputBuffer.split('\n');
919
- for (let i = 0; i < rawLines.length; i++) {
920
- const raw = rawLines[i] ?? '';
921
- if (raw.length === 0) {
922
- // Preserve empty lines (from newlines)
923
- wrappedLines.push('');
924
- lineStarts.push(absoluteIndex);
925
- // Check if cursor is at this empty line position
926
- if (!cursorFound && targetCursor === absoluteIndex) {
927
- cursorLine = wrappedLines.length - 1;
928
- cursorCol = 0;
929
- cursorFound = true;
930
- }
931
- }
932
- else {
933
- // Wrap long lines at width boundary
934
- for (let start = 0; start < raw.length; start += width) {
935
- const segment = raw.slice(start, start + width);
936
- const segmentStart = absoluteIndex + start;
937
- const segmentEnd = segmentStart + segment.length;
938
- wrappedLines.push(segment);
939
- lineStarts.push(segmentStart);
940
- // Check if cursor falls within this segment (inclusive on both ends)
941
- if (!cursorFound && targetCursor >= segmentStart && targetCursor <= segmentEnd) {
942
- cursorLine = wrappedLines.length - 1;
943
- cursorCol = targetCursor - segmentStart;
944
- cursorFound = true;
945
- }
946
- }
947
- }
948
- absoluteIndex += raw.length;
949
- // Account for newline character between raw lines
950
- if (i < rawLines.length - 1) {
951
- // Check if cursor is at the newline position (start of next line)
952
- if (!cursorFound && targetCursor === absoluteIndex) {
953
- cursorLine = wrappedLines.length; // Will be the next line index
954
- cursorCol = 0;
955
- cursorFound = true;
956
- }
957
- absoluteIndex += 1; // Account for '\n'
958
- }
959
- }
960
- // Fallback: if cursor wasn't found, calculate directly from position
961
- if (!cursorFound && wrappedLines.length > 0) {
962
- // Find which wrapped line contains the cursor by checking lineStarts
963
- for (let i = lineStarts.length - 1; i >= 0; i--) {
964
- const lineStart = lineStarts[i] ?? 0;
965
- if (targetCursor >= lineStart) {
966
- cursorLine = i;
967
- cursorCol = targetCursor - lineStart;
968
- break;
969
- }
970
- }
971
- }
972
- // Safety: ensure wrappedLines has at least one entry
973
- if (wrappedLines.length === 0) {
974
- wrappedLines.push('');
975
- lineStarts.push(0);
976
- cursorLine = 0;
977
- cursorCol = 0;
978
- }
979
- // Safety bounds checks
980
- cursorLine = Math.max(0, Math.min(cursorLine, wrappedLines.length - 1));
981
- const lineLen = wrappedLines[cursorLine]?.length ?? 0;
982
- cursorCol = Math.max(0, Math.min(cursorCol, lineLen));
983
- return { lines: wrappedLines, cursorLine, cursorCol, lineStarts };
984
- }
985
- /**
986
- * Convert a wrapped line/column back to an absolute cursor index.
987
- */
988
- getWrappedCursorIndex(lineStarts, lineIndex, column) {
989
- const start = lineStarts[lineIndex] ?? 0;
990
- const safeColumn = Math.max(0, column);
991
- return Math.max(0, Math.min(start + safeColumn, this.inputBuffer.length));
992
- }
993
- /**
994
- * Calculate and update reserved lines based on current input content.
995
- * Ensures multi-line content is fully visible within maxDisplayLines limit.
996
- */
997
- updateReservedLinesForContent(maxInputWidth, wrappedLineCount) {
998
- const { rows, maxInputWidth: derivedWidth } = this.getRenderDimensions();
999
- const width = Math.max(1, maxInputWidth ?? derivedWidth);
1000
- const totalLines = wrappedLineCount ?? this.wrapInputBuffer(width).lines.length;
1001
- const lineCount = Math.max(1, totalLines);
1002
- // Calculate needed lines: 1 for separator + lineCount for input (clamped to maxDisplayLines)
1003
- const inputLines = Math.min(lineCount, this.maxDisplayLines);
1004
- const calculated = 1 + inputLines; // 1 separator + input lines
1005
- // Ensure we keep at least one row for output scrolling
1006
- const maxAllowed = Math.max(1, rows - 1);
1007
- const desired = Math.max(this.baseReservedLines, calculated);
1008
- this.reservedLines = Math.min(desired, maxAllowed);
1009
- // If we have a scroll region active, update it
1010
- if (this.scrollRegionActive) {
1011
- const scrollBottom = Math.max(1, rows - this.reservedLines);
1012
- this.safeWrite(ANSI.SET_SCROLL_REGION(1, scrollBottom));
1013
- }
1014
- }
1015
- /**
1016
- * Handle backspace
1017
- */
1018
- handleBackspace() {
1019
- if (!this.isEnabled || this.isDisposed)
1020
- return;
1021
- this.cursorPosition = Math.min(this.cursorPosition, this.inputBuffer.length);
1022
- if (this.cursorPosition === 0)
1023
- return;
1024
- const newBuffer = this.inputBuffer.slice(0, this.cursorPosition - 1) +
1025
- this.inputBuffer.slice(this.cursorPosition);
1026
- const newCursor = Math.max(0, this.cursorPosition - 1);
1027
- this.startManualEdit();
1028
- this.applyBufferChange(newBuffer, newCursor);
1029
- }
1030
- /**
1031
- * Handle delete key
1032
- */
1033
- handleDelete() {
1034
- if (!this.isEnabled || this.isDisposed)
1035
- return;
1036
- this.cursorPosition = Math.min(this.cursorPosition, this.inputBuffer.length);
1037
- if (this.cursorPosition >= this.inputBuffer.length)
1038
- return;
1039
- const newBuffer = this.inputBuffer.slice(0, this.cursorPosition) +
1040
- this.inputBuffer.slice(this.cursorPosition + 1);
1041
- this.startManualEdit();
1042
- this.applyBufferChange(newBuffer, this.cursorPosition);
1043
- }
1044
- /**
1045
- * Handle cursor left
1046
- */
1047
- handleCursorLeft() {
1048
- if (this.isDisposed)
1049
- return;
1050
- const bounded = Math.min(this.cursorPosition, this.inputBuffer.length);
1051
- if (bounded > 0) {
1052
- this.setCursorPosition(bounded - 1);
1053
- }
1054
- }
1055
- /**
1056
- * Handle cursor right
1057
- */
1058
- handleCursorRight() {
1059
- if (this.isDisposed)
1060
- return;
1061
- const bounded = Math.min(this.cursorPosition, this.inputBuffer.length);
1062
- if (bounded < this.inputBuffer.length) {
1063
- this.setCursorPosition(bounded + 1);
1064
- }
1065
- }
1066
- /**
1067
- * Handle home key - move to start of current line (not start of buffer)
1068
- */
1069
- handleHome() {
1070
- if (this.isDisposed)
1071
- return;
1072
- // In multi-line mode, move to start of the current wrapped line
1073
- const { maxInputWidth } = this.getRenderDimensions();
1074
- const { lineStarts, cursorLine } = this.wrapInputBuffer(maxInputWidth);
1075
- const newPos = this.getWrappedCursorIndex(lineStarts, cursorLine, 0);
1076
- this.setCursorPosition(newPos);
1077
- }
1078
- /**
1079
- * Handle end key (Ctrl+E) - move to end of current line (not end of buffer)
1080
- */
1081
- handleEnd() {
1082
- if (this.isDisposed)
1083
- return;
1084
- // In multi-line mode, move to end of the current wrapped line
1085
- const { maxInputWidth } = this.getRenderDimensions();
1086
- const { lines: wrappedLines, lineStarts, cursorLine } = this.wrapInputBuffer(maxInputWidth);
1087
- const currentLine = wrappedLines[cursorLine] ?? '';
1088
- const newPos = this.getWrappedCursorIndex(lineStarts, cursorLine, currentLine.length);
1089
- this.setCursorPosition(newPos);
1090
- }
1091
- /**
1092
- * Handle inserting a newline character at cursor position (Shift+Enter or Option+Enter)
1093
- */
1094
- handleNewline() {
1095
- if (!this.isEnabled || this.isDisposed)
1096
- return;
1097
- // Respect max input length
1098
- if (this.inputBuffer.length >= this.maxInputLength)
1099
- return;
1100
- this.startManualEdit();
1101
- this.applyBufferChange(this.inputBuffer.slice(0, this.cursorPosition) +
1102
- '\n' +
1103
- this.inputBuffer.slice(this.cursorPosition), this.cursorPosition + 1);
1104
- }
1105
- /**
1106
- * Handle up arrow - move cursor up in multi-line content, or navigate history if on first line
1107
- */
1108
- handleHistoryUp() {
1109
- if (this.isDisposed)
1110
- return;
1111
- // For multi-line content (including wrapped lines), move cursor up within content first
1112
- const { maxInputWidth } = this.getRenderDimensions();
1113
- const { lines: wrappedLines, lineStarts, cursorLine, cursorCol } = this.wrapInputBuffer(maxInputWidth);
1114
- if (cursorLine > 0) {
1115
- const prevLine = wrappedLines[cursorLine - 1] ?? '';
1116
- const newCol = Math.min(cursorCol, prevLine.length);
1117
- const newPos = this.getWrappedCursorIndex(lineStarts, cursorLine - 1, newCol);
1118
- this.setCursorPosition(newPos);
1119
- return;
1120
- }
1121
- // On first line - navigate history
1122
- if (this.inputHistory.length === 0)
1123
- return;
1124
- // If at current input, save it temporarily
1125
- if (this.historyIndex === -1) {
1126
- this.tempCurrentInput = this.inputBuffer;
1127
- }
1128
- // Move up in history
1129
- if (this.historyIndex < this.inputHistory.length - 1) {
1130
- this.historyIndex++;
1131
- const nextBuffer = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex] || '';
1132
- this.startManualEdit();
1133
- this.applyBufferChange(nextBuffer, nextBuffer.length);
1134
- }
1135
- }
1136
- /**
1137
- * Handle down arrow - move cursor down in multi-line content, or navigate history if on last line
1138
- */
1139
- handleHistoryDown() {
1140
- if (this.isDisposed)
1141
- return;
1142
- // For multi-line content (including wrapped lines), move cursor down within content first
1143
- const { maxInputWidth } = this.getRenderDimensions();
1144
- const { lines: wrappedLines, lineStarts, cursorLine, cursorCol } = this.wrapInputBuffer(maxInputWidth);
1145
- if (cursorLine < wrappedLines.length - 1) {
1146
- const nextLine = wrappedLines[cursorLine + 1] ?? '';
1147
- const newCol = Math.min(cursorCol, nextLine.length);
1148
- const newPos = this.getWrappedCursorIndex(lineStarts, cursorLine + 1, newCol);
1149
- this.setCursorPosition(newPos);
1150
- return;
1151
- }
1152
- // On last line - navigate history
1153
- if (this.historyIndex > 0) {
1154
- // Move down in history
1155
- this.historyIndex--;
1156
- const nextBuffer = this.inputHistory[this.inputHistory.length - 1 - this.historyIndex] || '';
1157
- this.startManualEdit();
1158
- this.applyBufferChange(nextBuffer, nextBuffer.length);
1159
- }
1160
- else if (this.historyIndex === 0) {
1161
- // Return to current input
1162
- this.historyIndex = -1;
1163
- const restored = this.tempCurrentInput;
1164
- this.startManualEdit();
1165
- this.applyBufferChange(restored, restored.length);
1166
- }
1167
- }
1168
- /**
1169
- * Handle Ctrl+U - delete from cursor to start of line
1170
- */
1171
- handleDeleteToStart() {
1172
- if (this.isDisposed)
1173
- return;
1174
- if (this.cursorPosition === 0)
1175
- return;
1176
- this.startManualEdit();
1177
- const newBuffer = this.inputBuffer.slice(this.cursorPosition);
1178
- this.applyBufferChange(newBuffer, 0);
1179
- }
1180
- /**
1181
- * Handle Ctrl+K - delete from cursor to end of line
1182
- */
1183
- handleDeleteToEnd() {
1184
- if (this.isDisposed)
1185
- return;
1186
- if (this.cursorPosition >= this.inputBuffer.length)
1187
- return;
1188
- this.startManualEdit();
1189
- const newBuffer = this.inputBuffer.slice(0, this.cursorPosition);
1190
- this.applyBufferChange(newBuffer, this.cursorPosition);
1191
- }
1192
- /**
1193
- * Handle Ctrl+W - delete word before cursor
1194
- */
1195
- handleDeleteWord() {
1196
- if (this.isDisposed)
1197
- return;
1198
- if (this.cursorPosition === 0)
1199
- return;
1200
- // Find the start of the word (skip trailing spaces, then find word boundary)
1201
- let pos = this.cursorPosition;
1202
- // Skip any spaces before cursor
1203
- while (pos > 0 && this.isWhitespace(this.inputBuffer[pos - 1])) {
1204
- pos--;
1205
- }
1206
- // Find start of word
1207
- while (pos > 0 && !this.isWhitespace(this.inputBuffer[pos - 1])) {
1208
- pos--;
1209
- }
1210
- const newBuffer = this.inputBuffer.slice(0, pos) + this.inputBuffer.slice(this.cursorPosition);
1211
- this.startManualEdit();
1212
- this.applyBufferChange(newBuffer, pos);
1213
- }
1214
- /**
1215
- * Handle Alt+Left - move cursor to previous word
1216
- */
1217
- handleWordLeft() {
1218
- if (this.isDisposed)
1219
- return;
1220
- if (this.cursorPosition === 0)
1221
- return;
1222
- let pos = this.cursorPosition;
1223
- // Skip any spaces before cursor
1224
- while (pos > 0 && this.isWhitespace(this.inputBuffer[pos - 1])) {
1225
- pos--;
1226
- }
1227
- // Find start of word
1228
- while (pos > 0 && !this.isWhitespace(this.inputBuffer[pos - 1])) {
1229
- pos--;
1230
- }
1231
- this.setCursorPosition(pos);
1232
- }
1233
- /**
1234
- * Handle Alt+Right - move cursor to next word
1235
- */
1236
- handleWordRight() {
1237
- if (this.isDisposed)
1238
- return;
1239
- if (this.cursorPosition >= this.inputBuffer.length)
1240
- return;
1241
- let pos = this.cursorPosition;
1242
- // Skip current word
1243
- while (pos < this.inputBuffer.length && !this.isWhitespace(this.inputBuffer[pos])) {
1244
- pos++;
1245
- }
1246
- // Skip any spaces
1247
- while (pos < this.inputBuffer.length && this.isWhitespace(this.inputBuffer[pos])) {
1248
- pos++;
1249
- }
1250
- this.setCursorPosition(pos);
1251
- }
1252
- /**
1253
- * Add input to history (call after successful submit)
1254
- */
1255
- addToHistory(input) {
1256
- if (!input || this.isDisposed)
1257
- return;
1258
- // Don't add duplicates of the last entry
1259
- if (this.inputHistory.length > 0 && this.inputHistory[this.inputHistory.length - 1] === input) {
1260
- return;
1261
- }
1262
- this.inputHistory.push(input);
1263
- // Trim history if too large
1264
- if (this.inputHistory.length > this.maxHistorySize) {
1265
- this.inputHistory = this.inputHistory.slice(-this.maxHistorySize);
1266
- }
1267
- // Reset history navigation state
1268
- this.historyIndex = -1;
1269
- this.tempCurrentInput = '';
1270
- }
1271
- /**
1272
- * Handle enter/submit
1273
- * If there's pasted content, sends the full content (not the summary)
1274
- */
1275
- handleSubmit() {
1276
- if (!this.isEnabled || this.isDisposed)
1277
- return null;
1278
- // Guard: Ignore submit if it comes too soon after a paste (trailing newline issue)
1279
- const timeSincePaste = Date.now() - this.lastPasteTime;
1280
- if (timeSincePaste < this.pasteSubmitGuardMs) {
1281
- return null;
1282
- }
1283
- // If we have pasted content, use the full content instead of the display summary
1284
- let input;
1285
- if (this.isPastedBlock && this.pastedFullContent) {
1286
- input = this.pastedFullContent;
1287
- }
1288
- else {
1289
- input = this.sanitizeCommandText(this.inputBuffer);
1290
- }
1291
- if (!input)
1292
- return null;
1293
- // Add to history before processing (store summary for paste, not full content)
1294
- const historyEntry = this.isPastedBlock
1295
- ? this.inputBuffer // Store the summary in history
1296
- : input;
1297
- this.addToHistory(historyEntry);
1298
- // If processing, queue the command instead of submitting
1299
- if (this.state.isProcessing) {
1300
- const type = input.startsWith('/') ? 'slash' : 'request';
1301
- const queued = this.queueCommand(input, type);
1302
- if (!queued) {
1303
- this.setStatusMessage(`Queue is full (${this.maxQueueSize}). Submit after current task or clear queued items.`);
1304
- return null;
1305
- }
1306
- this.clearInput();
1307
- return null;
1308
- }
1309
- // Clear input and paste state, then notify
1310
- this.clearInput();
1311
- if (this.onInputSubmit) {
1312
- this.onInputSubmit(input);
1313
- }
1314
- return input;
1315
- }
1316
- /**
1317
- * Clear input buffer
1318
- */
1319
- clearInput() {
1320
- if (this.isDisposed)
1321
- return;
1322
- // Reset history navigation
1323
- this.historyIndex = -1;
1324
- this.tempCurrentInput = '';
1325
- // Clear paste state
1326
- this.clearPastedBlock();
1327
- this.applyBufferChange('', 0);
1328
- }
1329
- /**
1330
- * Set input text and optionally cursor position (for readline sync)
1331
- * Note: Does NOT trigger render - readline handles display during input
1332
- */
1333
- setInput(text, cursorPos) {
1334
- if (this.isDisposed)
1335
- return;
1336
- const normalized = this.sanitizeMultilineForDisplay(text).slice(0, this.maxInputLength);
1337
- this.inputBuffer = normalized;
1338
- // Use provided cursor position, or default to end of input
1339
- this.cursorPosition = typeof cursorPos === 'number'
1340
- ? Math.max(0, Math.min(cursorPos, normalized.length))
1341
- : normalized.length;
1342
- this.state.currentInput = normalized;
1343
- this.updateReservedLinesForContent();
1344
- // Do NOT schedule render here - readline handles display during input
1345
- // render() will only work when isProcessing=true anyway
1346
- }
1347
- /**
1348
- * Get current input
1349
- */
1350
- getInput() {
1351
- return this.inputBuffer;
1352
- }
1353
- /**
1354
- * Schedule a render on next tick (debounced and throttled)
1355
- */
1356
- scheduleRender() {
1357
- if (this.renderScheduled || !this.supportsRendering())
1358
- return;
1359
- const now = Date.now();
1360
- const timeSinceLastRender = now - this.lastRenderTime;
1361
- // Throttle renders to prevent terminal flooding
1362
- if (timeSinceLastRender < this.renderThrottleMs) {
1363
- this.renderScheduled = true;
1364
- setTimeout(() => {
1365
- this.renderScheduled = false;
1366
- if (!this.isDisposed) {
1367
- this.render();
1368
- }
1369
- }, this.renderThrottleMs - timeSinceLastRender);
1370
- return;
1371
- }
1372
- this.renderScheduled = true;
1373
- queueMicrotask(() => {
1374
- this.renderScheduled = false;
1375
- if (!this.isDisposed) {
1376
- this.render();
1377
- }
1378
- });
1379
- }
1380
- /**
1381
- * Render the chat box with cursor tracking and background.
1382
- *
1383
- * Always uses renderPersistentInput for consistent display:
1384
- * - Dark background to denote user typing area
1385
- * - Block cursor that tracks actual cursor position
1386
- * - Multi-line support with proper vertical layout
1387
- */
1388
- render() {
1389
- if (!this.state.isVisible || !this.supportsRendering())
1390
- return;
1391
- // Always use persistent input renderer for consistent cursor and background
1392
- this.renderPersistentInput();
1393
- }
1394
- /**
1395
- * Clear the pinned area
1396
- */
1397
- clear() {
1398
- if (!this.supportsRendering())
1399
- return;
1400
- // If we rendered a multi-line box, move up and clear it
1401
- if (this._lastRenderedHeight > 1) {
1402
- this.safeWrite(ANSI.MOVE_UP(this._lastRenderedHeight - 1));
1403
- }
1404
- this.safeWrite(`\r${ANSI.CLEAR_TO_END}`);
1405
- this._lastRenderedHeight = 0;
1406
- }
1407
- /**
1408
- * Show the chat box
1409
- */
1410
- show() {
1411
- if (this.isDisposed)
1412
- return;
1413
- this.state.isVisible = true;
1414
- this.render();
1415
- }
1416
- /**
1417
- * Hide the chat box
1418
- */
1419
- hide() {
1420
- if (this.isDisposed)
1421
- return;
1422
- this.state.isVisible = false;
1423
- this.clear();
1424
- }
1425
- /**
1426
- * Get current state
1427
- */
1428
- getState() {
1429
- return { ...this.state };
1430
- }
1431
- /**
1432
- * Handle terminal resize - update scroll region if active
1433
- */
1434
- handleResize() {
1435
- // Invalidate render state to force re-render with new dimensions
1436
- this.invalidateRenderedState();
1437
- const wasActive = this.scrollRegionActive;
1438
- if (wasActive) {
1439
- // Reset scroll region before recalculating dimensions
1440
- this.safeWrite(ANSI.RESET_SCROLL_REGION);
1441
- this.scrollRegionActive = false;
1442
- }
1443
- // Recompute reserved lines for the new terminal size
1444
- this.updateReservedLinesForContent();
1445
- if (wasActive) {
1446
- // Re-enable scroll region with the updated height
1447
- this.enableScrollRegion();
1448
- }
1449
- this.scheduleRender();
1450
- }
1451
- /**
1452
- * Get number of reserved lines at bottom
1453
- */
1454
- getReservedLines() {
1455
- return this.reservedLines;
1456
- }
1457
- /**
1458
- * Get last rendered height (for layout calculations)
1459
- */
1460
- getLastRenderedHeight() {
1461
- return this._lastRenderedHeight;
1462
- }
1463
- /**
1464
- * Dispose and cleanup
1465
- */
1466
- dispose() {
1467
- if (this.isDisposed)
1468
- return;
1469
- // Clean up pending render timeout
1470
- if (this.pendingAfterWriteRender) {
1471
- clearTimeout(this.pendingAfterWriteRender);
1472
- this.pendingAfterWriteRender = null;
1473
- }
1474
- // Clean up output interceptor registration
1475
- if (this.outputInterceptorCleanup) {
1476
- this.outputInterceptorCleanup();
1477
- this.outputInterceptorCleanup = undefined;
1478
- }
1479
- try {
1480
- this.clear();
1481
- }
1482
- catch {
1483
- // Ignore errors during cleanup
1484
- }
1485
- this.isDisposed = true;
1486
- this.isEnabled = false;
1487
- this.inputBuffer = '';
1488
- this.state.queuedCommands = [];
1489
- this.onCommandQueued = undefined;
1490
- this.onInputSubmit = undefined;
1491
- }
1492
- /**
1493
- * Check if disposed
1494
- */
1495
- isActive() {
1496
- return !this.isDisposed && this.isEnabled;
1497
- }
1498
- /**
1499
- * Force immediate render (bypass throttling and deduplication)
1500
- */
1501
- forceRender() {
1502
- if (this.isDisposed)
1503
- return;
1504
- this.lastRenderTime = 0;
1505
- this.renderScheduled = false;
1506
- this.invalidateRenderedState(); // Force re-render even if content unchanged
1507
- this.render();
1508
- }
1509
- /**
1510
- * Reset state to clean defaults
1511
- */
1512
- reset() {
1513
- if (this.isDisposed)
1514
- return;
1515
- this.inputBuffer = '';
1516
- this.cursorPosition = 0;
1517
- this.state = {
1518
- isProcessing: false,
1519
- queuedCommands: [],
1520
- currentInput: '',
1521
- contextUsage: 0,
1522
- statusMessage: null,
1523
- isVisible: true,
1524
- };
1525
- this.invalidateRenderedState();
1526
- this.scheduleRender();
1527
- }
1528
- }
1529
- //# sourceMappingURL=persistentPrompt.js.map