erosolar-cli 1.7.429 → 1.7.430

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 (83) hide show
  1. package/dist/capabilities/enhancedGitCapability.js +3 -3
  2. package/dist/capabilities/enhancedGitCapability.js.map +1 -1
  3. package/dist/capabilities/learnCapability.d.ts +1 -1
  4. package/dist/capabilities/learnCapability.d.ts.map +1 -1
  5. package/dist/capabilities/learnCapability.js +1 -1
  6. package/dist/capabilities/learnCapability.js.map +1 -1
  7. package/dist/core/checkpoint.d.ts +1 -1
  8. package/dist/core/checkpoint.js +1 -1
  9. package/dist/core/costTracker.d.ts +1 -1
  10. package/dist/core/costTracker.js +1 -1
  11. package/dist/core/hooks.d.ts +1 -1
  12. package/dist/core/hooks.js +1 -1
  13. package/dist/core/memorySystem.d.ts +2 -2
  14. package/dist/core/memorySystem.js +2 -2
  15. package/dist/core/outputStyles.d.ts +2 -2
  16. package/dist/core/outputStyles.js +2 -2
  17. package/dist/core/validationRunner.d.ts +1 -1
  18. package/dist/core/validationRunner.js +1 -1
  19. package/dist/shell/interactiveShell.d.ts +5 -1
  20. package/dist/shell/interactiveShell.d.ts.map +1 -1
  21. package/dist/shell/interactiveShell.js +115 -83
  22. package/dist/shell/interactiveShell.js.map +1 -1
  23. package/dist/shell/systemPrompt.d.ts.map +1 -1
  24. package/dist/shell/systemPrompt.js +13 -34
  25. package/dist/shell/systemPrompt.js.map +1 -1
  26. package/dist/shell/terminalInputAdapter.d.ts +75 -83
  27. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  28. package/dist/shell/terminalInputAdapter.js +159 -220
  29. package/dist/shell/terminalInputAdapter.js.map +1 -1
  30. package/dist/shell/vimMode.d.ts +1 -1
  31. package/dist/shell/vimMode.js +1 -1
  32. package/dist/tools/buildTools.d.ts +1 -1
  33. package/dist/tools/buildTools.js +1 -1
  34. package/dist/tools/diffUtils.d.ts +2 -2
  35. package/dist/tools/diffUtils.js +2 -2
  36. package/dist/tools/editTools.js +4 -4
  37. package/dist/tools/editTools.js.map +1 -1
  38. package/dist/tools/localExplore.d.ts +3 -3
  39. package/dist/tools/localExplore.js +3 -3
  40. package/dist/tools/skillTools.js +2 -2
  41. package/dist/tools/skillTools.js.map +1 -1
  42. package/dist/tools/validationTools.js +1 -1
  43. package/dist/ui/DisplayEventQueue.d.ts +99 -0
  44. package/dist/ui/DisplayEventQueue.d.ts.map +1 -0
  45. package/dist/ui/DisplayEventQueue.js +167 -0
  46. package/dist/ui/DisplayEventQueue.js.map +1 -0
  47. package/dist/ui/SequentialRenderer.d.ts +69 -0
  48. package/dist/ui/SequentialRenderer.d.ts.map +1 -0
  49. package/dist/ui/SequentialRenderer.js +137 -0
  50. package/dist/ui/SequentialRenderer.js.map +1 -0
  51. package/dist/ui/ShellUIAdapter.d.ts +18 -6
  52. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  53. package/dist/ui/ShellUIAdapter.js +65 -14
  54. package/dist/ui/ShellUIAdapter.js.map +1 -1
  55. package/dist/ui/UnifiedUIRenderer.d.ts +184 -0
  56. package/dist/ui/UnifiedUIRenderer.d.ts.map +1 -0
  57. package/dist/ui/UnifiedUIRenderer.js +567 -0
  58. package/dist/ui/UnifiedUIRenderer.js.map +1 -0
  59. package/dist/ui/display.d.ts +100 -173
  60. package/dist/ui/display.d.ts.map +1 -1
  61. package/dist/ui/display.js +359 -927
  62. package/dist/ui/display.js.map +1 -1
  63. package/dist/ui/errorFormatter.d.ts +1 -1
  64. package/dist/ui/errorFormatter.js +1 -1
  65. package/dist/ui/shortcutsHelp.d.ts +6 -6
  66. package/dist/ui/shortcutsHelp.js +6 -6
  67. package/dist/ui/streamingFormatter.d.ts +2 -5
  68. package/dist/ui/streamingFormatter.d.ts.map +1 -1
  69. package/dist/ui/streamingFormatter.js +9 -33
  70. package/dist/ui/streamingFormatter.js.map +1 -1
  71. package/dist/ui/textHighlighter.d.ts +8 -8
  72. package/dist/ui/textHighlighter.js +9 -9
  73. package/dist/ui/textHighlighter.js.map +1 -1
  74. package/dist/ui/theme.d.ts +2 -2
  75. package/dist/ui/theme.js +4 -4
  76. package/dist/ui/theme.js.map +1 -1
  77. package/dist/ui/toolDisplay.d.ts +8 -8
  78. package/dist/ui/toolDisplay.js +8 -8
  79. package/package.json +1 -1
  80. package/dist/shell/terminalInput.d.ts +0 -619
  81. package/dist/shell/terminalInput.d.ts.map +0 -1
  82. package/dist/shell/terminalInput.js +0 -2699
  83. package/dist/shell/terminalInput.js.map +0 -1
@@ -1,2699 +0,0 @@
1
- /**
2
- * TerminalInput - Clean, unified terminal input handling
3
- *
4
- * Design principles:
5
- * - Single source of truth for input state
6
- * - Hybrid floating/scroll approach:
7
- * - Initially: chat box floats below content
8
- * - When terminal fills: scroll region activates, chat box pins to bottom
9
- * - Native bracketed paste support (no heuristics)
10
- * - Clean cursor model with render-time wrapping
11
- * - State machine for different input modes
12
- * - No readline dependency for display
13
- * - Text selection enabled: mouse tracking disabled by default to preserve
14
- * native terminal text selection and copy/paste functionality
15
- * - Scrollback navigation via keyboard: PageUp/PageDown, Ctrl+Home/End
16
- */
17
- import { EventEmitter } from 'node:events';
18
- import { isMultilinePaste } from '../core/multilinePasteHandler.js';
19
- import { writeLock } from '../ui/writeLock.js';
20
- import { renderDivider, renderStatusLine, renderStatusLines } from '../ui/unified/layout.js';
21
- import { isStreamingMode } from '../ui/globalWriteLock.js';
22
- import { formatThinking } from '../ui/toolDisplay.js';
23
- import { theme } from '../ui/theme.js';
24
- import { formatEditModeIndicator } from '../ui/shortcutsHelp.js';
25
- // ANSI escape codes
26
- const ESC = {
27
- // Cursor control
28
- SAVE: '\x1b7',
29
- RESTORE: '\x1b8',
30
- HIDE: '\x1b[?25l',
31
- SHOW: '\x1b[?25h',
32
- TO: (row, col) => `\x1b[${row};${col}H`,
33
- TO_COL: (col) => `\x1b[${col}G`,
34
- // Line control
35
- CLEAR_LINE: '\x1b[2K',
36
- CLEAR_TO_END: '\x1b[0J',
37
- // Screen control
38
- HOME: '\x1b[H',
39
- CLEAR_SCREEN: '\x1b[2J',
40
- // Alternate screen buffer (like vim/tmux)
41
- ENTER_ALT_SCREEN: '\x1b[?1049h',
42
- EXIT_ALT_SCREEN: '\x1b[?1049l',
43
- // Scroll region
44
- SET_SCROLL: (top, bottom) => `\x1b[${top};${bottom}r`,
45
- RESET_SCROLL: '\x1b[r',
46
- // Style
47
- RESET: '\x1b[0m',
48
- DIM: '\x1b[2m',
49
- REVERSE: '\x1b[7m',
50
- BOLD: '\x1b[1m',
51
- BG_DARK: '\x1b[48;5;236m', // Dark gray background
52
- // Bracketed paste mode
53
- PASTE_ENABLE: '\x1b[?2004h',
54
- PASTE_DISABLE: '\x1b[?2004l',
55
- PASTE_START: '\x1b[200~',
56
- PASTE_END: '\x1b[201~',
57
- // Mouse tracking - Button events only (allows Shift+drag for text selection)
58
- // Mode 1000: Track button presses/releases (wheel events included)
59
- // Mode 1006: SGR extended format for better coordinate handling
60
- // Note: NOT using mode 1003 (all motion) to preserve terminal text selection
61
- MOUSE_ENABLE: '\x1b[?1000h\x1b[?1006h', // Enable button tracking + SGR mode
62
- MOUSE_DISABLE: '\x1b[?1006l\x1b[?1000l', // Disable mouse tracking
63
- };
64
- /**
65
- * Unified terminal input handler
66
- */
67
- export class TerminalInput extends EventEmitter {
68
- out;
69
- config;
70
- // Core state
71
- buffer = '';
72
- cursor = 0;
73
- mode = 'idle';
74
- // Paste accumulation
75
- pasteBuffer = '';
76
- isPasting = false;
77
- pastePlaceholders = [];
78
- pasteCounter = 0;
79
- // History
80
- history = [];
81
- historyIndex = -1;
82
- tempInput = '';
83
- maxHistory = 100;
84
- // Queue for streaming mode
85
- queue = [];
86
- queueIdCounter = 0;
87
- // Display state
88
- statusMessage = null;
89
- overrideStatusMessage = null; // Secondary status (warnings, etc.)
90
- streamingLabel = null; // Streaming progress indicator
91
- metaElapsedSeconds = null; // Optional elapsed time for header line
92
- metaTokensUsed = null; // Optional token usage
93
- metaTokenLimit = null; // Optional token window
94
- metaThinkingMs = null; // Optional thinking duration
95
- metaThinkingHasContent = false; // Whether collapsed thinking content exists
96
- lastRenderContent = '';
97
- lastRenderCursor = -1;
98
- renderDirty = false;
99
- isRendering = false;
100
- // Lifecycle
101
- disposed = false;
102
- enabled = true;
103
- contextUsage = null;
104
- contextAutoCompactThreshold = 90;
105
- // Track current content row (starts at top, moves down)
106
- contentRow = 1;
107
- // Track if scroll region is currently active
108
- scrollRegionActive = false;
109
- thinkingModeLabel = null;
110
- // Scrollback buffer
111
- scrollbackBuffer = [];
112
- maxScrollbackLines = 10000;
113
- scrollbackOffset = 0; // 0 = at bottom (live), > 0 = scrolled up
114
- isInScrollbackMode = false;
115
- scrollIndicatorFrame = 0; // For animated scroll indicator
116
- // Alternate screen state
117
- alternateScreenActive = false;
118
- editMode = 'display-edits';
119
- verificationEnabled = false;
120
- autoContinueEnabled = false;
121
- verificationHotkey = 'ctrl+shift+v';
122
- autoContinueHotkey = 'ctrl+shift+c';
123
- thinkingHotkey = 'tab';
124
- modelLabel = null;
125
- providerLabel = null;
126
- // Streaming render throttle
127
- lastStreamingRender = 0;
128
- streamingRenderInterval = 250; // ms between renders during streaming
129
- streamingRenderTimer = null;
130
- // Command autocomplete state
131
- commandSuggestions = [];
132
- showingSuggestions = false;
133
- selectedSuggestionIndex = 0;
134
- maxVisibleSuggestions = 10;
135
- suggestionRenderState = null;
136
- lastSuggestionStartRow = null;
137
- lastChatBoxStartRow = null;
138
- lastChatBoxHeight = 0;
139
- displayInterceptorDispose = null;
140
- constructor(writeStream = process.stdout, config = {}) {
141
- super();
142
- this.out = writeStream;
143
- this.config = {
144
- maxLines: config.maxLines ?? 1000,
145
- maxLength: config.maxLength ?? 10000,
146
- maxQueueSize: config.maxQueueSize ?? 100,
147
- promptChar: config.promptChar ?? '> ',
148
- continuationChar: config.continuationChar ?? '│ ',
149
- };
150
- }
151
- // ===========================================================================
152
- // PUBLIC API
153
- // ===========================================================================
154
- /**
155
- * Enable bracketed paste mode in terminal
156
- */
157
- enableBracketedPaste() {
158
- if (this.isTTY()) {
159
- this.write(ESC.PASTE_ENABLE);
160
- }
161
- }
162
- /**
163
- * Disable bracketed paste mode
164
- */
165
- disableBracketedPaste() {
166
- if (this.isTTY()) {
167
- this.write(ESC.PASTE_DISABLE);
168
- }
169
- }
170
- /**
171
- * Enable mouse tracking in terminal
172
- */
173
- enableMouseTracking() {
174
- if (this.isTTY()) {
175
- this.write(ESC.MOUSE_ENABLE);
176
- }
177
- }
178
- /**
179
- * Disable mouse tracking
180
- */
181
- disableMouseTracking() {
182
- if (this.isTTY()) {
183
- this.write(ESC.MOUSE_DISABLE);
184
- }
185
- }
186
- /**
187
- * Process raw terminal data (handles bracketed paste sequences and mouse events)
188
- * Returns true if the data was consumed (paste sequence)
189
- */
190
- processRawData(data) {
191
- // Handle ALL mouse events in the data (SGR mode: \x1b[<button;x;yM or m)
192
- // Process repeatedly until no more mouse events
193
- const mousePattern = /\x1b\[<(\d+);(\d+);(\d+)([Mm])/g;
194
- let remaining = data;
195
- let hadMouseEvents = false;
196
- // Remove all mouse escape sequences from the data
197
- let match;
198
- while ((match = mousePattern.exec(data)) !== null) {
199
- const button = parseInt(match[1], 10);
200
- const x = parseInt(match[2], 10);
201
- const y = parseInt(match[3], 10);
202
- this.handleMouseEvent(button, x, y, match[4]);
203
- hadMouseEvents = true;
204
- }
205
- if (hadMouseEvents) {
206
- // Strip all mouse sequences from the data
207
- remaining = data.replace(/\x1b\[<\d+;\d+;\d+[Mm]/g, '');
208
- if (!remaining) {
209
- return { consumed: true, passthrough: '' };
210
- }
211
- // If there's remaining content, continue processing it
212
- }
213
- // Check for paste start
214
- if (remaining.includes(ESC.PASTE_START)) {
215
- this.isPasting = true;
216
- this.pasteBuffer = '';
217
- // Extract content after paste start
218
- const startIdx = remaining.indexOf(ESC.PASTE_START) + ESC.PASTE_START.length;
219
- const afterStart = remaining.slice(startIdx);
220
- // Check if paste end is also in this chunk
221
- if (afterStart.includes(ESC.PASTE_END)) {
222
- const endIdx = afterStart.indexOf(ESC.PASTE_END);
223
- this.pasteBuffer = afterStart.slice(0, endIdx);
224
- this.finishPaste();
225
- return { consumed: true, passthrough: afterStart.slice(endIdx + ESC.PASTE_END.length) };
226
- }
227
- this.pasteBuffer = afterStart;
228
- return { consumed: true, passthrough: '' };
229
- }
230
- // Accumulating paste
231
- if (this.isPasting) {
232
- if (remaining.includes(ESC.PASTE_END)) {
233
- const endIdx = remaining.indexOf(ESC.PASTE_END);
234
- this.pasteBuffer += remaining.slice(0, endIdx);
235
- this.finishPaste();
236
- return { consumed: true, passthrough: remaining.slice(endIdx + ESC.PASTE_END.length) };
237
- }
238
- this.pasteBuffer += remaining;
239
- return { consumed: true, passthrough: '' };
240
- }
241
- // If we processed mouse events but have remaining text, return it as passthrough
242
- if (hadMouseEvents && remaining) {
243
- return { consumed: true, passthrough: remaining };
244
- }
245
- return { consumed: false, passthrough: remaining };
246
- }
247
- /**
248
- * Handle mouse events (button, x, y coordinates, action)
249
- */
250
- handleMouseEvent(button, x, y, action) {
251
- // Mouse wheel events: button 64 = scroll up, button 65 = scroll down
252
- if (button === 64) {
253
- // Scroll up (3 lines per wheel tick)
254
- this.scrollUp(3);
255
- return;
256
- }
257
- if (button === 65) {
258
- // Scroll down (3 lines per wheel tick)
259
- this.scrollDown(3);
260
- return;
261
- }
262
- // Left-click selection inside the command suggestions menu
263
- if (button === 0 && action === 'M') {
264
- if (this.handleSuggestionClick(y)) {
265
- return;
266
- }
267
- // No other left-click handling needed; fall through to consume silently
268
- }
269
- // Left button (0), middle button (1), right button (2)
270
- // These are captured by SGR mouse tracking but we don't need to handle them
271
- // Just consume silently - the escape sequence has already been processed
272
- // The 'M' action is press, 'm' is release
273
- if (button <= 2) {
274
- // Silently consume click events to prevent artifacts
275
- // Left clicks (button=0) in the input area could focus the input
276
- // but terminal apps typically handle this natively
277
- return;
278
- }
279
- // Button with motion flag (button + 32) - drag events
280
- if (button >= 32 && button <= 34) {
281
- // Drag events - consume silently
282
- return;
283
- }
284
- // Any other unrecognized button - log for debugging in dev mode
285
- // but don't output anything to prevent artifacts
286
- }
287
- /**
288
- * Handle a keypress event
289
- */
290
- handleKeypress(str, key) {
291
- if (this.disposed || !this.enabled)
292
- return;
293
- const normalizedName = key?.name ??
294
- this.getArrowKeyName(key?.sequence ?? str) ??
295
- this.getFallbackKeyName(str);
296
- const effectiveKey = normalizedName ? { ...key, name: normalizedName } : key;
297
- const safeStr = (this.isArrowEscapeFragment(str) || this.isBackspaceChar(str)) ? undefined : str;
298
- let handled = false;
299
- // Handle control keys
300
- if (effectiveKey?.ctrl) {
301
- handled = this.handleCtrlKey(effectiveKey);
302
- if (handled) {
303
- return;
304
- }
305
- }
306
- // Handle meta/alt keys
307
- if (effectiveKey?.meta) {
308
- this.handleMetaKey(effectiveKey);
309
- return;
310
- }
311
- // Handle special keys
312
- if (effectiveKey?.name) {
313
- const handled = this.handleSpecialKey(safeStr, effectiveKey);
314
- if (handled)
315
- return;
316
- }
317
- // Ignore orphaned escape fragments that sometimes leak through (e.g., "[D" from arrow keys)
318
- if (safeStr && this.isOrphanedEscapeSequence(safeStr, effectiveKey)) {
319
- return;
320
- }
321
- // Ignore stray control characters that weren't mapped to a key
322
- if (safeStr && this.isStandaloneControlChar(safeStr)) {
323
- return;
324
- }
325
- // Insert printable characters
326
- if (safeStr && !effectiveKey?.ctrl && !effectiveKey?.meta) {
327
- this.insertText(safeStr);
328
- }
329
- }
330
- /**
331
- * Set the input mode
332
- *
333
- * Content flows naturally - no scroll region pinning.
334
- */
335
- setMode(mode) {
336
- const prevMode = this.mode;
337
- this.mode = mode;
338
- if (mode === 'streaming' && prevMode !== 'streaming') {
339
- this.resetStreamingRenderThrottle();
340
- this.renderDirty = true;
341
- this.render();
342
- }
343
- else if (mode !== 'streaming' && prevMode === 'streaming') {
344
- this.resetStreamingRenderThrottle();
345
- this.renderDirty = true;
346
- this.render();
347
- }
348
- }
349
- /**
350
- * Legacy method - no longer used (content flows naturally).
351
- * @deprecated Use setContentRow instead
352
- */
353
- setPinnedHeaderLines(_count) {
354
- // No-op: scroll region pinning removed
355
- }
356
- /**
357
- * Get current mode
358
- */
359
- getMode() {
360
- return this.mode;
361
- }
362
- /**
363
- * Get the current buffer content (may contain placeholders for pasted text)
364
- */
365
- getBuffer() {
366
- return this.buffer;
367
- }
368
- /**
369
- * Get the actual text with paste placeholders expanded
370
- */
371
- getText() {
372
- return this.assembleText();
373
- }
374
- /**
375
- * Set buffer content
376
- */
377
- setBuffer(text, cursorPos) {
378
- this.pastePlaceholders = [];
379
- const clean = this.sanitize(text).slice(0, this.config.maxLength);
380
- this.buffer = clean;
381
- this.cursor = cursorPos ?? this.buffer.length;
382
- this.clampCursor();
383
- this.scheduleRender();
384
- }
385
- /**
386
- * Clear the buffer
387
- */
388
- clear() {
389
- this.buffer = '';
390
- this.cursor = 0;
391
- this.historyIndex = -1;
392
- this.tempInput = '';
393
- this.pastePlaceholders = [];
394
- this.showingSuggestions = false;
395
- this.selectedSuggestionIndex = 0;
396
- this.scheduleRender();
397
- }
398
- /**
399
- * Get queued inputs
400
- */
401
- getQueue() {
402
- return [...this.queue];
403
- }
404
- /**
405
- * Dequeue next input
406
- */
407
- dequeue() {
408
- const item = this.queue.shift();
409
- this.scheduleRender();
410
- return item;
411
- }
412
- /**
413
- * Clear the queue
414
- */
415
- clearQueue() {
416
- this.queue = [];
417
- this.scheduleRender();
418
- }
419
- // ===========================================================================
420
- // COMMAND AUTOCOMPLETE
421
- // ===========================================================================
422
- /**
423
- * Set available commands for autocomplete.
424
- * Call this once during initialization with all slash commands.
425
- */
426
- setAvailableCommands(commands) {
427
- this.commandSuggestions = commands;
428
- }
429
- /**
430
- * Check if command suggestions are currently being shown
431
- */
432
- isShowingSuggestions() {
433
- return this.showingSuggestions;
434
- }
435
- /**
436
- * Get filtered commands based on current input
437
- */
438
- getFilteredCommands() {
439
- const input = this.buffer.trim().toLowerCase();
440
- // Only show suggestions if buffer starts with "/"
441
- if (!input.startsWith('/')) {
442
- return [];
443
- }
444
- // If just "/", show all commands
445
- if (input === '/') {
446
- return this.commandSuggestions;
447
- }
448
- // Filter commands that match the typed prefix
449
- return this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(input));
450
- }
451
- /**
452
- * Update suggestion visibility based on current buffer
453
- */
454
- updateSuggestionVisibility() {
455
- const filtered = this.getFilteredCommands();
456
- const shouldShow = filtered.length > 0 && this.buffer.startsWith('/');
457
- if (shouldShow !== this.showingSuggestions) {
458
- this.showingSuggestions = shouldShow;
459
- this.selectedSuggestionIndex = 0;
460
- this.scheduleRender();
461
- }
462
- else if (shouldShow) {
463
- // Clamp selection index
464
- this.selectedSuggestionIndex = Math.min(this.selectedSuggestionIndex, Math.max(0, filtered.length - 1));
465
- }
466
- }
467
- /**
468
- * Handle selecting a suggestion (Enter key or click)
469
- */
470
- selectSuggestion(options = {}) {
471
- const { submit = false } = options;
472
- if (!this.showingSuggestions)
473
- return false;
474
- const filtered = this.getFilteredCommands();
475
- if (filtered.length === 0)
476
- return false;
477
- const selected = filtered[this.selectedSuggestionIndex];
478
- if (!selected)
479
- return false;
480
- // Replace buffer with selected command
481
- const nextBuffer = submit ? selected.command : `${selected.command} `;
482
- this.buffer = nextBuffer;
483
- this.cursor = this.buffer.length;
484
- this.showingSuggestions = false;
485
- this.scheduleRender();
486
- if (submit) {
487
- this.submit();
488
- }
489
- return true;
490
- }
491
- /**
492
- * Move selection up in suggestions list
493
- */
494
- moveSuggestionUp() {
495
- if (!this.showingSuggestions)
496
- return false;
497
- const filtered = this.getFilteredCommands();
498
- if (filtered.length === 0)
499
- return false;
500
- this.selectedSuggestionIndex = Math.max(0, this.selectedSuggestionIndex - 1);
501
- this.scheduleRender();
502
- return true;
503
- }
504
- /**
505
- * Move selection down in suggestions list
506
- */
507
- moveSuggestionDown() {
508
- if (!this.showingSuggestions)
509
- return false;
510
- const filtered = this.getFilteredCommands();
511
- if (filtered.length === 0)
512
- return false;
513
- this.selectedSuggestionIndex = Math.min(filtered.length - 1, this.selectedSuggestionIndex + 1);
514
- this.scheduleRender();
515
- return true;
516
- }
517
- /**
518
- * Handle mouse selection inside the suggestions list.
519
- * Returns true if the click selected a command.
520
- */
521
- handleSuggestionClick(clickRow) {
522
- if (!this.showingSuggestions || !this.suggestionRenderState)
523
- return false;
524
- if (this.lastSuggestionStartRow === null)
525
- return false;
526
- // First line is the header; suggestions start on the next row
527
- const relativeRow = clickRow - this.lastSuggestionStartRow - 1;
528
- if (relativeRow < 0)
529
- return false;
530
- const { startIdx, visibleCount } = this.suggestionRenderState;
531
- if (relativeRow >= visibleCount)
532
- return false;
533
- this.selectedSuggestionIndex = startIdx + relativeRow;
534
- this.selectSuggestion();
535
- return true;
536
- }
537
- /**
538
- * Cancel suggestion display (Escape)
539
- */
540
- cancelSuggestions() {
541
- if (!this.showingSuggestions)
542
- return false;
543
- this.showingSuggestions = false;
544
- this.scheduleRender();
545
- return true;
546
- }
547
- /**
548
- * Build command suggestion display lines
549
- */
550
- buildSuggestionLines(width) {
551
- this.suggestionRenderState = null;
552
- if (!this.showingSuggestions)
553
- return [];
554
- const filtered = this.getFilteredCommands();
555
- if (filtered.length === 0)
556
- return [];
557
- const lines = [];
558
- const maxDisplay = Math.min(filtered.length, this.maxVisibleSuggestions);
559
- // Calculate scroll window for long lists
560
- let startIdx = 0;
561
- if (filtered.length > maxDisplay) {
562
- // Keep selected item in view with some context
563
- const padding = Math.floor(maxDisplay / 3);
564
- startIdx = Math.max(0, this.selectedSuggestionIndex - padding);
565
- startIdx = Math.min(startIdx, filtered.length - maxDisplay);
566
- }
567
- this.suggestionRenderState = {
568
- startIdx,
569
- visibleCount: Math.min(maxDisplay, Math.max(0, filtered.length - startIdx))
570
- };
571
- // Header with navigation hint - keep it short
572
- lines.push(theme.ui.muted(`Commands (↑↓ Enter to run · Tab to fill)`));
573
- // Render visible suggestions
574
- for (let i = 0; i < maxDisplay; i++) {
575
- const idx = startIdx + i;
576
- if (idx >= filtered.length)
577
- break;
578
- const cmd = filtered[idx];
579
- const isSelected = idx === this.selectedSuggestionIndex;
580
- // Format: [indicator] /command description
581
- const indicator = isSelected ? theme.accent('▸') : ' ';
582
- // Command name - use available space wisely
583
- const cmdWidth = Math.min(20, Math.max(12, Math.floor(width * 0.3)));
584
- const cmdText = isSelected
585
- ? theme.accent(cmd.command.padEnd(cmdWidth))
586
- : theme.primary(cmd.command.padEnd(cmdWidth));
587
- // Description - use remaining space
588
- const descMaxLen = Math.max(20, width - cmdWidth - 4);
589
- const desc = cmd.description.length > descMaxLen
590
- ? cmd.description.slice(0, descMaxLen - 1) + '…'
591
- : cmd.description;
592
- const descText = isSelected
593
- ? theme.info(desc)
594
- : theme.ui.muted(desc);
595
- lines.push(`${indicator}${cmdText} ${descText}`);
596
- }
597
- // Show scroll indicator if needed
598
- if (filtered.length > maxDisplay) {
599
- const position = startIdx + 1;
600
- const endPos = Math.min(startIdx + maxDisplay, filtered.length);
601
- lines.push(theme.ui.muted(` [${position}-${endPos} of ${filtered.length}]`));
602
- }
603
- return lines;
604
- }
605
- /**
606
- * Update the inline status message shown before the prompt (e.g., streaming heartbeat).
607
- */
608
- setStatusMessage(message) {
609
- const normalized = message ? message.replace(/\s+/g, ' ').trim() : null;
610
- const maxLen = 48;
611
- const next = normalized && normalized.length > maxLen
612
- ? `${normalized.slice(0, maxLen - 3)}...`
613
- : normalized;
614
- if (this.statusMessage === next) {
615
- return;
616
- }
617
- this.statusMessage = next;
618
- // Schedule render - throttling during streaming is handled in render()
619
- this.scheduleRender();
620
- }
621
- /**
622
- * Set an override status message (warnings, errors) that shows alongside the main status.
623
- * Both streaming label and override can be displayed simultaneously.
624
- */
625
- setOverrideStatus(message) {
626
- const normalized = message ? message.replace(/\s+/g, ' ').trim() : null;
627
- const maxLen = 32;
628
- const next = normalized && normalized.length > maxLen
629
- ? `${normalized.slice(0, maxLen - 3)}...`
630
- : normalized;
631
- if (this.overrideStatusMessage === next) {
632
- return;
633
- }
634
- this.overrideStatusMessage = next;
635
- this.scheduleRender();
636
- }
637
- /**
638
- * Set the streaming label (e.g., "Streaming response...").
639
- * Can be shown alongside override status messages.
640
- */
641
- setStreamingLabel(label) {
642
- const normalized = label ? label.replace(/\s+/g, ' ').trim() : null;
643
- const maxLen = 24;
644
- const next = normalized && normalized.length > maxLen
645
- ? `${normalized.slice(0, maxLen - 3)}...`
646
- : normalized;
647
- if (this.streamingLabel === next) {
648
- return;
649
- }
650
- this.streamingLabel = next;
651
- this.scheduleRender();
652
- }
653
- /**
654
- * Surface meta status just above the divider (e.g., elapsed time or token usage).
655
- */
656
- setMetaStatus(meta) {
657
- const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
658
- ? Math.floor(meta.elapsedSeconds)
659
- : null;
660
- const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
661
- ? Math.floor(meta.tokensUsed)
662
- : null;
663
- const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
664
- ? Math.floor(meta.tokenLimit)
665
- : null;
666
- const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
667
- ? Math.floor(meta.thinkingMs)
668
- : null;
669
- const nextThinkingHasContent = !!meta.thinkingHasContent;
670
- if (this.metaElapsedSeconds === nextElapsed &&
671
- this.metaTokensUsed === nextTokens &&
672
- this.metaTokenLimit === nextLimit &&
673
- this.metaThinkingMs === nextThinking &&
674
- this.metaThinkingHasContent === nextThinkingHasContent) {
675
- return;
676
- }
677
- this.metaElapsedSeconds = nextElapsed;
678
- this.metaTokensUsed = nextTokens;
679
- this.metaTokenLimit = nextLimit;
680
- this.metaThinkingMs = nextThinking;
681
- this.metaThinkingHasContent = nextThinkingHasContent;
682
- this.scheduleRender();
683
- }
684
- /**
685
- * Keep mode toggles (verification/auto-continue) visible in the control bar.
686
- * Hotkey labels remain stable so the bar looks the same before/during streaming.
687
- */
688
- setModeToggles(options) {
689
- const nextVerification = !!options.verificationEnabled;
690
- const nextAutoContinue = !!options.autoContinueEnabled;
691
- const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
692
- const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
693
- const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
694
- const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
695
- if (this.verificationEnabled === nextVerification &&
696
- this.autoContinueEnabled === nextAutoContinue &&
697
- this.verificationHotkey === nextVerifyHotkey &&
698
- this.autoContinueHotkey === nextAutoHotkey &&
699
- this.thinkingHotkey === nextThinkingHotkey &&
700
- this.thinkingModeLabel === nextThinkingLabel) {
701
- return;
702
- }
703
- this.verificationEnabled = nextVerification;
704
- this.autoContinueEnabled = nextAutoContinue;
705
- this.verificationHotkey = nextVerifyHotkey;
706
- this.autoContinueHotkey = nextAutoHotkey;
707
- this.thinkingHotkey = nextThinkingHotkey;
708
- this.thinkingModeLabel = nextThinkingLabel;
709
- this.scheduleRender();
710
- }
711
- /**
712
- * Clear all status messages at once (convenience method).
713
- */
714
- clearAllStatus() {
715
- this.statusMessage = null;
716
- this.overrideStatusMessage = null;
717
- this.streamingLabel = null;
718
- this.scheduleRender();
719
- }
720
- /**
721
- * Surface model/provider context in the controls bar.
722
- */
723
- setModelContext(options) {
724
- const nextModel = options.model?.trim() || null;
725
- const nextProvider = options.provider?.trim() || null;
726
- if (this.modelLabel === nextModel && this.providerLabel === nextProvider) {
727
- return;
728
- }
729
- this.modelLabel = nextModel;
730
- this.providerLabel = nextProvider;
731
- this.scheduleRender();
732
- }
733
- /**
734
- * Render the floating input area at contentRow.
735
- *
736
- * The chat box "floats" - it renders right below the last streamed content.
737
- * As content is added, contentRow advances, and the chat box moves down.
738
- * No scroll regions - pure floating behavior.
739
- */
740
- render() {
741
- if (!this.canRender())
742
- return;
743
- if (this.isRendering)
744
- return;
745
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
746
- const bufferChanged = this.buffer !== this.lastRenderContent || this.cursor !== this.lastRenderCursor;
747
- // During streaming, throttle re-renders unless the buffer actually changed
748
- // (e.g., typing slash commands while streaming should update immediately).
749
- const shouldThrottle = streamingActive &&
750
- this.lastStreamingRender > 0 &&
751
- !this.renderDirty &&
752
- !bufferChanged &&
753
- !this.showingSuggestions;
754
- if (shouldThrottle) {
755
- const elapsed = Date.now() - this.lastStreamingRender;
756
- const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
757
- if (waitMs > 0) {
758
- this.renderDirty = true;
759
- this.scheduleStreamingRender(waitMs);
760
- return;
761
- }
762
- }
763
- const shouldSkip = !this.renderDirty && !bufferChanged;
764
- this.renderDirty = false;
765
- if (shouldSkip) {
766
- return;
767
- }
768
- if (writeLock.isLocked()) {
769
- writeLock.safeWrite(() => this.render());
770
- return;
771
- }
772
- this.renderPinnedChatBox();
773
- }
774
- /**
775
- * Unified scroll region renderer.
776
- * Chat box is ALWAYS pinned at the bottom of the terminal.
777
- * Content scrolls in the region above the chat box.
778
- */
779
- renderPinnedChatBox() {
780
- const { rows, cols } = this.getSize();
781
- const maxWidth = Math.max(8, cols - 4);
782
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
783
- const prevChatBoxStart = this.lastChatBoxStartRow;
784
- const prevChatBoxHeight = this.lastChatBoxHeight;
785
- // Wrap buffer into display lines
786
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
787
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, rows - 3));
788
- const displayLines = Math.min(lines.length, maxVisible);
789
- const metaLines = this.buildMetaLines(cols - 2);
790
- // Calculate display window (keep cursor visible)
791
- let startLine = 0;
792
- if (lines.length > displayLines) {
793
- startLine = Math.max(0, cursorLine - displayLines + 1);
794
- startLine = Math.min(startLine, lines.length - displayLines);
795
- }
796
- const visibleLines = lines.slice(startLine, startLine + displayLines);
797
- const adjustedCursorLine = cursorLine - startLine;
798
- // Chat box height
799
- const chatBoxHeight = this.getChatBoxHeight();
800
- // ALWAYS pin chat box at absolute bottom
801
- const chatBoxStartRow = Math.max(1, rows - chatBoxHeight + 1);
802
- const scrollEnd = chatBoxStartRow - 1;
803
- this.lastChatBoxStartRow = chatBoxStartRow;
804
- this.lastChatBoxHeight = chatBoxHeight;
805
- this.lastSuggestionStartRow = null;
806
- writeLock.lock('terminalInput.renderPinned');
807
- this.isRendering = true;
808
- try {
809
- // Ensure content stays above the expanded chat/menu area when idle
810
- if (!streamingActive && !this.scrollRegionActive) {
811
- this.ensureContentAboveChatBox(chatBoxStartRow, rows);
812
- }
813
- this.write(ESC.SAVE);
814
- this.write(ESC.HIDE);
815
- // Temporarily reset scroll region to write chat box cleanly
816
- if (this.scrollRegionActive) {
817
- this.write(ESC.RESET_SCROLL);
818
- }
819
- // Clear the current and previous chat box footprint to avoid ghost text when the height shrinks
820
- const prevHeight = prevChatBoxStart !== null ? Math.max(1, prevChatBoxHeight) : chatBoxHeight;
821
- const prevStart = prevChatBoxStart ?? chatBoxStartRow;
822
- const prevEnd = prevStart + prevHeight - 1;
823
- const newEnd = chatBoxStartRow + chatBoxHeight - 1;
824
- const clearStart = Math.max(1, Math.min(prevStart, chatBoxStartRow));
825
- const clearEnd = Math.min(rows, Math.max(prevEnd, newEnd));
826
- for (let row = clearStart; row <= clearEnd; row++) {
827
- this.write(ESC.TO(row, 1));
828
- this.write(ESC.CLEAR_LINE);
829
- }
830
- let currentRow = chatBoxStartRow;
831
- // Render scroll/status indicator on the left (Claude Code style)
832
- const scrollIndicator = this.buildScrollIndicator();
833
- // Meta/status header with scroll indicator
834
- for (const metaLine of metaLines) {
835
- this.write(ESC.TO(currentRow, 1));
836
- this.write(metaLine);
837
- currentRow += 1;
838
- }
839
- // Separator line with scroll status
840
- this.write(ESC.TO(currentRow, 1));
841
- const dividerLabel = scrollIndicator || undefined;
842
- this.write(renderDivider(cols - 2, dividerLabel));
843
- currentRow += 1;
844
- // Render input lines
845
- let finalRow = currentRow;
846
- let finalCol = 3;
847
- for (let i = 0; i < visibleLines.length; i++) {
848
- const rowNum = currentRow + i;
849
- this.write(ESC.TO(rowNum, 1));
850
- const line = visibleLines[i] ?? '';
851
- const isFirstLine = (startLine + i) === 0;
852
- const isCursorLine = i === adjustedCursorLine;
853
- const col = isCursorLine ? Math.max(0, Math.min(cursorCol, line.length)) : 0;
854
- this.write(ESC.BG_DARK);
855
- this.write(ESC.DIM);
856
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
857
- this.write(ESC.RESET);
858
- this.write(ESC.BG_DARK);
859
- if (isCursorLine) {
860
- const before = line.slice(0, col);
861
- const at = line.charAt(col) || ' ';
862
- const after = col < line.length ? line.slice(col + 1) : '';
863
- this.write(before);
864
- this.write(ESC.REVERSE + ESC.BOLD);
865
- this.write(at);
866
- this.write(ESC.RESET + ESC.BG_DARK);
867
- this.write(after);
868
- finalRow = rowNum;
869
- finalCol = this.config.promptChar.length + col + 1;
870
- }
871
- else {
872
- this.write(line);
873
- }
874
- // Pad to edge
875
- const lineLen = this.config.promptChar.length +
876
- line.length +
877
- (isCursorLine && col >= line.length ? 1 : 0);
878
- const padding = Math.max(0, cols - lineLen - 1);
879
- if (padding > 0)
880
- this.write(' '.repeat(padding));
881
- this.write(ESC.RESET);
882
- }
883
- // Command suggestions (shown above controls when "/" is typed)
884
- const suggestionLines = this.buildSuggestionLines(cols - 2);
885
- let suggestionRow = currentRow + visibleLines.length;
886
- if (suggestionLines.length > 0) {
887
- this.lastSuggestionStartRow = suggestionRow;
888
- }
889
- for (const suggestionLine of suggestionLines) {
890
- this.write(ESC.TO(suggestionRow, 1));
891
- this.write(' '); // Indent to match input
892
- this.write(suggestionLine);
893
- suggestionRow += 1;
894
- }
895
- // Mode controls lines with all keyboard shortcuts
896
- // Can be multiple lines during streaming (status + toggles)
897
- const controlLines = this.buildModeControls(cols);
898
- let controlRow = suggestionRow;
899
- for (const controlLine of controlLines) {
900
- this.write(ESC.TO(controlRow, 1));
901
- this.write(controlLine);
902
- controlRow += 1;
903
- }
904
- // Restore scroll region and cursor
905
- if (this.scrollRegionActive) {
906
- // Restore scroll region
907
- this.write(ESC.SET_SCROLL(1, scrollEnd));
908
- // Restore cursor to where it was before rendering (preserves column position)
909
- this.write(ESC.RESTORE);
910
- }
911
- else {
912
- // Not streaming - position cursor in input box
913
- this.write(ESC.RESTORE);
914
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
915
- }
916
- this.write(ESC.SHOW);
917
- // Update state
918
- this.lastRenderContent = this.buffer;
919
- this.lastRenderCursor = this.cursor;
920
- this.lastStreamingRender = streamingActive ? Date.now() : 0;
921
- if (this.streamingRenderTimer) {
922
- clearTimeout(this.streamingRenderTimer);
923
- this.streamingRenderTimer = null;
924
- }
925
- }
926
- finally {
927
- writeLock.unlock();
928
- this.isRendering = false;
929
- }
930
- }
931
- /**
932
- * Keep assistant/user content above the chat box when it expands (e.g., slash menu).
933
- * Scrolls just enough to free space without moving the cursor.
934
- */
935
- ensureContentAboveChatBox(chatBoxStartRow, rows) {
936
- const contentMaxRow = Math.max(1, chatBoxStartRow - 1);
937
- if (this.contentRow < contentMaxRow)
938
- return;
939
- const overflow = (this.contentRow - contentMaxRow) + 1;
940
- const scrollLines = Math.min(overflow, rows);
941
- this.write(ESC.SAVE);
942
- this.write(ESC.TO(rows, 1));
943
- this.write('\n'.repeat(scrollLines));
944
- this.write(ESC.RESTORE);
945
- this.contentRow = contentMaxRow;
946
- }
947
- /**
948
- * Track output written directly by the display (system/assistant messages) so
949
- * it is captured in scrollback and accounted for when re-rendering the chat box.
950
- */
951
- trackExternalOutput(content) {
952
- if (!content)
953
- return;
954
- // Normalize and capture in scrollback for history
955
- const normalized = content.replace(/\r/g, '\n');
956
- this.addToScrollback(normalized);
957
- // Advance content row based on rendered height (accounts for wrapping)
958
- const rowsUsed = this.countRenderedRows(normalized);
959
- if (rowsUsed > 0) {
960
- this.contentRow += rowsUsed;
961
- if (!this.scrollRegionActive) {
962
- this.scheduleRender();
963
- }
964
- }
965
- }
966
- /**
967
- * Estimate how many terminal rows the provided content will consume.
968
- * Considers ANSI-stripped visible length and terminal width so wrapped lines
969
- * correctly advance the tracked cursor position.
970
- */
971
- countRenderedRows(content) {
972
- if (!content)
973
- return 0;
974
- const width = Math.max(1, this.getSize().cols);
975
- const normalized = content.replace(/\r\n/g, '\n');
976
- const lines = normalized.split('\n');
977
- let rows = 0;
978
- for (let i = 0; i < lines.length; i++) {
979
- const line = this.normalizeCarriageReturnLine(lines[i] ?? '');
980
- const visibleLen = this.visibleLength(line);
981
- if (visibleLen > 0) {
982
- const wraps = Math.ceil(visibleLen / width);
983
- if (wraps > 1) {
984
- rows += wraps - 1;
985
- }
986
- }
987
- // Each newline moves the cursor to the start of the next row
988
- if (i < lines.length - 1) {
989
- rows += 1;
990
- }
991
- }
992
- return rows;
993
- }
994
- /**
995
- * Treat carriage returns as line rewrites rather than newlines for row estimates.
996
- * If a CR is followed by text, only the text after the last CR is visible.
997
- * If the line ends with a CR, the existing text stays visible.
998
- */
999
- normalizeCarriageReturnLine(line) {
1000
- if (!line.includes('\r')) {
1001
- return line;
1002
- }
1003
- const lastCr = line.lastIndexOf('\r');
1004
- const after = line.slice(lastCr + 1);
1005
- if (after.length > 0) {
1006
- return after;
1007
- }
1008
- return line.slice(0, lastCr);
1009
- }
1010
- /**
1011
- * Build compact meta line above the divider.
1012
- * Shows model/provider and key metrics in a single line.
1013
- */
1014
- buildMetaLines(width) {
1015
- if (!this.shouldShowMetaLine()) {
1016
- return [];
1017
- }
1018
- const leftParts = [];
1019
- const rightParts = [];
1020
- // Model/provider info (left side)
1021
- if (this.modelLabel) {
1022
- const modelText = this.providerLabel
1023
- ? `${this.modelLabel} @ ${this.providerLabel}`
1024
- : this.modelLabel;
1025
- leftParts.push({ text: modelText, tone: 'info' });
1026
- }
1027
- // Elapsed time (right side)
1028
- if (this.metaElapsedSeconds !== null) {
1029
- rightParts.push({ text: `⏱ ${this.formatElapsedLabel(this.metaElapsedSeconds)}`, tone: 'muted' });
1030
- }
1031
- // Token usage (right side)
1032
- if (this.metaTokensUsed !== null) {
1033
- const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
1034
- const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
1035
- rightParts.push({ text: `⊛ ${formattedUsed}${formattedLimit}`, tone: 'muted' });
1036
- }
1037
- // Context remaining warning
1038
- const tokensRemaining = this.computeTokensRemaining();
1039
- if (tokensRemaining !== null) {
1040
- const contextPct = this.contextUsage !== null ? `${100 - this.contextUsage}%` : '';
1041
- rightParts.push({ text: `↓${tokensRemaining} ${contextPct}`, tone: this.contextUsage && this.contextUsage > 80 ? 'warn' : 'muted' });
1042
- }
1043
- // Thinking indicator
1044
- if (this.metaThinkingMs !== null) {
1045
- leftParts.push({ text: formatThinking(this.metaThinkingMs, this.metaThinkingHasContent), tone: 'info' });
1046
- }
1047
- if (!leftParts.length && !rightParts.length) {
1048
- return [];
1049
- }
1050
- // Render left and right aligned
1051
- if (!rightParts.length || width < 50) {
1052
- return [renderStatusLine([...leftParts, ...rightParts], width)];
1053
- }
1054
- const leftWidth = Math.max(12, Math.floor(width * 0.5));
1055
- const rightWidth = Math.max(14, width - leftWidth - 1);
1056
- const leftText = renderStatusLine(leftParts, leftWidth);
1057
- const rightText = renderStatusLine(rightParts, rightWidth);
1058
- const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
1059
- return [`${leftText}${' '.repeat(spacing)}${rightText}`];
1060
- }
1061
- /**
1062
- * Build mode controls lines with all keyboard shortcuts.
1063
- * Shows status, all toggles, and contextual information.
1064
- * Enhanced with comprehensive feature status display.
1065
- *
1066
- * Returns multiple lines to ensure all context is visible during streaming:
1067
- * - Line 1 (during streaming): Status/spinner + interrupt shortcut
1068
- * - Line 2: Mode toggles (edit, verify, auto-continue, thinking)
1069
- *
1070
- * Format: [Key] Label:status · [Key] Label:status · context% · model
1071
- */
1072
- buildModeControls(cols) {
1073
- const width = Math.max(8, cols - 2);
1074
- const streamingActive = this.mode === 'streaming' || this.scrollRegionActive;
1075
- // === LINE 1: STATUS LINE (streaming status + interrupt) ===
1076
- const statusParts = [];
1077
- // Streaming indicator with animated spinner
1078
- if (this.streamingLabel) {
1079
- statusParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
1080
- }
1081
- // Override status (warnings, errors)
1082
- if (this.overrideStatusMessage) {
1083
- statusParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
1084
- }
1085
- // Main status message
1086
- if (this.statusMessage) {
1087
- statusParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
1088
- }
1089
- // Interrupt shortcut (during streaming)
1090
- // Queued commands
1091
- if (this.queue.length > 0) {
1092
- const queueIcon = streamingActive ? '⏳' : '▸';
1093
- statusParts.push({ text: `${queueIcon}${this.queue.length} queued`, tone: 'info' });
1094
- }
1095
- // === LINE 2: MODE TOGGLES (always visible, same as before streaming) ===
1096
- const toggleParts = [];
1097
- // Claude Code style mode indicator: "⏵⏵ accept edits on (shift+tab to cycle)"
1098
- const editModeLabel = this.editMode === 'display-edits' ? 'auto' :
1099
- this.editMode === 'ask-permission' ? 'ask' : 'plan';
1100
- toggleParts.push({ text: formatEditModeIndicator(editModeLabel), tone: editModeLabel === 'plan' ? 'info' : undefined });
1101
- // Context usage (show percentage remaining)
1102
- if (this.contextUsage !== null) {
1103
- const remaining = 100 - this.contextUsage;
1104
- const contextTone = this.contextUsage > 80 ? 'warn' : this.contextUsage > 60 ? 'info' : 'muted';
1105
- toggleParts.push({ text: `ctx:${remaining}%`, tone: contextTone });
1106
- }
1107
- const verifyHotkey = this.formatHotkey(this.verificationHotkey);
1108
- const verifyStatus = this.verificationEnabled ? 'Verify on' : 'Verify off';
1109
- toggleParts.push({
1110
- text: `${verifyHotkey} ${verifyStatus}`,
1111
- tone: this.verificationEnabled ? 'success' : 'muted'
1112
- });
1113
- const autoHotkey = this.formatHotkey(this.autoContinueHotkey);
1114
- const autoStatus = this.autoContinueEnabled ? 'Auto-continue on' : 'Auto-continue off';
1115
- toggleParts.push({
1116
- text: `${autoHotkey} ${autoStatus}`,
1117
- tone: this.autoContinueEnabled ? 'info' : 'muted'
1118
- });
1119
- // Thinking mode toggle
1120
- if (this.thinkingModeLabel) {
1121
- const shortThinking = this.thinkingModeLabel.length > 10
1122
- ? this.thinkingModeLabel.slice(0, 8) + '..'
1123
- : this.thinkingModeLabel;
1124
- const thinkingHotkey = this.formatHotkey(this.thinkingHotkey);
1125
- toggleParts.push({ text: `${thinkingHotkey} Thinking ${shortThinking}`, tone: 'info' });
1126
- }
1127
- // Navigation shortcuts - always show these
1128
- toggleParts.push({ text: 'PgUp/Dn:scroll', tone: 'muted' });
1129
- toggleParts.push({ text: '/help', tone: 'muted' });
1130
- // Multi-line indicator
1131
- if (this.buffer.includes('\n')) {
1132
- const lineCount = this.buffer.split('\n').length;
1133
- toggleParts.push({ text: `${lineCount}L`, tone: 'muted' });
1134
- }
1135
- // Paste indicator
1136
- if (this.pastePlaceholders.length > 0) {
1137
- const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
1138
- toggleParts.push({
1139
- text: `📋${latest.lineCount}L`,
1140
- tone: 'info',
1141
- });
1142
- }
1143
- // Build the output lines - use wrapping to never truncate
1144
- const lines = [];
1145
- // During streaming: show status line first, then toggles (wrapped as needed)
1146
- // Not streaming: show combined or just toggles (wrapped as needed)
1147
- if (streamingActive && statusParts.length > 0) {
1148
- // Status line (usually fits on one line)
1149
- lines.push(renderStatusLine(statusParts, width));
1150
- // Toggle parts - wrap to multiple lines if needed
1151
- lines.push(...renderStatusLines(toggleParts, width));
1152
- }
1153
- else if (statusParts.length > 0) {
1154
- // Not streaming but have status - use wrapping for all parts
1155
- const combined = [...statusParts, ...toggleParts];
1156
- lines.push(...renderStatusLines(combined, width));
1157
- }
1158
- else {
1159
- // No streaming, no status - just show toggles (wrapped as needed)
1160
- lines.push(...renderStatusLines(toggleParts, width));
1161
- }
1162
- // Ensure at least one line is returned
1163
- if (lines.length === 0) {
1164
- lines.push('');
1165
- }
1166
- return lines;
1167
- }
1168
- /**
1169
- * Render a mini context usage bar (5 chars)
1170
- */
1171
- renderMiniContextBar(percentRemaining) {
1172
- const bars = 5;
1173
- const filled = Math.round((percentRemaining / 100) * bars);
1174
- const empty = bars - filled;
1175
- return '█'.repeat(filled) + '░'.repeat(empty);
1176
- }
1177
- formatHotkey(hotkey) {
1178
- const normalized = hotkey.trim().toLowerCase();
1179
- if (!normalized)
1180
- return hotkey;
1181
- const parts = normalized.split('+').filter(Boolean);
1182
- const map = {
1183
- shift: 'Shift',
1184
- sh: 'Shift',
1185
- alt: 'Alt',
1186
- option: 'Alt',
1187
- opt: 'Alt',
1188
- ctrl: 'Ctrl',
1189
- control: 'Ctrl',
1190
- cmd: 'Cmd',
1191
- meta: 'Cmd',
1192
- };
1193
- const formatted = parts
1194
- .map((part) => {
1195
- const symbol = map[part];
1196
- if (symbol)
1197
- return symbol;
1198
- if (part.startsWith('/'))
1199
- return part;
1200
- if (part.length === 1)
1201
- return part.toUpperCase();
1202
- return part.charAt(0).toUpperCase() + part.slice(1);
1203
- })
1204
- .join('+');
1205
- return formatted || hotkey;
1206
- }
1207
- computeContextRemaining() {
1208
- if (this.contextUsage === null) {
1209
- return null;
1210
- }
1211
- return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
1212
- }
1213
- computeTokensRemaining() {
1214
- if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
1215
- return null;
1216
- }
1217
- const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
1218
- return this.formatTokenCount(remaining);
1219
- }
1220
- formatElapsedLabel(seconds) {
1221
- if (seconds < 60) {
1222
- return `${seconds}s`;
1223
- }
1224
- const mins = Math.floor(seconds / 60);
1225
- const secs = seconds % 60;
1226
- return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
1227
- }
1228
- formatTokenCount(value) {
1229
- if (!Number.isFinite(value)) {
1230
- return `${value}`;
1231
- }
1232
- if (value >= 1_000_000) {
1233
- return `${(value / 1_000_000).toFixed(1)}M`;
1234
- }
1235
- if (value >= 1_000) {
1236
- return `${(value / 1_000).toFixed(1)}k`;
1237
- }
1238
- return `${Math.round(value)}`;
1239
- }
1240
- shouldShowMetaLine() {
1241
- return (this.metaElapsedSeconds !== null ||
1242
- this.metaTokensUsed !== null ||
1243
- this.metaThinkingMs !== null);
1244
- }
1245
- clearChatBoxArea(rows, chatBoxHeight) {
1246
- const start = Math.max(1, rows - chatBoxHeight + 1);
1247
- writeLock.lock('clearChatBoxArea');
1248
- try {
1249
- for (let row = start; row <= rows; row++) {
1250
- this.write(ESC.TO(row, 1));
1251
- this.write(ESC.CLEAR_LINE);
1252
- }
1253
- }
1254
- finally {
1255
- writeLock.unlock();
1256
- }
1257
- }
1258
- visibleLength(value) {
1259
- const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
1260
- return value.replace(ansiPattern, '').length;
1261
- }
1262
- /**
1263
- * Debug-only snapshot used by tests to assert rendered strings without
1264
- * needing a TTY. Not used by production code.
1265
- */
1266
- getDebugUiSnapshot(width) {
1267
- const cols = Math.max(8, width ?? this.getSize().cols);
1268
- return {
1269
- meta: this.buildMetaLines(cols - 2),
1270
- controls: this.buildModeControls(cols),
1271
- };
1272
- }
1273
- /**
1274
- * Force a re-render
1275
- */
1276
- forceRender() {
1277
- this.lastRenderContent = '';
1278
- this.lastRenderCursor = -1;
1279
- this.renderDirty = true;
1280
- this.render();
1281
- }
1282
- /**
1283
- * Enable/disable input
1284
- */
1285
- setEnabled(enabled) {
1286
- this.enabled = enabled;
1287
- }
1288
- /**
1289
- * Handle terminal resize
1290
- */
1291
- handleResize() {
1292
- this.lastRenderContent = '';
1293
- this.lastRenderCursor = -1;
1294
- this.resetStreamingRenderThrottle();
1295
- // If in scrollback mode, re-render the scrollback view with new dimensions
1296
- if (this.isInScrollbackMode) {
1297
- this.renderScrollbackView();
1298
- }
1299
- else {
1300
- this.scheduleRender();
1301
- }
1302
- }
1303
- /**
1304
- * Enter streaming mode with scroll region.
1305
- * Sets up terminal scroll region to exclude chat box.
1306
- */
1307
- enterStreamingScrollRegion(options) {
1308
- const { rows } = this.getSize();
1309
- const chatBoxHeight = this.getChatBoxHeight();
1310
- const scrollEnd = Math.max(1, rows - chatBoxHeight);
1311
- // Clear any previously rendered chat box so only the streaming layout remains.
1312
- this.clearChatBoxArea(rows, chatBoxHeight);
1313
- this.lastChatBoxStartRow = null;
1314
- this.lastChatBoxHeight = 0;
1315
- writeLock.lock('enterStreamingScrollRegion');
1316
- try {
1317
- // Set scroll region for content area (above chat box)
1318
- this.write(ESC.SET_SCROLL(1, scrollEnd));
1319
- // Position cursor at current content row
1320
- this.contentRow = 1;
1321
- this.write(ESC.TO(this.contentRow, 1));
1322
- this.scrollRegionActive = true;
1323
- if (options?.statusMessage !== undefined) {
1324
- this.setStatusMessage(options.statusMessage);
1325
- }
1326
- }
1327
- finally {
1328
- writeLock.unlock();
1329
- }
1330
- // Render pinned chat box at bottom
1331
- this.forceRender();
1332
- }
1333
- /**
1334
- * Exit streaming mode and restore normal operation.
1335
- * Clears the old pinned chat box area to prevent duplication.
1336
- */
1337
- exitStreamingScrollRegion(options) {
1338
- const renderPrompt = options?.renderPrompt ?? true;
1339
- const statusMessage = options?.statusMessage !== undefined
1340
- ? options.statusMessage
1341
- : renderPrompt
1342
- ? 'Ready for prompts'
1343
- : null;
1344
- const { rows } = this.getSize();
1345
- const chatBoxHeight = this.getChatBoxHeight();
1346
- writeLock.lock('exitStreamingScrollRegion');
1347
- try {
1348
- // Reset scroll region to full terminal
1349
- this.write(ESC.RESET_SCROLL);
1350
- // Clear the old pinned chat box area to prevent duplication
1351
- // The old chat box was rendered at fixed bottom positions
1352
- const oldChatBoxStart = Math.max(1, rows - chatBoxHeight + 1);
1353
- for (let i = 0; i < chatBoxHeight; i++) {
1354
- const row = oldChatBoxStart + i;
1355
- if (row <= rows) {
1356
- this.write(ESC.TO(row, 1));
1357
- this.write(ESC.CLEAR_LINE);
1358
- }
1359
- }
1360
- // Move cursor back up to where content should continue
1361
- this.write(ESC.TO(oldChatBoxStart, 1));
1362
- this.scrollRegionActive = false;
1363
- // Only update the status message when requested
1364
- if (statusMessage !== undefined) {
1365
- this.setStatusMessage(statusMessage);
1366
- }
1367
- }
1368
- finally {
1369
- writeLock.unlock();
1370
- }
1371
- // Flag for re-render; caller controls whether to render immediately
1372
- if (renderPrompt) {
1373
- this.forceRender();
1374
- }
1375
- else {
1376
- this.renderDirty = true;
1377
- }
1378
- }
1379
- /**
1380
- * Stream content within the scroll region.
1381
- * Content is written directly and scrolls naturally.
1382
- */
1383
- streamContent(content) {
1384
- if (!content)
1385
- return;
1386
- // Capture content in scrollback buffer
1387
- this.addToScrollback(content);
1388
- writeLock.lock('streamContent');
1389
- try {
1390
- // Write content - scroll region handles scrolling
1391
- this.write(content);
1392
- // Track rendered rows (handles wrapping)
1393
- const rowsUsed = this.countRenderedRows(content);
1394
- this.contentRow += rowsUsed;
1395
- }
1396
- finally {
1397
- writeLock.unlock();
1398
- }
1399
- // Throttle chat box updates only when a re-render is needed.
1400
- // In streaming mode the chat box stays pinned, so skip redundant redraws.
1401
- const needsRender = !this.scrollRegionActive || this.renderDirty;
1402
- if (needsRender) {
1403
- if (!this.scrollRegionActive) {
1404
- // Content moved; force a render so the chat box follows.
1405
- this.renderDirty = true;
1406
- }
1407
- this.scheduleStreamingRender(200);
1408
- }
1409
- }
1410
- isScrollRegionActive() {
1411
- return this.scrollRegionActive;
1412
- }
1413
- /**
1414
- * Disable scroll region and restore full-screen layout (used on dispose).
1415
- */
1416
- disableScrollRegion() {
1417
- if (!this.scrollRegionActive)
1418
- return;
1419
- const { rows } = this.getSize();
1420
- const chatBoxHeight = this.getChatBoxHeight();
1421
- writeLock.withLock(() => {
1422
- this.write(ESC.RESET_SCROLL);
1423
- // Clear the chat box footprint
1424
- this.clearChatBoxArea(rows, chatBoxHeight);
1425
- this.scrollRegionActive = false;
1426
- }, 'disableScrollRegion');
1427
- }
1428
- /**
1429
- * Calculate chat box height dynamically.
1430
- * Accounts for:
1431
- * - Meta lines (model, elapsed, tokens)
1432
- * - Divider
1433
- * - Input line(s) - expands for multi-line/wrapped input
1434
- * - Command suggestions (when showing)
1435
- * - Control lines (wraps as needed to fit all toggles)
1436
- * - Buffer
1437
- */
1438
- getChatBoxHeight() {
1439
- const { rows, cols } = this.getSize();
1440
- // Calculate actual input lines needed (wrapped buffer)
1441
- const maxWidth = Math.max(1, cols - 4); // Account for prompt and padding
1442
- const { lines: wrappedLines } = this.wrapBuffer(maxWidth);
1443
- // Allow the chat box to grow with the prompt while respecting any explicit limit
1444
- const inputLines = Math.max(1, Math.min(wrappedLines.length, this.config.maxLines));
1445
- // Calculate actual control lines (they wrap as needed)
1446
- const controlLines = this.buildModeControls(cols);
1447
- const controlLineCount = Math.max(1, controlLines.length);
1448
- // Meta line only if we have model/elapsed info
1449
- const metaLines = this.shouldShowMetaLine() ? 1 : 0;
1450
- // Command suggestion lines (when showing)
1451
- let suggestionLines = 0;
1452
- if (this.showingSuggestions) {
1453
- const filtered = this.getFilteredCommands();
1454
- suggestionLines = Math.min(filtered.length, this.maxVisibleSuggestions) + 1; // +1 for header
1455
- }
1456
- // Total: meta + divider + input lines + suggestions + controls + buffer
1457
- // Leave a small buffer so streamed content still has space above the chat box
1458
- const totalHeight = metaLines + 1 + inputLines + suggestionLines + controlLineCount + 1;
1459
- const minContentRows = 2;
1460
- const maxHeight = Math.max(4, rows - minContentRows);
1461
- return Math.min(totalHeight, maxHeight);
1462
- }
1463
- /**
1464
- * @deprecated Use streamContent() instead
1465
- * Register with display's output interceptor - kept for backwards compatibility
1466
- */
1467
- registerOutputInterceptor(_display) {
1468
- const display = _display;
1469
- if (!display?.registerOutputInterceptor)
1470
- return;
1471
- // Remove prior hook if present
1472
- if (this.displayInterceptorDispose) {
1473
- this.displayInterceptorDispose();
1474
- this.displayInterceptorDispose = null;
1475
- }
1476
- this.displayInterceptorDispose = display.registerOutputInterceptor({
1477
- afterWrite: (content) => {
1478
- if (!content)
1479
- return;
1480
- this.trackExternalOutput(content);
1481
- },
1482
- });
1483
- }
1484
- /**
1485
- * Write content above the floating chat box.
1486
- * Works both during streaming and when idle.
1487
- */
1488
- writeToScrollRegion(content) {
1489
- if (!content)
1490
- return;
1491
- // Capture content in scrollback buffer
1492
- this.addToScrollback(content);
1493
- writeLock.lock('writeToScrollRegion');
1494
- try {
1495
- // Position cursor at content row and write
1496
- this.write(ESC.TO(this.contentRow, 1));
1497
- this.write(content);
1498
- // Track rendered rows (handles wrapping)
1499
- const rowsUsed = this.countRenderedRows(content);
1500
- this.contentRow += rowsUsed;
1501
- }
1502
- finally {
1503
- writeLock.unlock();
1504
- }
1505
- // Re-render chat box below new content (only when not streaming)
1506
- if (!this.scrollRegionActive) {
1507
- this.forceRender();
1508
- }
1509
- }
1510
- /**
1511
- * Enter alternate screen buffer.
1512
- * DISABLED: Using terminal-native mode for proper scrollback and text selection.
1513
- */
1514
- enterAlternateScreen() {
1515
- // Disabled - using terminal-native mode
1516
- this.contentRow = 1;
1517
- }
1518
- /**
1519
- * Exit alternate screen buffer.
1520
- * DISABLED: Using terminal-native mode.
1521
- */
1522
- exitAlternateScreen() {
1523
- // Disabled - using terminal-native mode
1524
- }
1525
- /**
1526
- * Check if alternate screen buffer is currently active.
1527
- * Always returns false - using terminal-native mode.
1528
- */
1529
- isAlternateScreenActive() {
1530
- return false;
1531
- }
1532
- /**
1533
- * Get a snapshot of the scrollback buffer (for display on exit).
1534
- */
1535
- getScrollbackSnapshot() {
1536
- return [...this.scrollbackBuffer];
1537
- }
1538
- /**
1539
- * Clear the visible terminal area and reset content position.
1540
- * In terminal-native mode, this just adds newlines to scroll past content
1541
- * rather than clearing history (preserving scrollback).
1542
- */
1543
- clearScreen() {
1544
- writeLock.lock('clearScreen');
1545
- try {
1546
- // In native mode, scroll past existing content rather than clearing
1547
- const { rows } = this.getSize();
1548
- this.write('\n'.repeat(rows));
1549
- this.write(ESC.HOME);
1550
- this.contentRow = 1;
1551
- }
1552
- finally {
1553
- writeLock.unlock();
1554
- }
1555
- }
1556
- /**
1557
- * Reset content position to row 1.
1558
- * Does NOT clear the terminal - content starts from current position.
1559
- */
1560
- resetContentPosition() {
1561
- this.contentRow = 1;
1562
- }
1563
- /**
1564
- * Set the content row explicitly (used after banner is written).
1565
- * This tells the input where content should start flowing from.
1566
- */
1567
- setContentRow(row) {
1568
- this.contentRow = Math.max(1, row);
1569
- }
1570
- /**
1571
- * Get the current content row position.
1572
- */
1573
- getContentRow() {
1574
- return this.contentRow;
1575
- }
1576
- /**
1577
- * Dispose and clean up
1578
- */
1579
- dispose() {
1580
- if (this.disposed)
1581
- return;
1582
- this.disposed = true;
1583
- this.enabled = false;
1584
- if (this.displayInterceptorDispose) {
1585
- this.displayInterceptorDispose();
1586
- this.displayInterceptorDispose = null;
1587
- }
1588
- this.disableScrollRegion();
1589
- this.resetStreamingRenderThrottle();
1590
- this.disableBracketedPaste();
1591
- this.buffer = '';
1592
- this.queue = [];
1593
- this.removeAllListeners();
1594
- }
1595
- // ===========================================================================
1596
- // INPUT HANDLING
1597
- // ===========================================================================
1598
- handleCtrlKey(key) {
1599
- switch (key.name) {
1600
- case 'c':
1601
- if (this.buffer.length > 0) {
1602
- this.clear();
1603
- }
1604
- else {
1605
- this.emit('interrupt');
1606
- }
1607
- return true;
1608
- case 'a': // Home
1609
- this.moveCursorToLineStart();
1610
- return true;
1611
- case 'e': // End
1612
- if (key.shift) {
1613
- this.toggleEditMode();
1614
- this.emit('toggleEditMode');
1615
- return true;
1616
- }
1617
- this.moveCursorToLineEnd();
1618
- return true;
1619
- case 'u': // Delete to start
1620
- this.deleteToStart();
1621
- return true;
1622
- case 'k': // Delete to end
1623
- this.deleteToEnd();
1624
- return true;
1625
- case 'w': // Delete word
1626
- this.deleteWord();
1627
- return true;
1628
- case 'left': // Word left
1629
- this.moveCursorWordLeft();
1630
- return true;
1631
- case 'right': // Word right
1632
- this.moveCursorWordRight();
1633
- return true;
1634
- case 'return': // Ctrl+Enter => newline instead of submit
1635
- this.insertNewline();
1636
- return true;
1637
- case 'v':
1638
- if (key.shift) {
1639
- this.emit('toggleVerify');
1640
- return true;
1641
- }
1642
- break;
1643
- case 'c':
1644
- if (key.shift) {
1645
- this.emit('toggleAutoContinue');
1646
- return true;
1647
- }
1648
- break;
1649
- case 't':
1650
- if (key.shift) {
1651
- this.emit('toggleThinking');
1652
- return true;
1653
- }
1654
- break;
1655
- case 'x':
1656
- if (key.shift) {
1657
- this.emit('clearContext');
1658
- return true;
1659
- }
1660
- break;
1661
- case 's':
1662
- if (key.shift) {
1663
- this.toggleScrollbackMode();
1664
- return true;
1665
- }
1666
- break;
1667
- }
1668
- return false;
1669
- }
1670
- /**
1671
- * Handle Alt/Meta key combinations for mode toggles and navigation.
1672
- * Ctrl-based shortcuts are primary; Alt/Meta remains as a compatibility fallback.
1673
- */
1674
- handleMetaKey(key) {
1675
- switch (key.name) {
1676
- // === CURSOR MOVEMENT ===
1677
- case 'left':
1678
- case 'b':
1679
- this.moveCursorWordLeft();
1680
- break;
1681
- case 'right':
1682
- case 'f':
1683
- this.moveCursorWordRight();
1684
- break;
1685
- case 'backspace':
1686
- this.deleteWord();
1687
- break;
1688
- case 'return':
1689
- this.insertNewline();
1690
- break;
1691
- // === MODE TOGGLES ===
1692
- case 'v':
1693
- // Alt+V: Toggle verification mode (auto-tests after edits)
1694
- this.emit('toggleVerify');
1695
- break;
1696
- case 'c':
1697
- // Alt+C: Toggle auto-continue mode
1698
- this.emit('toggleAutoContinue');
1699
- break;
1700
- case 't':
1701
- // Alt+T: Toggle/cycle thinking mode
1702
- this.emit('toggleThinking');
1703
- break;
1704
- case 'e':
1705
- // Alt+E: Toggle edit permission mode (ask/auto)
1706
- this.toggleEditMode();
1707
- this.emit('toggleEditMode');
1708
- break;
1709
- case 'x':
1710
- // Alt+X: Clear/compact context
1711
- this.emit('clearContext');
1712
- break;
1713
- // === SCROLLBACK NAVIGATION ===
1714
- case 's':
1715
- // Alt+S: Toggle scrollback mode
1716
- this.toggleScrollbackMode();
1717
- break;
1718
- case 'up':
1719
- // Alt+Up: Quick scroll up into history
1720
- if (!this.isInScrollbackMode) {
1721
- this.scrollUp(10);
1722
- }
1723
- else {
1724
- this.scrollUp(1);
1725
- }
1726
- break;
1727
- case 'down':
1728
- // Alt+Down: Quick scroll down
1729
- if (this.isInScrollbackMode) {
1730
- this.scrollDown(1);
1731
- }
1732
- break;
1733
- case 'pageup':
1734
- // Alt+PageUp: Page up in scrollback
1735
- this.scrollUp(this.getPageSize());
1736
- break;
1737
- case 'pagedown':
1738
- // Alt+PageDown: Page down in scrollback
1739
- this.scrollDown(this.getPageSize());
1740
- break;
1741
- case 'home':
1742
- // Alt+Home: Jump to top of scrollback
1743
- this.scrollToTop();
1744
- break;
1745
- case 'end':
1746
- // Alt+End: Jump to bottom (live)
1747
- this.scrollToBottom();
1748
- break;
1749
- }
1750
- }
1751
- /**
1752
- * Get page size for scrollback navigation.
1753
- */
1754
- getPageSize() {
1755
- const { rows } = this.getSize();
1756
- const chatBoxHeight = this.getChatBoxHeight();
1757
- return Math.max(5, rows - chatBoxHeight - 2);
1758
- }
1759
- /**
1760
- * Build scroll indicator for the divider line.
1761
- * Scrollback navigation is disabled in alternate screen mode.
1762
- * This returns null - no scroll indicator is shown.
1763
- */
1764
- buildScrollIndicator() {
1765
- // Scrollback navigation disabled - no indicator needed
1766
- return null;
1767
- }
1768
- handleSpecialKey(_str, key) {
1769
- switch (key.name) {
1770
- case 'return':
1771
- if (key.shift || key.meta || key.ctrl) {
1772
- this.insertNewline();
1773
- }
1774
- else if (this.showingSuggestions) {
1775
- // Select highlighted suggestion and submit immediately
1776
- this.selectSuggestion({ submit: true });
1777
- }
1778
- else {
1779
- this.submit();
1780
- }
1781
- return true;
1782
- case 'escape':
1783
- // Cancel suggestions first, or emit interrupt
1784
- if (this.cancelSuggestions()) {
1785
- return true;
1786
- }
1787
- this.emit('interrupt');
1788
- return true;
1789
- case 'backspace':
1790
- this.deleteBackward();
1791
- return true;
1792
- case 'delete':
1793
- this.deleteForward();
1794
- return true;
1795
- case 'left':
1796
- // Cancel suggestions on cursor movement
1797
- if (this.showingSuggestions) {
1798
- this.cancelSuggestions();
1799
- }
1800
- this.moveCursorLeft();
1801
- return true;
1802
- case 'right':
1803
- // Cancel suggestions on cursor movement
1804
- if (this.showingSuggestions) {
1805
- this.cancelSuggestions();
1806
- }
1807
- this.moveCursorRight();
1808
- return true;
1809
- case 'up':
1810
- // When showing suggestions, navigate up
1811
- if (this.showingSuggestions) {
1812
- this.moveSuggestionUp();
1813
- return true;
1814
- }
1815
- // Ctrl+Shift+Up or Shift+Up: Quick scroll up in scrollback
1816
- if ((key.ctrl && key.shift) || key.shift) {
1817
- this.scrollUp(5);
1818
- }
1819
- else {
1820
- this.handleUp();
1821
- }
1822
- return true;
1823
- case 'down':
1824
- // When showing suggestions, navigate down
1825
- if (this.showingSuggestions) {
1826
- this.moveSuggestionDown();
1827
- return true;
1828
- }
1829
- // Ctrl+Shift+Down or Shift+Down: Quick scroll down in scrollback
1830
- if ((key.ctrl && key.shift) || key.shift) {
1831
- this.scrollDown(5);
1832
- }
1833
- else {
1834
- this.handleDown();
1835
- }
1836
- return true;
1837
- case 'home':
1838
- // Ctrl+Home or in scrollback mode: scroll to top
1839
- if (key.ctrl || this.isInScrollbackMode) {
1840
- this.scrollToTop();
1841
- }
1842
- else {
1843
- this.moveCursorToLineStart();
1844
- }
1845
- return true;
1846
- case 'end':
1847
- // Ctrl+End or in scrollback mode: scroll to bottom (live mode)
1848
- if (key.ctrl || this.isInScrollbackMode) {
1849
- this.scrollToBottom();
1850
- }
1851
- else {
1852
- this.moveCursorToLineEnd();
1853
- }
1854
- return true;
1855
- case 'pageup':
1856
- // Scrollback disabled in alternate screen mode
1857
- // Users should use terminal's native scrollback if available
1858
- return true;
1859
- case 'pagedown':
1860
- // Scrollback disabled in alternate screen mode
1861
- return true;
1862
- case 'tab':
1863
- // Tab can select suggestion too without submitting
1864
- if (this.showingSuggestions && !key.shift) {
1865
- this.selectSuggestion();
1866
- return true;
1867
- }
1868
- if (key.shift) {
1869
- this.toggleEditMode();
1870
- return true;
1871
- }
1872
- this.emit('toggleThinking');
1873
- return true;
1874
- }
1875
- return false;
1876
- }
1877
- insertText(text) {
1878
- const clean = this.sanitize(text);
1879
- if (!clean)
1880
- return;
1881
- const available = this.config.maxLength - this.getComposedLength();
1882
- if (available <= 0)
1883
- return;
1884
- const chunk = clean.slice(0, available);
1885
- const placeholder = this.findPlaceholderAt(this.cursor);
1886
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1887
- this.insertPlainText(chunk, insertPos);
1888
- this.cursor = insertPos + chunk.length;
1889
- this.emit('change', this.buffer);
1890
- // Update command autocomplete visibility
1891
- this.updateSuggestionVisibility();
1892
- this.scheduleRender();
1893
- }
1894
- insertNewline() {
1895
- if (this.getComposedLength() >= this.config.maxLength)
1896
- return;
1897
- const placeholder = this.findPlaceholderAt(this.cursor);
1898
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1899
- this.insertPlainText('\n', insertPos);
1900
- this.cursor = insertPos + 1;
1901
- this.emit('change', this.buffer);
1902
- this.scheduleRender();
1903
- }
1904
- deleteBackward() {
1905
- if (this.cursor === 0)
1906
- return;
1907
- const placeholder = this.findPlaceholderAt(this.cursor - 1);
1908
- if (placeholder) {
1909
- this.deletePlaceholder(placeholder);
1910
- }
1911
- else {
1912
- this.removeRange(this.cursor - 1, this.cursor);
1913
- this.cursor = Math.max(0, this.cursor - 1);
1914
- }
1915
- this.emit('change', this.buffer);
1916
- this.updateSuggestionVisibility();
1917
- this.scheduleRender();
1918
- }
1919
- deleteForward() {
1920
- if (this.cursor >= this.buffer.length)
1921
- return;
1922
- const placeholder = this.findPlaceholderAt(this.cursor);
1923
- if (placeholder) {
1924
- this.deletePlaceholder(placeholder);
1925
- }
1926
- else {
1927
- this.removeRange(this.cursor, this.cursor + 1);
1928
- }
1929
- this.emit('change', this.buffer);
1930
- this.updateSuggestionVisibility();
1931
- this.scheduleRender();
1932
- }
1933
- deleteToStart() {
1934
- if (this.cursor === 0)
1935
- return;
1936
- this.removeRange(0, this.cursor);
1937
- this.cursor = 0;
1938
- this.emit('change', this.buffer);
1939
- this.updateSuggestionVisibility();
1940
- this.scheduleRender();
1941
- }
1942
- deleteToEnd() {
1943
- if (this.cursor >= this.buffer.length)
1944
- return;
1945
- this.removeRange(this.cursor, this.buffer.length);
1946
- this.emit('change', this.buffer);
1947
- this.updateSuggestionVisibility();
1948
- this.scheduleRender();
1949
- }
1950
- deleteWord() {
1951
- if (this.cursor === 0)
1952
- return;
1953
- const placeholder = this.findPlaceholderAt(this.cursor - 1);
1954
- if (placeholder) {
1955
- this.deletePlaceholder(placeholder);
1956
- this.emit('change', this.buffer);
1957
- this.scheduleRender();
1958
- return;
1959
- }
1960
- let pos = this.cursor;
1961
- // Skip whitespace
1962
- while (pos > 0 && this.isWhitespace(this.buffer[pos - 1]))
1963
- pos--;
1964
- // Skip word
1965
- while (pos > 0 && !this.isWhitespace(this.buffer[pos - 1]))
1966
- pos--;
1967
- this.buffer = this.buffer.slice(0, pos) + this.buffer.slice(this.cursor);
1968
- this.cursor = pos;
1969
- this.emit('change', this.buffer);
1970
- this.scheduleRender();
1971
- }
1972
- moveCursorLeft() {
1973
- if (this.cursor > 0) {
1974
- this.cursor--;
1975
- this.scheduleRender();
1976
- }
1977
- }
1978
- moveCursorRight() {
1979
- if (this.cursor < this.buffer.length) {
1980
- this.cursor++;
1981
- this.scheduleRender();
1982
- }
1983
- }
1984
- moveCursorWordLeft() {
1985
- let pos = this.cursor;
1986
- while (pos > 0 && this.isWhitespace(this.buffer[pos - 1]))
1987
- pos--;
1988
- while (pos > 0 && !this.isWhitespace(this.buffer[pos - 1]))
1989
- pos--;
1990
- this.cursor = pos;
1991
- this.scheduleRender();
1992
- }
1993
- moveCursorWordRight() {
1994
- let pos = this.cursor;
1995
- while (pos < this.buffer.length && !this.isWhitespace(this.buffer[pos]))
1996
- pos++;
1997
- while (pos < this.buffer.length && this.isWhitespace(this.buffer[pos]))
1998
- pos++;
1999
- this.cursor = pos;
2000
- this.scheduleRender();
2001
- }
2002
- moveCursorToLineStart() {
2003
- // Find start of current line
2004
- let pos = this.cursor;
2005
- while (pos > 0 && this.buffer[pos - 1] !== '\n')
2006
- pos--;
2007
- this.cursor = pos;
2008
- this.scheduleRender();
2009
- }
2010
- moveCursorToLineEnd() {
2011
- // Find end of current line
2012
- let pos = this.cursor;
2013
- while (pos < this.buffer.length && this.buffer[pos] !== '\n')
2014
- pos++;
2015
- this.cursor = pos;
2016
- this.scheduleRender();
2017
- }
2018
- handleUp() {
2019
- // Check if we can move up within buffer (multi-line)
2020
- const { cursorLine } = this.getCursorPosition();
2021
- if (cursorLine > 0) {
2022
- this.moveCursorUp();
2023
- return;
2024
- }
2025
- // Otherwise navigate history
2026
- if (this.history.length === 0)
2027
- return;
2028
- if (this.historyIndex === -1) {
2029
- this.tempInput = this.buffer;
2030
- }
2031
- if (this.historyIndex < this.history.length - 1) {
2032
- this.historyIndex++;
2033
- this.buffer = this.history[this.history.length - 1 - this.historyIndex] ?? '';
2034
- this.cursor = this.buffer.length;
2035
- this.scheduleRender();
2036
- }
2037
- }
2038
- handleDown() {
2039
- // Check if we can move down within buffer
2040
- const { cursorLine, totalLines } = this.getCursorPosition();
2041
- if (cursorLine < totalLines - 1) {
2042
- this.moveCursorDown();
2043
- return;
2044
- }
2045
- // Otherwise navigate history
2046
- if (this.historyIndex > 0) {
2047
- this.historyIndex--;
2048
- this.buffer = this.history[this.history.length - 1 - this.historyIndex] ?? '';
2049
- this.cursor = this.buffer.length;
2050
- this.scheduleRender();
2051
- }
2052
- else if (this.historyIndex === 0) {
2053
- this.historyIndex = -1;
2054
- this.buffer = this.tempInput;
2055
- this.cursor = this.buffer.length;
2056
- this.scheduleRender();
2057
- }
2058
- }
2059
- moveCursorUp() {
2060
- const lines = this.buffer.split('\n');
2061
- let lineStart = 0;
2062
- let lineIdx = 0;
2063
- // Find current line
2064
- for (let i = 0; i < lines.length; i++) {
2065
- const lineEnd = lineStart + (lines[i]?.length ?? 0);
2066
- if (this.cursor <= lineEnd) {
2067
- lineIdx = i;
2068
- break;
2069
- }
2070
- lineStart = lineEnd + 1; // +1 for newline
2071
- }
2072
- if (lineIdx === 0)
2073
- return;
2074
- // Calculate column in current line
2075
- const colInLine = this.cursor - lineStart;
2076
- // Move to previous line
2077
- const prevLineStart = lineStart - 1 - (lines[lineIdx - 1]?.length ?? 0);
2078
- const prevLineLen = lines[lineIdx - 1]?.length ?? 0;
2079
- this.cursor = prevLineStart + Math.min(colInLine, prevLineLen);
2080
- this.scheduleRender();
2081
- }
2082
- moveCursorDown() {
2083
- const lines = this.buffer.split('\n');
2084
- let lineStart = 0;
2085
- let lineIdx = 0;
2086
- // Find current line
2087
- for (let i = 0; i < lines.length; i++) {
2088
- const lineEnd = lineStart + (lines[i]?.length ?? 0);
2089
- if (this.cursor <= lineEnd) {
2090
- lineIdx = i;
2091
- break;
2092
- }
2093
- lineStart = lineEnd + 1;
2094
- }
2095
- if (lineIdx >= lines.length - 1)
2096
- return;
2097
- const colInLine = this.cursor - lineStart;
2098
- const nextLineStart = lineStart + (lines[lineIdx]?.length ?? 0) + 1;
2099
- const nextLineLen = lines[lineIdx + 1]?.length ?? 0;
2100
- this.cursor = nextLineStart + Math.min(colInLine, nextLineLen);
2101
- this.scheduleRender();
2102
- }
2103
- getCursorPosition() {
2104
- const lines = this.buffer.split('\n');
2105
- let pos = 0;
2106
- for (let i = 0; i < lines.length; i++) {
2107
- const lineLen = lines[i]?.length ?? 0;
2108
- if (this.cursor <= pos + lineLen) {
2109
- return {
2110
- cursorLine: i,
2111
- cursorCol: this.cursor - pos,
2112
- totalLines: lines.length,
2113
- };
2114
- }
2115
- pos += lineLen + 1;
2116
- }
2117
- return {
2118
- cursorLine: lines.length - 1,
2119
- cursorCol: lines[lines.length - 1]?.length ?? 0,
2120
- totalLines: lines.length,
2121
- };
2122
- }
2123
- submit() {
2124
- const text = this.assembleText();
2125
- if (!text.trim())
2126
- return;
2127
- const submission = text;
2128
- // Add to history
2129
- if (this.history[this.history.length - 1] !== submission) {
2130
- this.history.push(submission);
2131
- if (this.history.length > this.maxHistory) {
2132
- this.history.shift();
2133
- }
2134
- }
2135
- this.historyIndex = -1;
2136
- this.tempInput = '';
2137
- // Queue or submit based on mode
2138
- if (this.mode === 'streaming') {
2139
- if (this.queue.length >= this.config.maxQueueSize) {
2140
- return; // Queue full
2141
- }
2142
- this.queue.push({
2143
- id: ++this.queueIdCounter,
2144
- text: submission,
2145
- timestamp: Date.now(),
2146
- });
2147
- this.emit('queue', submission);
2148
- this.clear(); // Clear immediately for queued input
2149
- }
2150
- else {
2151
- // In idle mode, clear the input first, then emit submit.
2152
- // The prompt will be logged as a visible message by the caller.
2153
- this.clear();
2154
- this.emit('submit', submission);
2155
- }
2156
- }
2157
- finishPaste() {
2158
- const content = this.pasteBuffer;
2159
- this.pasteBuffer = '';
2160
- this.isPasting = false;
2161
- if (!content)
2162
- return;
2163
- const clean = this.sanitize(content);
2164
- if (!clean)
2165
- return;
2166
- const available = this.config.maxLength - this.getComposedLength();
2167
- if (available <= 0)
2168
- return;
2169
- const chunk = clean.slice(0, available);
2170
- const isMultiline = isMultilinePaste(chunk);
2171
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
2172
- if (isMultiline && !isShortMultiline) {
2173
- this.insertPastePlaceholder(chunk);
2174
- }
2175
- else {
2176
- const placeholder = this.findPlaceholderAt(this.cursor);
2177
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
2178
- this.insertPlainText(chunk, insertPos);
2179
- this.cursor = insertPos + chunk.length;
2180
- }
2181
- this.emit('change', this.buffer);
2182
- this.scheduleRender();
2183
- }
2184
- // ===========================================================================
2185
- // BUFFER WRAPPING
2186
- // ===========================================================================
2187
- wrapBuffer(maxWidth) {
2188
- const width = Math.max(1, maxWidth);
2189
- const lines = [];
2190
- let cursorLine = 0;
2191
- let cursorCol = 0;
2192
- let charIndex = 0;
2193
- if (this.buffer.length === 0) {
2194
- return { lines: [''], cursorLine: 0, cursorCol: 0 };
2195
- }
2196
- const rawLines = this.buffer.split('\n');
2197
- for (let i = 0; i < rawLines.length; i++) {
2198
- const raw = rawLines[i] ?? '';
2199
- if (raw.length === 0) {
2200
- // Empty line from newline
2201
- if (this.cursor === charIndex) {
2202
- cursorLine = lines.length;
2203
- cursorCol = 0;
2204
- }
2205
- lines.push('');
2206
- }
2207
- else {
2208
- // Wrap long lines
2209
- for (let start = 0; start < raw.length; start += width) {
2210
- const segment = raw.slice(start, start + width);
2211
- const segmentStart = charIndex + start;
2212
- const segmentEnd = segmentStart + segment.length;
2213
- if (this.cursor >= segmentStart && this.cursor <= segmentEnd) {
2214
- cursorLine = lines.length;
2215
- cursorCol = this.cursor - segmentStart;
2216
- }
2217
- lines.push(segment);
2218
- }
2219
- }
2220
- charIndex += raw.length;
2221
- // Account for newline between raw lines
2222
- if (i < rawLines.length - 1) {
2223
- if (this.cursor === charIndex) {
2224
- cursorLine = lines.length;
2225
- cursorCol = 0;
2226
- }
2227
- charIndex++;
2228
- }
2229
- }
2230
- // Safety: clamp values
2231
- cursorLine = Math.max(0, Math.min(cursorLine, lines.length - 1));
2232
- cursorCol = Math.max(0, Math.min(cursorCol, lines[cursorLine]?.length ?? 0));
2233
- return { lines, cursorLine, cursorCol };
2234
- }
2235
- // ===========================================================================
2236
- // SCROLLBACK BUFFER
2237
- // ===========================================================================
2238
- /**
2239
- * Add content to the scrollback buffer for history retention
2240
- */
2241
- addToScrollback(content) {
2242
- if (!content)
2243
- return;
2244
- // Split content into lines and add to buffer
2245
- const lines = content.split('\n');
2246
- for (let i = 0; i < lines.length; i++) {
2247
- const line = lines[i];
2248
- if (line !== undefined) {
2249
- // Only add non-empty lines or preserve newlines between content
2250
- if (i < lines.length - 1 || line.length > 0) {
2251
- this.scrollbackBuffer.push(line);
2252
- }
2253
- }
2254
- }
2255
- // Trim buffer if it exceeds max size
2256
- while (this.scrollbackBuffer.length > this.maxScrollbackLines) {
2257
- this.scrollbackBuffer.shift();
2258
- }
2259
- // If we're in live mode (not scrolled up), keep offset at 0
2260
- if (this.scrollbackOffset === 0) {
2261
- this.isInScrollbackMode = false;
2262
- }
2263
- }
2264
- /**
2265
- * Scroll up by a number of lines (PageUp)
2266
- * Note: Scrollback is disabled in alternate screen mode to avoid display corruption.
2267
- * Users should use their terminal's native scrollback or copy/paste features.
2268
- */
2269
- scrollUp(_lines = 10) {
2270
- // Scrollback disabled - alternate screen buffer doesn't support it well
2271
- // The scrollback buffer is still maintained for potential future use
2272
- // Users can select and copy text normally since mouse tracking is off
2273
- }
2274
- /**
2275
- * Scroll down by a number of lines (PageDown)
2276
- * Note: Scrollback disabled - see scrollUp comment
2277
- */
2278
- scrollDown(_lines = 10) {
2279
- // Scrollback disabled
2280
- }
2281
- /**
2282
- * Jump to the top of scrollback buffer
2283
- * DISABLED: Scrollback navigation causes display corruption in alternate screen mode.
2284
- * The scrollback buffer is maintained but cannot be rendered properly.
2285
- */
2286
- scrollToTop() {
2287
- // Disabled - causes display corruption in alternate screen buffer
2288
- }
2289
- /**
2290
- * Jump to the bottom (live mode)
2291
- * DISABLED: Scrollback navigation causes display corruption.
2292
- */
2293
- scrollToBottom() {
2294
- // Reset scrollback state in case it was somehow enabled
2295
- this.scrollbackOffset = 0;
2296
- this.isInScrollbackMode = false;
2297
- }
2298
- /**
2299
- * Toggle scrollback mode on/off (Ctrl+Shift+S hotkey; Alt+S legacy)
2300
- * DISABLED: Scrollback navigation causes display corruption in alternate screen mode.
2301
- */
2302
- toggleScrollbackMode() {
2303
- // Disabled - alternate screen buffer doesn't support manual scrollback rendering
2304
- }
2305
- /**
2306
- * Render the scrollback buffer view.
2307
- * DISABLED: This causes display corruption in alternate screen mode.
2308
- * The alternate screen buffer has its own rendering model that conflicts with
2309
- * manual scroll region manipulation.
2310
- */
2311
- renderScrollbackView() {
2312
- // Disabled - causes display corruption
2313
- }
2314
- /**
2315
- * Build scrollback header with navigation hints
2316
- */
2317
- buildScrollbackHeader(cols, totalLines, startIdx, endIdx) {
2318
- const percentage = Math.round((endIdx / totalLines) * 100);
2319
- // Animated scroll indicator
2320
- const scrollFrames = ['◆', '◇', '◆', '◈'];
2321
- this.scrollIndicatorFrame = (this.scrollIndicatorFrame + 1) % scrollFrames.length;
2322
- const indicator = scrollFrames[this.scrollIndicatorFrame];
2323
- // Build header parts
2324
- const leftPart = theme.info(`${indicator} SCROLLBACK`) +
2325
- theme.ui.muted(` [${startIdx + 1}-${endIdx} of ${totalLines}]`);
2326
- const progressBar = this.buildProgressBar(percentage, 15);
2327
- const rightPart = progressBar +
2328
- theme.ui.muted(` ${percentage}%`) +
2329
- theme.ui.muted(' │ ') +
2330
- theme.primary('PgUp') + theme.ui.muted('/') + theme.primary('PgDn') +
2331
- theme.ui.muted(' scroll · ') +
2332
- theme.primary('End') + theme.ui.muted(' exit');
2333
- const leftLen = this.visibleLength(leftPart);
2334
- const rightLen = this.visibleLength(rightPart);
2335
- const padding = Math.max(1, cols - leftLen - rightLen - 2);
2336
- return `${leftPart}${' '.repeat(padding)}${rightPart}`;
2337
- }
2338
- /**
2339
- * Render visual scroll track on the right side
2340
- */
2341
- renderScrollTrack(cols, contentHeight, totalLines, startIdx, endIdx) {
2342
- if (totalLines <= contentHeight || cols < 40)
2343
- return;
2344
- const trackHeight = contentHeight - 1; // Exclude header
2345
- const viewportRatio = (endIdx - startIdx) / totalLines;
2346
- const positionRatio = startIdx / Math.max(1, totalLines - (endIdx - startIdx));
2347
- // Calculate thumb size and position
2348
- const thumbSize = Math.max(1, Math.round(viewportRatio * trackHeight));
2349
- const thumbStart = Math.round(positionRatio * (trackHeight - thumbSize));
2350
- // Render track on right edge
2351
- for (let i = 0; i < trackHeight; i++) {
2352
- const row = 2 + i; // Start after header
2353
- this.write(ESC.TO(row, cols));
2354
- if (i >= thumbStart && i < thumbStart + thumbSize) {
2355
- // Thumb (viewport indicator)
2356
- this.write(theme.accent('█'));
2357
- }
2358
- else {
2359
- // Track background
2360
- this.write(theme.ui.muted('░'));
2361
- }
2362
- }
2363
- }
2364
- /**
2365
- * Build a visual progress bar
2366
- */
2367
- buildProgressBar(percentage, width = 10) {
2368
- const filled = Math.round((percentage / 100) * width);
2369
- const empty = width - filled;
2370
- const bar = theme.accent('█'.repeat(filled)) +
2371
- theme.ui.muted('░'.repeat(empty));
2372
- return `${theme.ui.muted('[')}${bar}${theme.ui.muted(']')}`;
2373
- }
2374
- /**
2375
- * Get scrollback buffer content (for persistence)
2376
- */
2377
- getScrollbackBuffer() {
2378
- return [...this.scrollbackBuffer];
2379
- }
2380
- /**
2381
- * Load scrollback buffer (for restoration)
2382
- */
2383
- loadScrollbackBuffer(lines) {
2384
- this.scrollbackBuffer = [...lines];
2385
- // Trim if necessary
2386
- while (this.scrollbackBuffer.length > this.maxScrollbackLines) {
2387
- this.scrollbackBuffer.shift();
2388
- }
2389
- }
2390
- /**
2391
- * Clear scrollback buffer
2392
- */
2393
- clearScrollbackBuffer() {
2394
- this.scrollbackBuffer = [];
2395
- this.scrollbackOffset = 0;
2396
- this.isInScrollbackMode = false;
2397
- }
2398
- // ===========================================================================
2399
- // UTILITIES
2400
- // ===========================================================================
2401
- getComposedLength() {
2402
- if (this.pastePlaceholders.length === 0) {
2403
- return this.buffer.length;
2404
- }
2405
- const sorted = [...this.pastePlaceholders].sort((a, b) => a.start - b.start);
2406
- let composed = 0;
2407
- let cursor = 0;
2408
- for (const placeholder of sorted) {
2409
- composed += Math.max(0, placeholder.start - cursor);
2410
- composed += placeholder.content.length;
2411
- cursor = placeholder.end;
2412
- }
2413
- composed += Math.max(0, this.buffer.length - cursor);
2414
- return composed;
2415
- }
2416
- assembleText() {
2417
- if (this.pastePlaceholders.length === 0) {
2418
- return this.buffer;
2419
- }
2420
- const sorted = [...this.pastePlaceholders].sort((a, b) => a.start - b.start);
2421
- let result = '';
2422
- let cursor = 0;
2423
- for (const placeholder of sorted) {
2424
- result += this.buffer.slice(cursor, placeholder.start);
2425
- result += placeholder.content;
2426
- cursor = placeholder.end;
2427
- }
2428
- result += this.buffer.slice(cursor);
2429
- return result;
2430
- }
2431
- shiftPlaceholders(start, delta, excludeId) {
2432
- if (delta === 0)
2433
- return;
2434
- for (const placeholder of this.pastePlaceholders) {
2435
- if (excludeId && placeholder.id === excludeId)
2436
- continue;
2437
- if (placeholder.start >= start) {
2438
- placeholder.start += delta;
2439
- placeholder.end += delta;
2440
- }
2441
- }
2442
- }
2443
- removeRange(start, end) {
2444
- // Expand deletion to cover any placeholder that overlaps the range
2445
- let adjustedStart = start;
2446
- let adjustedEnd = end;
2447
- for (const ph of this.pastePlaceholders) {
2448
- if (ph.start < adjustedEnd && ph.end > adjustedStart) {
2449
- adjustedStart = Math.min(adjustedStart, ph.start);
2450
- adjustedEnd = Math.max(adjustedEnd, ph.end);
2451
- }
2452
- }
2453
- const length = Math.max(0, adjustedEnd - adjustedStart);
2454
- if (length === 0)
2455
- return;
2456
- this.buffer = this.buffer.slice(0, adjustedStart) + this.buffer.slice(adjustedEnd);
2457
- // Remove any placeholders that were within the deleted range
2458
- this.pastePlaceholders = this.pastePlaceholders.filter((ph) => {
2459
- return ph.end <= adjustedStart || ph.start >= adjustedEnd;
2460
- });
2461
- // Shift remaining placeholders that were after the deleted range
2462
- this.shiftPlaceholders(adjustedEnd, -length);
2463
- this.clampCursor();
2464
- }
2465
- insertPlainText(text, position) {
2466
- if (!text)
2467
- return;
2468
- this.shiftPlaceholders(position, text.length);
2469
- this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
2470
- }
2471
- shouldInlineMultiline(content) {
2472
- const lines = content.split('\n').length;
2473
- const maxInlineLines = 4;
2474
- const maxInlineChars = 240;
2475
- return lines <= maxInlineLines && content.length <= maxInlineChars;
2476
- }
2477
- findPlaceholderAt(position) {
2478
- return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
2479
- }
2480
- buildPlaceholder(lineCount) {
2481
- const id = ++this.pasteCounter;
2482
- const plural = lineCount === 1 ? '' : 's';
2483
- const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
2484
- return { id, placeholder };
2485
- }
2486
- insertPastePlaceholder(content) {
2487
- const available = this.config.maxLength - this.getComposedLength();
2488
- if (available <= 0)
2489
- return;
2490
- const cleanContent = content.slice(0, available);
2491
- const lineCount = cleanContent.split('\n').length;
2492
- const { id, placeholder } = this.buildPlaceholder(lineCount);
2493
- const insertPos = this.cursor;
2494
- this.shiftPlaceholders(insertPos, placeholder.length);
2495
- this.pastePlaceholders.push({
2496
- id,
2497
- content: cleanContent,
2498
- lineCount,
2499
- placeholder,
2500
- start: insertPos,
2501
- end: insertPos + placeholder.length,
2502
- });
2503
- this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
2504
- this.cursor = insertPos + placeholder.length;
2505
- }
2506
- deletePlaceholder(placeholder) {
2507
- const length = placeholder.end - placeholder.start;
2508
- this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
2509
- this.pastePlaceholders = this.pastePlaceholders.filter((ph) => ph.id !== placeholder.id);
2510
- this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
2511
- this.cursor = placeholder.start;
2512
- }
2513
- updateContextUsage(value, autoCompactThreshold) {
2514
- if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
2515
- const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
2516
- this.contextAutoCompactThreshold = boundedThreshold;
2517
- }
2518
- if (value === null || !Number.isFinite(value)) {
2519
- this.contextUsage = null;
2520
- }
2521
- else {
2522
- const bounded = Math.max(0, Math.min(100, Math.round(value)));
2523
- this.contextUsage = bounded;
2524
- }
2525
- this.scheduleRender();
2526
- }
2527
- getEditMode() {
2528
- return this.editMode;
2529
- }
2530
- applyEditMode(mode) {
2531
- this.setEditMode(mode);
2532
- }
2533
- setEditMode(mode) {
2534
- if (this.editMode === mode)
2535
- return;
2536
- this.editMode = mode;
2537
- this.emit('mode-change', this.editMode);
2538
- this.scheduleRender();
2539
- }
2540
- toggleEditMode() {
2541
- // Cycle through: display-edits → ask-permission → plan → display-edits
2542
- const cycle = ['display-edits', 'ask-permission', 'plan'];
2543
- const currentIndex = cycle.indexOf(this.editMode);
2544
- const nextIndex = (currentIndex + 1) % cycle.length;
2545
- this.setEditMode(cycle[nextIndex]);
2546
- }
2547
- scheduleStreamingRender(delayMs) {
2548
- if (this.streamingRenderTimer)
2549
- return;
2550
- const wait = Math.max(16, delayMs);
2551
- this.streamingRenderTimer = setTimeout(() => {
2552
- this.streamingRenderTimer = null;
2553
- // During streaming, only update chat box (not full render)
2554
- if (this.scrollRegionActive) {
2555
- this.renderStreamingFrame();
2556
- }
2557
- else {
2558
- this.render();
2559
- }
2560
- }, wait);
2561
- }
2562
- /**
2563
- * Render the pinned chat box during streaming only when we actually have
2564
- * pending UI changes. Prevents meta/header lines from being echoed into the
2565
- * streamed log when nothing changed.
2566
- */
2567
- renderStreamingFrame() {
2568
- if (!this.canRender())
2569
- return;
2570
- if (this.isRendering)
2571
- return;
2572
- const shouldSkip = !this.renderDirty &&
2573
- this.buffer === this.lastRenderContent &&
2574
- this.cursor === this.lastRenderCursor;
2575
- // Clear the dirty flag even when skipping to avoid runaway retries.
2576
- this.renderDirty = false;
2577
- if (shouldSkip) {
2578
- return;
2579
- }
2580
- if (writeLock.isLocked()) {
2581
- writeLock.safeWrite(() => this.renderStreamingFrame());
2582
- return;
2583
- }
2584
- this.renderPinnedChatBox();
2585
- }
2586
- resetStreamingRenderThrottle() {
2587
- if (this.streamingRenderTimer) {
2588
- clearTimeout(this.streamingRenderTimer);
2589
- this.streamingRenderTimer = null;
2590
- }
2591
- this.lastStreamingRender = 0;
2592
- }
2593
- scheduleRender() {
2594
- if (!this.canRender())
2595
- return;
2596
- this.renderDirty = true;
2597
- queueMicrotask(() => this.render());
2598
- }
2599
- canRender() {
2600
- return !this.disposed && this.enabled && this.isTTY();
2601
- }
2602
- isTTY() {
2603
- return !!this.out.isTTY && this.out.writable !== false;
2604
- }
2605
- getSize() {
2606
- return {
2607
- rows: this.out.rows ?? 24,
2608
- cols: this.out.columns ?? 80,
2609
- };
2610
- }
2611
- write(data) {
2612
- try {
2613
- if (this.out.writable) {
2614
- this.out.write(data);
2615
- }
2616
- }
2617
- catch {
2618
- // Ignore write errors
2619
- }
2620
- }
2621
- sanitize(text) {
2622
- if (!text)
2623
- return '';
2624
- // Remove ANSI codes and control chars (except newlines)
2625
- return text
2626
- .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') // OSC sequences
2627
- .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') // CSI sequences
2628
- .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '') // Control chars except \n and \r
2629
- .replace(/\r\n?/g, '\n'); // Normalize line endings
2630
- }
2631
- getArrowKeyName(sequence) {
2632
- if (!sequence)
2633
- return null;
2634
- const match = sequence.match(/\x1b(?:\[[0-9;]*|O)([ABCD])$/) ||
2635
- sequence.match(/^\[?[0-9;]*([ABCD])$/);
2636
- if (!match)
2637
- return null;
2638
- switch (match[1]) {
2639
- case 'A':
2640
- return 'up';
2641
- case 'B':
2642
- return 'down';
2643
- case 'C':
2644
- return 'right';
2645
- case 'D':
2646
- return 'left';
2647
- default:
2648
- return null;
2649
- }
2650
- }
2651
- getFallbackKeyName(str) {
2652
- if (!str)
2653
- return null;
2654
- if (this.isBackspaceChar(str)) {
2655
- return 'backspace';
2656
- }
2657
- return null;
2658
- }
2659
- isArrowEscapeFragment(text) {
2660
- if (!text)
2661
- return false;
2662
- if (text.length > 8)
2663
- return false;
2664
- return (/^\x1b?(?:\[[0-9;]*|O)[ABCD]$/.test(text) ||
2665
- (/^\[?[0-9;]*[ABCD]$/.test(text) && text.length <= 6));
2666
- }
2667
- isOrphanedEscapeSequence(text, key) {
2668
- if (!text || key?.name)
2669
- return false;
2670
- // If sanitizing leaves printable content, treat it as user input.
2671
- const sanitized = this.sanitize(text);
2672
- if (sanitized.length > 0) {
2673
- return false;
2674
- }
2675
- if (text.includes('\x1b'))
2676
- return true;
2677
- // Common stray fragments when terminals partially echo arrow sequences (e.g., "[D")
2678
- return text.length <= 5 && (/^[\[O][0-9;]*[A-Za-z]$/.test(text));
2679
- }
2680
- isWhitespace(char) {
2681
- return char === ' ' || char === '\t' || char === '\n';
2682
- }
2683
- isBackspaceChar(text) {
2684
- if (!text)
2685
- return false;
2686
- return text === '\b' || text === '\x7f';
2687
- }
2688
- isStandaloneControlChar(text) {
2689
- if (text.length !== 1) {
2690
- return false;
2691
- }
2692
- const code = text.charCodeAt(0);
2693
- return (code <= 0x1f || code === 0x7f);
2694
- }
2695
- clampCursor() {
2696
- this.cursor = Math.max(0, Math.min(this.cursor, this.buffer.length));
2697
- }
2698
- }
2699
- //# sourceMappingURL=terminalInput.js.map