erosolar-cli 1.7.260 → 1.7.262

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 (247) hide show
  1. package/README.md +22 -148
  2. package/dist/core/customCommands.d.ts +1 -0
  3. package/dist/core/customCommands.d.ts.map +1 -1
  4. package/dist/core/customCommands.js +3 -0
  5. package/dist/core/customCommands.js.map +1 -1
  6. package/dist/core/hooks.d.ts +113 -0
  7. package/dist/core/hooks.d.ts.map +1 -0
  8. package/dist/core/hooks.js +267 -0
  9. package/dist/core/hooks.js.map +1 -0
  10. package/dist/core/metricsTracker.d.ts +122 -0
  11. package/dist/core/metricsTracker.d.ts.map +1 -0
  12. package/dist/{alpha-zero → core}/metricsTracker.js +2 -5
  13. package/dist/core/metricsTracker.js.map +1 -0
  14. package/dist/core/toolPreconditions.d.ts.map +1 -1
  15. package/dist/core/toolPreconditions.js +0 -14
  16. package/dist/core/toolPreconditions.js.map +1 -1
  17. package/dist/core/toolRuntime.d.ts.map +1 -1
  18. package/dist/core/toolRuntime.js +0 -5
  19. package/dist/core/toolRuntime.js.map +1 -1
  20. package/dist/core/toolValidation.d.ts.map +1 -1
  21. package/dist/core/toolValidation.js +14 -3
  22. package/dist/core/toolValidation.js.map +1 -1
  23. package/dist/core/validationRunner.d.ts +1 -3
  24. package/dist/core/validationRunner.d.ts.map +1 -1
  25. package/dist/core/validationRunner.js.map +1 -1
  26. package/dist/mcp/sseClient.d.ts.map +1 -1
  27. package/dist/mcp/sseClient.js +9 -18
  28. package/dist/mcp/sseClient.js.map +1 -1
  29. package/dist/plugins/tools/build/buildPlugin.d.ts +0 -6
  30. package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
  31. package/dist/plugins/tools/build/buildPlugin.js +4 -10
  32. package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
  33. package/dist/shell/interactiveShell.d.ts +10 -2
  34. package/dist/shell/interactiveShell.d.ts.map +1 -1
  35. package/dist/shell/interactiveShell.js +182 -36
  36. package/dist/shell/interactiveShell.js.map +1 -1
  37. package/dist/shell/terminalInput.d.ts +68 -140
  38. package/dist/shell/terminalInput.d.ts.map +1 -1
  39. package/dist/shell/terminalInput.js +424 -685
  40. package/dist/shell/terminalInput.js.map +1 -1
  41. package/dist/shell/terminalInputAdapter.d.ts +20 -15
  42. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  43. package/dist/shell/terminalInputAdapter.js +14 -22
  44. package/dist/shell/terminalInputAdapter.js.map +1 -1
  45. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  46. package/dist/ui/ShellUIAdapter.js +13 -12
  47. package/dist/ui/ShellUIAdapter.js.map +1 -1
  48. package/dist/ui/display.d.ts +19 -0
  49. package/dist/ui/display.d.ts.map +1 -1
  50. package/dist/ui/display.js +131 -33
  51. package/dist/ui/display.js.map +1 -1
  52. package/dist/ui/theme.d.ts.map +1 -1
  53. package/dist/ui/theme.js +6 -8
  54. package/dist/ui/theme.js.map +1 -1
  55. package/dist/ui/toolDisplay.d.ts +0 -158
  56. package/dist/ui/toolDisplay.d.ts.map +1 -1
  57. package/dist/ui/toolDisplay.js +0 -348
  58. package/dist/ui/toolDisplay.js.map +1 -1
  59. package/dist/ui/unified/layout.d.ts +1 -0
  60. package/dist/ui/unified/layout.d.ts.map +1 -1
  61. package/dist/ui/unified/layout.js +15 -25
  62. package/dist/ui/unified/layout.js.map +1 -1
  63. package/package.json +1 -1
  64. package/dist/alpha-zero/agentWrapper.d.ts +0 -84
  65. package/dist/alpha-zero/agentWrapper.d.ts.map +0 -1
  66. package/dist/alpha-zero/agentWrapper.js +0 -171
  67. package/dist/alpha-zero/agentWrapper.js.map +0 -1
  68. package/dist/alpha-zero/codeEvaluator.d.ts +0 -25
  69. package/dist/alpha-zero/codeEvaluator.d.ts.map +0 -1
  70. package/dist/alpha-zero/codeEvaluator.js +0 -273
  71. package/dist/alpha-zero/codeEvaluator.js.map +0 -1
  72. package/dist/alpha-zero/competitiveRunner.d.ts +0 -66
  73. package/dist/alpha-zero/competitiveRunner.d.ts.map +0 -1
  74. package/dist/alpha-zero/competitiveRunner.js +0 -224
  75. package/dist/alpha-zero/competitiveRunner.js.map +0 -1
  76. package/dist/alpha-zero/index.d.ts +0 -67
  77. package/dist/alpha-zero/index.d.ts.map +0 -1
  78. package/dist/alpha-zero/index.js +0 -99
  79. package/dist/alpha-zero/index.js.map +0 -1
  80. package/dist/alpha-zero/introspection.d.ts +0 -128
  81. package/dist/alpha-zero/introspection.d.ts.map +0 -1
  82. package/dist/alpha-zero/introspection.js +0 -300
  83. package/dist/alpha-zero/introspection.js.map +0 -1
  84. package/dist/alpha-zero/metricsTracker.d.ts +0 -71
  85. package/dist/alpha-zero/metricsTracker.d.ts.map +0 -1
  86. package/dist/alpha-zero/metricsTracker.js.map +0 -1
  87. package/dist/alpha-zero/security/core.d.ts +0 -125
  88. package/dist/alpha-zero/security/core.d.ts.map +0 -1
  89. package/dist/alpha-zero/security/core.js +0 -271
  90. package/dist/alpha-zero/security/core.js.map +0 -1
  91. package/dist/alpha-zero/security/google.d.ts +0 -125
  92. package/dist/alpha-zero/security/google.d.ts.map +0 -1
  93. package/dist/alpha-zero/security/google.js +0 -311
  94. package/dist/alpha-zero/security/google.js.map +0 -1
  95. package/dist/alpha-zero/security/googleLoader.d.ts +0 -17
  96. package/dist/alpha-zero/security/googleLoader.d.ts.map +0 -1
  97. package/dist/alpha-zero/security/googleLoader.js +0 -41
  98. package/dist/alpha-zero/security/googleLoader.js.map +0 -1
  99. package/dist/alpha-zero/security/index.d.ts +0 -29
  100. package/dist/alpha-zero/security/index.d.ts.map +0 -1
  101. package/dist/alpha-zero/security/index.js +0 -32
  102. package/dist/alpha-zero/security/index.js.map +0 -1
  103. package/dist/alpha-zero/security/simulation.d.ts +0 -124
  104. package/dist/alpha-zero/security/simulation.d.ts.map +0 -1
  105. package/dist/alpha-zero/security/simulation.js +0 -277
  106. package/dist/alpha-zero/security/simulation.js.map +0 -1
  107. package/dist/alpha-zero/selfModification.d.ts +0 -109
  108. package/dist/alpha-zero/selfModification.d.ts.map +0 -1
  109. package/dist/alpha-zero/selfModification.js +0 -233
  110. package/dist/alpha-zero/selfModification.js.map +0 -1
  111. package/dist/alpha-zero/types.d.ts +0 -170
  112. package/dist/alpha-zero/types.d.ts.map +0 -1
  113. package/dist/alpha-zero/types.js +0 -31
  114. package/dist/alpha-zero/types.js.map +0 -1
  115. package/dist/core/aiFlowOptimizer.d.ts +0 -26
  116. package/dist/core/aiFlowOptimizer.d.ts.map +0 -1
  117. package/dist/core/aiFlowOptimizer.js +0 -31
  118. package/dist/core/aiFlowOptimizer.js.map +0 -1
  119. package/dist/core/aiOptimizationEngine.d.ts +0 -158
  120. package/dist/core/aiOptimizationEngine.d.ts.map +0 -1
  121. package/dist/core/aiOptimizationEngine.js +0 -428
  122. package/dist/core/aiOptimizationEngine.js.map +0 -1
  123. package/dist/core/aiOptimizationIntegration.d.ts +0 -93
  124. package/dist/core/aiOptimizationIntegration.d.ts.map +0 -1
  125. package/dist/core/aiOptimizationIntegration.js +0 -250
  126. package/dist/core/aiOptimizationIntegration.js.map +0 -1
  127. package/dist/core/enhancedErrorRecovery.d.ts +0 -100
  128. package/dist/core/enhancedErrorRecovery.d.ts.map +0 -1
  129. package/dist/core/enhancedErrorRecovery.js +0 -345
  130. package/dist/core/enhancedErrorRecovery.js.map +0 -1
  131. package/dist/core/unified/errors.d.ts +0 -189
  132. package/dist/core/unified/errors.d.ts.map +0 -1
  133. package/dist/core/unified/errors.js +0 -497
  134. package/dist/core/unified/errors.js.map +0 -1
  135. package/dist/core/unified/index.d.ts +0 -19
  136. package/dist/core/unified/index.d.ts.map +0 -1
  137. package/dist/core/unified/index.js +0 -68
  138. package/dist/core/unified/index.js.map +0 -1
  139. package/dist/core/unified/schema.d.ts +0 -101
  140. package/dist/core/unified/schema.d.ts.map +0 -1
  141. package/dist/core/unified/schema.js +0 -350
  142. package/dist/core/unified/schema.js.map +0 -1
  143. package/dist/core/unified/toolRuntime.d.ts +0 -179
  144. package/dist/core/unified/toolRuntime.d.ts.map +0 -1
  145. package/dist/core/unified/toolRuntime.js +0 -517
  146. package/dist/core/unified/toolRuntime.js.map +0 -1
  147. package/dist/core/unified/tools.d.ts +0 -127
  148. package/dist/core/unified/tools.d.ts.map +0 -1
  149. package/dist/core/unified/tools.js +0 -1333
  150. package/dist/core/unified/tools.js.map +0 -1
  151. package/dist/core/unified/types.d.ts +0 -352
  152. package/dist/core/unified/types.d.ts.map +0 -1
  153. package/dist/core/unified/types.js +0 -12
  154. package/dist/core/unified/types.js.map +0 -1
  155. package/dist/core/unified/version.d.ts +0 -209
  156. package/dist/core/unified/version.d.ts.map +0 -1
  157. package/dist/core/unified/version.js +0 -454
  158. package/dist/core/unified/version.js.map +0 -1
  159. package/dist/security/active-stack-security.d.ts +0 -112
  160. package/dist/security/active-stack-security.d.ts.map +0 -1
  161. package/dist/security/active-stack-security.js +0 -296
  162. package/dist/security/active-stack-security.js.map +0 -1
  163. package/dist/security/advanced-persistence-research.d.ts +0 -92
  164. package/dist/security/advanced-persistence-research.d.ts.map +0 -1
  165. package/dist/security/advanced-persistence-research.js +0 -195
  166. package/dist/security/advanced-persistence-research.js.map +0 -1
  167. package/dist/security/advanced-targeting.d.ts +0 -119
  168. package/dist/security/advanced-targeting.d.ts.map +0 -1
  169. package/dist/security/advanced-targeting.js +0 -233
  170. package/dist/security/advanced-targeting.js.map +0 -1
  171. package/dist/security/assessment/vulnerabilityAssessment.d.ts +0 -104
  172. package/dist/security/assessment/vulnerabilityAssessment.d.ts.map +0 -1
  173. package/dist/security/assessment/vulnerabilityAssessment.js +0 -315
  174. package/dist/security/assessment/vulnerabilityAssessment.js.map +0 -1
  175. package/dist/security/authorization/securityAuthorization.d.ts +0 -88
  176. package/dist/security/authorization/securityAuthorization.d.ts.map +0 -1
  177. package/dist/security/authorization/securityAuthorization.js +0 -172
  178. package/dist/security/authorization/securityAuthorization.js.map +0 -1
  179. package/dist/security/comprehensive-targeting.d.ts +0 -85
  180. package/dist/security/comprehensive-targeting.d.ts.map +0 -1
  181. package/dist/security/comprehensive-targeting.js +0 -438
  182. package/dist/security/comprehensive-targeting.js.map +0 -1
  183. package/dist/security/global-security-integration.d.ts +0 -91
  184. package/dist/security/global-security-integration.d.ts.map +0 -1
  185. package/dist/security/global-security-integration.js +0 -218
  186. package/dist/security/global-security-integration.js.map +0 -1
  187. package/dist/security/index.d.ts +0 -38
  188. package/dist/security/index.d.ts.map +0 -1
  189. package/dist/security/index.js +0 -47
  190. package/dist/security/index.js.map +0 -1
  191. package/dist/security/persistence-analyzer.d.ts +0 -56
  192. package/dist/security/persistence-analyzer.d.ts.map +0 -1
  193. package/dist/security/persistence-analyzer.js +0 -187
  194. package/dist/security/persistence-analyzer.js.map +0 -1
  195. package/dist/security/persistence-cli.d.ts +0 -36
  196. package/dist/security/persistence-cli.d.ts.map +0 -1
  197. package/dist/security/persistence-cli.js +0 -160
  198. package/dist/security/persistence-cli.js.map +0 -1
  199. package/dist/security/persistence-research.d.ts +0 -92
  200. package/dist/security/persistence-research.d.ts.map +0 -1
  201. package/dist/security/persistence-research.js +0 -364
  202. package/dist/security/persistence-research.js.map +0 -1
  203. package/dist/security/research/persistenceResearch.d.ts +0 -97
  204. package/dist/security/research/persistenceResearch.d.ts.map +0 -1
  205. package/dist/security/research/persistenceResearch.js +0 -282
  206. package/dist/security/research/persistenceResearch.js.map +0 -1
  207. package/dist/security/security-integration.d.ts +0 -74
  208. package/dist/security/security-integration.d.ts.map +0 -1
  209. package/dist/security/security-integration.js +0 -137
  210. package/dist/security/security-integration.js.map +0 -1
  211. package/dist/security/security-testing-framework.d.ts +0 -112
  212. package/dist/security/security-testing-framework.d.ts.map +0 -1
  213. package/dist/security/security-testing-framework.js +0 -364
  214. package/dist/security/security-testing-framework.js.map +0 -1
  215. package/dist/security/simulation/attackSimulation.d.ts +0 -93
  216. package/dist/security/simulation/attackSimulation.d.ts.map +0 -1
  217. package/dist/security/simulation/attackSimulation.js +0 -341
  218. package/dist/security/simulation/attackSimulation.js.map +0 -1
  219. package/dist/security/strategic-operations.d.ts +0 -100
  220. package/dist/security/strategic-operations.d.ts.map +0 -1
  221. package/dist/security/strategic-operations.js +0 -276
  222. package/dist/security/strategic-operations.js.map +0 -1
  223. package/dist/security/tool-security-wrapper.d.ts +0 -58
  224. package/dist/security/tool-security-wrapper.d.ts.map +0 -1
  225. package/dist/security/tool-security-wrapper.js +0 -156
  226. package/dist/security/tool-security-wrapper.js.map +0 -1
  227. package/dist/shell/claudeCodeStreamHandler.d.ts +0 -145
  228. package/dist/shell/claudeCodeStreamHandler.d.ts.map +0 -1
  229. package/dist/shell/claudeCodeStreamHandler.js +0 -322
  230. package/dist/shell/claudeCodeStreamHandler.js.map +0 -1
  231. package/dist/shell/inputQueueManager.d.ts +0 -144
  232. package/dist/shell/inputQueueManager.d.ts.map +0 -1
  233. package/dist/shell/inputQueueManager.js +0 -290
  234. package/dist/shell/inputQueueManager.js.map +0 -1
  235. package/dist/shell/streamingOutputManager.d.ts +0 -115
  236. package/dist/shell/streamingOutputManager.d.ts.map +0 -1
  237. package/dist/shell/streamingOutputManager.js +0 -225
  238. package/dist/shell/streamingOutputManager.js.map +0 -1
  239. package/dist/ui/persistentPrompt.d.ts +0 -50
  240. package/dist/ui/persistentPrompt.d.ts.map +0 -1
  241. package/dist/ui/persistentPrompt.js +0 -92
  242. package/dist/ui/persistentPrompt.js.map +0 -1
  243. package/dist/ui/terminalUISchema.d.ts +0 -195
  244. package/dist/ui/terminalUISchema.d.ts.map +0 -1
  245. package/dist/ui/terminalUISchema.js +0 -113
  246. package/dist/ui/terminalUISchema.js.map +0 -1
  247. package/scripts/deploy-security-capabilities.js +0 -178
@@ -3,16 +3,18 @@
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)
6
7
  * - Native bracketed paste support (no heuristics)
7
8
  * - Clean cursor model with render-time wrapping
8
9
  * - State machine for different input modes
9
10
  * - No readline dependency for display
10
11
  */
11
12
  import { EventEmitter } from 'node:events';
12
- import { isMultilinePaste, generatePasteSummary } from '../core/multilinePasteHandler.js';
13
+ import { isMultilinePaste } from '../core/multilinePasteHandler.js';
13
14
  import { writeLock } from '../ui/writeLock.js';
14
- import { renderDivider } from '../ui/unified/layout.js';
15
- import { UI_COLORS, DEFAULT_UI_CONFIG, } from '../ui/terminalUISchema.js';
15
+ import { renderDivider, renderStatusLine } from '../ui/unified/layout.js';
16
+ import { isStreamingMode } from '../ui/globalWriteLock.js';
17
+ import { formatThinking } from '../ui/toolDisplay.js';
16
18
  // ANSI escape codes
17
19
  const ESC = {
18
20
  // Cursor control
@@ -67,6 +69,11 @@ export class TerminalInput extends EventEmitter {
67
69
  statusMessage = null;
68
70
  overrideStatusMessage = null; // Secondary status (warnings, etc.)
69
71
  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
70
77
  reservedLines = 2;
71
78
  scrollRegionActive = false;
72
79
  lastRenderContent = '';
@@ -74,45 +81,35 @@ export class TerminalInput extends EventEmitter {
74
81
  renderDirty = false;
75
82
  isRendering = false;
76
83
  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;
89
84
  // Lifecycle
90
85
  disposed = false;
91
86
  enabled = true;
92
87
  contextUsage = null;
88
+ contextAutoCompactThreshold = 90;
89
+ thinkingModeLabel = null;
93
90
  editMode = 'display-edits';
94
91
  verificationEnabled = true;
95
92
  autoContinueEnabled = false;
96
93
  verificationHotkey = 'alt+v';
97
94
  autoContinueHotkey = 'alt+c';
95
+ thinkingHotkey = '/thinking';
96
+ modelLabel = null;
97
+ providerLabel = null;
98
98
  // Output interceptor cleanup
99
99
  outputInterceptorCleanup;
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
+ // Streaming render throttle
101
+ lastStreamingRender = 0;
102
+ streamingRenderInterval = 250; // ms between renders during streaming
105
103
  streamingRenderTimer = null;
106
104
  constructor(writeStream = process.stdout, config = {}) {
107
105
  super();
108
106
  this.out = writeStream;
109
- // Use schema defaults for configuration consistency
110
107
  this.config = {
111
- maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
112
- maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
108
+ maxLines: config.maxLines ?? 1000,
109
+ maxLength: config.maxLength ?? 10000,
113
110
  maxQueueSize: config.maxQueueSize ?? 100,
114
- promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
115
- continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
111
+ promptChar: config.promptChar ?? '> ',
112
+ continuationChar: config.continuationChar ?? '│ ',
116
113
  };
117
114
  }
118
115
  // ===========================================================================
@@ -191,11 +188,6 @@ export class TerminalInput extends EventEmitter {
191
188
  if (handled)
192
189
  return;
193
190
  }
194
- // Handle '?' for help hint (if buffer is empty)
195
- if (str === '?' && this.buffer.length === 0) {
196
- this.emit('showHelp');
197
- return;
198
- }
199
191
  // Insert printable characters
200
192
  if (str && !key?.ctrl && !key?.meta) {
201
193
  this.insertText(str);
@@ -204,312 +196,38 @@ export class TerminalInput extends EventEmitter {
204
196
  /**
205
197
  * Set the input mode
206
198
  *
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).
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.
210
201
  */
211
202
  setMode(mode) {
212
203
  const prevMode = this.mode;
213
204
  this.mode = mode;
214
205
  if (mode === 'streaming' && prevMode !== 'streaming') {
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);
206
+ // Keep scroll region active so status/prompt stay pinned while streaming
207
+ this.resetStreamingRenderThrottle();
208
+ this.enableScrollRegion();
232
209
  this.renderDirty = true;
210
+ this.render();
233
211
  }
234
212
  else if (mode !== 'streaming' && prevMode === 'streaming') {
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();
249
- }
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`;
213
+ // Streaming ended - render the input area
214
+ this.resetStreamingRenderThrottle();
215
+ this.enableScrollRegion();
216
+ this.forceRender();
266
217
  }
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
218
  }
465
219
  /**
466
220
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
467
221
  */
468
222
  setPinnedHeaderLines(count) {
469
- // Set pinned header rows (banner area that scroll region excludes)
470
- if (this.pinnedTopRows !== count) {
471
- this.pinnedTopRows = count;
223
+ // No pinned header rows anymore; keep everything in the scroll region.
224
+ if (this.pinnedTopRows !== 0) {
225
+ this.pinnedTopRows = 0;
472
226
  if (this.scrollRegionActive) {
473
227
  this.applyScrollRegion();
474
228
  }
475
229
  }
476
230
  }
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
- }
513
231
  /**
514
232
  * Get current mode
515
233
  */
@@ -619,6 +337,37 @@ export class TerminalInput extends EventEmitter {
619
337
  this.streamingLabel = next;
620
338
  this.scheduleRender();
621
339
  }
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
+ }
622
371
  /**
623
372
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
624
373
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -628,16 +377,22 @@ export class TerminalInput extends EventEmitter {
628
377
  const nextAutoContinue = !!options.autoContinueEnabled;
629
378
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
630
379
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
380
+ const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
381
+ const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
631
382
  if (this.verificationEnabled === nextVerification &&
632
383
  this.autoContinueEnabled === nextAutoContinue &&
633
384
  this.verificationHotkey === nextVerifyHotkey &&
634
- this.autoContinueHotkey === nextAutoHotkey) {
385
+ this.autoContinueHotkey === nextAutoHotkey &&
386
+ this.thinkingHotkey === nextThinkingHotkey &&
387
+ this.thinkingModeLabel === nextThinkingLabel) {
635
388
  return;
636
389
  }
637
390
  this.verificationEnabled = nextVerification;
638
391
  this.autoContinueEnabled = nextAutoContinue;
639
392
  this.verificationHotkey = nextVerifyHotkey;
640
393
  this.autoContinueHotkey = nextAutoHotkey;
394
+ this.thinkingHotkey = nextThinkingHotkey;
395
+ this.thinkingModeLabel = nextThinkingLabel;
641
396
  this.scheduleRender();
642
397
  }
643
398
  /**
@@ -649,198 +404,104 @@ export class TerminalInput extends EventEmitter {
649
404
  this.streamingLabel = null;
650
405
  this.scheduleRender();
651
406
  }
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
+ }
652
420
  /**
653
421
  * Render the input area - Claude Code style with mode controls
654
422
  *
655
- * Same rendering for both normal and streaming modes - just different status bar.
656
- * During streaming, uses cursor save/restore to preserve streaming position.
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.
657
426
  */
658
427
  render() {
659
428
  if (!this.canRender())
660
429
  return;
661
430
  if (this.isRendering)
662
431
  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
+ }
663
444
  const shouldSkip = !this.renderDirty &&
664
445
  this.buffer === this.lastRenderContent &&
665
446
  this.cursor === this.lastRenderCursor;
666
447
  this.renderDirty = false;
667
- // Skip if nothing changed (unless explicitly forced)
448
+ // Skip if nothing changed and no explicit refresh requested
668
449
  if (shouldSkip) {
669
450
  return;
670
451
  }
671
- // If write lock is held, defer render
452
+ // If write lock is held, defer render to avoid race conditions
672
453
  if (writeLock.isLocked()) {
673
454
  writeLock.safeWrite(() => this.render());
674
455
  return;
675
456
  }
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++;
457
+ const performRender = () => {
458
+ if (!this.scrollRegionActive) {
459
+ this.enableScrollRegion();
787
460
  }
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++) {
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);
476
+ }
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) {
795
488
  this.write(ESC.TO(currentRow, 1));
796
489
  this.write(ESC.CLEAR_LINE);
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++;
490
+ this.write(metaLine);
491
+ currentRow += 1;
820
492
  }
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
493
+ // Separator line
835
494
  this.write(ESC.TO(currentRow, 1));
836
495
  this.write(ESC.CLEAR_LINE);
496
+ const divider = renderDivider(cols - 2);
837
497
  this.write(divider);
838
- currentRow++;
839
- // Input lines
498
+ currentRow += 1;
499
+ // Render input lines
840
500
  let finalRow = currentRow;
841
501
  let finalCol = 3;
842
502
  for (let i = 0; i < visibleLines.length; i++) {
843
- this.write(ESC.TO(currentRow, 1));
503
+ const rowNum = currentRow + i;
504
+ this.write(ESC.TO(rowNum, 1));
844
505
  this.write(ESC.CLEAR_LINE);
845
506
  const line = visibleLines[i] ?? '';
846
507
  const absoluteLineIdx = startLine + i;
@@ -854,6 +515,7 @@ export class TerminalInput extends EventEmitter {
854
515
  this.write(ESC.RESET);
855
516
  this.write(ESC.BG_DARK);
856
517
  if (isCursorLine) {
518
+ // Render with block cursor
857
519
  const col = Math.min(cursorCol, line.length);
858
520
  const before = line.slice(0, col);
859
521
  const at = col < line.length ? line[col] : ' ';
@@ -863,157 +525,265 @@ export class TerminalInput extends EventEmitter {
863
525
  this.write(at);
864
526
  this.write(ESC.RESET + ESC.BG_DARK);
865
527
  this.write(after);
866
- finalRow = currentRow;
528
+ finalRow = rowNum;
867
529
  finalCol = this.config.promptChar.length + col + 1;
868
530
  }
869
531
  else {
870
532
  this.write(line);
871
533
  }
872
- // Pad to edge
534
+ // Pad to edge for clean look
873
535
  const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
874
536
  const padding = Math.max(0, cols - lineLen - 1);
875
537
  if (padding > 0)
876
538
  this.write(' '.repeat(padding));
877
539
  this.write(ESC.RESET);
878
- currentRow++;
879
540
  }
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));
541
+ // Mode controls line (Claude Code style)
542
+ const controlRow = currentRow + visibleLines.length;
543
+ this.write(ESC.TO(controlRow, 1));
887
544
  this.write(ESC.CLEAR_LINE);
888
545
  this.write(this.buildModeControls(cols));
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)));
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;
895
556
  }
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;
896
567
  }
897
- this.write(ESC.SHOW);
898
- // Update state
899
- this.lastRenderContent = this.buffer;
900
- this.lastRenderCursor = this.cursor;
901
568
  }
902
569
  /**
903
- * Build status bar for streaming mode (shows elapsed time, queue count).
570
+ * Build one or more compact meta lines above the divider (thinking, status, usage).
571
+ * During streaming, consolidates into a single line to minimize cursor repositioning
572
+ * and prevent escape code interleaving with streamed content.
904
573
  */
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
574
+ buildMetaLines(width) {
575
+ const streamingActive = this.mode === 'streaming' || isStreamingMode();
576
+ // During streaming, consolidate everything into a single line to reduce escape codes
577
+ if (streamingActive) {
578
+ const parts = [];
579
+ // Essential streaming info only
580
+ if (this.metaThinkingMs !== null) {
581
+ parts.push({ text: formatThinking(this.metaThinkingMs, this.metaThinkingHasContent), tone: 'info' });
582
+ }
583
+ if (this.metaElapsedSeconds !== null) {
584
+ parts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
585
+ }
586
+ parts.push({ text: 'esc to stop', tone: 'warn' });
587
+ return parts.length ? [renderStatusLine(parts, width)] : [];
588
+ }
589
+ // Non-streaming: show full status info
590
+ const lines = [];
591
+ if (this.metaThinkingMs !== null) {
592
+ const thinkingText = formatThinking(this.metaThinkingMs, this.metaThinkingHasContent);
593
+ lines.push(renderStatusLine([{ text: thinkingText, tone: 'info' }], width));
594
+ }
595
+ if (this.modelLabel) {
596
+ const modelText = this.providerLabel
597
+ ? `${this.modelLabel} @ ${this.providerLabel}`
598
+ : this.modelLabel;
599
+ lines.push(renderStatusLine([{ text: `model ${modelText}`, tone: 'muted' }], width));
600
+ }
601
+ const statusParts = [];
602
+ const statusLabel = this.statusMessage ?? this.streamingLabel;
603
+ if (statusLabel) {
604
+ statusParts.push({ text: statusLabel, tone: 'info' });
605
+ }
606
+ if (this.metaElapsedSeconds !== null) {
607
+ statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
608
+ }
609
+ const tokensRemaining = this.computeTokensRemaining();
610
+ if (tokensRemaining !== null) {
611
+ statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
612
+ }
613
+ if (statusParts.length) {
614
+ lines.push(renderStatusLine(statusParts, width));
615
+ }
616
+ const usageParts = [];
617
+ if (this.metaTokensUsed !== null) {
618
+ const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
619
+ const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
620
+ usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
621
+ }
622
+ if (this.contextUsage !== null) {
623
+ const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
624
+ const left = Math.max(0, 100 - this.contextUsage);
625
+ usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
626
+ }
916
627
  if (this.queue.length > 0) {
917
- status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
628
+ usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
918
629
  }
919
- // Hint for typing
920
- status += ` ${DIM}· type to queue message${R}`;
921
- return status;
630
+ if (usageParts.length) {
631
+ lines.push(renderStatusLine(usageParts, width));
632
+ }
633
+ return lines;
922
634
  }
923
635
  /**
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.
636
+ * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
926
637
  */
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
940
- }
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
638
+ clearReservedArea(startRow, reservedLines, cols) {
639
+ const width = Math.max(1, cols);
640
+ for (let i = 0; i < reservedLines; i++) {
641
+ const row = startRow + i;
642
+ this.write(ESC.TO(row, 1));
643
+ this.write(' '.repeat(width));
944
644
  }
945
- // Paste indicator
946
- if (this.pastePlaceholders.length > 0) {
947
- const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
948
- parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
645
+ }
646
+ /**
647
+ * Build Claude Code style mode controls line.
648
+ * Combines streaming label + override status + main status for simultaneous display.
649
+ */
650
+ buildModeControls(cols) {
651
+ const width = Math.max(8, cols - 2);
652
+ const leftParts = [];
653
+ const rightParts = [];
654
+ if (this.streamingLabel) {
655
+ leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
949
656
  }
950
- // Override/warning status
951
657
  if (this.overrideStatusMessage) {
952
- parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
953
- }
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}`;
658
+ leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
659
+ }
660
+ if (this.statusMessage) {
661
+ leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
662
+ }
663
+ const editHotkey = this.formatHotkey('shift+tab');
664
+ const editLabel = this.editMode === 'display-edits' ? 'edits: accept' : 'edits: ask';
665
+ const editTone = this.editMode === 'display-edits' ? 'success' : 'muted';
666
+ leftParts.push({ text: `${editHotkey} ${editLabel}`, tone: editTone });
667
+ const verifyHotkey = this.formatHotkey(this.verificationHotkey);
668
+ const verifyLabel = this.verificationEnabled ? 'verify:on' : 'verify:off';
669
+ leftParts.push({ text: `${verifyHotkey} ${verifyLabel}`, tone: this.verificationEnabled ? 'success' : 'muted' });
670
+ const continueHotkey = this.formatHotkey(this.autoContinueHotkey);
671
+ const continueLabel = this.autoContinueEnabled ? 'auto:on' : 'auto:off';
672
+ leftParts.push({ text: `${continueHotkey} ${continueLabel}`, tone: this.autoContinueEnabled ? 'info' : 'muted' });
673
+ if (this.queue.length > 0 && this.mode !== 'streaming') {
674
+ leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
957
675
  }
958
- // Multi-line indicator
959
676
  if (this.buffer.includes('\n')) {
960
- parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
677
+ const lineCount = this.buffer.split('\n').length;
678
+ leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
961
679
  }
962
- if (parts.length === 0) {
963
- return ''; // Empty status bar when idle
680
+ if (this.pastePlaceholders.length > 0) {
681
+ const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
682
+ leftParts.push({
683
+ text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
684
+ tone: 'info',
685
+ });
964
686
  }
965
- const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
966
- return joined.slice(0, maxWidth);
687
+ const contextRemaining = this.computeContextRemaining();
688
+ if (this.thinkingModeLabel) {
689
+ const thinkingHotkey = this.formatHotkey(this.thinkingHotkey);
690
+ rightParts.push({ text: `${thinkingHotkey} thinking:${this.thinkingModeLabel}`, tone: 'info' });
691
+ }
692
+ if (this.modelLabel) {
693
+ const modelText = this.providerLabel ? `${this.modelLabel} @ ${this.providerLabel}` : this.modelLabel;
694
+ rightParts.push({ text: modelText, tone: 'muted' });
695
+ }
696
+ if (contextRemaining !== null) {
697
+ const tone = contextRemaining <= 10 ? 'warn' : 'muted';
698
+ const label = contextRemaining === 0 && this.contextUsage !== null
699
+ ? 'Context auto-compact imminent'
700
+ : `Context left until auto-compact: ${contextRemaining}%`;
701
+ rightParts.push({ text: label, tone });
702
+ }
703
+ if (!rightParts.length || width < 60) {
704
+ const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
705
+ return renderStatusLine(merged, width);
706
+ }
707
+ const leftWidth = Math.max(12, Math.floor(width * 0.6));
708
+ const rightWidth = Math.max(14, width - leftWidth - 1);
709
+ const leftText = renderStatusLine(leftParts, leftWidth);
710
+ const rightText = renderStatusLine(rightParts, rightWidth);
711
+ const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
712
+ return `${leftText}${' '.repeat(spacing)}${rightText}`;
713
+ }
714
+ formatHotkey(hotkey) {
715
+ const normalized = hotkey.trim().toLowerCase();
716
+ if (!normalized)
717
+ return hotkey;
718
+ const parts = normalized.split('+').filter(Boolean);
719
+ const map = {
720
+ shift: '⇧',
721
+ sh: '⇧',
722
+ alt: '⌥',
723
+ option: '⌥',
724
+ opt: '⌥',
725
+ ctrl: '⌃',
726
+ control: '⌃',
727
+ cmd: '⌘',
728
+ meta: '⌘',
729
+ };
730
+ const formatted = parts
731
+ .map((part) => {
732
+ const symbol = map[part];
733
+ if (symbol)
734
+ return symbol;
735
+ return part.length === 1 ? part.toUpperCase() : part.toUpperCase();
736
+ })
737
+ .join('');
738
+ return formatted || hotkey;
739
+ }
740
+ computeContextRemaining() {
741
+ if (this.contextUsage === null) {
742
+ return null;
743
+ }
744
+ return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
745
+ }
746
+ computeTokensRemaining() {
747
+ if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
748
+ return null;
749
+ }
750
+ const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
751
+ return this.formatTokenCount(remaining);
752
+ }
753
+ formatElapsedLabel(seconds) {
754
+ if (seconds < 60) {
755
+ return `${seconds}s`;
756
+ }
757
+ const mins = Math.floor(seconds / 60);
758
+ const secs = seconds % 60;
759
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
760
+ }
761
+ formatTokenCount(value) {
762
+ if (!Number.isFinite(value)) {
763
+ return `${value}`;
764
+ }
765
+ if (value >= 1_000_000) {
766
+ return `${(value / 1_000_000).toFixed(1)}M`;
767
+ }
768
+ if (value >= 1_000) {
769
+ return `${(value / 1_000).toFixed(1)}k`;
770
+ }
771
+ return `${Math.round(value)}`;
772
+ }
773
+ visibleLength(value) {
774
+ const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
775
+ return value.replace(ansiPattern, '').length;
967
776
  }
968
777
  /**
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]
778
+ * Debug-only snapshot used by tests to assert rendered strings without
779
+ * needing a TTY. Not used by production code.
973
780
  */
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;
781
+ getDebugUiSnapshot(width) {
782
+ const cols = Math.max(8, width ?? this.getSize().cols);
783
+ return {
784
+ meta: this.buildMetaLines(cols - 2),
785
+ controls: this.buildModeControls(cols),
786
+ };
1017
787
  }
1018
788
  /**
1019
789
  * Force a re-render
@@ -1036,17 +806,19 @@ export class TerminalInput extends EventEmitter {
1036
806
  handleResize() {
1037
807
  this.lastRenderContent = '';
1038
808
  this.lastRenderCursor = -1;
809
+ this.resetStreamingRenderThrottle();
1039
810
  // Re-clamp pinned header rows to the new terminal height
1040
811
  this.setPinnedHeaderLines(this.pinnedTopRows);
812
+ if (this.scrollRegionActive) {
813
+ this.disableScrollRegion();
814
+ this.enableScrollRegion();
815
+ }
1041
816
  this.scheduleRender();
1042
817
  }
1043
818
  /**
1044
819
  * Register with display's output interceptor to position cursor correctly.
1045
820
  * When scroll region is active, output needs to go to the scroll region,
1046
821
  * 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.
1050
822
  */
1051
823
  registerOutputInterceptor(display) {
1052
824
  if (this.outputInterceptorCleanup) {
@@ -1054,11 +826,20 @@ export class TerminalInput extends EventEmitter {
1054
826
  }
1055
827
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
1056
828
  beforeWrite: () => {
1057
- // Scroll region handles content containment automatically
1058
- // No per-write cursor manipulation needed
829
+ // When the scroll region is active, temporarily move the cursor into
830
+ // the scrollable area so streamed output lands above the pinned prompt.
831
+ if (this.scrollRegionActive) {
832
+ const { rows } = this.getSize();
833
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
834
+ this.write(ESC.SAVE);
835
+ this.write(ESC.TO(scrollBottom, 1));
836
+ }
1059
837
  },
1060
838
  afterWrite: () => {
1061
- // No cursor manipulation needed
839
+ // Restore cursor back to the pinned prompt after output completes.
840
+ if (this.scrollRegionActive) {
841
+ this.write(ESC.RESTORE);
842
+ }
1062
843
  },
1063
844
  });
1064
845
  }
@@ -1068,11 +849,6 @@ export class TerminalInput extends EventEmitter {
1068
849
  dispose() {
1069
850
  if (this.disposed)
1070
851
  return;
1071
- // Clean up streaming render timer
1072
- if (this.streamingRenderTimer) {
1073
- clearInterval(this.streamingRenderTimer);
1074
- this.streamingRenderTimer = null;
1075
- }
1076
852
  // Clean up output interceptor
1077
853
  if (this.outputInterceptorCleanup) {
1078
854
  this.outputInterceptorCleanup();
@@ -1080,6 +856,7 @@ export class TerminalInput extends EventEmitter {
1080
856
  }
1081
857
  this.disposed = true;
1082
858
  this.enabled = false;
859
+ this.resetStreamingRenderThrottle();
1083
860
  this.disableScrollRegion();
1084
861
  this.disableBracketedPaste();
1085
862
  this.buffer = '';
@@ -1185,22 +962,7 @@ export class TerminalInput extends EventEmitter {
1185
962
  this.toggleEditMode();
1186
963
  return true;
1187
964
  }
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
- }
965
+ this.insertText(' ');
1204
966
  return true;
1205
967
  }
1206
968
  return false;
@@ -1218,7 +980,6 @@ export class TerminalInput extends EventEmitter {
1218
980
  this.insertPlainText(chunk, insertPos);
1219
981
  this.cursor = insertPos + chunk.length;
1220
982
  this.emit('change', this.buffer);
1221
- this.updateSuggestions();
1222
983
  this.scheduleRender();
1223
984
  }
1224
985
  insertNewline() {
@@ -1243,7 +1004,6 @@ export class TerminalInput extends EventEmitter {
1243
1004
  this.cursor = Math.max(0, this.cursor - 1);
1244
1005
  }
1245
1006
  this.emit('change', this.buffer);
1246
- this.updateSuggestions();
1247
1007
  this.scheduleRender();
1248
1008
  }
1249
1009
  deleteForward() {
@@ -1493,7 +1253,9 @@ export class TerminalInput extends EventEmitter {
1493
1253
  if (available <= 0)
1494
1254
  return;
1495
1255
  const chunk = clean.slice(0, available);
1496
- if (isMultilinePaste(chunk)) {
1256
+ const isMultiline = isMultilinePaste(chunk);
1257
+ const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1258
+ if (isMultiline && !isShortMultiline) {
1497
1259
  this.insertPastePlaceholder(chunk);
1498
1260
  }
1499
1261
  else {
@@ -1513,6 +1275,7 @@ export class TerminalInput extends EventEmitter {
1513
1275
  return;
1514
1276
  this.applyScrollRegion();
1515
1277
  this.scrollRegionActive = true;
1278
+ this.forceRender();
1516
1279
  }
1517
1280
  disableScrollRegion() {
1518
1281
  if (!this.scrollRegionActive)
@@ -1663,17 +1426,19 @@ export class TerminalInput extends EventEmitter {
1663
1426
  this.shiftPlaceholders(position, text.length);
1664
1427
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1665
1428
  }
1429
+ shouldInlineMultiline(content) {
1430
+ const lines = content.split('\n').length;
1431
+ const maxInlineLines = 4;
1432
+ const maxInlineChars = 240;
1433
+ return lines <= maxInlineLines && content.length <= maxInlineChars;
1434
+ }
1666
1435
  findPlaceholderAt(position) {
1667
1436
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1668
1437
  }
1669
- buildPlaceholder(summary) {
1438
+ buildPlaceholder(lineCount) {
1670
1439
  const id = ++this.pasteCounter;
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}"`;
1440
+ const plural = lineCount === 1 ? '' : 's';
1441
+ const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1677
1442
  return { id, placeholder };
1678
1443
  }
1679
1444
  insertPastePlaceholder(content) {
@@ -1681,67 +1446,21 @@ export class TerminalInput extends EventEmitter {
1681
1446
  if (available <= 0)
1682
1447
  return;
1683
1448
  const cleanContent = content.slice(0, available);
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);
1449
+ const lineCount = cleanContent.split('\n').length;
1450
+ const { id, placeholder } = this.buildPlaceholder(lineCount);
1694
1451
  const insertPos = this.cursor;
1695
1452
  this.shiftPlaceholders(insertPos, placeholder.length);
1696
1453
  this.pastePlaceholders.push({
1697
1454
  id,
1698
1455
  content: cleanContent,
1699
- lineCount: summary.lineCount,
1456
+ lineCount,
1700
1457
  placeholder,
1701
1458
  start: insertPos,
1702
1459
  end: insertPos + placeholder.length,
1703
- summary,
1704
- expanded: false,
1705
1460
  });
1706
1461
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1707
1462
  this.cursor = insertPos + placeholder.length;
1708
1463
  }
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
- }
1745
1464
  deletePlaceholder(placeholder) {
1746
1465
  const length = placeholder.end - placeholder.start;
1747
1466
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1749,7 +1468,11 @@ export class TerminalInput extends EventEmitter {
1749
1468
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1750
1469
  this.cursor = placeholder.start;
1751
1470
  }
1752
- updateContextUsage(value) {
1471
+ updateContextUsage(value, autoCompactThreshold) {
1472
+ if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1473
+ const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1474
+ this.contextAutoCompactThreshold = boundedThreshold;
1475
+ }
1753
1476
  if (value === null || !Number.isFinite(value)) {
1754
1477
  this.contextUsage = null;
1755
1478
  }
@@ -1776,6 +1499,22 @@ export class TerminalInput extends EventEmitter {
1776
1499
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1777
1500
  this.setEditMode(next);
1778
1501
  }
1502
+ scheduleStreamingRender(delayMs) {
1503
+ if (this.streamingRenderTimer)
1504
+ return;
1505
+ const wait = Math.max(16, delayMs);
1506
+ this.streamingRenderTimer = setTimeout(() => {
1507
+ this.streamingRenderTimer = null;
1508
+ this.render();
1509
+ }, wait);
1510
+ }
1511
+ resetStreamingRenderThrottle() {
1512
+ if (this.streamingRenderTimer) {
1513
+ clearTimeout(this.streamingRenderTimer);
1514
+ this.streamingRenderTimer = null;
1515
+ }
1516
+ this.lastStreamingRender = 0;
1517
+ }
1779
1518
  scheduleRender() {
1780
1519
  if (!this.canRender())
1781
1520
  return;