erosolar-cli 1.7.262 → 1.7.263

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