erosolar-cli 1.7.275 → 1.7.277

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 (323) hide show
  1. package/README.md +24 -148
  2. package/dist/bin/erosolar.js +1 -0
  3. package/dist/bin/erosolar.js.map +1 -1
  4. package/dist/capabilities/agentSpawningCapability.d.ts.map +1 -1
  5. package/dist/capabilities/agentSpawningCapability.js +56 -31
  6. package/dist/capabilities/agentSpawningCapability.js.map +1 -1
  7. package/dist/contracts/agent-schemas.json +0 -15
  8. package/dist/contracts/tools.schema.json +0 -9
  9. package/dist/core/agent.d.ts +2 -2
  10. package/dist/core/agent.d.ts.map +1 -1
  11. package/dist/core/agent.js.map +1 -1
  12. package/dist/core/customCommands.d.ts +1 -0
  13. package/dist/core/customCommands.d.ts.map +1 -1
  14. package/dist/core/customCommands.js +3 -0
  15. package/dist/core/customCommands.js.map +1 -1
  16. package/dist/core/hooks.d.ts +113 -0
  17. package/dist/core/hooks.d.ts.map +1 -0
  18. package/dist/core/hooks.js +267 -0
  19. package/dist/core/hooks.js.map +1 -0
  20. package/dist/core/metricsTracker.d.ts +122 -0
  21. package/dist/core/metricsTracker.d.ts.map +1 -0
  22. package/dist/{alpha-zero → core}/metricsTracker.js +2 -5
  23. package/dist/core/metricsTracker.js.map +1 -0
  24. package/dist/core/securityAssessment.d.ts +91 -0
  25. package/dist/core/securityAssessment.d.ts.map +1 -0
  26. package/dist/core/securityAssessment.js +580 -0
  27. package/dist/core/securityAssessment.js.map +1 -0
  28. package/dist/core/toolPreconditions.d.ts.map +1 -1
  29. package/dist/core/toolPreconditions.js +0 -14
  30. package/dist/core/toolPreconditions.js.map +1 -1
  31. package/dist/core/toolRuntime.d.ts +22 -1
  32. package/dist/core/toolRuntime.d.ts.map +1 -1
  33. package/dist/core/toolRuntime.js +0 -5
  34. package/dist/core/toolRuntime.js.map +1 -1
  35. package/dist/core/toolValidation.d.ts.map +1 -1
  36. package/dist/core/toolValidation.js +14 -3
  37. package/dist/core/toolValidation.js.map +1 -1
  38. package/dist/core/validationRunner.d.ts +1 -3
  39. package/dist/core/validationRunner.d.ts.map +1 -1
  40. package/dist/core/validationRunner.js.map +1 -1
  41. package/dist/core/verification.d.ts +137 -0
  42. package/dist/core/verification.d.ts.map +1 -0
  43. package/dist/core/verification.js +323 -0
  44. package/dist/core/verification.js.map +1 -0
  45. package/dist/headless/headlessApp.d.ts.map +1 -1
  46. package/dist/headless/headlessApp.js +21 -0
  47. package/dist/headless/headlessApp.js.map +1 -1
  48. package/dist/mcp/sseClient.d.ts.map +1 -1
  49. package/dist/mcp/sseClient.js +9 -18
  50. package/dist/mcp/sseClient.js.map +1 -1
  51. package/dist/plugins/tools/build/buildPlugin.d.ts +0 -6
  52. package/dist/plugins/tools/build/buildPlugin.d.ts.map +1 -1
  53. package/dist/plugins/tools/build/buildPlugin.js +4 -10
  54. package/dist/plugins/tools/build/buildPlugin.js.map +1 -1
  55. package/dist/plugins/tools/nodeDefaults.d.ts.map +1 -1
  56. package/dist/plugins/tools/nodeDefaults.js +0 -2
  57. package/dist/plugins/tools/nodeDefaults.js.map +1 -1
  58. package/dist/runtime/agentSession.d.ts +2 -2
  59. package/dist/runtime/agentSession.d.ts.map +1 -1
  60. package/dist/runtime/agentSession.js +2 -2
  61. package/dist/runtime/agentSession.js.map +1 -1
  62. package/dist/shell/interactiveShell.d.ts +10 -7
  63. package/dist/shell/interactiveShell.d.ts.map +1 -1
  64. package/dist/shell/interactiveShell.js +198 -160
  65. package/dist/shell/interactiveShell.js.map +1 -1
  66. package/dist/shell/shellApp.d.ts +2 -0
  67. package/dist/shell/shellApp.d.ts.map +1 -1
  68. package/dist/shell/shellApp.js +36 -1
  69. package/dist/shell/shellApp.js.map +1 -1
  70. package/dist/shell/systemPrompt.d.ts.map +1 -1
  71. package/dist/shell/systemPrompt.js +1 -4
  72. package/dist/shell/systemPrompt.js.map +1 -1
  73. package/dist/shell/terminalInput.d.ts +76 -176
  74. package/dist/shell/terminalInput.d.ts.map +1 -1
  75. package/dist/shell/terminalInput.js +491 -879
  76. package/dist/shell/terminalInput.js.map +1 -1
  77. package/dist/shell/terminalInputAdapter.d.ts +28 -33
  78. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  79. package/dist/shell/terminalInputAdapter.js +26 -46
  80. package/dist/shell/terminalInputAdapter.js.map +1 -1
  81. package/dist/subagents/agentConfig.d.ts +27 -0
  82. package/dist/subagents/agentConfig.d.ts.map +1 -0
  83. package/dist/subagents/agentConfig.js +89 -0
  84. package/dist/subagents/agentConfig.js.map +1 -0
  85. package/dist/subagents/agentRegistry.d.ts +33 -0
  86. package/dist/subagents/agentRegistry.d.ts.map +1 -0
  87. package/dist/subagents/agentRegistry.js +162 -0
  88. package/dist/subagents/agentRegistry.js.map +1 -0
  89. package/dist/subagents/taskRunner.d.ts +7 -1
  90. package/dist/subagents/taskRunner.d.ts.map +1 -1
  91. package/dist/subagents/taskRunner.js +180 -47
  92. package/dist/subagents/taskRunner.js.map +1 -1
  93. package/dist/ui/ShellUIAdapter.d.ts.map +1 -1
  94. package/dist/ui/ShellUIAdapter.js +13 -12
  95. package/dist/ui/ShellUIAdapter.js.map +1 -1
  96. package/dist/ui/display.d.ts +25 -10
  97. package/dist/ui/display.d.ts.map +1 -1
  98. package/dist/ui/display.js +146 -62
  99. package/dist/ui/display.js.map +1 -1
  100. package/dist/ui/theme.d.ts.map +1 -1
  101. package/dist/ui/theme.js +6 -8
  102. package/dist/ui/theme.js.map +1 -1
  103. package/dist/ui/toolDisplay.d.ts +0 -158
  104. package/dist/ui/toolDisplay.d.ts.map +1 -1
  105. package/dist/ui/toolDisplay.js +0 -348
  106. package/dist/ui/toolDisplay.js.map +1 -1
  107. package/dist/ui/unified/layout.d.ts +1 -0
  108. package/dist/ui/unified/layout.d.ts.map +1 -1
  109. package/dist/ui/unified/layout.js +15 -25
  110. package/dist/ui/unified/layout.js.map +1 -1
  111. package/dist/utils/frontmatter.d.ts +10 -0
  112. package/dist/utils/frontmatter.d.ts.map +1 -0
  113. package/dist/utils/frontmatter.js +78 -0
  114. package/dist/utils/frontmatter.js.map +1 -0
  115. package/package.json +1 -1
  116. package/dist/alpha-zero/agentWrapper.d.ts +0 -84
  117. package/dist/alpha-zero/agentWrapper.d.ts.map +0 -1
  118. package/dist/alpha-zero/agentWrapper.js +0 -171
  119. package/dist/alpha-zero/agentWrapper.js.map +0 -1
  120. package/dist/alpha-zero/codeEvaluator.d.ts +0 -25
  121. package/dist/alpha-zero/codeEvaluator.d.ts.map +0 -1
  122. package/dist/alpha-zero/codeEvaluator.js +0 -273
  123. package/dist/alpha-zero/codeEvaluator.js.map +0 -1
  124. package/dist/alpha-zero/competitiveRunner.d.ts +0 -66
  125. package/dist/alpha-zero/competitiveRunner.d.ts.map +0 -1
  126. package/dist/alpha-zero/competitiveRunner.js +0 -224
  127. package/dist/alpha-zero/competitiveRunner.js.map +0 -1
  128. package/dist/alpha-zero/index.d.ts +0 -67
  129. package/dist/alpha-zero/index.d.ts.map +0 -1
  130. package/dist/alpha-zero/index.js +0 -99
  131. package/dist/alpha-zero/index.js.map +0 -1
  132. package/dist/alpha-zero/introspection.d.ts +0 -128
  133. package/dist/alpha-zero/introspection.d.ts.map +0 -1
  134. package/dist/alpha-zero/introspection.js +0 -300
  135. package/dist/alpha-zero/introspection.js.map +0 -1
  136. package/dist/alpha-zero/metricsTracker.d.ts +0 -71
  137. package/dist/alpha-zero/metricsTracker.d.ts.map +0 -1
  138. package/dist/alpha-zero/metricsTracker.js.map +0 -1
  139. package/dist/alpha-zero/security/core.d.ts +0 -125
  140. package/dist/alpha-zero/security/core.d.ts.map +0 -1
  141. package/dist/alpha-zero/security/core.js +0 -271
  142. package/dist/alpha-zero/security/core.js.map +0 -1
  143. package/dist/alpha-zero/security/google.d.ts +0 -125
  144. package/dist/alpha-zero/security/google.d.ts.map +0 -1
  145. package/dist/alpha-zero/security/google.js +0 -311
  146. package/dist/alpha-zero/security/google.js.map +0 -1
  147. package/dist/alpha-zero/security/googleLoader.d.ts +0 -17
  148. package/dist/alpha-zero/security/googleLoader.d.ts.map +0 -1
  149. package/dist/alpha-zero/security/googleLoader.js +0 -41
  150. package/dist/alpha-zero/security/googleLoader.js.map +0 -1
  151. package/dist/alpha-zero/security/index.d.ts +0 -29
  152. package/dist/alpha-zero/security/index.d.ts.map +0 -1
  153. package/dist/alpha-zero/security/index.js +0 -32
  154. package/dist/alpha-zero/security/index.js.map +0 -1
  155. package/dist/alpha-zero/security/simulation.d.ts +0 -124
  156. package/dist/alpha-zero/security/simulation.d.ts.map +0 -1
  157. package/dist/alpha-zero/security/simulation.js +0 -277
  158. package/dist/alpha-zero/security/simulation.js.map +0 -1
  159. package/dist/alpha-zero/selfModification.d.ts +0 -109
  160. package/dist/alpha-zero/selfModification.d.ts.map +0 -1
  161. package/dist/alpha-zero/selfModification.js +0 -233
  162. package/dist/alpha-zero/selfModification.js.map +0 -1
  163. package/dist/alpha-zero/types.d.ts +0 -170
  164. package/dist/alpha-zero/types.d.ts.map +0 -1
  165. package/dist/alpha-zero/types.js +0 -31
  166. package/dist/alpha-zero/types.js.map +0 -1
  167. package/dist/capabilities/securityTestingCapability.d.ts +0 -13
  168. package/dist/capabilities/securityTestingCapability.d.ts.map +0 -1
  169. package/dist/capabilities/securityTestingCapability.js +0 -25
  170. package/dist/capabilities/securityTestingCapability.js.map +0 -1
  171. package/dist/core/aiFlowOptimizer.d.ts +0 -26
  172. package/dist/core/aiFlowOptimizer.d.ts.map +0 -1
  173. package/dist/core/aiFlowOptimizer.js +0 -31
  174. package/dist/core/aiFlowOptimizer.js.map +0 -1
  175. package/dist/core/aiOptimizationEngine.d.ts +0 -158
  176. package/dist/core/aiOptimizationEngine.d.ts.map +0 -1
  177. package/dist/core/aiOptimizationEngine.js +0 -428
  178. package/dist/core/aiOptimizationEngine.js.map +0 -1
  179. package/dist/core/aiOptimizationIntegration.d.ts +0 -93
  180. package/dist/core/aiOptimizationIntegration.d.ts.map +0 -1
  181. package/dist/core/aiOptimizationIntegration.js +0 -250
  182. package/dist/core/aiOptimizationIntegration.js.map +0 -1
  183. package/dist/core/enhancedErrorRecovery.d.ts +0 -100
  184. package/dist/core/enhancedErrorRecovery.d.ts.map +0 -1
  185. package/dist/core/enhancedErrorRecovery.js +0 -345
  186. package/dist/core/enhancedErrorRecovery.js.map +0 -1
  187. package/dist/core/hooksSystem.d.ts +0 -65
  188. package/dist/core/hooksSystem.d.ts.map +0 -1
  189. package/dist/core/hooksSystem.js +0 -273
  190. package/dist/core/hooksSystem.js.map +0 -1
  191. package/dist/core/memorySystem.d.ts +0 -48
  192. package/dist/core/memorySystem.d.ts.map +0 -1
  193. package/dist/core/memorySystem.js +0 -271
  194. package/dist/core/memorySystem.js.map +0 -1
  195. package/dist/core/unified/errors.d.ts +0 -189
  196. package/dist/core/unified/errors.d.ts.map +0 -1
  197. package/dist/core/unified/errors.js +0 -497
  198. package/dist/core/unified/errors.js.map +0 -1
  199. package/dist/core/unified/index.d.ts +0 -19
  200. package/dist/core/unified/index.d.ts.map +0 -1
  201. package/dist/core/unified/index.js +0 -68
  202. package/dist/core/unified/index.js.map +0 -1
  203. package/dist/core/unified/schema.d.ts +0 -101
  204. package/dist/core/unified/schema.d.ts.map +0 -1
  205. package/dist/core/unified/schema.js +0 -350
  206. package/dist/core/unified/schema.js.map +0 -1
  207. package/dist/core/unified/toolRuntime.d.ts +0 -179
  208. package/dist/core/unified/toolRuntime.d.ts.map +0 -1
  209. package/dist/core/unified/toolRuntime.js +0 -517
  210. package/dist/core/unified/toolRuntime.js.map +0 -1
  211. package/dist/core/unified/tools.d.ts +0 -127
  212. package/dist/core/unified/tools.d.ts.map +0 -1
  213. package/dist/core/unified/tools.js +0 -1333
  214. package/dist/core/unified/tools.js.map +0 -1
  215. package/dist/core/unified/types.d.ts +0 -352
  216. package/dist/core/unified/types.d.ts.map +0 -1
  217. package/dist/core/unified/types.js +0 -12
  218. package/dist/core/unified/types.js.map +0 -1
  219. package/dist/core/unified/version.d.ts +0 -209
  220. package/dist/core/unified/version.d.ts.map +0 -1
  221. package/dist/core/unified/version.js +0 -454
  222. package/dist/core/unified/version.js.map +0 -1
  223. package/dist/plugins/tools/security/securityPlugin.d.ts +0 -3
  224. package/dist/plugins/tools/security/securityPlugin.d.ts.map +0 -1
  225. package/dist/plugins/tools/security/securityPlugin.js +0 -12
  226. package/dist/plugins/tools/security/securityPlugin.js.map +0 -1
  227. package/dist/security/active-stack-security.d.ts +0 -112
  228. package/dist/security/active-stack-security.d.ts.map +0 -1
  229. package/dist/security/active-stack-security.js +0 -296
  230. package/dist/security/active-stack-security.js.map +0 -1
  231. package/dist/security/advanced-persistence-research.d.ts +0 -92
  232. package/dist/security/advanced-persistence-research.d.ts.map +0 -1
  233. package/dist/security/advanced-persistence-research.js +0 -195
  234. package/dist/security/advanced-persistence-research.js.map +0 -1
  235. package/dist/security/advanced-targeting.d.ts +0 -119
  236. package/dist/security/advanced-targeting.d.ts.map +0 -1
  237. package/dist/security/advanced-targeting.js +0 -233
  238. package/dist/security/advanced-targeting.js.map +0 -1
  239. package/dist/security/assessment/vulnerabilityAssessment.d.ts +0 -104
  240. package/dist/security/assessment/vulnerabilityAssessment.d.ts.map +0 -1
  241. package/dist/security/assessment/vulnerabilityAssessment.js +0 -315
  242. package/dist/security/assessment/vulnerabilityAssessment.js.map +0 -1
  243. package/dist/security/authorization/securityAuthorization.d.ts +0 -88
  244. package/dist/security/authorization/securityAuthorization.d.ts.map +0 -1
  245. package/dist/security/authorization/securityAuthorization.js +0 -172
  246. package/dist/security/authorization/securityAuthorization.js.map +0 -1
  247. package/dist/security/comprehensive-targeting.d.ts +0 -85
  248. package/dist/security/comprehensive-targeting.d.ts.map +0 -1
  249. package/dist/security/comprehensive-targeting.js +0 -438
  250. package/dist/security/comprehensive-targeting.js.map +0 -1
  251. package/dist/security/global-security-integration.d.ts +0 -91
  252. package/dist/security/global-security-integration.d.ts.map +0 -1
  253. package/dist/security/global-security-integration.js +0 -218
  254. package/dist/security/global-security-integration.js.map +0 -1
  255. package/dist/security/index.d.ts +0 -38
  256. package/dist/security/index.d.ts.map +0 -1
  257. package/dist/security/index.js +0 -47
  258. package/dist/security/index.js.map +0 -1
  259. package/dist/security/persistence-analyzer.d.ts +0 -56
  260. package/dist/security/persistence-analyzer.d.ts.map +0 -1
  261. package/dist/security/persistence-analyzer.js +0 -187
  262. package/dist/security/persistence-analyzer.js.map +0 -1
  263. package/dist/security/persistence-cli.d.ts +0 -36
  264. package/dist/security/persistence-cli.d.ts.map +0 -1
  265. package/dist/security/persistence-cli.js +0 -160
  266. package/dist/security/persistence-cli.js.map +0 -1
  267. package/dist/security/persistence-research.d.ts +0 -92
  268. package/dist/security/persistence-research.d.ts.map +0 -1
  269. package/dist/security/persistence-research.js +0 -364
  270. package/dist/security/persistence-research.js.map +0 -1
  271. package/dist/security/research/persistenceResearch.d.ts +0 -97
  272. package/dist/security/research/persistenceResearch.d.ts.map +0 -1
  273. package/dist/security/research/persistenceResearch.js +0 -282
  274. package/dist/security/research/persistenceResearch.js.map +0 -1
  275. package/dist/security/security-integration.d.ts +0 -74
  276. package/dist/security/security-integration.d.ts.map +0 -1
  277. package/dist/security/security-integration.js +0 -137
  278. package/dist/security/security-integration.js.map +0 -1
  279. package/dist/security/security-testing-framework.d.ts +0 -112
  280. package/dist/security/security-testing-framework.d.ts.map +0 -1
  281. package/dist/security/security-testing-framework.js +0 -364
  282. package/dist/security/security-testing-framework.js.map +0 -1
  283. package/dist/security/simulation/attackSimulation.d.ts +0 -93
  284. package/dist/security/simulation/attackSimulation.d.ts.map +0 -1
  285. package/dist/security/simulation/attackSimulation.js +0 -341
  286. package/dist/security/simulation/attackSimulation.js.map +0 -1
  287. package/dist/security/strategic-operations.d.ts +0 -100
  288. package/dist/security/strategic-operations.d.ts.map +0 -1
  289. package/dist/security/strategic-operations.js +0 -276
  290. package/dist/security/strategic-operations.js.map +0 -1
  291. package/dist/security/tool-security-wrapper.d.ts +0 -58
  292. package/dist/security/tool-security-wrapper.d.ts.map +0 -1
  293. package/dist/security/tool-security-wrapper.js +0 -156
  294. package/dist/security/tool-security-wrapper.js.map +0 -1
  295. package/dist/shell/claudeCodeStreamHandler.d.ts +0 -145
  296. package/dist/shell/claudeCodeStreamHandler.d.ts.map +0 -1
  297. package/dist/shell/claudeCodeStreamHandler.js +0 -322
  298. package/dist/shell/claudeCodeStreamHandler.js.map +0 -1
  299. package/dist/shell/inputQueueManager.d.ts +0 -144
  300. package/dist/shell/inputQueueManager.d.ts.map +0 -1
  301. package/dist/shell/inputQueueManager.js +0 -290
  302. package/dist/shell/inputQueueManager.js.map +0 -1
  303. package/dist/shell/metricsTracker.d.ts +0 -60
  304. package/dist/shell/metricsTracker.d.ts.map +0 -1
  305. package/dist/shell/metricsTracker.js +0 -119
  306. package/dist/shell/metricsTracker.js.map +0 -1
  307. package/dist/shell/streamingOutputManager.d.ts +0 -115
  308. package/dist/shell/streamingOutputManager.d.ts.map +0 -1
  309. package/dist/shell/streamingOutputManager.js +0 -225
  310. package/dist/shell/streamingOutputManager.js.map +0 -1
  311. package/dist/tools/securityTools.d.ts +0 -22
  312. package/dist/tools/securityTools.d.ts.map +0 -1
  313. package/dist/tools/securityTools.js +0 -448
  314. package/dist/tools/securityTools.js.map +0 -1
  315. package/dist/ui/persistentPrompt.d.ts +0 -50
  316. package/dist/ui/persistentPrompt.d.ts.map +0 -1
  317. package/dist/ui/persistentPrompt.js +0 -92
  318. package/dist/ui/persistentPrompt.js.map +0 -1
  319. package/dist/ui/terminalUISchema.d.ts +0 -195
  320. package/dist/ui/terminalUISchema.d.ts.map +0 -1
  321. package/dist/ui/terminalUISchema.js +0 -113
  322. package/dist/ui/terminalUISchema.js.map +0 -1
  323. 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
@@ -22,9 +24,6 @@ const ESC = {
22
24
  SHOW: '\x1b[?25h',
23
25
  TO: (row, col) => `\x1b[${row};${col}H`,
24
26
  TO_COL: (col) => `\x1b[${col}G`,
25
- // Screen control
26
- CLEAR_SCREEN: '\x1b[2J',
27
- HOME: '\x1b[H',
28
27
  // Line control
29
28
  CLEAR_LINE: '\x1b[2K',
30
29
  CLEAR_TO_END: '\x1b[0J',
@@ -70,6 +69,11 @@ export class TerminalInput extends EventEmitter {
70
69
  statusMessage = null;
71
70
  overrideStatusMessage = null; // Secondary status (warnings, etc.)
72
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
73
77
  reservedLines = 2;
74
78
  scrollRegionActive = false;
75
79
  lastRenderContent = '';
@@ -77,51 +81,37 @@ export class TerminalInput extends EventEmitter {
77
81
  renderDirty = false;
78
82
  isRendering = false;
79
83
  pinnedTopRows = 0;
80
- inlineAnchorRow = null;
81
- inlineLayout = false;
82
- anchorProvider = null;
83
- // Flow mode: when true, renders inline after content (no absolute positioning)
84
- flowMode = true;
85
- flowModeRenderedLines = 0; // Track lines rendered for clearing
86
- contentEndRow = 0; // Row where content ends (for idle mode positioning)
87
- // Command suggestions (Claude Code style auto-complete)
88
- commandSuggestions = [];
89
- filteredSuggestions = [];
90
- selectedSuggestionIndex = 0;
91
- showSuggestions = false;
92
- maxVisibleSuggestions = 10;
93
84
  // Lifecycle
94
85
  disposed = false;
95
86
  enabled = true;
96
87
  contextUsage = null;
88
+ contextAutoCompactThreshold = 90;
89
+ // Track current content row in scroll region (starts at top, moves down)
90
+ contentRow = 1;
91
+ thinkingModeLabel = null;
97
92
  editMode = 'display-edits';
98
93
  verificationEnabled = true;
99
94
  autoContinueEnabled = false;
100
95
  verificationHotkey = 'alt+v';
101
96
  autoContinueHotkey = 'alt+c';
97
+ thinkingHotkey = '/thinking';
98
+ modelLabel = null;
99
+ providerLabel = null;
102
100
  // Output interceptor cleanup
103
101
  outputInterceptorCleanup;
104
- // Metrics tracking for status bar
105
- streamingStartTime = null;
106
- tokensUsed = 0;
107
- thinkingEnabled = true;
108
- modelInfo = null; // Provider · Model info
109
- // Streaming input area render timer (updates elapsed time display)
102
+ // Streaming render throttle
103
+ lastStreamingRender = 0;
104
+ streamingRenderInterval = 250; // ms between renders during streaming
110
105
  streamingRenderTimer = null;
111
- // Banner renderer callback - called when entering streaming mode to re-render banner
112
- // inside the scroll region (unified UI system)
113
- bannerRenderer = null;
114
- unifiedUIInitialized = false;
115
106
  constructor(writeStream = process.stdout, config = {}) {
116
107
  super();
117
108
  this.out = writeStream;
118
- // Use schema defaults for configuration consistency
119
109
  this.config = {
120
- maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
121
- maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
110
+ maxLines: config.maxLines ?? 1000,
111
+ maxLength: config.maxLength ?? 10000,
122
112
  maxQueueSize: config.maxQueueSize ?? 100,
123
- promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
124
- continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
113
+ promptChar: config.promptChar ?? '> ',
114
+ continuationChar: config.continuationChar ?? '│ ',
125
115
  };
126
116
  }
127
117
  // ===========================================================================
@@ -200,11 +190,6 @@ export class TerminalInput extends EventEmitter {
200
190
  if (handled)
201
191
  return;
202
192
  }
203
- // Handle '?' for help hint (if buffer is empty)
204
- if (str === '?' && this.buffer.length === 0) {
205
- this.emit('showHelp');
206
- return;
207
- }
208
193
  // Insert printable characters
209
194
  if (str && !key?.ctrl && !key?.meta) {
210
195
  this.insertText(str);
@@ -213,525 +198,38 @@ export class TerminalInput extends EventEmitter {
213
198
  /**
214
199
  * Set the input mode
215
200
  *
216
- * Streaming mode disables scroll region and lets content flow naturally.
217
- * The input area will be re-rendered after streaming ends at wherever
218
- * the cursor is (below the streamed content).
201
+ * Streaming keeps the scroll region active so the prompt/status stay pinned
202
+ * below the streaming output. When streaming ends, we refresh the input area.
219
203
  */
220
204
  setMode(mode) {
221
205
  const prevMode = this.mode;
222
206
  this.mode = mode;
223
207
  if (mode === 'streaming' && prevMode !== 'streaming') {
224
- // Track streaming start time for elapsed display
225
- this.streamingStartTime = Date.now();
226
- const { rows } = this.getSize();
227
- // Set up scroll region to reserve bottom for persistent input area
228
- this.pinnedTopRows = 0;
229
- this.reservedLines = 6; // status + model + divider + input + divider + controls
230
- // UNIFIED UI INITIALIZATION: On first streaming transition, clear screen
231
- // and re-render banner inside the scroll region for a consistent layout
232
- if (!this.unifiedUIInitialized && this.bannerRenderer) {
233
- // Hide cursor during screen redraw
234
- this.write(ESC.HIDE);
235
- // Clear screen and move to home position
236
- this.write(ESC.HOME);
237
- this.write(ESC.CLEAR_SCREEN);
238
- // Set up scroll region FIRST (reserve bottom for input area)
239
- this.enableScrollRegion();
240
- // Move to top of content area (row 1 inside scroll region)
241
- this.write(ESC.TO(1, 1));
242
- // Re-render banner inside the scroll region
243
- const bannerLines = this.bannerRenderer();
244
- // Position cursor just after banner for content flow
245
- const contentStartRow = Math.max(1, bannerLines + 1);
246
- this.write(ESC.TO(contentStartRow, 1));
247
- // Mark unified UI as initialized
248
- this.unifiedUIInitialized = true;
249
- // Initial render of bottom input area
250
- this.renderBottomInputArea();
251
- // Show cursor
252
- this.write(ESC.SHOW);
253
- }
254
- else {
255
- // Normal streaming transition (not first time)
256
- // CRITICAL: Position cursor in content area BEFORE enabling scroll region
257
- // Content area is rows 1 to (rows - reservedLines)
258
- // Move cursor to just after the banner (where content should appear)
259
- const contentBottomRow = Math.max(1, rows - this.reservedLines);
260
- this.write(ESC.TO(contentBottomRow, 1));
261
- // Enable scroll region: content scrolls above, bottom is reserved
262
- this.enableScrollRegion();
263
- // Initial render of bottom input area (will save cursor at content area position)
264
- this.renderBottomInputArea();
265
- }
266
- // Start timer to update bottom input area (updates elapsed time)
267
- this.streamingRenderTimer = setInterval(() => {
268
- if (this.mode === 'streaming') {
269
- this.updateStreamingStatus();
270
- this.renderBottomInputArea();
271
- }
272
- }, 1000);
208
+ // Keep scroll region active so status/prompt stay pinned while streaming
209
+ this.resetStreamingRenderThrottle();
210
+ this.enableScrollRegion();
273
211
  this.renderDirty = true;
212
+ this.render();
274
213
  }
275
214
  else if (mode !== 'streaming' && prevMode === 'streaming') {
276
- // Stop streaming render timer
277
- if (this.streamingRenderTimer) {
278
- clearInterval(this.streamingRenderTimer);
279
- this.streamingRenderTimer = null;
280
- }
281
- // Reset streaming time
282
- this.streamingStartTime = null;
283
- // Keep scroll region active for consistent bottom-pinned UI
284
- // (scroll region reserves bottom for input area in all modes)
285
- // Reset flow mode tracking
286
- this.flowModeRenderedLines = 0;
287
- // Render using unified bottom input area (same layout as streaming)
288
- writeLock.withLock(() => {
289
- this.renderBottomInputArea();
290
- }, 'terminalInput.streamingEnd');
291
- }
292
- }
293
- /**
294
- * Update streaming status label (called by timer)
295
- */
296
- updateStreamingStatus() {
297
- if (this.mode !== 'streaming' || !this.streamingStartTime)
298
- return;
299
- // Calculate elapsed time
300
- const elapsed = Date.now() - this.streamingStartTime;
301
- const seconds = Math.floor(elapsed / 1000);
302
- const minutes = Math.floor(seconds / 60);
303
- const secs = seconds % 60;
304
- // Format elapsed time
305
- let elapsedStr;
306
- if (minutes > 0) {
307
- elapsedStr = `${minutes}m ${secs}s`;
308
- }
309
- else {
310
- elapsedStr = `${secs}s`;
311
- }
312
- // Update streaming label
313
- this.streamingLabel = `Streaming ${elapsedStr}`;
314
- }
315
- /**
316
- * Render input area - unified for streaming and normal modes.
317
- *
318
- * In streaming mode: renders at absolute bottom, uses cursor save/restore
319
- * In normal mode: renders right after the banner (pinnedTopRows + 1)
320
- */
321
- renderPinnedInputArea() {
322
- const { rows, cols } = this.getSize();
323
- const maxWidth = Math.max(8, cols - 4);
324
- const divider = renderDivider(cols - 2);
325
- const isStreaming = this.mode === 'streaming';
326
- // Wrap buffer into display lines (multi-line support)
327
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
328
- const availableForContent = Math.max(1, rows - 5); // Reserve 5 lines for UI
329
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
330
- const displayLines = Math.min(lines.length, maxVisible);
331
- // Calculate display window (keep cursor visible)
332
- let startLine = 0;
333
- if (lines.length > displayLines) {
334
- startLine = Math.max(0, cursorLine - displayLines + 1);
335
- startLine = Math.min(startLine, lines.length - displayLines);
336
- }
337
- const visibleLines = lines.slice(startLine, startLine + displayLines);
338
- const adjustedCursorLine = cursorLine - startLine;
339
- // Calculate total height: status + (model info if set) + topDiv + input lines + bottomDiv + controls
340
- const hasModelInfo = !!this.modelInfo;
341
- const totalHeight = 4 + visibleLines.length + (hasModelInfo ? 1 : 0);
342
- // Save cursor position during streaming (so content flow resumes correctly)
343
- if (isStreaming) {
344
- this.write(ESC.SAVE);
345
- }
346
- this.write(ESC.HIDE);
347
- this.write(ESC.RESET);
348
- // Calculate start row based on mode:
349
- // - Streaming: absolute bottom (rows - totalHeight + 1)
350
- // - Normal: right after content (contentEndRow + 1)
351
- let currentRow;
352
- if (isStreaming) {
353
- currentRow = Math.max(1, rows - totalHeight + 1);
354
- }
355
- else {
356
- // In normal mode, render right after content
357
- // Use contentEndRow if set, otherwise use pinnedTopRows
358
- const contentRow = this.contentEndRow > 0 ? this.contentEndRow : this.pinnedTopRows;
359
- currentRow = Math.max(1, contentRow + 1);
360
- }
361
- let finalRow = currentRow;
362
- let finalCol = 3;
363
- // Clear from current position to end of screen to remove any "ghost" content
364
- this.write(ESC.TO(currentRow, 1));
365
- this.write(ESC.CLEAR_TO_END);
366
- // Status bar
367
- this.write(ESC.TO(currentRow, 1));
368
- this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
369
- currentRow++;
370
- // Model info line (if set) - displayed below status, above input
371
- if (hasModelInfo) {
372
- const { dim: DIM, reset: R } = UI_COLORS;
373
- this.write(ESC.TO(currentRow, 1));
374
- // Build model info with context usage
375
- let modelLine = `${DIM}${this.modelInfo}${R}`;
376
- if (this.contextUsage !== null) {
377
- const rem = Math.max(0, 100 - this.contextUsage);
378
- if (rem < 10)
379
- modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
380
- else if (rem < 25)
381
- modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
382
- else
383
- modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
384
- }
385
- this.write(modelLine);
386
- currentRow++;
387
- }
388
- // Top divider
389
- this.write(ESC.TO(currentRow, 1));
390
- this.write(divider);
391
- currentRow++;
392
- // Input lines with background styling
393
- for (let i = 0; i < visibleLines.length; i++) {
394
- this.write(ESC.TO(currentRow, 1));
395
- const line = visibleLines[i] ?? '';
396
- const absoluteLineIdx = startLine + i;
397
- const isFirstLine = absoluteLineIdx === 0;
398
- const isCursorLine = i === adjustedCursorLine;
399
- // Background
400
- this.write(ESC.BG_DARK);
401
- // Prompt prefix
402
- this.write(ESC.DIM);
403
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
404
- this.write(ESC.RESET);
405
- this.write(ESC.BG_DARK);
406
- if (isCursorLine) {
407
- const col = Math.min(cursorCol, line.length);
408
- const before = line.slice(0, col);
409
- const at = col < line.length ? line[col] : ' ';
410
- const after = col < line.length ? line.slice(col + 1) : '';
411
- this.write(before);
412
- this.write(ESC.REVERSE + ESC.BOLD);
413
- this.write(at);
414
- this.write(ESC.RESET + ESC.BG_DARK);
415
- this.write(after);
416
- finalRow = currentRow;
417
- finalCol = this.config.promptChar.length + col + 1;
418
- }
419
- else {
420
- this.write(line);
421
- }
422
- // Pad to edge
423
- const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
424
- const padding = Math.max(0, cols - lineLen - 1);
425
- if (padding > 0)
426
- this.write(' '.repeat(padding));
427
- this.write(ESC.RESET);
428
- currentRow++;
429
- }
430
- // Bottom divider
431
- this.write(ESC.TO(currentRow, 1));
432
- this.write(divider);
433
- currentRow++;
434
- // Mode controls line
435
- this.write(ESC.TO(currentRow, 1));
436
- this.write(this.buildModeControls(cols));
437
- // Restore cursor position during streaming, or show cursor in normal mode
438
- if (isStreaming) {
439
- this.write(ESC.RESTORE);
440
- }
441
- else {
442
- // Position cursor in input area
443
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
444
- this.write(ESC.SHOW);
445
- }
446
- // Update reserved lines for scroll region calculations
447
- this.updateReservedLines(totalHeight);
448
- }
449
- /**
450
- * Render input area during streaming (alias for unified method)
451
- */
452
- renderStreamingInputArea() {
453
- this.renderPinnedInputArea();
454
- }
455
- /**
456
- * Render bottom input area - UNIFIED for all modes.
457
- * Uses cursor save/restore to update bottom without affecting content flow.
458
- *
459
- * Layout (same for idle/streaming/ready):
460
- * - Status bar (streaming timer or "Type a message")
461
- * - Model info line (provider · model · ctx)
462
- * - Divider
463
- * - Input area
464
- * - Divider
465
- * - Mode controls
466
- */
467
- renderBottomInputArea() {
468
- const { rows, cols } = this.getSize();
469
- const maxWidth = Math.max(8, cols - 4);
470
- const divider = renderDivider(cols - 2);
471
- const { dim: DIM, reset: R } = UI_COLORS;
472
- const isStreaming = this.mode === 'streaming';
473
- // Wrap buffer into display lines
474
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
475
- // Allow multi-line in non-streaming, single line during streaming
476
- const maxDisplayLines = isStreaming ? 1 : 3;
477
- const displayLines = Math.min(lines.length, maxDisplayLines);
478
- const visibleLines = lines.slice(0, displayLines);
479
- // Calculate total height for bottom area
480
- const hasModelInfo = !!this.modelInfo;
481
- const totalHeight = 4 + displayLines + (hasModelInfo ? 1 : 0);
482
- // Ensure scroll region is always enabled (unified behavior)
483
- if (!this.scrollRegionActive || this.reservedLines !== totalHeight) {
484
- this.reservedLines = totalHeight;
215
+ // Streaming ended - render the input area
216
+ this.resetStreamingRenderThrottle();
485
217
  this.enableScrollRegion();
218
+ this.forceRender();
486
219
  }
487
- const startRow = Math.max(1, rows - totalHeight + 1);
488
- // Save cursor, hide it
489
- this.write(ESC.SAVE);
490
- this.write(ESC.HIDE);
491
- let currentRow = startRow;
492
- // Clear the bottom reserved area
493
- for (let r = startRow; r <= rows; r++) {
494
- this.write(ESC.TO(r, 1));
495
- this.write(ESC.CLEAR_LINE);
496
- }
497
- // Status bar - UNIFIED: same format for all modes
498
- this.write(ESC.TO(currentRow, 1));
499
- this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
500
- currentRow++;
501
- // Model info line (if set)
502
- if (hasModelInfo) {
503
- this.write(ESC.TO(currentRow, 1));
504
- let modelLine = `${DIM}${this.modelInfo}${R}`;
505
- if (this.contextUsage !== null) {
506
- const rem = Math.max(0, 100 - this.contextUsage);
507
- if (rem < 10)
508
- modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
509
- else if (rem < 25)
510
- modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
511
- else
512
- modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
513
- }
514
- this.write(modelLine);
515
- currentRow++;
516
- }
517
- // Top divider
518
- this.write(ESC.TO(currentRow, 1));
519
- this.write(divider);
520
- currentRow++;
521
- // Input lines with background styling
522
- for (let i = 0; i < visibleLines.length; i++) {
523
- this.write(ESC.TO(currentRow, 1));
524
- const line = visibleLines[i] ?? '';
525
- const isFirstLine = i === 0;
526
- this.write(ESC.BG_DARK);
527
- this.write(ESC.DIM);
528
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
529
- this.write(ESC.RESET);
530
- this.write(ESC.BG_DARK);
531
- this.write(line);
532
- // Pad to edge
533
- const lineLen = this.config.promptChar.length + line.length;
534
- const padding = Math.max(0, cols - lineLen - 1);
535
- if (padding > 0)
536
- this.write(' '.repeat(padding));
537
- this.write(ESC.RESET);
538
- currentRow++;
539
- }
540
- // Bottom divider
541
- this.write(ESC.TO(currentRow, 1));
542
- this.write(divider);
543
- currentRow++;
544
- // Mode controls
545
- this.write(ESC.TO(currentRow, 1));
546
- this.write(this.buildModeControls(cols));
547
- // Cursor positioning depends on mode:
548
- // - Streaming: restore to content area (where streaming output continues)
549
- // - Normal: position in input area for typing
550
- if (isStreaming) {
551
- this.write(ESC.RESTORE);
552
- }
553
- else {
554
- // Position cursor in input area
555
- // Input line is at: startRow + (hasModelInfo ? 2 : 1) + cursorLine
556
- const inputStartRow = startRow + (hasModelInfo ? 2 : 1) + 1; // +1 for status bar, +1 for divider
557
- const targetRow = inputStartRow + Math.min(cursorLine, displayLines - 1);
558
- const targetCol = this.config.promptChar.length + cursorCol + 1;
559
- this.write(ESC.TO(targetRow, Math.min(targetCol, cols)));
560
- }
561
- this.write(ESC.SHOW);
562
- // Track last render state
563
- this.lastRenderContent = this.buffer;
564
- this.lastRenderCursor = this.cursor;
565
- }
566
- /**
567
- * Enable or disable flow mode.
568
- * In flow mode, the input renders immediately after content (wherever cursor is).
569
- * When disabled, input renders at the absolute bottom of terminal.
570
- */
571
- setFlowMode(enabled) {
572
- if (this.flowMode === enabled)
573
- return;
574
- this.flowMode = enabled;
575
- this.renderDirty = true;
576
- this.scheduleRender();
577
- }
578
- /**
579
- * Check if flow mode is enabled.
580
- */
581
- isFlowMode() {
582
- return this.flowMode;
583
- }
584
- /**
585
- * Set the row where content ends (for idle mode positioning).
586
- * Input area will render starting from this row + 1.
587
- */
588
- setContentEndRow(row) {
589
- this.contentEndRow = Math.max(0, row);
590
- this.renderDirty = true;
591
- this.scheduleRender();
592
- }
593
- /**
594
- * Set available slash commands for auto-complete suggestions.
595
- */
596
- setCommands(commands) {
597
- this.commandSuggestions = commands;
598
- this.updateSuggestions();
599
- }
600
- /**
601
- * Update filtered suggestions based on current input.
602
- */
603
- updateSuggestions() {
604
- const input = this.buffer.trim();
605
- // Only show suggestions when input starts with "/"
606
- if (!input.startsWith('/')) {
607
- this.showSuggestions = false;
608
- this.filteredSuggestions = [];
609
- this.selectedSuggestionIndex = 0;
610
- return;
611
- }
612
- const query = input.toLowerCase();
613
- this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
614
- cmd.command.toLowerCase().includes(query.slice(1)));
615
- // Show suggestions if we have matches
616
- this.showSuggestions = this.filteredSuggestions.length > 0;
617
- // Keep selection in bounds
618
- if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
619
- this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
620
- }
621
- }
622
- /**
623
- * Select next suggestion (arrow down / tab).
624
- */
625
- selectNextSuggestion() {
626
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
627
- return;
628
- this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
629
- this.renderDirty = true;
630
- this.scheduleRender();
631
- }
632
- /**
633
- * Select previous suggestion (arrow up / shift+tab).
634
- */
635
- selectPrevSuggestion() {
636
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
637
- return;
638
- this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
639
- ? this.filteredSuggestions.length - 1
640
- : this.selectedSuggestionIndex - 1;
641
- this.renderDirty = true;
642
- this.scheduleRender();
643
- }
644
- /**
645
- * Accept current suggestion and insert into buffer.
646
- */
647
- acceptSuggestion() {
648
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
649
- return false;
650
- const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
651
- if (!selected)
652
- return false;
653
- // Replace buffer with selected command
654
- this.buffer = selected.command + ' ';
655
- this.cursor = this.buffer.length;
656
- this.showSuggestions = false;
657
- this.renderDirty = true;
658
- this.scheduleRender();
659
- return true;
660
- }
661
- /**
662
- * Check if suggestions are visible.
663
- */
664
- areSuggestionsVisible() {
665
- return this.showSuggestions && this.filteredSuggestions.length > 0;
666
- }
667
- /**
668
- * Update token count for metrics display
669
- */
670
- setTokensUsed(tokens) {
671
- this.tokensUsed = tokens;
672
- }
673
- /**
674
- * Toggle thinking/reasoning mode
675
- */
676
- toggleThinking() {
677
- this.thinkingEnabled = !this.thinkingEnabled;
678
- this.emit('thinkingToggle', this.thinkingEnabled);
679
- this.scheduleRender();
680
- }
681
- /**
682
- * Get thinking enabled state
683
- */
684
- isThinkingEnabled() {
685
- return this.thinkingEnabled;
686
220
  }
687
221
  /**
688
222
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
689
223
  */
690
224
  setPinnedHeaderLines(count) {
691
- // Set pinned header rows (banner area that scroll region excludes)
692
- if (this.pinnedTopRows !== count) {
693
- this.pinnedTopRows = count;
225
+ // No pinned header rows anymore; keep everything in the scroll region.
226
+ if (this.pinnedTopRows !== 0) {
227
+ this.pinnedTopRows = 0;
694
228
  if (this.scrollRegionActive) {
695
229
  this.applyScrollRegion();
696
230
  }
697
231
  }
698
232
  }
699
- /**
700
- * Anchor prompt rendering near a specific row (inline layout). Pass null to
701
- * restore the default bottom-aligned layout.
702
- */
703
- setInlineAnchor(row) {
704
- if (row === null || row === undefined) {
705
- this.inlineAnchorRow = null;
706
- this.inlineLayout = false;
707
- this.renderDirty = true;
708
- this.render();
709
- return;
710
- }
711
- const { rows } = this.getSize();
712
- const clamped = Math.max(1, Math.min(Math.floor(row), rows));
713
- this.inlineAnchorRow = clamped;
714
- this.inlineLayout = true;
715
- this.renderDirty = true;
716
- this.render();
717
- }
718
- /**
719
- * Provide a dynamic anchor callback. When set, the prompt will follow the
720
- * output by re-evaluating the anchor before each render.
721
- */
722
- setInlineAnchorProvider(provider) {
723
- this.anchorProvider = provider;
724
- if (!provider) {
725
- this.inlineLayout = false;
726
- this.inlineAnchorRow = null;
727
- this.renderDirty = true;
728
- this.render();
729
- return;
730
- }
731
- this.inlineLayout = true;
732
- this.renderDirty = true;
733
- this.render();
734
- }
735
233
  /**
736
234
  * Get current mode
737
235
  */
@@ -841,6 +339,37 @@ export class TerminalInput extends EventEmitter {
841
339
  this.streamingLabel = next;
842
340
  this.scheduleRender();
843
341
  }
342
+ /**
343
+ * Surface meta status just above the divider (e.g., elapsed time or token usage).
344
+ */
345
+ setMetaStatus(meta) {
346
+ const nextElapsed = typeof meta.elapsedSeconds === 'number' && Number.isFinite(meta.elapsedSeconds) && meta.elapsedSeconds >= 0
347
+ ? Math.floor(meta.elapsedSeconds)
348
+ : null;
349
+ const nextTokens = typeof meta.tokensUsed === 'number' && Number.isFinite(meta.tokensUsed) && meta.tokensUsed >= 0
350
+ ? Math.floor(meta.tokensUsed)
351
+ : null;
352
+ const nextLimit = typeof meta.tokenLimit === 'number' && Number.isFinite(meta.tokenLimit) && meta.tokenLimit > 0
353
+ ? Math.floor(meta.tokenLimit)
354
+ : null;
355
+ const nextThinking = typeof meta.thinkingMs === 'number' && Number.isFinite(meta.thinkingMs) && meta.thinkingMs >= 0
356
+ ? Math.floor(meta.thinkingMs)
357
+ : null;
358
+ const nextThinkingHasContent = !!meta.thinkingHasContent;
359
+ if (this.metaElapsedSeconds === nextElapsed &&
360
+ this.metaTokensUsed === nextTokens &&
361
+ this.metaTokenLimit === nextLimit &&
362
+ this.metaThinkingMs === nextThinking &&
363
+ this.metaThinkingHasContent === nextThinkingHasContent) {
364
+ return;
365
+ }
366
+ this.metaElapsedSeconds = nextElapsed;
367
+ this.metaTokensUsed = nextTokens;
368
+ this.metaTokenLimit = nextLimit;
369
+ this.metaThinkingMs = nextThinking;
370
+ this.metaThinkingHasContent = nextThinkingHasContent;
371
+ this.scheduleRender();
372
+ }
844
373
  /**
845
374
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
846
375
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -850,26 +379,22 @@ export class TerminalInput extends EventEmitter {
850
379
  const nextAutoContinue = !!options.autoContinueEnabled;
851
380
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
852
381
  const nextAutoHotkey = options.autoContinueHotkey ?? this.autoContinueHotkey;
382
+ const nextThinkingHotkey = options.thinkingHotkey ?? this.thinkingHotkey;
383
+ const nextThinkingLabel = options.thinkingModeLabel === undefined ? this.thinkingModeLabel : (options.thinkingModeLabel || null);
853
384
  if (this.verificationEnabled === nextVerification &&
854
385
  this.autoContinueEnabled === nextAutoContinue &&
855
386
  this.verificationHotkey === nextVerifyHotkey &&
856
- this.autoContinueHotkey === nextAutoHotkey) {
387
+ this.autoContinueHotkey === nextAutoHotkey &&
388
+ this.thinkingHotkey === nextThinkingHotkey &&
389
+ this.thinkingModeLabel === nextThinkingLabel) {
857
390
  return;
858
391
  }
859
392
  this.verificationEnabled = nextVerification;
860
393
  this.autoContinueEnabled = nextAutoContinue;
861
394
  this.verificationHotkey = nextVerifyHotkey;
862
395
  this.autoContinueHotkey = nextAutoHotkey;
863
- this.scheduleRender();
864
- }
865
- /**
866
- * Set the model info string (e.g., "OpenAI · gpt-4")
867
- * This is displayed persistently above the input area.
868
- */
869
- setModelInfo(info) {
870
- if (this.modelInfo === info)
871
- return;
872
- this.modelInfo = info;
396
+ this.thinkingHotkey = nextThinkingHotkey;
397
+ this.thinkingModeLabel = nextThinkingLabel;
873
398
  this.scheduleRender();
874
399
  }
875
400
  /**
@@ -882,298 +407,390 @@ export class TerminalInput extends EventEmitter {
882
407
  this.scheduleRender();
883
408
  }
884
409
  /**
885
- * Render the input area - UNIFIED for all modes
410
+ * Surface model/provider context in the controls bar.
411
+ */
412
+ setModelContext(options) {
413
+ const nextModel = options.model?.trim() || null;
414
+ const nextProvider = options.provider?.trim() || null;
415
+ if (this.modelLabel === nextModel && this.providerLabel === nextProvider) {
416
+ return;
417
+ }
418
+ this.modelLabel = nextModel;
419
+ this.providerLabel = nextProvider;
420
+ this.scheduleRender();
421
+ }
422
+ /**
423
+ * Render the input area - Claude Code style with mode controls
886
424
  *
887
- * Uses the same bottom-pinned layout with scroll regions for:
888
- * - Idle mode: Shows "Type a message" hint
889
- * - Streaming mode: Shows "● Streaming Xs" timer
890
- * - Ready mode: Shows status info
425
+ * During streaming we keep the scroll region active and repaint only the
426
+ * pinned status/input block (throttled) so streamed content can scroll
427
+ * naturally above while elapsed time and status stay fresh.
891
428
  */
892
429
  render() {
893
430
  if (!this.canRender())
894
431
  return;
895
432
  if (this.isRendering)
896
433
  return;
434
+ const streamingActive = this.mode === 'streaming' || isStreamingMode();
435
+ // During streaming we still render the pinned input/status region, but throttle
436
+ // to avoid fighting with the streamed content flow.
437
+ if (streamingActive && this.lastStreamingRender > 0) {
438
+ const elapsed = Date.now() - this.lastStreamingRender;
439
+ const waitMs = Math.max(0, this.streamingRenderInterval - elapsed);
440
+ if (waitMs > 0) {
441
+ this.renderDirty = true;
442
+ this.scheduleStreamingRender(waitMs);
443
+ return;
444
+ }
445
+ }
897
446
  const shouldSkip = !this.renderDirty &&
898
447
  this.buffer === this.lastRenderContent &&
899
448
  this.cursor === this.lastRenderCursor;
900
449
  this.renderDirty = false;
901
- // Skip if nothing changed (unless explicitly forced)
450
+ // Skip if nothing changed and no explicit refresh requested
902
451
  if (shouldSkip) {
903
452
  return;
904
453
  }
905
- // If write lock is held, defer render
454
+ // If write lock is held, defer render to avoid race conditions
906
455
  if (writeLock.isLocked()) {
907
456
  writeLock.safeWrite(() => this.render());
908
457
  return;
909
458
  }
910
- this.isRendering = true;
911
- writeLock.lock('terminalInput.render');
912
- try {
913
- // UNIFIED: Use the same bottom input area for all modes
914
- this.renderBottomInputArea();
915
- }
916
- finally {
917
- writeLock.unlock();
918
- this.isRendering = false;
919
- }
920
- }
921
- /**
922
- * Render in flow mode - delegates to bottom-pinned for stability.
923
- *
924
- * Flow mode attempted inline rendering but caused duplicate renders
925
- * due to unreliable cursor position tracking. Bottom-pinned is reliable.
926
- */
927
- renderFlowMode() {
928
- // Use stable bottom-pinned approach
929
- this.renderBottomPinned();
930
- }
931
- /**
932
- * Render in bottom-pinned mode - Claude Code style with suggestions
933
- *
934
- * Works for both normal and streaming modes:
935
- * - During streaming: saves/restores cursor position
936
- * - Status bar shows streaming info or "Type a message"
937
- *
938
- * Layout when suggestions visible:
939
- * - Top divider
940
- * - Input line(s)
941
- * - Bottom divider
942
- * - Suggestions (command list)
943
- *
944
- * Layout when suggestions hidden:
945
- * - Status bar (Ready/Streaming)
946
- * - Top divider
947
- * - Input line(s)
948
- * - Bottom divider
949
- * - Mode controls
950
- */
951
- renderBottomPinned() {
952
- const { rows, cols } = this.getSize();
953
- const maxWidth = Math.max(8, cols - 4);
954
- const isStreaming = this.mode === 'streaming';
955
- // Use unified pinned input area (works for both streaming and normal)
956
- // Only use complex rendering when suggestions are visible
957
- const hasSuggestions = !isStreaming && this.showSuggestions && this.filteredSuggestions.length > 0;
958
- if (!hasSuggestions) {
959
- this.renderPinnedInputArea();
960
- return;
961
- }
962
- // Wrap buffer into display lines
963
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
964
- const availableForContent = Math.max(1, rows - 3);
965
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
966
- const displayLines = Math.min(lines.length, maxVisible);
967
- // Calculate display window (keep cursor visible)
968
- let startLine = 0;
969
- if (lines.length > displayLines) {
970
- startLine = Math.max(0, cursorLine - displayLines + 1);
971
- startLine = Math.min(startLine, lines.length - displayLines);
972
- }
973
- const visibleLines = lines.slice(startLine, startLine + displayLines);
974
- const adjustedCursorLine = cursorLine - startLine;
975
- // Calculate suggestion display (not during streaming)
976
- const suggestionsToShow = (!isStreaming && this.showSuggestions)
977
- ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
978
- : [];
979
- const suggestionLines = suggestionsToShow.length;
980
- this.write(ESC.HIDE);
981
- this.write(ESC.RESET);
982
- const divider = renderDivider(cols - 2);
983
- // Calculate positions from absolute bottom
984
- let currentRow;
985
- if (suggestionLines > 0) {
986
- // With suggestions: input area + dividers + suggestions
987
- // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
988
- const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
989
- currentRow = Math.max(1, rows - totalHeight + 1);
990
- this.updateReservedLines(totalHeight);
991
- // Clear from current position to end of screen to remove any "ghost" content
992
- this.write(ESC.TO(currentRow, 1));
993
- this.write(ESC.CLEAR_TO_END);
994
- // Top divider
459
+ const performRender = () => {
460
+ if (!this.scrollRegionActive) {
461
+ this.enableScrollRegion();
462
+ }
463
+ const { rows, cols } = this.getSize();
464
+ const maxWidth = Math.max(8, cols - 4);
465
+ // Wrap buffer into display lines
466
+ const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
467
+ const availableForContent = Math.max(1, rows - 3); // room for separator + input + controls
468
+ const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
469
+ const displayLines = Math.min(lines.length, maxVisible);
470
+ const metaLines = this.buildMetaLines(cols - 2);
471
+ // Reserved lines: optional meta lines + separator(1) + input lines + controls(1)
472
+ this.updateReservedLines(displayLines + 2 + metaLines.length);
473
+ // Calculate display window (keep cursor visible)
474
+ let startLine = 0;
475
+ if (lines.length > displayLines) {
476
+ startLine = Math.max(0, cursorLine - displayLines + 1);
477
+ startLine = Math.min(startLine, lines.length - displayLines);
478
+ }
479
+ const visibleLines = lines.slice(startLine, startLine + displayLines);
480
+ const adjustedCursorLine = cursorLine - startLine;
481
+ // Hide cursor during render to prevent flicker
482
+ this.write(ESC.HIDE);
483
+ this.write(ESC.RESET);
484
+ const startRow = Math.max(1, rows - this.reservedLines + 1);
485
+ let currentRow = startRow;
486
+ // Clear the reserved block to avoid stale meta/status lines
487
+ this.clearReservedArea(startRow, this.reservedLines, cols);
488
+ // Meta/status header (elapsed, tokens/context)
489
+ for (const metaLine of metaLines) {
490
+ this.write(ESC.TO(currentRow, 1));
491
+ this.write(ESC.CLEAR_LINE);
492
+ this.write(metaLine);
493
+ currentRow += 1;
494
+ }
495
+ // Separator line
995
496
  this.write(ESC.TO(currentRow, 1));
497
+ this.write(ESC.CLEAR_LINE);
498
+ const divider = renderDivider(cols - 2);
996
499
  this.write(divider);
997
- currentRow++;
998
- // Input lines
500
+ currentRow += 1;
501
+ // Render input lines
999
502
  let finalRow = currentRow;
1000
503
  let finalCol = 3;
1001
504
  for (let i = 0; i < visibleLines.length; i++) {
1002
- this.write(ESC.TO(currentRow, 1));
505
+ const rowNum = currentRow + i;
506
+ this.write(ESC.TO(rowNum, 1));
507
+ this.write(ESC.CLEAR_LINE);
1003
508
  const line = visibleLines[i] ?? '';
1004
509
  const absoluteLineIdx = startLine + i;
1005
510
  const isFirstLine = absoluteLineIdx === 0;
1006
511
  const isCursorLine = i === adjustedCursorLine;
512
+ // Background
513
+ this.write(ESC.BG_DARK);
514
+ // Prompt prefix
515
+ this.write(ESC.DIM);
1007
516
  this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
517
+ this.write(ESC.RESET);
518
+ this.write(ESC.BG_DARK);
1008
519
  if (isCursorLine) {
520
+ // Render with block cursor
1009
521
  const col = Math.min(cursorCol, line.length);
1010
- this.write(line.slice(0, col));
1011
- this.write(ESC.REVERSE);
1012
- this.write(col < line.length ? line[col] : ' ');
1013
- this.write(ESC.RESET);
1014
- this.write(line.slice(col + 1));
1015
- finalRow = currentRow;
522
+ const before = line.slice(0, col);
523
+ const at = col < line.length ? line[col] : ' ';
524
+ const after = col < line.length ? line.slice(col + 1) : '';
525
+ this.write(before);
526
+ this.write(ESC.REVERSE + ESC.BOLD);
527
+ this.write(at);
528
+ this.write(ESC.RESET + ESC.BG_DARK);
529
+ this.write(after);
530
+ finalRow = rowNum;
1016
531
  finalCol = this.config.promptChar.length + col + 1;
1017
532
  }
1018
533
  else {
1019
534
  this.write(line);
1020
535
  }
1021
- currentRow++;
536
+ // Pad to edge for clean look
537
+ const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
538
+ const padding = Math.max(0, cols - lineLen - 1);
539
+ if (padding > 0)
540
+ this.write(' '.repeat(padding));
541
+ this.write(ESC.RESET);
1022
542
  }
1023
- // Bottom divider
1024
- this.write(ESC.TO(currentRow, 1));
1025
- this.write(divider);
1026
- currentRow++;
1027
- // Suggestions (Claude Code style)
1028
- for (let i = 0; i < suggestionsToShow.length; i++) {
1029
- this.write(ESC.TO(currentRow, 1));
1030
- const suggestion = suggestionsToShow[i];
1031
- const isSelected = i === this.selectedSuggestionIndex;
1032
- // Indent and highlight selected
1033
- this.write(' ');
1034
- if (isSelected) {
1035
- this.write(ESC.REVERSE);
1036
- this.write(ESC.BOLD);
1037
- }
1038
- this.write(suggestion.command);
1039
- if (isSelected) {
1040
- this.write(ESC.RESET);
1041
- }
1042
- // Description (dimmed)
1043
- const descSpace = cols - suggestion.command.length - 8;
1044
- if (descSpace > 10 && suggestion.description) {
1045
- const desc = suggestion.description.slice(0, descSpace);
1046
- this.write(ESC.RESET);
1047
- this.write(ESC.DIM);
1048
- this.write(' ');
1049
- this.write(desc);
1050
- this.write(ESC.RESET);
1051
- }
1052
- currentRow++;
1053
- }
1054
- // Position cursor in input area
543
+ // Mode controls line (Claude Code style)
544
+ const controlRow = currentRow + visibleLines.length;
545
+ this.write(ESC.TO(controlRow, 1));
546
+ this.write(ESC.CLEAR_LINE);
547
+ this.write(this.buildModeControls(cols));
548
+ // Position cursor in the input box for user editing
1055
549
  this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
550
+ this.write(ESC.SHOW);
551
+ // Update state
552
+ this.lastRenderContent = this.buffer;
553
+ this.lastRenderCursor = this.cursor;
554
+ this.lastStreamingRender = streamingActive ? Date.now() : 0;
555
+ if (this.streamingRenderTimer) {
556
+ clearTimeout(this.streamingRenderTimer);
557
+ this.streamingRenderTimer = null;
558
+ }
559
+ };
560
+ // Use write lock during render to prevent interleaved output
561
+ writeLock.lock('terminalInput.render');
562
+ this.isRendering = true;
563
+ try {
564
+ performRender();
565
+ }
566
+ finally {
567
+ writeLock.unlock();
568
+ this.isRendering = false;
1056
569
  }
1057
- this.write(ESC.SHOW);
1058
- // Update state
1059
- this.lastRenderContent = this.buffer;
1060
- this.lastRenderCursor = this.cursor;
1061
570
  }
1062
571
  /**
1063
- * Build status bar for streaming mode (shows elapsed time, queue count).
572
+ * Build one or more compact meta lines above the divider (thinking, status, usage).
573
+ * During streaming, shows model line pinned above streaming info.
1064
574
  */
1065
- buildStreamingStatusBar(cols) {
1066
- const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
1067
- // Streaming status with elapsed time
1068
- let elapsed = '0s';
1069
- if (this.streamingStartTime) {
1070
- const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1071
- const mins = Math.floor(secs / 60);
1072
- elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
1073
- }
1074
- let status = `${GREEN}● Streaming${R} ${elapsed}`;
1075
- // Queue indicator
575
+ buildMetaLines(width) {
576
+ const streamingActive = this.mode === 'streaming' || isStreamingMode();
577
+ const lines = [];
578
+ // Model line should ALWAYS be shown (pinned above streaming content)
579
+ if (this.modelLabel) {
580
+ const modelText = this.providerLabel
581
+ ? `model ${this.modelLabel} @ ${this.providerLabel}`
582
+ : `model ${this.modelLabel}`;
583
+ lines.push(renderStatusLine([{ text: modelText, tone: 'info' }], width));
584
+ }
585
+ // During streaming, add a compact status line with essential info
586
+ if (streamingActive) {
587
+ const parts = [];
588
+ // Essential streaming info
589
+ if (this.metaThinkingMs !== null) {
590
+ parts.push({ text: formatThinking(this.metaThinkingMs, this.metaThinkingHasContent), tone: 'info' });
591
+ }
592
+ if (this.metaElapsedSeconds !== null) {
593
+ parts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
594
+ }
595
+ parts.push({ text: 'esc to stop', tone: 'warn' });
596
+ if (parts.length) {
597
+ lines.push(renderStatusLine(parts, width));
598
+ }
599
+ return lines;
600
+ }
601
+ // Non-streaming: show full status info (model line already added above)
602
+ if (this.metaThinkingMs !== null) {
603
+ const thinkingText = formatThinking(this.metaThinkingMs, this.metaThinkingHasContent);
604
+ lines.push(renderStatusLine([{ text: thinkingText, tone: 'info' }], width));
605
+ }
606
+ const statusParts = [];
607
+ const statusLabel = this.statusMessage ?? this.streamingLabel;
608
+ if (statusLabel) {
609
+ statusParts.push({ text: statusLabel, tone: 'info' });
610
+ }
611
+ if (this.metaElapsedSeconds !== null) {
612
+ statusParts.push({ text: this.formatElapsedLabel(this.metaElapsedSeconds), tone: 'muted' });
613
+ }
614
+ const tokensRemaining = this.computeTokensRemaining();
615
+ if (tokensRemaining !== null) {
616
+ statusParts.push({ text: `↓ ${tokensRemaining}`, tone: 'muted' });
617
+ }
618
+ if (statusParts.length) {
619
+ lines.push(renderStatusLine(statusParts, width));
620
+ }
621
+ const usageParts = [];
622
+ if (this.metaTokensUsed !== null) {
623
+ const formattedUsed = this.formatTokenCount(this.metaTokensUsed);
624
+ const formattedLimit = this.metaTokenLimit ? `/${this.formatTokenCount(this.metaTokenLimit)}` : '';
625
+ usageParts.push({ text: `tokens ${formattedUsed}${formattedLimit}`, tone: 'muted' });
626
+ }
627
+ if (this.contextUsage !== null) {
628
+ const tone = this.contextUsage >= 75 ? 'warn' : 'muted';
629
+ const left = Math.max(0, 100 - this.contextUsage);
630
+ usageParts.push({ text: `context used ${this.contextUsage}% (${left}% left)`, tone });
631
+ }
1076
632
  if (this.queue.length > 0) {
1077
- status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
633
+ usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
1078
634
  }
1079
- // Hint for typing
1080
- status += ` ${DIM}· type to queue message${R}`;
1081
- return status;
635
+ if (usageParts.length) {
636
+ lines.push(renderStatusLine(usageParts, width));
637
+ }
638
+ return lines;
1082
639
  }
1083
640
  /**
1084
- * Build status bar showing streaming/ready status and key info.
1085
- * This is the TOP line above the input area - minimal Claude Code style.
641
+ * Clear the reserved bottom block (meta + divider + input + controls) before repainting.
1086
642
  */
1087
- buildStatusBar(cols) {
1088
- const maxWidth = cols - 2;
1089
- const parts = [];
1090
- // Streaming status with elapsed time (left side)
1091
- if (this.mode === 'streaming') {
1092
- let statusText = ' Streaming';
1093
- if (this.streamingStartTime) {
1094
- const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1095
- const mins = Math.floor(elapsed / 60);
1096
- const secs = elapsed % 60;
1097
- statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
1098
- }
1099
- parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
1100
- }
1101
- // Queue indicator during streaming
1102
- if (this.mode === 'streaming' && this.queue.length > 0) {
1103
- parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
643
+ clearReservedArea(startRow, reservedLines, cols) {
644
+ const width = Math.max(1, cols);
645
+ for (let i = 0; i < reservedLines; i++) {
646
+ const row = startRow + i;
647
+ this.write(ESC.TO(row, 1));
648
+ this.write(' '.repeat(width));
1104
649
  }
1105
- // Paste indicator
1106
- if (this.pastePlaceholders.length > 0) {
1107
- const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
1108
- parts.push(`\x1b[36m📋 ${totalLines}L\x1b[0m`);
650
+ }
651
+ /**
652
+ * Build Claude Code style mode controls line.
653
+ * Combines streaming label + override status + main status for simultaneous display.
654
+ */
655
+ buildModeControls(cols) {
656
+ const width = Math.max(8, cols - 2);
657
+ const leftParts = [];
658
+ const rightParts = [];
659
+ if (this.streamingLabel) {
660
+ leftParts.push({ text: `◐ ${this.streamingLabel}`, tone: 'info' });
1109
661
  }
1110
- // Override/warning status
1111
662
  if (this.overrideStatusMessage) {
1112
- parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
663
+ leftParts.push({ text: `⚠ ${this.overrideStatusMessage}`, tone: 'warn' });
664
+ }
665
+ if (this.statusMessage) {
666
+ leftParts.push({ text: this.statusMessage, tone: this.streamingLabel ? 'muted' : 'info' });
667
+ }
668
+ const editHotkey = this.formatHotkey('shift+tab');
669
+ const editLabel = this.editMode === 'display-edits' ? 'edits: accept' : 'edits: ask';
670
+ const editTone = this.editMode === 'display-edits' ? 'success' : 'muted';
671
+ leftParts.push({ text: `${editHotkey} ${editLabel}`, tone: editTone });
672
+ const verifyHotkey = this.formatHotkey(this.verificationHotkey);
673
+ const verifyLabel = this.verificationEnabled ? 'verify:on' : 'verify:off';
674
+ leftParts.push({ text: `${verifyHotkey} ${verifyLabel}`, tone: this.verificationEnabled ? 'success' : 'muted' });
675
+ const continueHotkey = this.formatHotkey(this.autoContinueHotkey);
676
+ const continueLabel = this.autoContinueEnabled ? 'auto:on' : 'auto:off';
677
+ leftParts.push({ text: `${continueHotkey} ${continueLabel}`, tone: this.autoContinueEnabled ? 'info' : 'muted' });
678
+ if (this.queue.length > 0 && this.mode !== 'streaming') {
679
+ leftParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
1113
680
  }
1114
- // If idle with empty buffer, show quick shortcuts
1115
- if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
1116
- return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
1117
- }
1118
- // Multi-line indicator
1119
681
  if (this.buffer.includes('\n')) {
1120
- parts.push(`${ESC.DIM}${this.buffer.split('\n').length}L${ESC.RESET}`);
682
+ const lineCount = this.buffer.split('\n').length;
683
+ leftParts.push({ text: `${lineCount} lines`, tone: 'muted' });
684
+ }
685
+ if (this.pastePlaceholders.length > 0) {
686
+ const latest = this.pastePlaceholders[this.pastePlaceholders.length - 1];
687
+ leftParts.push({
688
+ text: `paste #${latest.id} +${latest.lineCount} lines (⌫ to drop)`,
689
+ tone: 'info',
690
+ });
1121
691
  }
1122
- if (parts.length === 0) {
1123
- return ''; // Empty status bar when idle
692
+ const contextRemaining = this.computeContextRemaining();
693
+ if (this.thinkingModeLabel) {
694
+ const thinkingHotkey = this.formatHotkey(this.thinkingHotkey);
695
+ rightParts.push({ text: `${thinkingHotkey} thinking:${this.thinkingModeLabel}`, tone: 'info' });
696
+ }
697
+ // Show model in controls only when NOT streaming (during streaming it's in meta lines)
698
+ const streamingActive = this.mode === 'streaming' || isStreamingMode();
699
+ if (this.modelLabel && !streamingActive) {
700
+ const modelText = this.providerLabel ? `${this.modelLabel} @ ${this.providerLabel}` : this.modelLabel;
701
+ rightParts.push({ text: modelText, tone: 'muted' });
702
+ }
703
+ if (contextRemaining !== null) {
704
+ const tone = contextRemaining <= 10 ? 'warn' : 'muted';
705
+ const label = contextRemaining === 0 && this.contextUsage !== null
706
+ ? 'Context auto-compact imminent'
707
+ : `Context left until auto-compact: ${contextRemaining}%`;
708
+ rightParts.push({ text: label, tone });
709
+ }
710
+ if (!rightParts.length || width < 60) {
711
+ const merged = rightParts.length ? [...leftParts, ...rightParts] : leftParts;
712
+ return renderStatusLine(merged, width);
713
+ }
714
+ const leftWidth = Math.max(12, Math.floor(width * 0.6));
715
+ const rightWidth = Math.max(14, width - leftWidth - 1);
716
+ const leftText = renderStatusLine(leftParts, leftWidth);
717
+ const rightText = renderStatusLine(rightParts, rightWidth);
718
+ const spacing = Math.max(1, width - this.visibleLength(leftText) - this.visibleLength(rightText));
719
+ return `${leftText}${' '.repeat(spacing)}${rightText}`;
720
+ }
721
+ formatHotkey(hotkey) {
722
+ const normalized = hotkey.trim().toLowerCase();
723
+ if (!normalized)
724
+ return hotkey;
725
+ const parts = normalized.split('+').filter(Boolean);
726
+ const map = {
727
+ shift: '⇧',
728
+ sh: '⇧',
729
+ alt: '⌥',
730
+ option: '⌥',
731
+ opt: '⌥',
732
+ ctrl: '⌃',
733
+ control: '⌃',
734
+ cmd: '⌘',
735
+ meta: '⌘',
736
+ };
737
+ const formatted = parts
738
+ .map((part) => {
739
+ const symbol = map[part];
740
+ if (symbol)
741
+ return symbol;
742
+ return part.length === 1 ? part.toUpperCase() : part.toUpperCase();
743
+ })
744
+ .join('');
745
+ return formatted || hotkey;
746
+ }
747
+ computeContextRemaining() {
748
+ if (this.contextUsage === null) {
749
+ return null;
750
+ }
751
+ return Math.max(0, this.contextAutoCompactThreshold - this.contextUsage);
752
+ }
753
+ computeTokensRemaining() {
754
+ if (this.metaTokensUsed === null || this.metaTokenLimit === null) {
755
+ return null;
756
+ }
757
+ const remaining = Math.max(0, this.metaTokenLimit - this.metaTokensUsed);
758
+ return this.formatTokenCount(remaining);
759
+ }
760
+ formatElapsedLabel(seconds) {
761
+ if (seconds < 60) {
762
+ return `${seconds}s`;
763
+ }
764
+ const mins = Math.floor(seconds / 60);
765
+ const secs = seconds % 60;
766
+ return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`;
767
+ }
768
+ formatTokenCount(value) {
769
+ if (!Number.isFinite(value)) {
770
+ return `${value}`;
1124
771
  }
1125
- const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
1126
- return joined.slice(0, maxWidth);
772
+ if (value >= 1_000_000) {
773
+ return `${(value / 1_000_000).toFixed(1)}M`;
774
+ }
775
+ if (value >= 1_000) {
776
+ return `${(value / 1_000).toFixed(1)}k`;
777
+ }
778
+ return `${Math.round(value)}`;
779
+ }
780
+ visibleLength(value) {
781
+ const ansiPattern = /\u001B\[[0-?]*[ -/]*[@-~]/g;
782
+ return value.replace(ansiPattern, '').length;
1127
783
  }
1128
784
  /**
1129
- * Build mode controls line showing toggles and context info.
1130
- * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
1131
- *
1132
- * Layout: [toggles on left] ... [context info on right]
785
+ * Debug-only snapshot used by tests to assert rendered strings without
786
+ * needing a TTY. Not used by production code.
1133
787
  */
1134
- buildModeControls(cols) {
1135
- const maxWidth = cols - 2;
1136
- // Use schema-defined colors for consistency
1137
- const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
1138
- // Mode toggles with colors (following ModeControlsSchema)
1139
- const toggles = [];
1140
- // Edit mode (green=auto, yellow=ask) - per schema.editMode
1141
- if (this.editMode === 'display-edits') {
1142
- toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
1143
- }
1144
- else {
1145
- toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
1146
- }
1147
- // Thinking mode (cyan when on) - per schema.thinkingMode
1148
- toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
1149
- // Verification (green when on) - per schema.verificationMode
1150
- toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
1151
- // Auto-continue (magenta when on) - per schema.autoContinueMode
1152
- toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
1153
- const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
1154
- // Context usage with color - per schema.contextUsage thresholds
1155
- let rightPart = '';
1156
- if (this.contextUsage !== null) {
1157
- const rem = Math.max(0, 100 - this.contextUsage);
1158
- // Thresholds: critical < 10%, warning < 25%
1159
- if (rem < 10)
1160
- rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
1161
- else if (rem < 25)
1162
- rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
1163
- else
1164
- rightPart = `${DIM}ctx: ${rem}%${R}`;
1165
- }
1166
- // Calculate visible lengths (strip ANSI)
1167
- const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
1168
- const leftLen = strip(leftPart).length;
1169
- const rightLen = strip(rightPart).length;
1170
- if (leftLen + rightLen < maxWidth - 4) {
1171
- return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
1172
- }
1173
- if (rightLen > 0 && leftLen + 8 < maxWidth) {
1174
- return `${leftPart} ${rightPart}`;
1175
- }
1176
- return leftPart;
788
+ getDebugUiSnapshot(width) {
789
+ const cols = Math.max(8, width ?? this.getSize().cols);
790
+ return {
791
+ meta: this.buildMetaLines(cols - 2),
792
+ controls: this.buildModeControls(cols),
793
+ };
1177
794
  }
1178
795
  /**
1179
796
  * Force a re-render
@@ -1196,17 +813,19 @@ export class TerminalInput extends EventEmitter {
1196
813
  handleResize() {
1197
814
  this.lastRenderContent = '';
1198
815
  this.lastRenderCursor = -1;
816
+ this.resetStreamingRenderThrottle();
1199
817
  // Re-clamp pinned header rows to the new terminal height
1200
818
  this.setPinnedHeaderLines(this.pinnedTopRows);
819
+ if (this.scrollRegionActive) {
820
+ this.disableScrollRegion();
821
+ this.enableScrollRegion();
822
+ }
1201
823
  this.scheduleRender();
1202
824
  }
1203
825
  /**
1204
826
  * Register with display's output interceptor to position cursor correctly.
1205
827
  * When scroll region is active, output needs to go to the scroll region,
1206
828
  * not the protected bottom area where the input is rendered.
1207
- *
1208
- * NOTE: With scroll region properly set, content naturally stays within
1209
- * the region boundaries - no cursor manipulation needed per-write.
1210
829
  */
1211
830
  registerOutputInterceptor(display) {
1212
831
  if (this.outputInterceptorCleanup) {
@@ -1214,23 +833,58 @@ export class TerminalInput extends EventEmitter {
1214
833
  }
1215
834
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
1216
835
  beforeWrite: () => {
1217
- // Scroll region handles content containment automatically
1218
- // No per-write cursor manipulation needed
836
+ // Position cursor at current content row (starts at top, moves down).
837
+ // When contentRow reaches scrollBottom, terminal handles scrolling.
838
+ if (this.scrollRegionActive) {
839
+ const { rows } = this.getSize();
840
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
841
+ const targetRow = Math.min(this.contentRow, scrollBottom);
842
+ this.write(ESC.SAVE);
843
+ this.write(ESC.TO(targetRow, 1));
844
+ }
1219
845
  },
1220
846
  afterWrite: () => {
1221
- // No cursor manipulation needed
847
+ // Advance content row and restore cursor.
848
+ if (this.scrollRegionActive) {
849
+ const { rows } = this.getSize();
850
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
851
+ // Advance row (clamp at scrollBottom, terminal handles scrolling from there)
852
+ this.contentRow = Math.min(this.contentRow + 1, scrollBottom);
853
+ this.write(ESC.RESTORE);
854
+ }
1222
855
  },
1223
856
  });
1224
857
  }
1225
858
  /**
1226
- * Set the banner renderer callback for unified UI initialization.
1227
- * This callback is called when entering streaming mode for the first time,
1228
- * to re-render the banner inside the scroll region.
1229
- *
1230
- * @param renderer Function that renders the banner and returns the number of lines written
859
+ * Write content directly into the scroll region (for banner, user prompts, etc.).
860
+ * Content starts at top and flows down, then scrolls when bottom is reached.
1231
861
  */
1232
- setBannerRenderer(renderer) {
1233
- this.bannerRenderer = renderer;
862
+ writeToScrollRegion(content) {
863
+ if (!content)
864
+ return;
865
+ // Ensure scroll region is active
866
+ if (!this.scrollRegionActive) {
867
+ this.enableScrollRegion();
868
+ }
869
+ const { rows } = this.getSize();
870
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
871
+ const targetRow = Math.min(this.contentRow, scrollBottom);
872
+ // Write at current content position
873
+ this.write(ESC.SAVE);
874
+ this.write(ESC.TO(targetRow, 1));
875
+ this.write(content);
876
+ this.write(ESC.RESTORE);
877
+ // Advance contentRow by number of lines written
878
+ const lineCount = (content.match(/\n/g) || []).length + 1;
879
+ this.contentRow = Math.min(this.contentRow + lineCount, scrollBottom);
880
+ }
881
+ /**
882
+ * Reset content position to start of scroll region.
883
+ * Does NOT clear the terminal - content starts from current position.
884
+ */
885
+ resetContentPosition() {
886
+ const scrollTop = Math.max(1, this.pinnedTopRows + 1);
887
+ this.contentRow = scrollTop;
1234
888
  }
1235
889
  /**
1236
890
  * Dispose and clean up
@@ -1238,11 +892,6 @@ export class TerminalInput extends EventEmitter {
1238
892
  dispose() {
1239
893
  if (this.disposed)
1240
894
  return;
1241
- // Clean up streaming render timer
1242
- if (this.streamingRenderTimer) {
1243
- clearInterval(this.streamingRenderTimer);
1244
- this.streamingRenderTimer = null;
1245
- }
1246
895
  // Clean up output interceptor
1247
896
  if (this.outputInterceptorCleanup) {
1248
897
  this.outputInterceptorCleanup();
@@ -1250,6 +899,7 @@ export class TerminalInput extends EventEmitter {
1250
899
  }
1251
900
  this.disposed = true;
1252
901
  this.enabled = false;
902
+ this.resetStreamingRenderThrottle();
1253
903
  this.disableScrollRegion();
1254
904
  this.disableBracketedPaste();
1255
905
  this.buffer = '';
@@ -1355,22 +1005,7 @@ export class TerminalInput extends EventEmitter {
1355
1005
  this.toggleEditMode();
1356
1006
  return true;
1357
1007
  }
1358
- // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1359
- if (this.findPlaceholderAt(this.cursor)) {
1360
- this.togglePasteExpansion();
1361
- }
1362
- else {
1363
- this.toggleThinking();
1364
- }
1365
- return true;
1366
- case 'escape':
1367
- // Esc: interrupt if streaming, otherwise clear buffer
1368
- if (this.mode === 'streaming') {
1369
- this.emit('interrupt');
1370
- }
1371
- else if (this.buffer.length > 0) {
1372
- this.clear();
1373
- }
1008
+ this.insertText(' ');
1374
1009
  return true;
1375
1010
  }
1376
1011
  return false;
@@ -1388,7 +1023,6 @@ export class TerminalInput extends EventEmitter {
1388
1023
  this.insertPlainText(chunk, insertPos);
1389
1024
  this.cursor = insertPos + chunk.length;
1390
1025
  this.emit('change', this.buffer);
1391
- this.updateSuggestions();
1392
1026
  this.scheduleRender();
1393
1027
  }
1394
1028
  insertNewline() {
@@ -1413,7 +1047,6 @@ export class TerminalInput extends EventEmitter {
1413
1047
  this.cursor = Math.max(0, this.cursor - 1);
1414
1048
  }
1415
1049
  this.emit('change', this.buffer);
1416
- this.updateSuggestions();
1417
1050
  this.scheduleRender();
1418
1051
  }
1419
1052
  deleteForward() {
@@ -1663,7 +1296,9 @@ export class TerminalInput extends EventEmitter {
1663
1296
  if (available <= 0)
1664
1297
  return;
1665
1298
  const chunk = clean.slice(0, available);
1666
- if (isMultilinePaste(chunk)) {
1299
+ const isMultiline = isMultilinePaste(chunk);
1300
+ const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1301
+ if (isMultiline && !isShortMultiline) {
1667
1302
  this.insertPastePlaceholder(chunk);
1668
1303
  }
1669
1304
  else {
@@ -1683,6 +1318,7 @@ export class TerminalInput extends EventEmitter {
1683
1318
  return;
1684
1319
  this.applyScrollRegion();
1685
1320
  this.scrollRegionActive = true;
1321
+ this.forceRender();
1686
1322
  }
1687
1323
  disableScrollRegion() {
1688
1324
  if (!this.scrollRegionActive)
@@ -1833,17 +1469,19 @@ export class TerminalInput extends EventEmitter {
1833
1469
  this.shiftPlaceholders(position, text.length);
1834
1470
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1835
1471
  }
1472
+ shouldInlineMultiline(content) {
1473
+ const lines = content.split('\n').length;
1474
+ const maxInlineLines = 4;
1475
+ const maxInlineChars = 240;
1476
+ return lines <= maxInlineLines && content.length <= maxInlineChars;
1477
+ }
1836
1478
  findPlaceholderAt(position) {
1837
1479
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1838
1480
  }
1839
- buildPlaceholder(summary) {
1481
+ buildPlaceholder(lineCount) {
1840
1482
  const id = ++this.pasteCounter;
1841
- const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1842
- // Show first line preview (truncated)
1843
- const preview = summary.preview.length > 30
1844
- ? `${summary.preview.slice(0, 30)}...`
1845
- : summary.preview;
1846
- const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1483
+ const plural = lineCount === 1 ? '' : 's';
1484
+ const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1847
1485
  return { id, placeholder };
1848
1486
  }
1849
1487
  insertPastePlaceholder(content) {
@@ -1851,67 +1489,21 @@ export class TerminalInput extends EventEmitter {
1851
1489
  if (available <= 0)
1852
1490
  return;
1853
1491
  const cleanContent = content.slice(0, available);
1854
- const summary = generatePasteSummary(cleanContent);
1855
- // For short pastes (< 5 lines), show full content instead of placeholder
1856
- if (summary.lineCount < 5) {
1857
- const placeholder = this.findPlaceholderAt(this.cursor);
1858
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1859
- this.insertPlainText(cleanContent, insertPos);
1860
- this.cursor = insertPos + cleanContent.length;
1861
- return;
1862
- }
1863
- const { id, placeholder } = this.buildPlaceholder(summary);
1492
+ const lineCount = cleanContent.split('\n').length;
1493
+ const { id, placeholder } = this.buildPlaceholder(lineCount);
1864
1494
  const insertPos = this.cursor;
1865
1495
  this.shiftPlaceholders(insertPos, placeholder.length);
1866
1496
  this.pastePlaceholders.push({
1867
1497
  id,
1868
1498
  content: cleanContent,
1869
- lineCount: summary.lineCount,
1499
+ lineCount,
1870
1500
  placeholder,
1871
1501
  start: insertPos,
1872
1502
  end: insertPos + placeholder.length,
1873
- summary,
1874
- expanded: false,
1875
1503
  });
1876
1504
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1877
1505
  this.cursor = insertPos + placeholder.length;
1878
1506
  }
1879
- /**
1880
- * Toggle expansion of a paste placeholder at the current cursor position.
1881
- * When expanded, shows first 3 and last 2 lines of the content.
1882
- */
1883
- togglePasteExpansion() {
1884
- const placeholder = this.findPlaceholderAt(this.cursor);
1885
- if (!placeholder)
1886
- return false;
1887
- placeholder.expanded = !placeholder.expanded;
1888
- // Update the placeholder text in buffer
1889
- const newPlaceholder = placeholder.expanded
1890
- ? this.buildExpandedPlaceholder(placeholder)
1891
- : this.buildPlaceholder(placeholder.summary).placeholder;
1892
- const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1893
- // Update buffer
1894
- this.buffer =
1895
- this.buffer.slice(0, placeholder.start) +
1896
- newPlaceholder +
1897
- this.buffer.slice(placeholder.end);
1898
- // Update placeholder tracking
1899
- placeholder.placeholder = newPlaceholder;
1900
- placeholder.end = placeholder.start + newPlaceholder.length;
1901
- // Shift other placeholders
1902
- this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1903
- this.scheduleRender();
1904
- return true;
1905
- }
1906
- buildExpandedPlaceholder(ph) {
1907
- const lines = ph.content.split('\n');
1908
- const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1909
- const lastLines = lines.length > 5
1910
- ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1911
- : '';
1912
- const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1913
- return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1914
- }
1915
1507
  deletePlaceholder(placeholder) {
1916
1508
  const length = placeholder.end - placeholder.start;
1917
1509
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1919,7 +1511,11 @@ export class TerminalInput extends EventEmitter {
1919
1511
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1920
1512
  this.cursor = placeholder.start;
1921
1513
  }
1922
- updateContextUsage(value) {
1514
+ updateContextUsage(value, autoCompactThreshold) {
1515
+ if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1516
+ const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1517
+ this.contextAutoCompactThreshold = boundedThreshold;
1518
+ }
1923
1519
  if (value === null || !Number.isFinite(value)) {
1924
1520
  this.contextUsage = null;
1925
1521
  }
@@ -1946,6 +1542,22 @@ export class TerminalInput extends EventEmitter {
1946
1542
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1947
1543
  this.setEditMode(next);
1948
1544
  }
1545
+ scheduleStreamingRender(delayMs) {
1546
+ if (this.streamingRenderTimer)
1547
+ return;
1548
+ const wait = Math.max(16, delayMs);
1549
+ this.streamingRenderTimer = setTimeout(() => {
1550
+ this.streamingRenderTimer = null;
1551
+ this.render();
1552
+ }, wait);
1553
+ }
1554
+ resetStreamingRenderThrottle() {
1555
+ if (this.streamingRenderTimer) {
1556
+ clearTimeout(this.streamingRenderTimer);
1557
+ this.streamingRenderTimer = null;
1558
+ }
1559
+ this.lastStreamingRender = 0;
1560
+ }
1949
1561
  scheduleRender() {
1950
1562
  if (!this.canRender())
1951
1563
  return;