erosolar-cli 1.7.253 → 1.7.254

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