erosolar-cli 1.7.258 → 1.7.259

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 (91) hide show
  1. package/README.md +148 -22
  2. package/dist/core/aiFlowOptimizer.d.ts +26 -0
  3. package/dist/core/aiFlowOptimizer.d.ts.map +1 -0
  4. package/dist/core/aiFlowOptimizer.js +31 -0
  5. package/dist/core/aiFlowOptimizer.js.map +1 -0
  6. package/dist/core/aiOptimizationEngine.d.ts +158 -0
  7. package/dist/core/aiOptimizationEngine.d.ts.map +1 -0
  8. package/dist/core/aiOptimizationEngine.js +428 -0
  9. package/dist/core/aiOptimizationEngine.js.map +1 -0
  10. package/dist/core/aiOptimizationIntegration.d.ts +93 -0
  11. package/dist/core/aiOptimizationIntegration.d.ts.map +1 -0
  12. package/dist/core/aiOptimizationIntegration.js +250 -0
  13. package/dist/core/aiOptimizationIntegration.js.map +1 -0
  14. package/dist/core/customCommands.d.ts +0 -1
  15. package/dist/core/customCommands.d.ts.map +1 -1
  16. package/dist/core/customCommands.js +0 -3
  17. package/dist/core/customCommands.js.map +1 -1
  18. package/dist/core/enhancedErrorRecovery.d.ts +100 -0
  19. package/dist/core/enhancedErrorRecovery.d.ts.map +1 -0
  20. package/dist/core/enhancedErrorRecovery.js +345 -0
  21. package/dist/core/enhancedErrorRecovery.js.map +1 -0
  22. package/dist/core/toolPreconditions.d.ts.map +1 -1
  23. package/dist/core/toolPreconditions.js +14 -0
  24. package/dist/core/toolPreconditions.js.map +1 -1
  25. package/dist/core/toolRuntime.d.ts.map +1 -1
  26. package/dist/core/toolRuntime.js +5 -0
  27. package/dist/core/toolRuntime.js.map +1 -1
  28. package/dist/core/toolValidation.d.ts.map +1 -1
  29. package/dist/core/toolValidation.js +3 -14
  30. package/dist/core/toolValidation.js.map +1 -1
  31. package/dist/core/validationRunner.d.ts +3 -1
  32. package/dist/core/validationRunner.d.ts.map +1 -1
  33. package/dist/core/validationRunner.js.map +1 -1
  34. package/dist/mcp/sseClient.d.ts.map +1 -1
  35. package/dist/mcp/sseClient.js +18 -9
  36. package/dist/mcp/sseClient.js.map +1 -1
  37. package/dist/plugins/tools/build/buildPlugin.d.ts +6 -0
  38. package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
  39. package/dist/plugins/tools/build/buildPlugin.js +10 -4
  40. package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
  41. package/dist/shell/claudeCodeStreamHandler.d.ts +145 -0
  42. package/dist/shell/claudeCodeStreamHandler.d.ts.map +1 -0
  43. package/dist/shell/claudeCodeStreamHandler.js +322 -0
  44. package/dist/shell/claudeCodeStreamHandler.js.map +1 -0
  45. package/dist/shell/inputQueueManager.d.ts +144 -0
  46. package/dist/shell/inputQueueManager.d.ts.map +1 -0
  47. package/dist/shell/inputQueueManager.js +290 -0
  48. package/dist/shell/inputQueueManager.js.map +1 -0
  49. package/dist/shell/interactiveShell.d.ts +2 -10
  50. package/dist/shell/interactiveShell.d.ts.map +1 -1
  51. package/dist/shell/interactiveShell.js +35 -190
  52. package/dist/shell/interactiveShell.js.map +1 -1
  53. package/dist/shell/streamingOutputManager.d.ts +115 -0
  54. package/dist/shell/streamingOutputManager.d.ts.map +1 -0
  55. package/dist/shell/streamingOutputManager.js +225 -0
  56. package/dist/shell/streamingOutputManager.js.map +1 -0
  57. package/dist/shell/terminalInput.d.ts +136 -66
  58. package/dist/shell/terminalInput.d.ts.map +1 -1
  59. package/dist/shell/terminalInput.js +662 -409
  60. package/dist/shell/terminalInput.js.map +1 -1
  61. package/dist/shell/terminalInputAdapter.d.ts +15 -20
  62. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  63. package/dist/shell/terminalInputAdapter.js +22 -14
  64. package/dist/shell/terminalInputAdapter.js.map +1 -1
  65. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  66. package/dist/ui/ShellUIAdapter.js +12 -13
  67. package/dist/ui/ShellUIAdapter.js.map +1 -1
  68. package/dist/ui/display.d.ts +0 -19
  69. package/dist/ui/display.d.ts.map +1 -1
  70. package/dist/ui/display.js +22 -135
  71. package/dist/ui/display.js.map +1 -1
  72. package/dist/ui/persistentPrompt.d.ts +50 -0
  73. package/dist/ui/persistentPrompt.d.ts.map +1 -0
  74. package/dist/ui/persistentPrompt.js +92 -0
  75. package/dist/ui/persistentPrompt.js.map +1 -0
  76. package/dist/ui/terminalUISchema.d.ts +195 -0
  77. package/dist/ui/terminalUISchema.d.ts.map +1 -0
  78. package/dist/ui/terminalUISchema.js +113 -0
  79. package/dist/ui/terminalUISchema.js.map +1 -0
  80. package/dist/ui/theme.d.ts.map +1 -1
  81. package/dist/ui/theme.js +8 -6
  82. package/dist/ui/theme.js.map +1 -1
  83. package/dist/ui/toolDisplay.d.ts +158 -0
  84. package/dist/ui/toolDisplay.d.ts.map +1 -1
  85. package/dist/ui/toolDisplay.js +348 -0
  86. package/dist/ui/toolDisplay.js.map +1 -1
  87. package/dist/ui/unified/layout.d.ts +0 -1
  88. package/dist/ui/unified/layout.d.ts.map +1 -1
  89. package/dist/ui/unified/layout.js +25 -15
  90. package/dist/ui/unified/layout.js.map +1 -1
  91. package/package.json +1 -1
@@ -3,18 +3,16 @@
3
3
  *
4
4
  * Design principles:
5
5
  * - Single source of truth for input state
6
- * - One bottom-pinned chat box for the entire session (no inline anchors)
7
6
  * - Native bracketed paste support (no heuristics)
8
7
  * - Clean cursor model with render-time wrapping
9
8
  * - State machine for different input modes
10
9
  * - No readline dependency for display
11
10
  */
12
11
  import { EventEmitter } from 'node:events';
13
- import { isMultilinePaste } from '../core/multilinePasteHandler.js';
12
+ import { isMultilinePaste, generatePasteSummary } from '../core/multilinePasteHandler.js';
14
13
  import { writeLock } from '../ui/writeLock.js';
15
- import { renderDivider, renderStatusLine } from '../ui/unified/layout.js';
16
- import { isStreamingMode } from '../ui/globalWriteLock.js';
17
- import { formatThinking } from '../ui/toolDisplay.js';
14
+ import { renderDivider } from '../ui/unified/layout.js';
15
+ import { UI_COLORS, DEFAULT_UI_CONFIG, } from '../ui/terminalUISchema.js';
18
16
  // ANSI escape codes
19
17
  const ESC = {
20
18
  // Cursor control
@@ -69,11 +67,6 @@ export class TerminalInput extends EventEmitter {
69
67
  statusMessage = null;
70
68
  overrideStatusMessage = null; // Secondary status (warnings, etc.)
71
69
  streamingLabel = null; // Streaming progress indicator
72
- metaElapsedSeconds = null; // Optional elapsed time for header line
73
- metaTokensUsed = null; // Optional token usage
74
- metaTokenLimit = null; // Optional token window
75
- metaThinkingMs = null; // Optional thinking duration
76
- metaThinkingHasContent = false; // Whether collapsed thinking content exists
77
70
  reservedLines = 2;
78
71
  scrollRegionActive = false;
79
72
  lastRenderContent = '';
@@ -81,35 +74,45 @@ export class TerminalInput extends EventEmitter {
81
74
  renderDirty = false;
82
75
  isRendering = false;
83
76
  pinnedTopRows = 0;
77
+ inlineAnchorRow = null;
78
+ inlineLayout = false;
79
+ anchorProvider = null;
80
+ // Flow mode: when true, renders inline after content (no absolute positioning)
81
+ flowMode = true;
82
+ flowModeRenderedLines = 0; // Track lines rendered for clearing
83
+ // Command suggestions (Claude Code style auto-complete)
84
+ commandSuggestions = [];
85
+ filteredSuggestions = [];
86
+ selectedSuggestionIndex = 0;
87
+ showSuggestions = false;
88
+ maxVisibleSuggestions = 10;
84
89
  // Lifecycle
85
90
  disposed = false;
86
91
  enabled = true;
87
92
  contextUsage = null;
88
- contextAutoCompactThreshold = 90;
89
- thinkingModeLabel = null;
90
93
  editMode = 'display-edits';
91
94
  verificationEnabled = true;
92
95
  autoContinueEnabled = false;
93
96
  verificationHotkey = 'alt+v';
94
97
  autoContinueHotkey = 'alt+c';
95
- thinkingHotkey = '/thinking';
96
- modelLabel = null;
97
- providerLabel = null;
98
98
  // Output interceptor cleanup
99
99
  outputInterceptorCleanup;
100
- // Streaming render throttle
101
- lastStreamingRender = 0;
102
- streamingRenderInterval = 250; // ms between renders during streaming
100
+ // Metrics tracking for status bar
101
+ streamingStartTime = null;
102
+ tokensUsed = 0;
103
+ thinkingEnabled = true;
104
+ // Streaming input area render timer (updates elapsed time display)
103
105
  streamingRenderTimer = null;
104
106
  constructor(writeStream = process.stdout, config = {}) {
105
107
  super();
106
108
  this.out = writeStream;
109
+ // Use schema defaults for configuration consistency
107
110
  this.config = {
108
- maxLines: config.maxLines ?? 1000,
109
- maxLength: config.maxLength ?? 10000,
111
+ maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
112
+ maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
110
113
  maxQueueSize: config.maxQueueSize ?? 100,
111
- promptChar: config.promptChar ?? '> ',
112
- continuationChar: config.continuationChar ?? '│ ',
114
+ promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
115
+ continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
113
116
  };
114
117
  }
115
118
  // ===========================================================================
@@ -188,6 +191,11 @@ export class TerminalInput extends EventEmitter {
188
191
  if (handled)
189
192
  return;
190
193
  }
194
+ // Handle '?' for help hint (if buffer is empty)
195
+ if (str === '?' && this.buffer.length === 0) {
196
+ this.emit('showHelp');
197
+ return;
198
+ }
191
199
  // Insert printable characters
192
200
  if (str && !key?.ctrl && !key?.meta) {
193
201
  this.insertText(str);
@@ -196,38 +204,291 @@ export class TerminalInput extends EventEmitter {
196
204
  /**
197
205
  * Set the input mode
198
206
  *
199
- * Streaming keeps the scroll region active so the prompt/status stay pinned
200
- * below the streaming output. When streaming ends, we refresh the input area.
207
+ * Streaming mode disables scroll region and lets content flow naturally.
208
+ * The input area will be re-rendered after streaming ends at wherever
209
+ * the cursor is (below the streamed content).
201
210
  */
202
211
  setMode(mode) {
203
212
  const prevMode = this.mode;
204
213
  this.mode = mode;
205
214
  if (mode === 'streaming' && prevMode !== 'streaming') {
206
- // Keep scroll region active so status/prompt stay pinned while streaming
207
- this.resetStreamingRenderThrottle();
208
- this.enableScrollRegion();
215
+ // Track streaming start time for elapsed display
216
+ this.streamingStartTime = Date.now();
217
+ // NO scroll regions - content flows naturally to terminal scrollback
218
+ // Input area renders at absolute bottom using cursor save/restore
219
+ this.pinnedTopRows = 0;
220
+ this.reservedLines = 5; // Reserve space for input area at bottom
221
+ // Disable any existing scroll region
222
+ this.disableScrollRegion();
223
+ // Initial render of input area at bottom
224
+ this.renderStreamingInputArea();
225
+ // Start timer to update streaming status and re-render input area
226
+ this.streamingRenderTimer = setInterval(() => {
227
+ if (this.mode === 'streaming') {
228
+ this.updateStreamingStatus();
229
+ this.renderStreamingInputArea();
230
+ }
231
+ }, 1000);
209
232
  this.renderDirty = true;
210
- this.render();
211
233
  }
212
234
  else if (mode !== 'streaming' && prevMode === 'streaming') {
213
- // Streaming ended - render the input area
214
- this.resetStreamingRenderThrottle();
215
- this.enableScrollRegion();
235
+ // Stop streaming render timer
236
+ if (this.streamingRenderTimer) {
237
+ clearInterval(this.streamingRenderTimer);
238
+ this.streamingRenderTimer = null;
239
+ }
240
+ // Reset streaming time
241
+ this.streamingStartTime = null;
242
+ this.pinnedTopRows = 0;
243
+ // Ensure no scroll region is active
244
+ this.disableScrollRegion();
245
+ // Show cursor again
246
+ this.write(ESC.SHOW);
247
+ // Add newline after streaming content, then render input area
248
+ this.write('\n');
249
+ // Reset flow mode tracking
250
+ this.flowModeRenderedLines = 0;
251
+ // Re-render the input area in normal mode
216
252
  this.forceRender();
217
253
  }
218
254
  }
255
+ /**
256
+ * Update streaming status label (called by timer)
257
+ */
258
+ updateStreamingStatus() {
259
+ if (this.mode !== 'streaming' || !this.streamingStartTime)
260
+ return;
261
+ // Calculate elapsed time
262
+ const elapsed = Date.now() - this.streamingStartTime;
263
+ const seconds = Math.floor(elapsed / 1000);
264
+ const minutes = Math.floor(seconds / 60);
265
+ const secs = seconds % 60;
266
+ // Format elapsed time
267
+ let elapsedStr;
268
+ if (minutes > 0) {
269
+ elapsedStr = `${minutes}m ${secs}s`;
270
+ }
271
+ else {
272
+ elapsedStr = `${secs}s`;
273
+ }
274
+ // Update streaming label
275
+ this.streamingLabel = `Streaming ${elapsedStr}`;
276
+ }
277
+ /**
278
+ * Render input area at absolute bottom during streaming.
279
+ * Uses cursor save/restore so content flow is not disrupted.
280
+ * Content writes to terminal scrollback, input area overlays at bottom.
281
+ */
282
+ renderStreamingInputArea() {
283
+ if (this.mode !== 'streaming')
284
+ return;
285
+ const { rows, cols } = this.getSize();
286
+ const divider = renderDivider(cols - 2);
287
+ // Calculate elapsed time for status
288
+ let elapsedStr = '0s';
289
+ if (this.streamingStartTime) {
290
+ const elapsed = Date.now() - this.streamingStartTime;
291
+ const seconds = Math.floor(elapsed / 1000);
292
+ const minutes = Math.floor(seconds / 60);
293
+ const secs = seconds % 60;
294
+ elapsedStr = minutes > 0 ? `${minutes}m ${secs}s` : `${secs}s`;
295
+ }
296
+ // Save cursor position (so content flow resumes correctly)
297
+ this.write(ESC.SAVE);
298
+ this.write(ESC.HIDE);
299
+ // Input area: 5 lines from bottom
300
+ // Row layout (from bottom): controls | bottomDiv | input | topDiv | status
301
+ const controlsRow = rows;
302
+ const bottomDivRow = rows - 1;
303
+ const inputRow = rows - 2;
304
+ const topDivRow = rows - 3;
305
+ const statusRow = rows - 4;
306
+ // Status bar with streaming info
307
+ this.write(ESC.TO(statusRow, 1));
308
+ this.write(ESC.CLEAR_LINE);
309
+ const statusText = `${UI_COLORS.dim}● Streaming ${elapsedStr} · type to queue message${UI_COLORS.reset}`;
310
+ this.write(statusText);
311
+ // Top divider
312
+ this.write(ESC.TO(topDivRow, 1));
313
+ this.write(ESC.CLEAR_LINE);
314
+ this.write(divider);
315
+ // Input line with buffer content
316
+ this.write(ESC.TO(inputRow, 1));
317
+ this.write(ESC.CLEAR_LINE);
318
+ const inputDisplay = this.buffer.length > 0 ? this.buffer.slice(0, cols - 4) : '';
319
+ const cursorChar = this.buffer.length > 0 ? '' : ' ';
320
+ this.write(`${this.config.promptChar}${inputDisplay}${ESC.REVERSE}${cursorChar}${ESC.RESET}`);
321
+ // Bottom divider
322
+ this.write(ESC.TO(bottomDivRow, 1));
323
+ this.write(ESC.CLEAR_LINE);
324
+ this.write(divider);
325
+ // Mode controls line
326
+ this.write(ESC.TO(controlsRow, 1));
327
+ this.write(ESC.CLEAR_LINE);
328
+ this.write(this.buildModeControls(cols));
329
+ // Restore cursor position (back to content area)
330
+ this.write(ESC.RESTORE);
331
+ }
332
+ /**
333
+ * Enable or disable flow mode.
334
+ * In flow mode, the input renders immediately after content (wherever cursor is).
335
+ * When disabled, input renders at the absolute bottom of terminal.
336
+ */
337
+ setFlowMode(enabled) {
338
+ if (this.flowMode === enabled)
339
+ return;
340
+ this.flowMode = enabled;
341
+ this.renderDirty = true;
342
+ this.scheduleRender();
343
+ }
344
+ /**
345
+ * Check if flow mode is enabled.
346
+ */
347
+ isFlowMode() {
348
+ return this.flowMode;
349
+ }
350
+ /**
351
+ * Set available slash commands for auto-complete suggestions.
352
+ */
353
+ setCommands(commands) {
354
+ this.commandSuggestions = commands;
355
+ this.updateSuggestions();
356
+ }
357
+ /**
358
+ * Update filtered suggestions based on current input.
359
+ */
360
+ updateSuggestions() {
361
+ const input = this.buffer.trim();
362
+ // Only show suggestions when input starts with "/"
363
+ if (!input.startsWith('/')) {
364
+ this.showSuggestions = false;
365
+ this.filteredSuggestions = [];
366
+ this.selectedSuggestionIndex = 0;
367
+ return;
368
+ }
369
+ const query = input.toLowerCase();
370
+ this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
371
+ cmd.command.toLowerCase().includes(query.slice(1)));
372
+ // Show suggestions if we have matches
373
+ this.showSuggestions = this.filteredSuggestions.length > 0;
374
+ // Keep selection in bounds
375
+ if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
376
+ this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
377
+ }
378
+ }
379
+ /**
380
+ * Select next suggestion (arrow down / tab).
381
+ */
382
+ selectNextSuggestion() {
383
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
384
+ return;
385
+ this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
386
+ this.renderDirty = true;
387
+ this.scheduleRender();
388
+ }
389
+ /**
390
+ * Select previous suggestion (arrow up / shift+tab).
391
+ */
392
+ selectPrevSuggestion() {
393
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
394
+ return;
395
+ this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
396
+ ? this.filteredSuggestions.length - 1
397
+ : this.selectedSuggestionIndex - 1;
398
+ this.renderDirty = true;
399
+ this.scheduleRender();
400
+ }
401
+ /**
402
+ * Accept current suggestion and insert into buffer.
403
+ */
404
+ acceptSuggestion() {
405
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
406
+ return false;
407
+ const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
408
+ if (!selected)
409
+ return false;
410
+ // Replace buffer with selected command
411
+ this.buffer = selected.command + ' ';
412
+ this.cursor = this.buffer.length;
413
+ this.showSuggestions = false;
414
+ this.renderDirty = true;
415
+ this.scheduleRender();
416
+ return true;
417
+ }
418
+ /**
419
+ * Check if suggestions are visible.
420
+ */
421
+ areSuggestionsVisible() {
422
+ return this.showSuggestions && this.filteredSuggestions.length > 0;
423
+ }
424
+ /**
425
+ * Update token count for metrics display
426
+ */
427
+ setTokensUsed(tokens) {
428
+ this.tokensUsed = tokens;
429
+ }
430
+ /**
431
+ * Toggle thinking/reasoning mode
432
+ */
433
+ toggleThinking() {
434
+ this.thinkingEnabled = !this.thinkingEnabled;
435
+ this.emit('thinkingToggle', this.thinkingEnabled);
436
+ this.scheduleRender();
437
+ }
438
+ /**
439
+ * Get thinking enabled state
440
+ */
441
+ isThinkingEnabled() {
442
+ return this.thinkingEnabled;
443
+ }
219
444
  /**
220
445
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
221
446
  */
222
447
  setPinnedHeaderLines(count) {
223
- // No pinned header rows anymore; keep everything in the scroll region.
224
- if (this.pinnedTopRows !== 0) {
225
- this.pinnedTopRows = 0;
448
+ // Set pinned header rows (banner area that scroll region excludes)
449
+ if (this.pinnedTopRows !== count) {
450
+ this.pinnedTopRows = count;
226
451
  if (this.scrollRegionActive) {
227
452
  this.applyScrollRegion();
228
453
  }
229
454
  }
230
455
  }
456
+ /**
457
+ * Anchor prompt rendering near a specific row (inline layout). Pass null to
458
+ * restore the default bottom-aligned layout.
459
+ */
460
+ setInlineAnchor(row) {
461
+ if (row === null || row === undefined) {
462
+ this.inlineAnchorRow = null;
463
+ this.inlineLayout = false;
464
+ this.renderDirty = true;
465
+ this.render();
466
+ return;
467
+ }
468
+ const { rows } = this.getSize();
469
+ const clamped = Math.max(1, Math.min(Math.floor(row), rows));
470
+ this.inlineAnchorRow = clamped;
471
+ this.inlineLayout = true;
472
+ this.renderDirty = true;
473
+ this.render();
474
+ }
475
+ /**
476
+ * Provide a dynamic anchor callback. When set, the prompt will follow the
477
+ * output by re-evaluating the anchor before each render.
478
+ */
479
+ setInlineAnchorProvider(provider) {
480
+ this.anchorProvider = provider;
481
+ if (!provider) {
482
+ this.inlineLayout = false;
483
+ this.inlineAnchorRow = null;
484
+ this.renderDirty = true;
485
+ this.render();
486
+ return;
487
+ }
488
+ this.inlineLayout = true;
489
+ this.renderDirty = true;
490
+ this.render();
491
+ }
231
492
  /**
232
493
  * Get current mode
233
494
  */
@@ -337,37 +598,6 @@ export class TerminalInput extends EventEmitter {
337
598
  this.streamingLabel = next;
338
599
  this.scheduleRender();
339
600
  }
340
- /**
341
- * Surface meta status just above the divider (e.g., elapsed time or token usage).
342
- */
343
- setMetaStatus(meta) {
344
- const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
345
- ? Math.floor(meta.elapsedSeconds)
346
- : null;
347
- const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
348
- ? Math.floor(meta.tokensUsed)
349
- : null;
350
- const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
351
- ? Math.floor(meta.tokenLimit)
352
- : null;
353
- const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
354
- ? Math.floor(meta.thinkingMs)
355
- : null;
356
- const nextThinkingHasContent = !!meta.thinkingHasContent;
357
- if (this.metaElapsedSeconds === nextElapsed &&
358
- this.metaTokensUsed === nextTokens &&
359
- this.metaTokenLimit === nextLimit &&
360
- this.metaThinkingMs === nextThinking &&
361
- this.metaThinkingHasContent === nextThinkingHasContent) {
362
- return;
363
- }
364
- this.metaElapsedSeconds = nextElapsed;
365
- this.metaTokensUsed = nextTokens;
366
- this.metaTokenLimit = nextLimit;
367
- this.metaThinkingMs = nextThinking;
368
- this.metaThinkingHasContent = nextThinkingHasContent;
369
- this.scheduleRender();
370
- }
371
601
  /**
372
602
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
373
603
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -377,22 +607,16 @@ export class TerminalInput extends EventEmitter {
377
607
  const nextAutoContinue = !!options.autoContinueEnabled;
378
608
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
379
609
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
380
- const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
381
- const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
382
610
  if (this.verificationEnabled === nextVerification &&
383
611
  this.autoContinueEnabled === nextAutoContinue &&
384
612
  this.verificationHotkey === nextVerifyHotkey &&
385
- this.autoContinueHotkey === nextAutoHotkey &&
386
- this.thinkingHotkey === nextThinkingHotkey &&
387
- this.thinkingModeLabel === nextThinkingLabel) {
613
+ this.autoContinueHotkey === nextAutoHotkey) {
388
614
  return;
389
615
  }
390
616
  this.verificationEnabled = nextVerification;
391
617
  this.autoContinueEnabled = nextAutoContinue;
392
618
  this.verificationHotkey = nextVerifyHotkey;
393
619
  this.autoContinueHotkey = nextAutoHotkey;
394
- this.thinkingHotkey = nextThinkingHotkey;
395
- this.thinkingModeLabel = nextThinkingLabel;
396
620
  this.scheduleRender();
397
621
  }
398
622
  /**
@@ -404,104 +628,197 @@ export class TerminalInput extends EventEmitter {
404
628
  this.streamingLabel = null;
405
629
  this.scheduleRender();
406
630
  }
407
- /**
408
- * Surface model/provider context in the controls bar.
409
- */
410
- setModelContext(options) {
411
- const nextModel = options.model?.trim() || null;
412
- const nextProvider = options.provider?.trim() || null;
413
- if (this.modelLabel === nextModel && this.providerLabel === nextProvider) {
414
- return;
415
- }
416
- this.modelLabel = nextModel;
417
- this.providerLabel = nextProvider;
418
- this.scheduleRender();
419
- }
420
631
  /**
421
632
  * Render the input area - Claude Code style with mode controls
422
633
  *
423
- * During streaming we keep the scroll region active and repaint only the
424
- * pinned status/input block (throttled) so streamed content can scroll
425
- * naturally above while elapsed time and status stay fresh.
634
+ * Same rendering for both normal and streaming modes - just different status bar.
635
+ * During streaming, uses cursor save/restore to preserve streaming position.
426
636
  */
427
637
  render() {
428
638
  if (!this.canRender())
429
639
  return;
430
640
  if (this.isRendering)
431
641
  return;
432
- const streamingActive = this.mode === 'streaming' || isStreamingMode();
433
- // During streaming we still render the pinned input/status region, but throttle
434
- // to avoid fighting with the streamed content flow.
435
- if (streamingActive && this.lastStreamingRender > 0) {
436
- const elapsed = Date.now() - this.lastStreamingRender;
437
- const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
438
- if (waitMs > 0) {
439
- this.renderDirty = true;
440
- this.scheduleStreamingRender(waitMs);
441
- return;
442
- }
443
- }
444
642
  const shouldSkip = !this.renderDirty &&
445
643
  this.buffer === this.lastRenderContent &&
446
644
  this.cursor === this.lastRenderCursor;
447
645
  this.renderDirty = false;
448
- // Skip if nothing changed and no explicit refresh requested
646
+ // Skip if nothing changed (unless explicitly forced)
449
647
  if (shouldSkip) {
450
648
  return;
451
649
  }
452
- // If write lock is held, defer render to avoid race conditions
650
+ // If write lock is held, defer render
453
651
  if (writeLock.isLocked()) {
454
652
  writeLock.safeWrite(() => this.render());
455
653
  return;
456
654
  }
457
- const performRender = () => {
458
- if (!this.scrollRegionActive) {
459
- this.enableScrollRegion();
460
- }
461
- const { rows, cols } = this.getSize();
462
- const maxWidth = Math.max(8, cols - 4);
463
- // Wrap buffer into display lines
464
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
465
- const availableForContent = Math.max(1, rows - 3); // room for separator + input + controls
466
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
467
- const displayLines = Math.min(lines.length, maxVisible);
468
- const metaLines = this.buildMetaLines(cols - 2);
469
- // Reserved lines: optional meta lines + separator(1) + input lines + controls(1)
470
- this.updateReservedLines(displayLines + 2 + metaLines.length);
471
- // Calculate display window (keep cursor visible)
472
- let startLine = 0;
473
- if (lines.length > displayLines) {
474
- startLine = Math.max(0, cursorLine - displayLines + 1);
475
- startLine = Math.min(startLine, lines.length - displayLines);
655
+ this.isRendering = true;
656
+ writeLock.lock('terminalInput.render');
657
+ try {
658
+ // Render input area at bottom (outside scroll region)
659
+ this.renderBottomPinned();
660
+ }
661
+ finally {
662
+ writeLock.unlock();
663
+ this.isRendering = false;
664
+ }
665
+ }
666
+ /**
667
+ * Render in flow mode - delegates to bottom-pinned for stability.
668
+ *
669
+ * Flow mode attempted inline rendering but caused duplicate renders
670
+ * due to unreliable cursor position tracking. Bottom-pinned is reliable.
671
+ */
672
+ renderFlowMode() {
673
+ // Use stable bottom-pinned approach
674
+ this.renderBottomPinned();
675
+ }
676
+ /**
677
+ * Render in bottom-pinned mode - Claude Code style with suggestions
678
+ *
679
+ * Works for both normal and streaming modes:
680
+ * - During streaming: saves/restores cursor position
681
+ * - Status bar shows streaming info or "Type a message"
682
+ *
683
+ * Layout when suggestions visible:
684
+ * - Top divider
685
+ * - Input line(s)
686
+ * - Bottom divider
687
+ * - Suggestions (command list)
688
+ *
689
+ * Layout when suggestions hidden:
690
+ * - Status bar (Ready/Streaming)
691
+ * - Top divider
692
+ * - Input line(s)
693
+ * - Bottom divider
694
+ * - Mode controls
695
+ */
696
+ renderBottomPinned() {
697
+ const { rows, cols } = this.getSize();
698
+ const maxWidth = Math.max(8, cols - 4);
699
+ const isStreaming = this.mode === 'streaming';
700
+ // During streaming, skip rendering input area entirely
701
+ // Content flows naturally to terminal - no positioning disruption
702
+ // Terminal scrollback preserves full history
703
+ if (isStreaming) {
704
+ return;
705
+ }
706
+ // Wrap buffer into display lines
707
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
708
+ const availableForContent = Math.max(1, rows - 3);
709
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
710
+ const displayLines = Math.min(lines.length, maxVisible);
711
+ // Calculate display window (keep cursor visible)
712
+ let startLine = 0;
713
+ if (lines.length > displayLines) {
714
+ startLine = Math.max(0, cursorLine - displayLines + 1);
715
+ startLine = Math.min(startLine, lines.length - displayLines);
716
+ }
717
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
718
+ const adjustedCursorLine = cursorLine - startLine;
719
+ // Calculate suggestion display (not during streaming)
720
+ const suggestionsToShow = (!isStreaming && this.showSuggestions)
721
+ ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
722
+ : [];
723
+ const suggestionLines = suggestionsToShow.length;
724
+ this.write(ESC.HIDE);
725
+ this.write(ESC.RESET);
726
+ const divider = renderDivider(cols - 2);
727
+ // Calculate positions from absolute bottom
728
+ let currentRow;
729
+ if (suggestionLines > 0) {
730
+ // With suggestions: input area + dividers + suggestions
731
+ // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
732
+ const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
733
+ currentRow = Math.max(1, rows - totalHeight + 1);
734
+ this.updateReservedLines(totalHeight);
735
+ // Top divider
736
+ this.write(ESC.TO(currentRow, 1));
737
+ this.write(ESC.CLEAR_LINE);
738
+ this.write(divider);
739
+ currentRow++;
740
+ // Input lines
741
+ let finalRow = currentRow;
742
+ let finalCol = 3;
743
+ for (let i = 0; i < visibleLines.length; i++) {
744
+ this.write(ESC.TO(currentRow, 1));
745
+ this.write(ESC.CLEAR_LINE);
746
+ const line = visibleLines[i] ?? '';
747
+ const absoluteLineIdx = startLine + i;
748
+ const isFirstLine = absoluteLineIdx === 0;
749
+ const isCursorLine = i === adjustedCursorLine;
750
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
751
+ if (isCursorLine) {
752
+ const col = Math.min(cursorCol, line.length);
753
+ this.write(line.slice(0, col));
754
+ this.write(ESC.REVERSE);
755
+ this.write(col < line.length ? line[col] : ' ');
756
+ this.write(ESC.RESET);
757
+ this.write(line.slice(col + 1));
758
+ finalRow = currentRow;
759
+ finalCol = this.config.promptChar.length + col + 1;
760
+ }
761
+ else {
762
+ this.write(line);
763
+ }
764
+ currentRow++;
476
765
  }
477
- const visibleLines = lines.slice(startLine, startLine + displayLines);
478
- const adjustedCursorLine = cursorLine - startLine;
479
- // Render
480
- this.write(ESC.HIDE);
481
- this.write(ESC.RESET);
482
- const startRow = Math.max(1, rows - this.reservedLines + 1);
483
- let currentRow = startRow;
484
- // Clear the reserved block to avoid stale meta/status lines
485
- this.clearReservedArea(startRow, this.reservedLines, cols);
486
- // Meta/status header (elapsed, tokens/context)
487
- for (const metaLine of metaLines) {
766
+ // Bottom divider
767
+ this.write(ESC.TO(currentRow, 1));
768
+ this.write(ESC.CLEAR_LINE);
769
+ this.write(divider);
770
+ currentRow++;
771
+ // Suggestions (Claude Code style)
772
+ for (let i = 0; i < suggestionsToShow.length; i++) {
488
773
  this.write(ESC.TO(currentRow, 1));
489
774
  this.write(ESC.CLEAR_LINE);
490
- this.write(metaLine);
491
- currentRow += 1;
775
+ const suggestion = suggestionsToShow[i];
776
+ const isSelected = i === this.selectedSuggestionIndex;
777
+ // Indent and highlight selected
778
+ this.write(' ');
779
+ if (isSelected) {
780
+ this.write(ESC.REVERSE);
781
+ this.write(ESC.BOLD);
782
+ }
783
+ this.write(suggestion.command);
784
+ if (isSelected) {
785
+ this.write(ESC.RESET);
786
+ }
787
+ // Description (dimmed)
788
+ const descSpace = cols - suggestion.command.length - 8;
789
+ if (descSpace > 10 && suggestion.description) {
790
+ const desc = suggestion.description.slice(0, descSpace);
791
+ this.write(ESC.RESET);
792
+ this.write(ESC.DIM);
793
+ this.write(' ');
794
+ this.write(desc);
795
+ this.write(ESC.RESET);
796
+ }
797
+ currentRow++;
492
798
  }
493
- // Separator line
799
+ // Position cursor in input area
800
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
801
+ }
802
+ else {
803
+ // Without suggestions: normal layout with status bar and controls
804
+ const totalHeight = visibleLines.length + 4; // status + topDiv + input + bottomDiv + controls
805
+ currentRow = Math.max(1, rows - totalHeight + 1);
806
+ this.updateReservedLines(totalHeight);
807
+ // Status bar (streaming or normal)
808
+ this.write(ESC.TO(currentRow, 1));
809
+ this.write(ESC.CLEAR_LINE);
810
+ this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
811
+ currentRow++;
812
+ // Top divider
494
813
  this.write(ESC.TO(currentRow, 1));
495
814
  this.write(ESC.CLEAR_LINE);
496
- const divider = renderDivider(cols - 2);
497
815
  this.write(divider);
498
- currentRow += 1;
499
- // Render input lines
816
+ currentRow++;
817
+ // Input lines
500
818
  let finalRow = currentRow;
501
819
  let finalCol = 3;
502
820
  for (let i = 0; i < visibleLines.length; i++) {
503
- const rowNum = currentRow + i;
504
- this.write(ESC.TO(rowNum, 1));
821
+ this.write(ESC.TO(currentRow, 1));
505
822
  this.write(ESC.CLEAR_LINE);
506
823
  const line = visibleLines[i] ?? '';
507
824
  const absoluteLineIdx = startLine + i;
@@ -515,7 +832,6 @@ export class TerminalInput extends EventEmitter {
515
832
  this.write(ESC.RESET);
516
833
  this.write(ESC.BG_DARK);
517
834
  if (isCursorLine) {
518
- // Render with block cursor
519
835
  const col = Math.min(cursorCol, line.length);
520
836
  const before = line.slice(0, col);
521
837
  const at = col < line.length ? line[col] : ' ';
@@ -525,251 +841,157 @@ export class TerminalInput extends EventEmitter {
525
841
  this.write(at);
526
842
  this.write(ESC.RESET + ESC.BG_DARK);
527
843
  this.write(after);
528
- finalRow = rowNum;
844
+ finalRow = currentRow;
529
845
  finalCol = this.config.promptChar.length + col + 1;
530
846
  }
531
847
  else {
532
848
  this.write(line);
533
849
  }
534
- // Pad to edge for clean look
850
+ // Pad to edge
535
851
  const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
536
852
  const padding = Math.max(0, cols - lineLen - 1);
537
853
  if (padding > 0)
538
854
  this.write(' '.repeat(padding));
539
855
  this.write(ESC.RESET);
856
+ currentRow++;
540
857
  }
541
- // Mode controls line (Claude Code style)
542
- const controlRow = currentRow + visibleLines.length;
543
- this.write(ESC.TO(controlRow, 1));
858
+ // Bottom divider
859
+ this.write(ESC.TO(currentRow, 1));
860
+ this.write(ESC.CLEAR_LINE);
861
+ this.write(divider);
862
+ currentRow++;
863
+ // Mode controls
864
+ this.write(ESC.TO(currentRow, 1));
544
865
  this.write(ESC.CLEAR_LINE);
545
866
  this.write(this.buildModeControls(cols));
546
- // Position cursor
547
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
548
- this.write(ESC.SHOW);
549
- // Update state
550
- this.lastRenderContent = this.buffer;
551
- this.lastRenderCursor = this.cursor;
552
- this.lastStreamingRender = streamingActive ? Date.now() : 0;
553
- if (this.streamingRenderTimer) {
554
- clearTimeout(this.streamingRenderTimer);
555
- this.streamingRenderTimer = null;
867
+ // Position cursor: restore for streaming, or position in input for normal
868
+ if (isStreaming) {
869
+ this.write(ESC.RESTORE);
870
+ }
871
+ else {
872
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
556
873
  }
557
- };
558
- // Use write lock during render to prevent interleaved output
559
- writeLock.lock('terminalInput.render');
560
- this.isRendering = true;
561
- try {
562
- performRender();
563
- }
564
- finally {
565
- writeLock.unlock();
566
- this.isRendering = false;
567
874
  }
875
+ this.write(ESC.SHOW);
876
+ // Update state
877
+ this.lastRenderContent = this.buffer;
878
+ this.lastRenderCursor = this.cursor;
568
879
  }
569
880
  /**
570
- * Build one or more compact meta lines above the divider (thinking, status, usage).
881
+ * Build status bar for streaming mode (shows elapsed time, queue count).
571
882
  */
572
- buildMetaLines(width) {
573
- const lines = [];
574
- if (this.metaThinkingMs !== null) {
575
- const thinkingText = formatThinking(this.metaThinkingMs, this.metaThinkingHasContent);
576
- lines.push(renderStatusLine([{ text: thinkingText, tone: 'info' }], width));
577
- }
578
- if (this.modelLabel) {
579
- const modelText = this.providerLabel
580
- ? `${this.modelLabel} @ ${this.providerLabel}`
581
- : this.modelLabel;
582
- lines.push(renderStatusLine([{ text: `model ${modelText}`, tone: 'muted' }], width));
583
- }
584
- const statusParts = [];
585
- const statusLabel = this.statusMessage ?? this.streamingLabel;
586
- if (statusLabel) {
587
- statusParts.push({ text: statusLabel, tone: 'info' });
588
- }
589
- if (this.mode === 'streaming' || isStreamingMode()) {
590
- statusParts.push({ text: 'esc to interrupt', tone: 'warn' });
591
- }
592
- if (this.metaElapsedSeconds !== null) {
593
- statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
594
- }
595
- const tokensRemaining = this.computeTokensRemaining();
596
- if (tokensRemaining !== null) {
597
- statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
598
- }
599
- if (statusParts.length) {
600
- lines.push(renderStatusLine(statusParts, width));
601
- }
602
- const usageParts = [];
603
- if (this.metaTokensUsed !== null) {
604
- const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
605
- const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
606
- usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
607
- }
608
- if (this.contextUsage !== null) {
609
- const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
610
- const left = Math.max(0, 100 - this.contextUsage);
611
- usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
612
- }
883
+ buildStreamingStatusBar(cols) {
884
+ const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
885
+ // Streaming status with elapsed time
886
+ let elapsed = '0s';
887
+ if (this.streamingStartTime) {
888
+ const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
889
+ const mins = Math.floor(secs / 60);
890
+ elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
891
+ }
892
+ let status = `${GREEN}● Streaming${R} ${elapsed}`;
893
+ // Queue indicator
613
894
  if (this.queue.length > 0) {
614
- usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
615
- }
616
- if (usageParts.length) {
617
- lines.push(renderStatusLine(usageParts, width));
618
- }
619
- return lines;
620
- }
621
- /**
622
- * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
623
- */
624
- clearReservedArea(startRow, reservedLines, cols) {
625
- const width = Math.max(1, cols);
626
- for (let i = 0; i < reservedLines; i++) {
627
- const row = startRow + i;
628
- this.write(ESC.TO(row, 1));
629
- this.write(' '.repeat(width));
895
+ status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
630
896
  }
897
+ // Hint for typing
898
+ status += ` ${DIM}· type to queue message${R}`;
899
+ return status;
631
900
  }
632
901
  /**
633
- * Build Claude Code style mode controls line.
634
- * Combines streaming label + override status + main status for simultaneous display.
902
+ * Build status bar showing streaming/ready status and key info.
903
+ * This is the TOP line above the input area - minimal Claude Code style.
635
904
  */
636
- buildModeControls(cols) {
637
- const width = Math.max(8, cols - 2);
638
- const leftParts = [];
639
- const rightParts = [];
640
- if (this.streamingLabel) {
641
- leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
642
- }
643
- if (this.overrideStatusMessage) {
644
- leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
645
- }
646
- if (this.statusMessage) {
647
- leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
648
- }
649
- const editHotkey = this.formatHotkey('shift+tab');
650
- const editLabel = this.editMode === 'display-edits' ? 'edits: accept' : 'edits: ask';
651
- const editTone = this.editMode === 'display-edits' ? 'success' : 'muted';
652
- leftParts.push({ text: `${editHotkey} ${editLabel}`, tone: editTone });
653
- const verifyHotkey = this.formatHotkey(this.verificationHotkey);
654
- const verifyLabel = this.verificationEnabled ? 'verify:on' : 'verify:off';
655
- leftParts.push({ text: `${verifyHotkey} ${verifyLabel}`, tone: this.verificationEnabled ? 'success' : 'muted' });
656
- const continueHotkey = this.formatHotkey(this.autoContinueHotkey);
657
- const continueLabel = this.autoContinueEnabled ? 'auto:on' : 'auto:off';
658
- leftParts.push({ text: `${continueHotkey} ${continueLabel}`, tone: this.autoContinueEnabled ? 'info' : 'muted' });
659
- if (this.queue.length > 0 && this.mode !== 'streaming') {
660
- leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
905
+ buildStatusBar(cols) {
906
+ const maxWidth = cols - 2;
907
+ const parts = [];
908
+ // Streaming status with elapsed time (left side)
909
+ if (this.mode === 'streaming') {
910
+ let statusText = '● Streaming';
911
+ if (this.streamingStartTime) {
912
+ const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
913
+ const mins = Math.floor(elapsed / 60);
914
+ const secs = elapsed % 60;
915
+ statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
916
+ }
917
+ parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
661
918
  }
662
- if (this.buffer.includes('\n')) {
663
- const lineCount = this.buffer.split('\n').length;
664
- leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
919
+ // Queue indicator during streaming
920
+ if (this.mode === 'streaming' && this.queue.length > 0) {
921
+ parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
665
922
  }
923
+ // Paste indicator
666
924
  if (this.pastePlaceholders.length > 0) {
667
- const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
668
- leftParts.push({
669
- text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
670
- tone: 'info',
671
- });
925
+ const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
926
+ parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
672
927
  }
673
- const contextRemaining = this.computeContextRemaining();
674
- if (this.thinkingModeLabel) {
675
- const thinkingHotkey = this.formatHotkey(this.thinkingHotkey);
676
- rightParts.push({ text: `${thinkingHotkey} thinking:${this.thinkingModeLabel}`, tone: 'info' });
677
- }
678
- if (this.modelLabel) {
679
- const modelText = this.providerLabel ? `${this.modelLabel} @ ${this.providerLabel}` : this.modelLabel;
680
- rightParts.push({ text: modelText, tone: 'muted' });
681
- }
682
- if (contextRemaining !== null) {
683
- const tone = contextRemaining <= 10 ? 'warn' : 'muted';
684
- const label = contextRemaining === 0 && this.contextUsage !== null
685
- ? 'Context auto-compact imminent'
686
- : `Context left until auto-compact: ${contextRemaining}%`;
687
- rightParts.push({ text: label, tone });
688
- }
689
- if (!rightParts.length || width < 60) {
690
- const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
691
- return renderStatusLine(merged, width);
692
- }
693
- const leftWidth = Math.max(12, Math.floor(width * 0.6));
694
- const rightWidth = Math.max(14, width - leftWidth - 1);
695
- const leftText = renderStatusLine(leftParts, leftWidth);
696
- const rightText = renderStatusLine(rightParts, rightWidth);
697
- const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
698
- return `${leftText}${' '.repeat(spacing)}${rightText}`;
699
- }
700
- formatHotkey(hotkey) {
701
- const normalized = hotkey.trim().toLowerCase();
702
- if (!normalized)
703
- return hotkey;
704
- const parts = normalized.split('+').filter(Boolean);
705
- const map = {
706
- shift: '⇧',
707
- sh: '⇧',
708
- alt: '⌥',
709
- option: '⌥',
710
- opt: '⌥',
711
- ctrl: '⌃',
712
- control: '⌃',
713
- cmd: '⌘',
714
- meta: '⌘',
715
- };
716
- const formatted = parts
717
- .map((part) => {
718
- const symbol = map[part];
719
- if (symbol)
720
- return symbol;
721
- return part.length === 1 ? part.toUpperCase() : part.toUpperCase();
722
- })
723
- .join('');
724
- return formatted || hotkey;
725
- }
726
- computeContextRemaining() {
727
- if (this.contextUsage === null) {
728
- return null;
729
- }
730
- return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
731
- }
732
- computeTokensRemaining() {
733
- if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
734
- return null;
735
- }
736
- const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
737
- return this.formatTokenCount(remaining);
738
- }
739
- formatElapsedLabel(seconds) {
740
- if (seconds < 60) {
741
- return `${seconds}s`;
742
- }
743
- const mins = Math.floor(seconds / 60);
744
- const secs = seconds % 60;
745
- return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
746
- }
747
- formatTokenCount(value) {
748
- if (!Number.isFinite(value)) {
749
- return `${value}`;
928
+ // Override/warning status
929
+ if (this.overrideStatusMessage) {
930
+ parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
750
931
  }
751
- if (value >= 1_000_000) {
752
- return `${(value / 1_000_000).toFixed(1)}M`;
932
+ // If idle with empty buffer, show quick shortcuts
933
+ if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
934
+ return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
753
935
  }
754
- if (value >= 1_000) {
755
- return `${(value / 1_000).toFixed(1)}k`;
936
+ // Multi-line indicator
937
+ if (this.buffer.includes('\n')) {
938
+ parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
756
939
  }
757
- return `${Math.round(value)}`;
758
- }
759
- visibleLength(value) {
760
- const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
761
- return value.replace(ansiPattern, '').length;
940
+ if (parts.length === 0) {
941
+ return ''; // Empty status bar when idle
942
+ }
943
+ const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
944
+ return joined.slice(0, maxWidth);
762
945
  }
763
946
  /**
764
- * Debug-only snapshot used by tests to assert rendered strings without
765
- * needing a TTY. Not used by production code.
947
+ * Build mode controls line showing toggles and context info.
948
+ * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
949
+ *
950
+ * Layout: [toggles on left] ... [context info on right]
766
951
  */
767
- getDebugUiSnapshot(width) {
768
- const cols = Math.max(8, width ?? this.getSize().cols);
769
- return {
770
- meta: this.buildMetaLines(cols - 2),
771
- controls: this.buildModeControls(cols),
772
- };
952
+ buildModeControls(cols) {
953
+ const maxWidth = cols - 2;
954
+ // Use schema-defined colors for consistency
955
+ const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
956
+ // Mode toggles with colors (following ModeControlsSchema)
957
+ const toggles = [];
958
+ // Edit mode (green=auto, yellow=ask) - per schema.editMode
959
+ if (this.editMode === 'display-edits') {
960
+ toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
961
+ }
962
+ else {
963
+ toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
964
+ }
965
+ // Thinking mode (cyan when on) - per schema.thinkingMode
966
+ toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
967
+ // Verification (green when on) - per schema.verificationMode
968
+ toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
969
+ // Auto-continue (magenta when on) - per schema.autoContinueMode
970
+ toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
971
+ const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
972
+ // Context usage with color - per schema.contextUsage thresholds
973
+ let rightPart = '';
974
+ if (this.contextUsage !== null) {
975
+ const rem = Math.max(0, 100 - this.contextUsage);
976
+ // Thresholds: critical < 10%, warning < 25%
977
+ if (rem < 10)
978
+ rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
979
+ else if (rem < 25)
980
+ rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
981
+ else
982
+ rightPart = `${DIM}ctx: ${rem}%${R}`;
983
+ }
984
+ // Calculate visible lengths (strip ANSI)
985
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
986
+ const leftLen = strip(leftPart).length;
987
+ const rightLen = strip(rightPart).length;
988
+ if (leftLen + rightLen < maxWidth - 4) {
989
+ return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
990
+ }
991
+ if (rightLen > 0 && leftLen + 8 < maxWidth) {
992
+ return `${leftPart} ${rightPart}`;
993
+ }
994
+ return leftPart;
773
995
  }
774
996
  /**
775
997
  * Force a re-render
@@ -792,19 +1014,17 @@ export class TerminalInput extends EventEmitter {
792
1014
  handleResize() {
793
1015
  this.lastRenderContent = '';
794
1016
  this.lastRenderCursor = -1;
795
- this.resetStreamingRenderThrottle();
796
1017
  // Re-clamp pinned header rows to the new terminal height
797
1018
  this.setPinnedHeaderLines(this.pinnedTopRows);
798
- if (this.scrollRegionActive) {
799
- this.disableScrollRegion();
800
- this.enableScrollRegion();
801
- }
802
1019
  this.scheduleRender();
803
1020
  }
804
1021
  /**
805
1022
  * Register with display's output interceptor to position cursor correctly.
806
1023
  * When scroll region is active, output needs to go to the scroll region,
807
1024
  * not the protected bottom area where the input is rendered.
1025
+ *
1026
+ * NOTE: With scroll region properly set, content naturally stays within
1027
+ * the region boundaries - no cursor manipulation needed per-write.
808
1028
  */
809
1029
  registerOutputInterceptor(display) {
810
1030
  if (this.outputInterceptorCleanup) {
@@ -812,20 +1032,11 @@ export class TerminalInput extends EventEmitter {
812
1032
  }
813
1033
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
814
1034
  beforeWrite: () => {
815
- // When the scroll region is active, temporarily move the cursor into
816
- // the scrollable area so streamed output lands above the pinned prompt.
817
- if (this.scrollRegionActive) {
818
- const { rows } = this.getSize();
819
- const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
820
- this.write(ESC.SAVE);
821
- this.write(ESC.TO(scrollBottom, 1));
822
- }
1035
+ // Scroll region handles content containment automatically
1036
+ // No per-write cursor manipulation needed
823
1037
  },
824
1038
  afterWrite: () => {
825
- // Restore cursor back to the pinned prompt after output completes.
826
- if (this.scrollRegionActive) {
827
- this.write(ESC.RESTORE);
828
- }
1039
+ // No cursor manipulation needed
829
1040
  },
830
1041
  });
831
1042
  }
@@ -835,6 +1046,11 @@ export class TerminalInput extends EventEmitter {
835
1046
  dispose() {
836
1047
  if (this.disposed)
837
1048
  return;
1049
+ // Clean up streaming render timer
1050
+ if (this.streamingRenderTimer) {
1051
+ clearInterval(this.streamingRenderTimer);
1052
+ this.streamingRenderTimer = null;
1053
+ }
838
1054
  // Clean up output interceptor
839
1055
  if (this.outputInterceptorCleanup) {
840
1056
  this.outputInterceptorCleanup();
@@ -842,7 +1058,6 @@ export class TerminalInput extends EventEmitter {
842
1058
  }
843
1059
  this.disposed = true;
844
1060
  this.enabled = false;
845
- this.resetStreamingRenderThrottle();
846
1061
  this.disableScrollRegion();
847
1062
  this.disableBracketedPaste();
848
1063
  this.buffer = '';
@@ -948,7 +1163,22 @@ export class TerminalInput extends EventEmitter {
948
1163
  this.toggleEditMode();
949
1164
  return true;
950
1165
  }
951
- this.insertText(' ');
1166
+ // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1167
+ if (this.findPlaceholderAt(this.cursor)) {
1168
+ this.togglePasteExpansion();
1169
+ }
1170
+ else {
1171
+ this.toggleThinking();
1172
+ }
1173
+ return true;
1174
+ case 'escape':
1175
+ // Esc: interrupt if streaming, otherwise clear buffer
1176
+ if (this.mode === 'streaming') {
1177
+ this.emit('interrupt');
1178
+ }
1179
+ else if (this.buffer.length > 0) {
1180
+ this.clear();
1181
+ }
952
1182
  return true;
953
1183
  }
954
1184
  return false;
@@ -966,6 +1196,7 @@ export class TerminalInput extends EventEmitter {
966
1196
  this.insertPlainText(chunk, insertPos);
967
1197
  this.cursor = insertPos + chunk.length;
968
1198
  this.emit('change', this.buffer);
1199
+ this.updateSuggestions();
969
1200
  this.scheduleRender();
970
1201
  }
971
1202
  insertNewline() {
@@ -990,6 +1221,7 @@ export class TerminalInput extends EventEmitter {
990
1221
  this.cursor = Math.max(0, this.cursor - 1);
991
1222
  }
992
1223
  this.emit('change', this.buffer);
1224
+ this.updateSuggestions();
993
1225
  this.scheduleRender();
994
1226
  }
995
1227
  deleteForward() {
@@ -1239,9 +1471,7 @@ export class TerminalInput extends EventEmitter {
1239
1471
  if (available <= 0)
1240
1472
  return;
1241
1473
  const chunk = clean.slice(0, available);
1242
- const isMultiline = isMultilinePaste(chunk);
1243
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1244
- if (isMultiline && !isShortMultiline) {
1474
+ if (isMultilinePaste(chunk)) {
1245
1475
  this.insertPastePlaceholder(chunk);
1246
1476
  }
1247
1477
  else {
@@ -1261,7 +1491,6 @@ export class TerminalInput extends EventEmitter {
1261
1491
  return;
1262
1492
  this.applyScrollRegion();
1263
1493
  this.scrollRegionActive = true;
1264
- this.forceRender();
1265
1494
  }
1266
1495
  disableScrollRegion() {
1267
1496
  if (!this.scrollRegionActive)
@@ -1412,19 +1641,17 @@ export class TerminalInput extends EventEmitter {
1412
1641
  this.shiftPlaceholders(position, text.length);
1413
1642
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1414
1643
  }
1415
- shouldInlineMultiline(content) {
1416
- const lines = content.split('\n').length;
1417
- const maxInlineLines = 4;
1418
- const maxInlineChars = 240;
1419
- return lines <= maxInlineLines && content.length <= maxInlineChars;
1420
- }
1421
1644
  findPlaceholderAt(position) {
1422
1645
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1423
1646
  }
1424
- buildPlaceholder(lineCount) {
1647
+ buildPlaceholder(summary) {
1425
1648
  const id = ++this.pasteCounter;
1426
- const plural = lineCount === 1 ? '' : 's';
1427
- const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1649
+ const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1650
+ // Show first line preview (truncated)
1651
+ const preview = summary.preview.length > 30
1652
+ ? `${summary.preview.slice(0, 30)}...`
1653
+ : summary.preview;
1654
+ const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1428
1655
  return { id, placeholder };
1429
1656
  }
1430
1657
  insertPastePlaceholder(content) {
@@ -1432,21 +1659,67 @@ export class TerminalInput extends EventEmitter {
1432
1659
  if (available <= 0)
1433
1660
  return;
1434
1661
  const cleanContent = content.slice(0, available);
1435
- const lineCount = cleanContent.split('\n').length;
1436
- const { id, placeholder } = this.buildPlaceholder(lineCount);
1662
+ const summary = generatePasteSummary(cleanContent);
1663
+ // For short pastes (< 5 lines), show full content instead of placeholder
1664
+ if (summary.lineCount < 5) {
1665
+ const placeholder = this.findPlaceholderAt(this.cursor);
1666
+ const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1667
+ this.insertPlainText(cleanContent, insertPos);
1668
+ this.cursor = insertPos + cleanContent.length;
1669
+ return;
1670
+ }
1671
+ const { id, placeholder } = this.buildPlaceholder(summary);
1437
1672
  const insertPos = this.cursor;
1438
1673
  this.shiftPlaceholders(insertPos, placeholder.length);
1439
1674
  this.pastePlaceholders.push({
1440
1675
  id,
1441
1676
  content: cleanContent,
1442
- lineCount,
1677
+ lineCount: summary.lineCount,
1443
1678
  placeholder,
1444
1679
  start: insertPos,
1445
1680
  end: insertPos + placeholder.length,
1681
+ summary,
1682
+ expanded: false,
1446
1683
  });
1447
1684
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1448
1685
  this.cursor = insertPos + placeholder.length;
1449
1686
  }
1687
+ /**
1688
+ * Toggle expansion of a paste placeholder at the current cursor position.
1689
+ * When expanded, shows first 3 and last 2 lines of the content.
1690
+ */
1691
+ togglePasteExpansion() {
1692
+ const placeholder = this.findPlaceholderAt(this.cursor);
1693
+ if (!placeholder)
1694
+ return false;
1695
+ placeholder.expanded = !placeholder.expanded;
1696
+ // Update the placeholder text in buffer
1697
+ const newPlaceholder = placeholder.expanded
1698
+ ? this.buildExpandedPlaceholder(placeholder)
1699
+ : this.buildPlaceholder(placeholder.summary).placeholder;
1700
+ const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1701
+ // Update buffer
1702
+ this.buffer =
1703
+ this.buffer.slice(0, placeholder.start) +
1704
+ newPlaceholder +
1705
+ this.buffer.slice(placeholder.end);
1706
+ // Update placeholder tracking
1707
+ placeholder.placeholder = newPlaceholder;
1708
+ placeholder.end = placeholder.start + newPlaceholder.length;
1709
+ // Shift other placeholders
1710
+ this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1711
+ this.scheduleRender();
1712
+ return true;
1713
+ }
1714
+ buildExpandedPlaceholder(ph) {
1715
+ const lines = ph.content.split('\n');
1716
+ const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1717
+ const lastLines = lines.length > 5
1718
+ ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1719
+ : '';
1720
+ const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1721
+ return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1722
+ }
1450
1723
  deletePlaceholder(placeholder) {
1451
1724
  const length = placeholder.end - placeholder.start;
1452
1725
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1454,11 +1727,7 @@ export class TerminalInput extends EventEmitter {
1454
1727
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1455
1728
  this.cursor = placeholder.start;
1456
1729
  }
1457
- updateContextUsage(value, autoCompactThreshold) {
1458
- if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1459
- const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1460
- this.contextAutoCompactThreshold = boundedThreshold;
1461
- }
1730
+ updateContextUsage(value) {
1462
1731
  if (value === null || !Number.isFinite(value)) {
1463
1732
  this.contextUsage = null;
1464
1733
  }
@@ -1485,22 +1754,6 @@ export class TerminalInput extends EventEmitter {
1485
1754
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1486
1755
  this.setEditMode(next);
1487
1756
  }
1488
- scheduleStreamingRender(delayMs) {
1489
- if (this.streamingRenderTimer)
1490
- return;
1491
- const wait = Math.max(16, delayMs);
1492
- this.streamingRenderTimer = setTimeout(() => {
1493
- this.streamingRenderTimer = null;
1494
- this.render();
1495
- }, wait);
1496
- }
1497
- resetStreamingRenderThrottle() {
1498
- if (this.streamingRenderTimer) {
1499
- clearTimeout(this.streamingRenderTimer);
1500
- this.streamingRenderTimer = null;
1501
- }
1502
- this.lastStreamingRender = 0;
1503
- }
1504
1757
  scheduleRender() {
1505
1758
  if (!this.canRender())
1506
1759
  return;