erosolar-cli 1.7.258 → 1.7.260

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 +140 -66
  58. package/dist/shell/terminalInput.d.ts.map +1 -1
  59. package/dist/shell/terminalInput.js +685 -410
  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,312 @@ 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();
216
- this.forceRender();
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
+ // Reset flow mode tracking
246
+ this.flowModeRenderedLines = 0;
247
+ // Render input area using unified method (same as streaming, but normal mode)
248
+ this.renderPinnedInputArea();
217
249
  }
218
250
  }
251
+ /**
252
+ * Update streaming status label (called by timer)
253
+ */
254
+ updateStreamingStatus() {
255
+ if (this.mode !== 'streaming' || !this.streamingStartTime)
256
+ return;
257
+ // Calculate elapsed time
258
+ const elapsed = Date.now() - this.streamingStartTime;
259
+ const seconds = Math.floor(elapsed / 1000);
260
+ const minutes = Math.floor(seconds / 60);
261
+ const secs = seconds % 60;
262
+ // Format elapsed time
263
+ let elapsedStr;
264
+ if (minutes > 0) {
265
+ elapsedStr = `${minutes}m ${secs}s`;
266
+ }
267
+ else {
268
+ elapsedStr = `${secs}s`;
269
+ }
270
+ // Update streaming label
271
+ this.streamingLabel = `Streaming ${elapsedStr}`;
272
+ }
273
+ /**
274
+ * Render input area at absolute bottom - unified for streaming and normal modes.
275
+ * Uses cursor save/restore during streaming so content flow is not disrupted.
276
+ * In normal mode, cursor is positioned in the input area.
277
+ */
278
+ renderPinnedInputArea() {
279
+ const { rows, cols } = this.getSize();
280
+ const divider = renderDivider(cols - 2);
281
+ const isStreaming = this.mode === 'streaming';
282
+ // Build status text based on mode
283
+ let statusText;
284
+ if (isStreaming && this.streamingStartTime) {
285
+ const elapsed = Date.now() - this.streamingStartTime;
286
+ const seconds = Math.floor(elapsed / 1000);
287
+ const minutes = Math.floor(seconds / 60);
288
+ const secs = seconds % 60;
289
+ const elapsedStr = minutes > 0 ? `${minutes}m ${secs}s` : `${secs}s`;
290
+ statusText = `${UI_COLORS.dim}● Streaming ${elapsedStr} · type to queue message${UI_COLORS.reset}`;
291
+ }
292
+ else {
293
+ statusText = `${UI_COLORS.dim}Type a message...${UI_COLORS.reset}`;
294
+ }
295
+ // Save cursor position during streaming (so content flow resumes correctly)
296
+ if (isStreaming) {
297
+ this.write(ESC.SAVE);
298
+ }
299
+ this.write(ESC.HIDE);
300
+ // Input area: 5 lines from bottom
301
+ // Row layout (from bottom): controls | bottomDiv | input | topDiv | status
302
+ const controlsRow = rows;
303
+ const bottomDivRow = rows - 1;
304
+ const inputRow = rows - 2;
305
+ const topDivRow = rows - 3;
306
+ const statusRow = rows - 4;
307
+ // Status bar
308
+ this.write(ESC.TO(statusRow, 1));
309
+ this.write(ESC.CLEAR_LINE);
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 and cursor
316
+ this.write(ESC.TO(inputRow, 1));
317
+ this.write(ESC.CLEAR_LINE);
318
+ const maxInputWidth = cols - 4;
319
+ const inputDisplay = this.buffer.slice(0, maxInputWidth);
320
+ const cursorPos = Math.min(this.cursor, maxInputWidth);
321
+ // Render with cursor highlight
322
+ this.write(this.config.promptChar);
323
+ this.write(inputDisplay.slice(0, cursorPos));
324
+ this.write(ESC.REVERSE);
325
+ this.write(cursorPos < inputDisplay.length ? inputDisplay[cursorPos] : ' ');
326
+ this.write(ESC.RESET);
327
+ this.write(inputDisplay.slice(cursorPos + 1));
328
+ // Bottom divider
329
+ this.write(ESC.TO(bottomDivRow, 1));
330
+ this.write(ESC.CLEAR_LINE);
331
+ this.write(divider);
332
+ // Mode controls line
333
+ this.write(ESC.TO(controlsRow, 1));
334
+ this.write(ESC.CLEAR_LINE);
335
+ this.write(this.buildModeControls(cols));
336
+ // Restore cursor position during streaming, or show cursor in normal mode
337
+ if (isStreaming) {
338
+ this.write(ESC.RESTORE);
339
+ }
340
+ else {
341
+ // Position cursor in input area
342
+ const cursorCol = this.config.promptChar.length + cursorPos + 1;
343
+ this.write(ESC.TO(inputRow, cursorCol));
344
+ this.write(ESC.SHOW);
345
+ }
346
+ }
347
+ /**
348
+ * Render input area during streaming (alias for unified method)
349
+ */
350
+ renderStreamingInputArea() {
351
+ this.renderPinnedInputArea();
352
+ }
353
+ /**
354
+ * Enable or disable flow mode.
355
+ * In flow mode, the input renders immediately after content (wherever cursor is).
356
+ * When disabled, input renders at the absolute bottom of terminal.
357
+ */
358
+ setFlowMode(enabled) {
359
+ if (this.flowMode === enabled)
360
+ return;
361
+ this.flowMode = enabled;
362
+ this.renderDirty = true;
363
+ this.scheduleRender();
364
+ }
365
+ /**
366
+ * Check if flow mode is enabled.
367
+ */
368
+ isFlowMode() {
369
+ return this.flowMode;
370
+ }
371
+ /**
372
+ * Set available slash commands for auto-complete suggestions.
373
+ */
374
+ setCommands(commands) {
375
+ this.commandSuggestions = commands;
376
+ this.updateSuggestions();
377
+ }
378
+ /**
379
+ * Update filtered suggestions based on current input.
380
+ */
381
+ updateSuggestions() {
382
+ const input = this.buffer.trim();
383
+ // Only show suggestions when input starts with "/"
384
+ if (!input.startsWith('/')) {
385
+ this.showSuggestions = false;
386
+ this.filteredSuggestions = [];
387
+ this.selectedSuggestionIndex = 0;
388
+ return;
389
+ }
390
+ const query = input.toLowerCase();
391
+ this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
392
+ cmd.command.toLowerCase().includes(query.slice(1)));
393
+ // Show suggestions if we have matches
394
+ this.showSuggestions = this.filteredSuggestions.length > 0;
395
+ // Keep selection in bounds
396
+ if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
397
+ this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
398
+ }
399
+ }
400
+ /**
401
+ * Select next suggestion (arrow down / tab).
402
+ */
403
+ selectNextSuggestion() {
404
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
405
+ return;
406
+ this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
407
+ this.renderDirty = true;
408
+ this.scheduleRender();
409
+ }
410
+ /**
411
+ * Select previous suggestion (arrow up / shift+tab).
412
+ */
413
+ selectPrevSuggestion() {
414
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
415
+ return;
416
+ this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
417
+ ? this.filteredSuggestions.length - 1
418
+ : this.selectedSuggestionIndex - 1;
419
+ this.renderDirty = true;
420
+ this.scheduleRender();
421
+ }
422
+ /**
423
+ * Accept current suggestion and insert into buffer.
424
+ */
425
+ acceptSuggestion() {
426
+ if (!this.showSuggestions || this.filteredSuggestions.length === 0)
427
+ return false;
428
+ const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
429
+ if (!selected)
430
+ return false;
431
+ // Replace buffer with selected command
432
+ this.buffer = selected.command + ' ';
433
+ this.cursor = this.buffer.length;
434
+ this.showSuggestions = false;
435
+ this.renderDirty = true;
436
+ this.scheduleRender();
437
+ return true;
438
+ }
439
+ /**
440
+ * Check if suggestions are visible.
441
+ */
442
+ areSuggestionsVisible() {
443
+ return this.showSuggestions && this.filteredSuggestions.length > 0;
444
+ }
445
+ /**
446
+ * Update token count for metrics display
447
+ */
448
+ setTokensUsed(tokens) {
449
+ this.tokensUsed = tokens;
450
+ }
451
+ /**
452
+ * Toggle thinking/reasoning mode
453
+ */
454
+ toggleThinking() {
455
+ this.thinkingEnabled = !this.thinkingEnabled;
456
+ this.emit('thinkingToggle', this.thinkingEnabled);
457
+ this.scheduleRender();
458
+ }
459
+ /**
460
+ * Get thinking enabled state
461
+ */
462
+ isThinkingEnabled() {
463
+ return this.thinkingEnabled;
464
+ }
219
465
  /**
220
466
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
221
467
  */
222
468
  setPinnedHeaderLines(count) {
223
- // No pinned header rows anymore; keep everything in the scroll region.
224
- if (this.pinnedTopRows !== 0) {
225
- this.pinnedTopRows = 0;
469
+ // Set pinned header rows (banner area that scroll region excludes)
470
+ if (this.pinnedTopRows !== count) {
471
+ this.pinnedTopRows = count;
226
472
  if (this.scrollRegionActive) {
227
473
  this.applyScrollRegion();
228
474
  }
229
475
  }
230
476
  }
477
+ /**
478
+ * Anchor prompt rendering near a specific row (inline layout). Pass null to
479
+ * restore the default bottom-aligned layout.
480
+ */
481
+ setInlineAnchor(row) {
482
+ if (row === null || row === undefined) {
483
+ this.inlineAnchorRow = null;
484
+ this.inlineLayout = false;
485
+ this.renderDirty = true;
486
+ this.render();
487
+ return;
488
+ }
489
+ const { rows } = this.getSize();
490
+ const clamped = Math.max(1, Math.min(Math.floor(row), rows));
491
+ this.inlineAnchorRow = clamped;
492
+ this.inlineLayout = true;
493
+ this.renderDirty = true;
494
+ this.render();
495
+ }
496
+ /**
497
+ * Provide a dynamic anchor callback. When set, the prompt will follow the
498
+ * output by re-evaluating the anchor before each render.
499
+ */
500
+ setInlineAnchorProvider(provider) {
501
+ this.anchorProvider = provider;
502
+ if (!provider) {
503
+ this.inlineLayout = false;
504
+ this.inlineAnchorRow = null;
505
+ this.renderDirty = true;
506
+ this.render();
507
+ return;
508
+ }
509
+ this.inlineLayout = true;
510
+ this.renderDirty = true;
511
+ this.render();
512
+ }
231
513
  /**
232
514
  * Get current mode
233
515
  */
@@ -337,37 +619,6 @@ export class TerminalInput extends EventEmitter {
337
619
  this.streamingLabel = next;
338
620
  this.scheduleRender();
339
621
  }
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
622
  /**
372
623
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
373
624
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -377,22 +628,16 @@ export class TerminalInput extends EventEmitter {
377
628
  const nextAutoContinue = !!options.autoContinueEnabled;
378
629
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
379
630
  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
631
  if (this.verificationEnabled === nextVerification &&
383
632
  this.autoContinueEnabled === nextAutoContinue &&
384
633
  this.verificationHotkey === nextVerifyHotkey &&
385
- this.autoContinueHotkey === nextAutoHotkey &&
386
- this.thinkingHotkey === nextThinkingHotkey &&
387
- this.thinkingModeLabel === nextThinkingLabel) {
634
+ this.autoContinueHotkey === nextAutoHotkey) {
388
635
  return;
389
636
  }
390
637
  this.verificationEnabled = nextVerification;
391
638
  this.autoContinueEnabled = nextAutoContinue;
392
639
  this.verificationHotkey = nextVerifyHotkey;
393
640
  this.autoContinueHotkey = nextAutoHotkey;
394
- this.thinkingHotkey = nextThinkingHotkey;
395
- this.thinkingModeLabel = nextThinkingLabel;
396
641
  this.scheduleRender();
397
642
  }
398
643
  /**
@@ -404,104 +649,198 @@ export class TerminalInput extends EventEmitter {
404
649
  this.streamingLabel = null;
405
650
  this.scheduleRender();
406
651
  }
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
652
  /**
421
653
  * Render the input area - Claude Code style with mode controls
422
654
  *
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.
655
+ * Same rendering for both normal and streaming modes - just different status bar.
656
+ * During streaming, uses cursor save/restore to preserve streaming position.
426
657
  */
427
658
  render() {
428
659
  if (!this.canRender())
429
660
  return;
430
661
  if (this.isRendering)
431
662
  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
663
  const shouldSkip = !this.renderDirty &&
445
664
  this.buffer === this.lastRenderContent &&
446
665
  this.cursor === this.lastRenderCursor;
447
666
  this.renderDirty = false;
448
- // Skip if nothing changed and no explicit refresh requested
667
+ // Skip if nothing changed (unless explicitly forced)
449
668
  if (shouldSkip) {
450
669
  return;
451
670
  }
452
- // If write lock is held, defer render to avoid race conditions
671
+ // If write lock is held, defer render
453
672
  if (writeLock.isLocked()) {
454
673
  writeLock.safeWrite(() => this.render());
455
674
  return;
456
675
  }
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);
676
+ this.isRendering = true;
677
+ writeLock.lock('terminalInput.render');
678
+ try {
679
+ // Render input area at bottom (outside scroll region)
680
+ this.renderBottomPinned();
681
+ }
682
+ finally {
683
+ writeLock.unlock();
684
+ this.isRendering = false;
685
+ }
686
+ }
687
+ /**
688
+ * Render in flow mode - delegates to bottom-pinned for stability.
689
+ *
690
+ * Flow mode attempted inline rendering but caused duplicate renders
691
+ * due to unreliable cursor position tracking. Bottom-pinned is reliable.
692
+ */
693
+ renderFlowMode() {
694
+ // Use stable bottom-pinned approach
695
+ this.renderBottomPinned();
696
+ }
697
+ /**
698
+ * Render in bottom-pinned mode - Claude Code style with suggestions
699
+ *
700
+ * Works for both normal and streaming modes:
701
+ * - During streaming: saves/restores cursor position
702
+ * - Status bar shows streaming info or "Type a message"
703
+ *
704
+ * Layout when suggestions visible:
705
+ * - Top divider
706
+ * - Input line(s)
707
+ * - Bottom divider
708
+ * - Suggestions (command list)
709
+ *
710
+ * Layout when suggestions hidden:
711
+ * - Status bar (Ready/Streaming)
712
+ * - Top divider
713
+ * - Input line(s)
714
+ * - Bottom divider
715
+ * - Mode controls
716
+ */
717
+ renderBottomPinned() {
718
+ const { rows, cols } = this.getSize();
719
+ const maxWidth = Math.max(8, cols - 4);
720
+ const isStreaming = this.mode === 'streaming';
721
+ // Use unified pinned input area (works for both streaming and normal)
722
+ // Only use complex rendering when suggestions are visible
723
+ const hasSuggestions = !isStreaming && this.showSuggestions && this.filteredSuggestions.length > 0;
724
+ if (!hasSuggestions) {
725
+ this.renderPinnedInputArea();
726
+ return;
727
+ }
728
+ // Wrap buffer into display lines
729
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
730
+ const availableForContent = Math.max(1, rows - 3);
731
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
732
+ const displayLines = Math.min(lines.length, maxVisible);
733
+ // Calculate display window (keep cursor visible)
734
+ let startLine = 0;
735
+ if (lines.length > displayLines) {
736
+ startLine = Math.max(0, cursorLine - displayLines + 1);
737
+ startLine = Math.min(startLine, lines.length - displayLines);
738
+ }
739
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
740
+ const adjustedCursorLine = cursorLine - startLine;
741
+ // Calculate suggestion display (not during streaming)
742
+ const suggestionsToShow = (!isStreaming && this.showSuggestions)
743
+ ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
744
+ : [];
745
+ const suggestionLines = suggestionsToShow.length;
746
+ this.write(ESC.HIDE);
747
+ this.write(ESC.RESET);
748
+ const divider = renderDivider(cols - 2);
749
+ // Calculate positions from absolute bottom
750
+ let currentRow;
751
+ if (suggestionLines > 0) {
752
+ // With suggestions: input area + dividers + suggestions
753
+ // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
754
+ const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
755
+ currentRow = Math.max(1, rows - totalHeight + 1);
756
+ this.updateReservedLines(totalHeight);
757
+ // Top divider
758
+ this.write(ESC.TO(currentRow, 1));
759
+ this.write(ESC.CLEAR_LINE);
760
+ this.write(divider);
761
+ currentRow++;
762
+ // Input lines
763
+ let finalRow = currentRow;
764
+ let finalCol = 3;
765
+ for (let i = 0; i < visibleLines.length; i++) {
766
+ this.write(ESC.TO(currentRow, 1));
767
+ this.write(ESC.CLEAR_LINE);
768
+ const line = visibleLines[i] ?? '';
769
+ const absoluteLineIdx = startLine + i;
770
+ const isFirstLine = absoluteLineIdx === 0;
771
+ const isCursorLine = i === adjustedCursorLine;
772
+ this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
773
+ if (isCursorLine) {
774
+ const col = Math.min(cursorCol, line.length);
775
+ this.write(line.slice(0, col));
776
+ this.write(ESC.REVERSE);
777
+ this.write(col < line.length ? line[col] : ' ');
778
+ this.write(ESC.RESET);
779
+ this.write(line.slice(col + 1));
780
+ finalRow = currentRow;
781
+ finalCol = this.config.promptChar.length + col + 1;
782
+ }
783
+ else {
784
+ this.write(line);
785
+ }
786
+ currentRow++;
476
787
  }
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) {
788
+ // Bottom divider
789
+ this.write(ESC.TO(currentRow, 1));
790
+ this.write(ESC.CLEAR_LINE);
791
+ this.write(divider);
792
+ currentRow++;
793
+ // Suggestions (Claude Code style)
794
+ for (let i = 0; i < suggestionsToShow.length; i++) {
488
795
  this.write(ESC.TO(currentRow, 1));
489
796
  this.write(ESC.CLEAR_LINE);
490
- this.write(metaLine);
491
- currentRow += 1;
797
+ const suggestion = suggestionsToShow[i];
798
+ const isSelected = i === this.selectedSuggestionIndex;
799
+ // Indent and highlight selected
800
+ this.write(' ');
801
+ if (isSelected) {
802
+ this.write(ESC.REVERSE);
803
+ this.write(ESC.BOLD);
804
+ }
805
+ this.write(suggestion.command);
806
+ if (isSelected) {
807
+ this.write(ESC.RESET);
808
+ }
809
+ // Description (dimmed)
810
+ const descSpace = cols - suggestion.command.length - 8;
811
+ if (descSpace > 10 && suggestion.description) {
812
+ const desc = suggestion.description.slice(0, descSpace);
813
+ this.write(ESC.RESET);
814
+ this.write(ESC.DIM);
815
+ this.write(' ');
816
+ this.write(desc);
817
+ this.write(ESC.RESET);
818
+ }
819
+ currentRow++;
492
820
  }
493
- // Separator line
821
+ // Position cursor in input area
822
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
823
+ }
824
+ else {
825
+ // Without suggestions: normal layout with status bar and controls
826
+ const totalHeight = visibleLines.length + 4; // status + topDiv + input + bottomDiv + controls
827
+ currentRow = Math.max(1, rows - totalHeight + 1);
828
+ this.updateReservedLines(totalHeight);
829
+ // Status bar (streaming or normal)
830
+ this.write(ESC.TO(currentRow, 1));
831
+ this.write(ESC.CLEAR_LINE);
832
+ this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
833
+ currentRow++;
834
+ // Top divider
494
835
  this.write(ESC.TO(currentRow, 1));
495
836
  this.write(ESC.CLEAR_LINE);
496
- const divider = renderDivider(cols - 2);
497
837
  this.write(divider);
498
- currentRow += 1;
499
- // Render input lines
838
+ currentRow++;
839
+ // Input lines
500
840
  let finalRow = currentRow;
501
841
  let finalCol = 3;
502
842
  for (let i = 0; i < visibleLines.length; i++) {
503
- const rowNum = currentRow + i;
504
- this.write(ESC.TO(rowNum, 1));
843
+ this.write(ESC.TO(currentRow, 1));
505
844
  this.write(ESC.CLEAR_LINE);
506
845
  const line = visibleLines[i] ?? '';
507
846
  const absoluteLineIdx = startLine + i;
@@ -515,7 +854,6 @@ export class TerminalInput extends EventEmitter {
515
854
  this.write(ESC.RESET);
516
855
  this.write(ESC.BG_DARK);
517
856
  if (isCursorLine) {
518
- // Render with block cursor
519
857
  const col = Math.min(cursorCol, line.length);
520
858
  const before = line.slice(0, col);
521
859
  const at = col < line.length ? line[col] : ' ';
@@ -525,251 +863,157 @@ export class TerminalInput extends EventEmitter {
525
863
  this.write(at);
526
864
  this.write(ESC.RESET + ESC.BG_DARK);
527
865
  this.write(after);
528
- finalRow = rowNum;
866
+ finalRow = currentRow;
529
867
  finalCol = this.config.promptChar.length + col + 1;
530
868
  }
531
869
  else {
532
870
  this.write(line);
533
871
  }
534
- // Pad to edge for clean look
872
+ // Pad to edge
535
873
  const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
536
874
  const padding = Math.max(0, cols - lineLen - 1);
537
875
  if (padding > 0)
538
876
  this.write(' '.repeat(padding));
539
877
  this.write(ESC.RESET);
878
+ currentRow++;
540
879
  }
541
- // Mode controls line (Claude Code style)
542
- const controlRow = currentRow + visibleLines.length;
543
- this.write(ESC.TO(controlRow, 1));
880
+ // Bottom divider
881
+ this.write(ESC.TO(currentRow, 1));
882
+ this.write(ESC.CLEAR_LINE);
883
+ this.write(divider);
884
+ currentRow++;
885
+ // Mode controls
886
+ this.write(ESC.TO(currentRow, 1));
544
887
  this.write(ESC.CLEAR_LINE);
545
888
  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;
889
+ // Position cursor: restore for streaming, or position in input for normal
890
+ if (isStreaming) {
891
+ this.write(ESC.RESTORE);
892
+ }
893
+ else {
894
+ this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
556
895
  }
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
896
  }
897
+ this.write(ESC.SHOW);
898
+ // Update state
899
+ this.lastRenderContent = this.buffer;
900
+ this.lastRenderCursor = this.cursor;
568
901
  }
569
902
  /**
570
- * Build one or more compact meta lines above the divider (thinking, status, usage).
903
+ * Build status bar for streaming mode (shows elapsed time, queue count).
571
904
  */
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
- }
905
+ buildStreamingStatusBar(cols) {
906
+ const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
907
+ // Streaming status with elapsed time
908
+ let elapsed = '0s';
909
+ if (this.streamingStartTime) {
910
+ const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
911
+ const mins = Math.floor(secs / 60);
912
+ elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
913
+ }
914
+ let status = `${GREEN}● Streaming${R} ${elapsed}`;
915
+ // Queue indicator
613
916
  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));
917
+ status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
630
918
  }
919
+ // Hint for typing
920
+ status += ` ${DIM}· type to queue message${R}`;
921
+ return status;
631
922
  }
632
923
  /**
633
- * Build Claude Code style mode controls line.
634
- * Combines streaming label + override status + main status for simultaneous display.
924
+ * Build status bar showing streaming/ready status and key info.
925
+ * This is the TOP line above the input area - minimal Claude Code style.
635
926
  */
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' });
927
+ buildStatusBar(cols) {
928
+ const maxWidth = cols - 2;
929
+ const parts = [];
930
+ // Streaming status with elapsed time (left side)
931
+ if (this.mode === 'streaming') {
932
+ let statusText = '● Streaming';
933
+ if (this.streamingStartTime) {
934
+ const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
935
+ const mins = Math.floor(elapsed / 60);
936
+ const secs = elapsed % 60;
937
+ statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
938
+ }
939
+ parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
661
940
  }
662
- if (this.buffer.includes('\n')) {
663
- const lineCount = this.buffer.split('\n').length;
664
- leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
941
+ // Queue indicator during streaming
942
+ if (this.mode === 'streaming' && this.queue.length > 0) {
943
+ parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
665
944
  }
945
+ // Paste indicator
666
946
  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
- });
947
+ const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
948
+ parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
672
949
  }
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}`;
950
+ // Override/warning status
951
+ if (this.overrideStatusMessage) {
952
+ parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
750
953
  }
751
- if (value >= 1_000_000) {
752
- return `${(value / 1_000_000).toFixed(1)}M`;
954
+ // If idle with empty buffer, show quick shortcuts
955
+ if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
956
+ return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
753
957
  }
754
- if (value >= 1_000) {
755
- return `${(value / 1_000).toFixed(1)}k`;
958
+ // Multi-line indicator
959
+ if (this.buffer.includes('\n')) {
960
+ parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
756
961
  }
757
- return `${Math.round(value)}`;
758
- }
759
- visibleLength(value) {
760
- const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
761
- return value.replace(ansiPattern, '').length;
962
+ if (parts.length === 0) {
963
+ return ''; // Empty status bar when idle
964
+ }
965
+ const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
966
+ return joined.slice(0, maxWidth);
762
967
  }
763
968
  /**
764
- * Debug-only snapshot used by tests to assert rendered strings without
765
- * needing a TTY. Not used by production code.
969
+ * Build mode controls line showing toggles and context info.
970
+ * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
971
+ *
972
+ * Layout: [toggles on left] ... [context info on right]
766
973
  */
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
- };
974
+ buildModeControls(cols) {
975
+ const maxWidth = cols - 2;
976
+ // Use schema-defined colors for consistency
977
+ const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
978
+ // Mode toggles with colors (following ModeControlsSchema)
979
+ const toggles = [];
980
+ // Edit mode (green=auto, yellow=ask) - per schema.editMode
981
+ if (this.editMode === 'display-edits') {
982
+ toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
983
+ }
984
+ else {
985
+ toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
986
+ }
987
+ // Thinking mode (cyan when on) - per schema.thinkingMode
988
+ toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
989
+ // Verification (green when on) - per schema.verificationMode
990
+ toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
991
+ // Auto-continue (magenta when on) - per schema.autoContinueMode
992
+ toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
993
+ const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
994
+ // Context usage with color - per schema.contextUsage thresholds
995
+ let rightPart = '';
996
+ if (this.contextUsage !== null) {
997
+ const rem = Math.max(0, 100 - this.contextUsage);
998
+ // Thresholds: critical < 10%, warning < 25%
999
+ if (rem < 10)
1000
+ rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
1001
+ else if (rem < 25)
1002
+ rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
1003
+ else
1004
+ rightPart = `${DIM}ctx: ${rem}%${R}`;
1005
+ }
1006
+ // Calculate visible lengths (strip ANSI)
1007
+ const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
1008
+ const leftLen = strip(leftPart).length;
1009
+ const rightLen = strip(rightPart).length;
1010
+ if (leftLen + rightLen < maxWidth - 4) {
1011
+ return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
1012
+ }
1013
+ if (rightLen > 0 && leftLen + 8 < maxWidth) {
1014
+ return `${leftPart} ${rightPart}`;
1015
+ }
1016
+ return leftPart;
773
1017
  }
774
1018
  /**
775
1019
  * Force a re-render
@@ -792,19 +1036,17 @@ export class TerminalInput extends EventEmitter {
792
1036
  handleResize() {
793
1037
  this.lastRenderContent = '';
794
1038
  this.lastRenderCursor = -1;
795
- this.resetStreamingRenderThrottle();
796
1039
  // Re-clamp pinned header rows to the new terminal height
797
1040
  this.setPinnedHeaderLines(this.pinnedTopRows);
798
- if (this.scrollRegionActive) {
799
- this.disableScrollRegion();
800
- this.enableScrollRegion();
801
- }
802
1041
  this.scheduleRender();
803
1042
  }
804
1043
  /**
805
1044
  * Register with display's output interceptor to position cursor correctly.
806
1045
  * When scroll region is active, output needs to go to the scroll region,
807
1046
  * not the protected bottom area where the input is rendered.
1047
+ *
1048
+ * NOTE: With scroll region properly set, content naturally stays within
1049
+ * the region boundaries - no cursor manipulation needed per-write.
808
1050
  */
809
1051
  registerOutputInterceptor(display) {
810
1052
  if (this.outputInterceptorCleanup) {
@@ -812,20 +1054,11 @@ export class TerminalInput extends EventEmitter {
812
1054
  }
813
1055
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
814
1056
  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
- }
1057
+ // Scroll region handles content containment automatically
1058
+ // No per-write cursor manipulation needed
823
1059
  },
824
1060
  afterWrite: () => {
825
- // Restore cursor back to the pinned prompt after output completes.
826
- if (this.scrollRegionActive) {
827
- this.write(ESC.RESTORE);
828
- }
1061
+ // No cursor manipulation needed
829
1062
  },
830
1063
  });
831
1064
  }
@@ -835,6 +1068,11 @@ export class TerminalInput extends EventEmitter {
835
1068
  dispose() {
836
1069
  if (this.disposed)
837
1070
  return;
1071
+ // Clean up streaming render timer
1072
+ if (this.streamingRenderTimer) {
1073
+ clearInterval(this.streamingRenderTimer);
1074
+ this.streamingRenderTimer = null;
1075
+ }
838
1076
  // Clean up output interceptor
839
1077
  if (this.outputInterceptorCleanup) {
840
1078
  this.outputInterceptorCleanup();
@@ -842,7 +1080,6 @@ export class TerminalInput extends EventEmitter {
842
1080
  }
843
1081
  this.disposed = true;
844
1082
  this.enabled = false;
845
- this.resetStreamingRenderThrottle();
846
1083
  this.disableScrollRegion();
847
1084
  this.disableBracketedPaste();
848
1085
  this.buffer = '';
@@ -948,7 +1185,22 @@ export class TerminalInput extends EventEmitter {
948
1185
  this.toggleEditMode();
949
1186
  return true;
950
1187
  }
951
- this.insertText(' ');
1188
+ // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1189
+ if (this.findPlaceholderAt(this.cursor)) {
1190
+ this.togglePasteExpansion();
1191
+ }
1192
+ else {
1193
+ this.toggleThinking();
1194
+ }
1195
+ return true;
1196
+ case 'escape':
1197
+ // Esc: interrupt if streaming, otherwise clear buffer
1198
+ if (this.mode === 'streaming') {
1199
+ this.emit('interrupt');
1200
+ }
1201
+ else if (this.buffer.length > 0) {
1202
+ this.clear();
1203
+ }
952
1204
  return true;
953
1205
  }
954
1206
  return false;
@@ -966,6 +1218,7 @@ export class TerminalInput extends EventEmitter {
966
1218
  this.insertPlainText(chunk, insertPos);
967
1219
  this.cursor = insertPos + chunk.length;
968
1220
  this.emit('change', this.buffer);
1221
+ this.updateSuggestions();
969
1222
  this.scheduleRender();
970
1223
  }
971
1224
  insertNewline() {
@@ -990,6 +1243,7 @@ export class TerminalInput extends EventEmitter {
990
1243
  this.cursor = Math.max(0, this.cursor - 1);
991
1244
  }
992
1245
  this.emit('change', this.buffer);
1246
+ this.updateSuggestions();
993
1247
  this.scheduleRender();
994
1248
  }
995
1249
  deleteForward() {
@@ -1239,9 +1493,7 @@ export class TerminalInput extends EventEmitter {
1239
1493
  if (available <= 0)
1240
1494
  return;
1241
1495
  const chunk = clean.slice(0, available);
1242
- const isMultiline = isMultilinePaste(chunk);
1243
- const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1244
- if (isMultiline && !isShortMultiline) {
1496
+ if (isMultilinePaste(chunk)) {
1245
1497
  this.insertPastePlaceholder(chunk);
1246
1498
  }
1247
1499
  else {
@@ -1261,7 +1513,6 @@ export class TerminalInput extends EventEmitter {
1261
1513
  return;
1262
1514
  this.applyScrollRegion();
1263
1515
  this.scrollRegionActive = true;
1264
- this.forceRender();
1265
1516
  }
1266
1517
  disableScrollRegion() {
1267
1518
  if (!this.scrollRegionActive)
@@ -1412,19 +1663,17 @@ export class TerminalInput extends EventEmitter {
1412
1663
  this.shiftPlaceholders(position, text.length);
1413
1664
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1414
1665
  }
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
1666
  findPlaceholderAt(position) {
1422
1667
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1423
1668
  }
1424
- buildPlaceholder(lineCount) {
1669
+ buildPlaceholder(summary) {
1425
1670
  const id = ++this.pasteCounter;
1426
- const plural = lineCount === 1 ? '' : 's';
1427
- const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1671
+ const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1672
+ // Show first line preview (truncated)
1673
+ const preview = summary.preview.length > 30
1674
+ ? `${summary.preview.slice(0, 30)}...`
1675
+ : summary.preview;
1676
+ const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1428
1677
  return { id, placeholder };
1429
1678
  }
1430
1679
  insertPastePlaceholder(content) {
@@ -1432,21 +1681,67 @@ export class TerminalInput extends EventEmitter {
1432
1681
  if (available <= 0)
1433
1682
  return;
1434
1683
  const cleanContent = content.slice(0, available);
1435
- const lineCount = cleanContent.split('\n').length;
1436
- const { id, placeholder } = this.buildPlaceholder(lineCount);
1684
+ const summary = generatePasteSummary(cleanContent);
1685
+ // For short pastes (< 5 lines), show full content instead of placeholder
1686
+ if (summary.lineCount < 5) {
1687
+ const placeholder = this.findPlaceholderAt(this.cursor);
1688
+ const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1689
+ this.insertPlainText(cleanContent, insertPos);
1690
+ this.cursor = insertPos + cleanContent.length;
1691
+ return;
1692
+ }
1693
+ const { id, placeholder } = this.buildPlaceholder(summary);
1437
1694
  const insertPos = this.cursor;
1438
1695
  this.shiftPlaceholders(insertPos, placeholder.length);
1439
1696
  this.pastePlaceholders.push({
1440
1697
  id,
1441
1698
  content: cleanContent,
1442
- lineCount,
1699
+ lineCount: summary.lineCount,
1443
1700
  placeholder,
1444
1701
  start: insertPos,
1445
1702
  end: insertPos + placeholder.length,
1703
+ summary,
1704
+ expanded: false,
1446
1705
  });
1447
1706
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1448
1707
  this.cursor = insertPos + placeholder.length;
1449
1708
  }
1709
+ /**
1710
+ * Toggle expansion of a paste placeholder at the current cursor position.
1711
+ * When expanded, shows first 3 and last 2 lines of the content.
1712
+ */
1713
+ togglePasteExpansion() {
1714
+ const placeholder = this.findPlaceholderAt(this.cursor);
1715
+ if (!placeholder)
1716
+ return false;
1717
+ placeholder.expanded = !placeholder.expanded;
1718
+ // Update the placeholder text in buffer
1719
+ const newPlaceholder = placeholder.expanded
1720
+ ? this.buildExpandedPlaceholder(placeholder)
1721
+ : this.buildPlaceholder(placeholder.summary).placeholder;
1722
+ const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1723
+ // Update buffer
1724
+ this.buffer =
1725
+ this.buffer.slice(0, placeholder.start) +
1726
+ newPlaceholder +
1727
+ this.buffer.slice(placeholder.end);
1728
+ // Update placeholder tracking
1729
+ placeholder.placeholder = newPlaceholder;
1730
+ placeholder.end = placeholder.start + newPlaceholder.length;
1731
+ // Shift other placeholders
1732
+ this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1733
+ this.scheduleRender();
1734
+ return true;
1735
+ }
1736
+ buildExpandedPlaceholder(ph) {
1737
+ const lines = ph.content.split('\n');
1738
+ const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1739
+ const lastLines = lines.length > 5
1740
+ ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1741
+ : '';
1742
+ const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1743
+ return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1744
+ }
1450
1745
  deletePlaceholder(placeholder) {
1451
1746
  const length = placeholder.end - placeholder.start;
1452
1747
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1454,11 +1749,7 @@ export class TerminalInput extends EventEmitter {
1454
1749
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1455
1750
  this.cursor = placeholder.start;
1456
1751
  }
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
- }
1752
+ updateContextUsage(value) {
1462
1753
  if (value === null || !Number.isFinite(value)) {
1463
1754
  this.contextUsage = null;
1464
1755
  }
@@ -1485,22 +1776,6 @@ export class TerminalInput extends EventEmitter {
1485
1776
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1486
1777
  this.setEditMode(next);
1487
1778
  }
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
1779
  scheduleRender() {
1505
1780
  if (!this.canRender())
1506
1781
  return;