erosolar-cli 1.7.285 → 1.7.286

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 +22 -7
  63. package/dist/shell/interactiveShell.d.ts.map +1 -1
  64. package/dist/shell/interactiveShell.js +229 -159
  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 +40 -9
  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 +78 -186
  74. package/dist/shell/terminalInput.d.ts.map +1 -1
  75. package/dist/shell/terminalInput.js +496 -927
  76. package/dist/shell/terminalInput.js.map +1 -1
  77. package/dist/shell/terminalInputAdapter.d.ts +28 -35
  78. package/dist/shell/terminalInputAdapter.d.ts.map +1 -1
  79. package/dist/shell/terminalInputAdapter.js +26 -50
  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 +23 -44
  97. package/dist/ui/display.d.ts.map +1 -1
  98. package/dist/ui/display.js +139 -286
  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,49 +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
- // Unified UI initialization flag
112
- unifiedUIInitialized = false;
113
106
  constructor(writeStream = process.stdout, config = {}) {
114
107
  super();
115
108
  this.out = writeStream;
116
- // Use schema defaults for configuration consistency
117
109
  this.config = {
118
- maxLines: config.maxLines ?? DEFAULT_UI_CONFIG.inputArea.maxLines,
119
- maxLength: config.maxLength ?? DEFAULT_UI_CONFIG.inputArea.maxLength,
110
+ maxLines: config.maxLines ?? 1000,
111
+ maxLength: config.maxLength ?? 10000,
120
112
  maxQueueSize: config.maxQueueSize ?? 100,
121
- promptChar: config.promptChar ?? DEFAULT_UI_CONFIG.inputArea.promptChar,
122
- continuationChar: config.continuationChar ?? DEFAULT_UI_CONFIG.inputArea.continuationChar,
113
+ promptChar: config.promptChar ?? '> ',
114
+ continuationChar: config.continuationChar ?? '│ ',
123
115
  };
124
116
  }
125
117
  // ===========================================================================
@@ -198,594 +190,46 @@ export class TerminalInput extends EventEmitter {
198
190
  if (handled)
199
191
  return;
200
192
  }
201
- // Handle '?' for help hint (if buffer is empty)
202
- if (str === '?' && this.buffer.length === 0) {
203
- this.emit('showHelp');
204
- return;
205
- }
206
193
  // Insert printable characters
207
194
  if (str && !key?.ctrl && !key?.meta) {
208
195
  this.insertText(str);
209
196
  }
210
197
  }
211
- // Banner content to write on init (set via setBannerContent before initializeUnifiedUI)
212
- bannerContent = null;
213
- /**
214
- * Set banner content to be written when unified UI initializes.
215
- */
216
- setBannerContent(content) {
217
- this.bannerContent = content;
218
- }
219
- /**
220
- * Initialize the unified UI system immediately.
221
- * Clears screen, writes banner, renders input area immediately below.
222
- * This creates a compact layout on launch (no empty space).
223
- * Scroll region is set up later when streaming starts.
224
- */
225
- initializeUnifiedUI() {
226
- if (this.unifiedUIInitialized) {
227
- return;
228
- }
229
- // Reserve lines for input area (used later when scroll region is set up)
230
- this.pinnedTopRows = 0;
231
- this.reservedLines = 6; // status + model + divider + input + divider + controls
232
- // Hide cursor during setup
233
- this.write(ESC.HIDE);
234
- // Clear screen
235
- this.write(ESC.HOME);
236
- this.write(ESC.CLEAR_SCREEN);
237
- // Position cursor at row 1
238
- this.write(ESC.TO(1, 1));
239
- // Write banner as first content
240
- let currentRow = 1;
241
- if (this.bannerContent) {
242
- process.stdout.write(this.bannerContent + '\n');
243
- // Count banner lines
244
- currentRow += this.bannerContent.split('\n').length;
245
- }
246
- // Mark unified UI as initialized
247
- this.unifiedUIInitialized = true;
248
- // Render input area immediately after banner (not at bottom)
249
- this.renderInlineInputArea(currentRow);
250
- // Show cursor
251
- this.write(ESC.SHOW);
252
- }
253
- /**
254
- * Render input area at a specific row (inline, not pinned to bottom).
255
- * Used on launch for compact layout.
256
- */
257
- renderInlineInputArea(startRow) {
258
- const { cols } = this.getSize();
259
- const divider = '─'.repeat(cols - 1);
260
- // Move to start row
261
- this.write(ESC.TO(startRow, 1));
262
- // Status bar
263
- process.stdout.write(this.buildStatusBar(cols) + '\n');
264
- // Model info line (if set)
265
- if (this.modelInfo) {
266
- const { dim: DIM, reset: R } = UI_COLORS;
267
- let modelLine = `${DIM}${this.modelInfo}${R}`;
268
- if (this.contextUsage !== null) {
269
- const rem = Math.max(0, 100 - this.contextUsage);
270
- if (rem < 10)
271
- modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
272
- else if (rem < 25)
273
- modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
274
- else
275
- modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
276
- }
277
- process.stdout.write(modelLine + '\n');
278
- }
279
- // Top divider
280
- process.stdout.write(divider + '\n');
281
- // Input line with prompt
282
- process.stdout.write(ESC.BG_DARK + ESC.DIM + this.config.promptChar + ESC.RESET);
283
- process.stdout.write(ESC.BG_DARK + ' '.repeat(Math.max(0, cols - this.config.promptChar.length - 1)) + ESC.RESET + '\n');
284
- // Bottom divider
285
- process.stdout.write(divider + '\n');
286
- // Mode controls
287
- process.stdout.write(this.buildModeControls(cols) + '\n');
288
- // Position cursor in input area
289
- this.write(ESC.TO(startRow + (this.modelInfo ? 3 : 2), this.config.promptChar.length + 1));
290
- }
291
198
  /**
292
199
  * Set the input mode
293
200
  *
294
- * Streaming mode disables scroll region and lets content flow naturally.
295
- * The input area will be re-rendered after streaming ends at wherever
296
- * 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.
297
203
  */
298
204
  setMode(mode) {
299
205
  const prevMode = this.mode;
300
206
  this.mode = mode;
301
207
  if (mode === 'streaming' && prevMode !== 'streaming') {
302
- // Track streaming start time for elapsed display
303
- this.streamingStartTime = Date.now();
304
- const { rows } = this.getSize();
305
- // Ensure unified UI is initialized (if not already done on launch)
306
- if (!this.unifiedUIInitialized) {
307
- this.initializeUnifiedUI();
308
- }
309
- // Set up scroll region to reserve bottom for persistent input area
310
- this.pinnedTopRows = 0;
311
- this.reservedLines = 6; // status + model + divider + input + divider + controls
312
- // Ensure scroll region is enabled (may have been initialized already)
313
- if (!this.scrollRegionActive) {
314
- const contentBottomRow = Math.max(1, rows - this.reservedLines);
315
- this.write(ESC.TO(contentBottomRow, 1));
316
- this.enableScrollRegion();
317
- }
318
- // Render bottom input area
319
- this.renderBottomInputArea();
320
- // Start timer to update bottom input area (updates elapsed time)
321
- this.streamingRenderTimer = setInterval(() => {
322
- if (this.mode === 'streaming') {
323
- this.updateStreamingStatus();
324
- this.renderBottomInputArea();
325
- }
326
- }, 1000);
208
+ // Keep scroll region active so status/prompt stay pinned while streaming
209
+ this.resetStreamingRenderThrottle();
210
+ this.enableScrollRegion();
327
211
  this.renderDirty = true;
212
+ this.render();
328
213
  }
329
214
  else if (mode !== 'streaming' && prevMode === 'streaming') {
330
- // Stop streaming render timer
331
- if (this.streamingRenderTimer) {
332
- clearInterval(this.streamingRenderTimer);
333
- this.streamingRenderTimer = null;
334
- }
335
- // Reset streaming time
336
- this.streamingStartTime = null;
337
- // Keep scroll region active for consistent bottom-pinned UI
338
- // (scroll region reserves bottom for input area in all modes)
339
- // Reset flow mode tracking
340
- this.flowModeRenderedLines = 0;
341
- // Render using unified bottom input area (same layout as streaming)
342
- writeLock.withLock(() => {
343
- this.renderBottomInputArea();
344
- }, 'terminalInput.streamingEnd');
345
- }
346
- }
347
- /**
348
- * Update streaming status label (called by timer)
349
- */
350
- updateStreamingStatus() {
351
- if (this.mode !== 'streaming' || !this.streamingStartTime)
352
- return;
353
- // Calculate elapsed time
354
- const elapsed = Date.now() - this.streamingStartTime;
355
- const seconds = Math.floor(elapsed / 1000);
356
- const minutes = Math.floor(seconds / 60);
357
- const secs = seconds % 60;
358
- // Format elapsed time
359
- let elapsedStr;
360
- if (minutes > 0) {
361
- elapsedStr = `${minutes}m ${secs}s`;
362
- }
363
- else {
364
- elapsedStr = `${secs}s`;
365
- }
366
- // Update streaming label
367
- this.streamingLabel = `Streaming ${elapsedStr}`;
368
- }
369
- /**
370
- * Render input area - unified for streaming and normal modes.
371
- *
372
- * In streaming mode: renders at absolute bottom, uses cursor save/restore
373
- * In normal mode: renders right after the banner (pinnedTopRows + 1)
374
- */
375
- renderPinnedInputArea() {
376
- const { rows, cols } = this.getSize();
377
- const maxWidth = Math.max(8, cols - 4);
378
- const divider = renderDivider(cols - 2);
379
- const isStreaming = this.mode === 'streaming';
380
- // Wrap buffer into display lines (multi-line support)
381
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
382
- const availableForContent = Math.max(1, rows - 5); // Reserve 5 lines for UI
383
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
384
- const displayLines = Math.min(lines.length, maxVisible);
385
- // Calculate display window (keep cursor visible)
386
- let startLine = 0;
387
- if (lines.length > displayLines) {
388
- startLine = Math.max(0, cursorLine - displayLines + 1);
389
- startLine = Math.min(startLine, lines.length - displayLines);
390
- }
391
- const visibleLines = lines.slice(startLine, startLine + displayLines);
392
- const adjustedCursorLine = cursorLine - startLine;
393
- // Calculate total height: status + (model info if set) + topDiv + input lines + bottomDiv + controls
394
- const hasModelInfo = !!this.modelInfo;
395
- const totalHeight = 4 + visibleLines.length + (hasModelInfo ? 1 : 0);
396
- // Save cursor position during streaming (so content flow resumes correctly)
397
- if (isStreaming) {
398
- this.write(ESC.SAVE);
399
- }
400
- this.write(ESC.HIDE);
401
- this.write(ESC.RESET);
402
- // Calculate start row based on mode:
403
- // - Streaming: absolute bottom (rows - totalHeight + 1)
404
- // - Normal: right after content (contentEndRow + 1)
405
- let currentRow;
406
- if (isStreaming) {
407
- currentRow = Math.max(1, rows - totalHeight + 1);
408
- }
409
- else {
410
- // In normal mode, render right after content
411
- // Use contentEndRow if set, otherwise use pinnedTopRows
412
- const contentRow = this.contentEndRow > 0 ? this.contentEndRow : this.pinnedTopRows;
413
- currentRow = Math.max(1, contentRow + 1);
414
- }
415
- let finalRow = currentRow;
416
- let finalCol = 3;
417
- // Clear from current position to end of screen to remove any "ghost" content
418
- this.write(ESC.TO(currentRow, 1));
419
- this.write(ESC.CLEAR_TO_END);
420
- // Status bar
421
- this.write(ESC.TO(currentRow, 1));
422
- this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
423
- currentRow++;
424
- // Model info line (if set) - displayed below status, above input
425
- if (hasModelInfo) {
426
- const { dim: DIM, reset: R } = UI_COLORS;
427
- this.write(ESC.TO(currentRow, 1));
428
- // Build model info with context usage
429
- let modelLine = `${DIM}${this.modelInfo}${R}`;
430
- if (this.contextUsage !== null) {
431
- const rem = Math.max(0, 100 - this.contextUsage);
432
- if (rem < 10)
433
- modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
434
- else if (rem < 25)
435
- modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
436
- else
437
- modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
438
- }
439
- this.write(modelLine);
440
- currentRow++;
441
- }
442
- // Top divider
443
- this.write(ESC.TO(currentRow, 1));
444
- this.write(divider);
445
- currentRow++;
446
- // Input lines with background styling
447
- for (let i = 0; i < visibleLines.length; i++) {
448
- this.write(ESC.TO(currentRow, 1));
449
- const line = visibleLines[i] ?? '';
450
- const absoluteLineIdx = startLine + i;
451
- const isFirstLine = absoluteLineIdx === 0;
452
- const isCursorLine = i === adjustedCursorLine;
453
- // Background
454
- this.write(ESC.BG_DARK);
455
- // Prompt prefix
456
- this.write(ESC.DIM);
457
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
458
- this.write(ESC.RESET);
459
- this.write(ESC.BG_DARK);
460
- if (isCursorLine) {
461
- const col = Math.min(cursorCol, line.length);
462
- const before = line.slice(0, col);
463
- const at = col < line.length ? line[col] : ' ';
464
- const after = col < line.length ? line.slice(col + 1) : '';
465
- this.write(before);
466
- this.write(ESC.REVERSE + ESC.BOLD);
467
- this.write(at);
468
- this.write(ESC.RESET + ESC.BG_DARK);
469
- this.write(after);
470
- finalRow = currentRow;
471
- finalCol = this.config.promptChar.length + col + 1;
472
- }
473
- else {
474
- this.write(line);
475
- }
476
- // Pad to edge
477
- const lineLen = this.config.promptChar.length + line.length + (isCursorLine && cursorCol >= line.length ? 1 : 0);
478
- const padding = Math.max(0, cols - lineLen - 1);
479
- if (padding > 0)
480
- this.write(' '.repeat(padding));
481
- this.write(ESC.RESET);
482
- currentRow++;
483
- }
484
- // Bottom divider
485
- this.write(ESC.TO(currentRow, 1));
486
- this.write(divider);
487
- currentRow++;
488
- // Mode controls line
489
- this.write(ESC.TO(currentRow, 1));
490
- this.write(this.buildModeControls(cols));
491
- // Restore cursor position during streaming, or show cursor in normal mode
492
- if (isStreaming) {
493
- this.write(ESC.RESTORE);
494
- }
495
- else {
496
- // Position cursor in input area
497
- this.write(ESC.TO(finalRow, Math.min(finalCol, cols)));
498
- this.write(ESC.SHOW);
499
- }
500
- // Update reserved lines for scroll region calculations
501
- this.updateReservedLines(totalHeight);
502
- }
503
- /**
504
- * Render input area during streaming (alias for unified method)
505
- */
506
- renderStreamingInputArea() {
507
- this.renderPinnedInputArea();
508
- }
509
- /**
510
- * Render bottom input area - UNIFIED for all modes.
511
- * Uses cursor save/restore to update bottom without affecting content flow.
512
- *
513
- * Layout (same for idle/streaming/ready):
514
- * - Status bar (streaming timer or "Type a message")
515
- * - Model info line (provider · model · ctx)
516
- * - Divider
517
- * - Input area
518
- * - Divider
519
- * - Mode controls
520
- */
521
- renderBottomInputArea() {
522
- const { rows, cols } = this.getSize();
523
- const maxWidth = Math.max(8, cols - 4);
524
- const divider = renderDivider(cols - 2);
525
- const { dim: DIM, reset: R } = UI_COLORS;
526
- const isStreaming = this.mode === 'streaming';
527
- // Wrap buffer into display lines
528
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
529
- // Allow multi-line in non-streaming, single line during streaming
530
- const maxDisplayLines = isStreaming ? 1 : 3;
531
- const displayLines = Math.min(lines.length, maxDisplayLines);
532
- const visibleLines = lines.slice(0, displayLines);
533
- // Calculate total height for bottom area
534
- const hasModelInfo = !!this.modelInfo;
535
- const totalHeight = 4 + displayLines + (hasModelInfo ? 1 : 0);
536
- // Ensure scroll region is always enabled (unified behavior)
537
- if (!this.scrollRegionActive || this.reservedLines !== totalHeight) {
538
- this.reservedLines = totalHeight;
215
+ // Streaming ended - render the input area
216
+ this.resetStreamingRenderThrottle();
539
217
  this.enableScrollRegion();
218
+ this.forceRender();
540
219
  }
541
- const startRow = Math.max(1, rows - totalHeight + 1);
542
- // Save cursor, hide it
543
- this.write(ESC.SAVE);
544
- this.write(ESC.HIDE);
545
- let currentRow = startRow;
546
- // Clear the bottom reserved area
547
- for (let r = startRow; r <= rows; r++) {
548
- this.write(ESC.TO(r, 1));
549
- this.write(ESC.CLEAR_LINE);
550
- }
551
- // Status bar - UNIFIED: same format for all modes
552
- this.write(ESC.TO(currentRow, 1));
553
- this.write(isStreaming ? this.buildStreamingStatusBar(cols) : this.buildStatusBar(cols));
554
- currentRow++;
555
- // Model info line (if set)
556
- if (hasModelInfo) {
557
- this.write(ESC.TO(currentRow, 1));
558
- let modelLine = `${DIM}${this.modelInfo}${R}`;
559
- if (this.contextUsage !== null) {
560
- const rem = Math.max(0, 100 - this.contextUsage);
561
- if (rem < 10)
562
- modelLine += ` ${UI_COLORS.red}⚠ ctx: ${rem}%${R}`;
563
- else if (rem < 25)
564
- modelLine += ` ${UI_COLORS.yellow}! ctx: ${rem}%${R}`;
565
- else
566
- modelLine += ` ${DIM}· ctx: ${rem}%${R}`;
567
- }
568
- this.write(modelLine);
569
- currentRow++;
570
- }
571
- // Top divider
572
- this.write(ESC.TO(currentRow, 1));
573
- this.write(divider);
574
- currentRow++;
575
- // Input lines with background styling
576
- for (let i = 0; i < visibleLines.length; i++) {
577
- this.write(ESC.TO(currentRow, 1));
578
- const line = visibleLines[i] ?? '';
579
- const isFirstLine = i === 0;
580
- this.write(ESC.BG_DARK);
581
- this.write(ESC.DIM);
582
- this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
583
- this.write(ESC.RESET);
584
- this.write(ESC.BG_DARK);
585
- this.write(line);
586
- // Pad to edge
587
- const lineLen = this.config.promptChar.length + line.length;
588
- const padding = Math.max(0, cols - lineLen - 1);
589
- if (padding > 0)
590
- this.write(' '.repeat(padding));
591
- this.write(ESC.RESET);
592
- currentRow++;
593
- }
594
- // Bottom divider
595
- this.write(ESC.TO(currentRow, 1));
596
- this.write(divider);
597
- currentRow++;
598
- // Mode controls
599
- this.write(ESC.TO(currentRow, 1));
600
- this.write(this.buildModeControls(cols));
601
- // Cursor positioning depends on mode:
602
- // - Streaming: restore to content area (where streaming output continues)
603
- // - Normal: position in input area for typing
604
- if (isStreaming) {
605
- this.write(ESC.RESTORE);
606
- }
607
- else {
608
- // Position cursor in input area
609
- // Input line is at: startRow + (hasModelInfo ? 2 : 1) + cursorLine
610
- const inputStartRow = startRow + (hasModelInfo ? 2 : 1) + 1; // +1 for status bar, +1 for divider
611
- const targetRow = inputStartRow + Math.min(cursorLine, displayLines - 1);
612
- const targetCol = this.config.promptChar.length + cursorCol + 1;
613
- this.write(ESC.TO(targetRow, Math.min(targetCol, cols)));
614
- }
615
- this.write(ESC.SHOW);
616
- // Track last render state
617
- this.lastRenderContent = this.buffer;
618
- this.lastRenderCursor = this.cursor;
619
- }
620
- /**
621
- * Enable or disable flow mode.
622
- * In flow mode, the input renders immediately after content (wherever cursor is).
623
- * When disabled, input renders at the absolute bottom of terminal.
624
- */
625
- setFlowMode(enabled) {
626
- if (this.flowMode === enabled)
627
- return;
628
- this.flowMode = enabled;
629
- this.renderDirty = true;
630
- this.scheduleRender();
631
- }
632
- /**
633
- * Check if flow mode is enabled.
634
- */
635
- isFlowMode() {
636
- return this.flowMode;
637
- }
638
- /**
639
- * Set the row where content ends (for idle mode positioning).
640
- * Input area will render starting from this row + 1.
641
- */
642
- setContentEndRow(row) {
643
- this.contentEndRow = Math.max(0, row);
644
- this.renderDirty = true;
645
- this.scheduleRender();
646
- }
647
- /**
648
- * Set available slash commands for auto-complete suggestions.
649
- */
650
- setCommands(commands) {
651
- this.commandSuggestions = commands;
652
- this.updateSuggestions();
653
- }
654
- /**
655
- * Update filtered suggestions based on current input.
656
- */
657
- updateSuggestions() {
658
- const input = this.buffer.trim();
659
- // Only show suggestions when input starts with "/"
660
- if (!input.startsWith('/')) {
661
- this.showSuggestions = false;
662
- this.filteredSuggestions = [];
663
- this.selectedSuggestionIndex = 0;
664
- return;
665
- }
666
- const query = input.toLowerCase();
667
- this.filteredSuggestions = this.commandSuggestions.filter(cmd => cmd.command.toLowerCase().startsWith(query) ||
668
- cmd.command.toLowerCase().includes(query.slice(1)));
669
- // Show suggestions if we have matches
670
- this.showSuggestions = this.filteredSuggestions.length > 0;
671
- // Keep selection in bounds
672
- if (this.selectedSuggestionIndex >= this.filteredSuggestions.length) {
673
- this.selectedSuggestionIndex = Math.max(0, this.filteredSuggestions.length - 1);
674
- }
675
- }
676
- /**
677
- * Select next suggestion (arrow down / tab).
678
- */
679
- selectNextSuggestion() {
680
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
681
- return;
682
- this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % this.filteredSuggestions.length;
683
- this.renderDirty = true;
684
- this.scheduleRender();
685
- }
686
- /**
687
- * Select previous suggestion (arrow up / shift+tab).
688
- */
689
- selectPrevSuggestion() {
690
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
691
- return;
692
- this.selectedSuggestionIndex = this.selectedSuggestionIndex === 0
693
- ? this.filteredSuggestions.length - 1
694
- : this.selectedSuggestionIndex - 1;
695
- this.renderDirty = true;
696
- this.scheduleRender();
697
- }
698
- /**
699
- * Accept current suggestion and insert into buffer.
700
- */
701
- acceptSuggestion() {
702
- if (!this.showSuggestions || this.filteredSuggestions.length === 0)
703
- return false;
704
- const selected = this.filteredSuggestions[this.selectedSuggestionIndex];
705
- if (!selected)
706
- return false;
707
- // Replace buffer with selected command
708
- this.buffer = selected.command + ' ';
709
- this.cursor = this.buffer.length;
710
- this.showSuggestions = false;
711
- this.renderDirty = true;
712
- this.scheduleRender();
713
- return true;
714
- }
715
- /**
716
- * Check if suggestions are visible.
717
- */
718
- areSuggestionsVisible() {
719
- return this.showSuggestions && this.filteredSuggestions.length > 0;
720
- }
721
- /**
722
- * Update token count for metrics display
723
- */
724
- setTokensUsed(tokens) {
725
- this.tokensUsed = tokens;
726
- }
727
- /**
728
- * Toggle thinking/reasoning mode
729
- */
730
- toggleThinking() {
731
- this.thinkingEnabled = !this.thinkingEnabled;
732
- this.emit('thinkingToggle', this.thinkingEnabled);
733
- this.scheduleRender();
734
- }
735
- /**
736
- * Get thinking enabled state
737
- */
738
- isThinkingEnabled() {
739
- return this.thinkingEnabled;
740
220
  }
741
221
  /**
742
222
  * Keep the top N rows pinned outside the scroll region (used for the launch banner).
743
223
  */
744
224
  setPinnedHeaderLines(count) {
745
- // Set pinned header rows (banner area that scroll region excludes)
746
- if (this.pinnedTopRows !== count) {
747
- this.pinnedTopRows = count;
225
+ // No pinned header rows anymore; keep everything in the scroll region.
226
+ if (this.pinnedTopRows !== 0) {
227
+ this.pinnedTopRows = 0;
748
228
  if (this.scrollRegionActive) {
749
229
  this.applyScrollRegion();
750
230
  }
751
231
  }
752
232
  }
753
- /**
754
- * Anchor prompt rendering near a specific row (inline layout). Pass null to
755
- * restore the default bottom-aligned layout.
756
- */
757
- setInlineAnchor(row) {
758
- if (row === null || row === undefined) {
759
- this.inlineAnchorRow = null;
760
- this.inlineLayout = false;
761
- this.renderDirty = true;
762
- this.render();
763
- return;
764
- }
765
- const { rows } = this.getSize();
766
- const clamped = Math.max(1, Math.min(Math.floor(row), rows));
767
- this.inlineAnchorRow = clamped;
768
- this.inlineLayout = true;
769
- this.renderDirty = true;
770
- this.render();
771
- }
772
- /**
773
- * Provide a dynamic anchor callback. When set, the prompt will follow the
774
- * output by re-evaluating the anchor before each render.
775
- */
776
- setInlineAnchorProvider(provider) {
777
- this.anchorProvider = provider;
778
- if (!provider) {
779
- this.inlineLayout = false;
780
- this.inlineAnchorRow = null;
781
- this.renderDirty = true;
782
- this.render();
783
- return;
784
- }
785
- this.inlineLayout = true;
786
- this.renderDirty = true;
787
- this.render();
788
- }
789
233
  /**
790
234
  * Get current mode
791
235
  */
@@ -895,6 +339,37 @@ export class TerminalInput extends EventEmitter {
895
339
  this.streamingLabel = next;
896
340
  this.scheduleRender();
897
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
+ }
898
373
  /**
899
374
  * Keep mode toggles (verification/auto-continue) visible in the control bar.
900
375
  * Hotkey labels remain stable so the bar looks the same before/during streaming.
@@ -904,26 +379,22 @@ export class TerminalInput extends EventEmitter {
904
379
  const nextAutoContinue = !!options.autoContinueEnabled;
905
380
  const nextVerifyHotkey = options.verificationHotkey ?? this.verificationHotkey;
906
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);
907
384
  if (this.verificationEnabled === nextVerification &&
908
385
  this.autoContinueEnabled === nextAutoContinue &&
909
386
  this.verificationHotkey === nextVerifyHotkey &&
910
- this.autoContinueHotkey === nextAutoHotkey) {
387
+ this.autoContinueHotkey === nextAutoHotkey &&
388
+ this.thinkingHotkey === nextThinkingHotkey &&
389
+ this.thinkingModeLabel === nextThinkingLabel) {
911
390
  return;
912
391
  }
913
392
  this.verificationEnabled = nextVerification;
914
393
  this.autoContinueEnabled = nextAutoContinue;
915
394
  this.verificationHotkey = nextVerifyHotkey;
916
395
  this.autoContinueHotkey = nextAutoHotkey;
917
- this.scheduleRender();
918
- }
919
- /**
920
- * Set the model info string (e.g., "OpenAI · gpt-4")
921
- * This is displayed persistently above the input area.
922
- */
923
- setModelInfo(info) {
924
- if (this.modelInfo === info)
925
- return;
926
- this.modelInfo = info;
396
+ this.thinkingHotkey = nextThinkingHotkey;
397
+ this.thinkingModeLabel = nextThinkingLabel;
927
398
  this.scheduleRender();
928
399
  }
929
400
  /**
@@ -936,298 +407,390 @@ export class TerminalInput extends EventEmitter {
936
407
  this.scheduleRender();
937
408
  }
938
409
  /**
939
- * 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
940
424
  *
941
- * Uses the same bottom-pinned layout with scroll regions for:
942
- * - Idle mode: Shows "Type a message" hint
943
- * - Streaming mode: Shows "● Streaming Xs" timer
944
- * - 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.
945
428
  */
946
429
  render() {
947
430
  if (!this.canRender())
948
431
  return;
949
432
  if (this.isRendering)
950
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
+ }
951
446
  const shouldSkip = !this.renderDirty &&
952
447
  this.buffer === this.lastRenderContent &&
953
448
  this.cursor === this.lastRenderCursor;
954
449
  this.renderDirty = false;
955
- // Skip if nothing changed (unless explicitly forced)
450
+ // Skip if nothing changed and no explicit refresh requested
956
451
  if (shouldSkip) {
957
452
  return;
958
453
  }
959
- // If write lock is held, defer render
454
+ // If write lock is held, defer render to avoid race conditions
960
455
  if (writeLock.isLocked()) {
961
456
  writeLock.safeWrite(() => this.render());
962
457
  return;
963
458
  }
964
- this.isRendering = true;
965
- writeLock.lock('terminalInput.render');
966
- try {
967
- // UNIFIED: Use the same bottom input area for all modes
968
- this.renderBottomInputArea();
969
- }
970
- finally {
971
- writeLock.unlock();
972
- this.isRendering = false;
973
- }
974
- }
975
- /**
976
- * Render in flow mode - delegates to bottom-pinned for stability.
977
- *
978
- * Flow mode attempted inline rendering but caused duplicate renders
979
- * due to unreliable cursor position tracking. Bottom-pinned is reliable.
980
- */
981
- renderFlowMode() {
982
- // Use stable bottom-pinned approach
983
- this.renderBottomPinned();
984
- }
985
- /**
986
- * Render in bottom-pinned mode - Claude Code style with suggestions
987
- *
988
- * Works for both normal and streaming modes:
989
- * - During streaming: saves/restores cursor position
990
- * - Status bar shows streaming info or "Type a message"
991
- *
992
- * Layout when suggestions visible:
993
- * - Top divider
994
- * - Input line(s)
995
- * - Bottom divider
996
- * - Suggestions (command list)
997
- *
998
- * Layout when suggestions hidden:
999
- * - Status bar (Ready/Streaming)
1000
- * - Top divider
1001
- * - Input line(s)
1002
- * - Bottom divider
1003
- * - Mode controls
1004
- */
1005
- renderBottomPinned() {
1006
- const { rows, cols } = this.getSize();
1007
- const maxWidth = Math.max(8, cols - 4);
1008
- const isStreaming = this.mode === 'streaming';
1009
- // Use unified pinned input area (works for both streaming and normal)
1010
- // Only use complex rendering when suggestions are visible
1011
- const hasSuggestions = !isStreaming && this.showSuggestions && this.filteredSuggestions.length > 0;
1012
- if (!hasSuggestions) {
1013
- this.renderPinnedInputArea();
1014
- return;
1015
- }
1016
- // Wrap buffer into display lines
1017
- const { lines, cursorLine, cursorCol } = this.wrapBuffer(maxWidth);
1018
- const availableForContent = Math.max(1, rows - 3);
1019
- const maxVisible = Math.max(1, Math.min(this.config.maxLines, availableForContent));
1020
- const displayLines = Math.min(lines.length, maxVisible);
1021
- // Calculate display window (keep cursor visible)
1022
- let startLine = 0;
1023
- if (lines.length > displayLines) {
1024
- startLine = Math.max(0, cursorLine - displayLines + 1);
1025
- startLine = Math.min(startLine, lines.length - displayLines);
1026
- }
1027
- const visibleLines = lines.slice(startLine, startLine + displayLines);
1028
- const adjustedCursorLine = cursorLine - startLine;
1029
- // Calculate suggestion display (not during streaming)
1030
- const suggestionsToShow = (!isStreaming && this.showSuggestions)
1031
- ? this.filteredSuggestions.slice(0, this.maxVisibleSuggestions)
1032
- : [];
1033
- const suggestionLines = suggestionsToShow.length;
1034
- this.write(ESC.HIDE);
1035
- this.write(ESC.RESET);
1036
- const divider = renderDivider(cols - 2);
1037
- // Calculate positions from absolute bottom
1038
- let currentRow;
1039
- if (suggestionLines > 0) {
1040
- // With suggestions: input area + dividers + suggestions
1041
- // Layout: [topDiv] [input] [bottomDiv] [suggestions...]
1042
- const totalHeight = 2 + visibleLines.length + suggestionLines; // 2 dividers
1043
- currentRow = Math.max(1, rows - totalHeight + 1);
1044
- this.updateReservedLines(totalHeight);
1045
- // Clear from current position to end of screen to remove any "ghost" content
1046
- this.write(ESC.TO(currentRow, 1));
1047
- this.write(ESC.CLEAR_TO_END);
1048
- // 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
1049
496
  this.write(ESC.TO(currentRow, 1));
497
+ this.write(ESC.CLEAR_LINE);
498
+ const divider = renderDivider(cols - 2);
1050
499
  this.write(divider);
1051
- currentRow++;
1052
- // Input lines
500
+ currentRow += 1;
501
+ // Render input lines
1053
502
  let finalRow = currentRow;
1054
503
  let finalCol = 3;
1055
504
  for (let i = 0; i < visibleLines.length; i++) {
1056
- this.write(ESC.TO(currentRow, 1));
505
+ const rowNum = currentRow + i;
506
+ this.write(ESC.TO(rowNum, 1));
507
+ this.write(ESC.CLEAR_LINE);
1057
508
  const line = visibleLines[i] ?? '';
1058
509
  const absoluteLineIdx = startLine + i;
1059
510
  const isFirstLine = absoluteLineIdx === 0;
1060
511
  const isCursorLine = i === adjustedCursorLine;
512
+ // Background
513
+ this.write(ESC.BG_DARK);
514
+ // Prompt prefix
515
+ this.write(ESC.DIM);
1061
516
  this.write(isFirstLine ? this.config.promptChar : this.config.continuationChar);
517
+ this.write(ESC.RESET);
518
+ this.write(ESC.BG_DARK);
1062
519
  if (isCursorLine) {
520
+ // Render with block cursor
1063
521
  const col = Math.min(cursorCol, line.length);
1064
- this.write(line.slice(0, col));
1065
- this.write(ESC.REVERSE);
1066
- this.write(col < line.length ? line[col] : ' ');
1067
- this.write(ESC.RESET);
1068
- this.write(line.slice(col + 1));
1069
- 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;
1070
531
  finalCol = this.config.promptChar.length + col + 1;
1071
532
  }
1072
533
  else {
1073
534
  this.write(line);
1074
535
  }
1075
- 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);
1076
542
  }
1077
- // Bottom divider
1078
- this.write(ESC.TO(currentRow, 1));
1079
- this.write(divider);
1080
- currentRow++;
1081
- // Suggestions (Claude Code style)
1082
- for (let i = 0; i < suggestionsToShow.length; i++) {
1083
- this.write(ESC.TO(currentRow, 1));
1084
- const suggestion = suggestionsToShow[i];
1085
- const isSelected = i === this.selectedSuggestionIndex;
1086
- // Indent and highlight selected
1087
- this.write(' ');
1088
- if (isSelected) {
1089
- this.write(ESC.REVERSE);
1090
- this.write(ESC.BOLD);
1091
- }
1092
- this.write(suggestion.command);
1093
- if (isSelected) {
1094
- this.write(ESC.RESET);
1095
- }
1096
- // Description (dimmed)
1097
- const descSpace = cols - suggestion.command.length - 8;
1098
- if (descSpace > 10 && suggestion.description) {
1099
- const desc = suggestion.description.slice(0, descSpace);
1100
- this.write(ESC.RESET);
1101
- this.write(ESC.DIM);
1102
- this.write(' ');
1103
- this.write(desc);
1104
- this.write(ESC.RESET);
1105
- }
1106
- currentRow++;
1107
- }
1108
- // 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
1109
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;
1110
569
  }
1111
- this.write(ESC.SHOW);
1112
- // Update state
1113
- this.lastRenderContent = this.buffer;
1114
- this.lastRenderCursor = this.cursor;
1115
570
  }
1116
571
  /**
1117
- * 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.
1118
574
  */
1119
- buildStreamingStatusBar(cols) {
1120
- const { green: GREEN, cyan: CYAN, dim: DIM, reset: R } = UI_COLORS;
1121
- // Streaming status with elapsed time
1122
- let elapsed = '0s';
1123
- if (this.streamingStartTime) {
1124
- const secs = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1125
- const mins = Math.floor(secs / 60);
1126
- elapsed = mins > 0 ? `${mins}m ${secs % 60}s` : `${secs}s`;
1127
- }
1128
- let status = `${GREEN}● Streaming${R} ${elapsed}`;
1129
- // 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
+ }
1130
632
  if (this.queue.length > 0) {
1131
- status += ` ${DIM}·${R} ${CYAN}${this.queue.length} queued${R}`;
633
+ usageParts.push({ text: `queued ${this.queue.length}`, tone: 'info' });
1132
634
  }
1133
- // Hint for typing
1134
- status += ` ${DIM}· type to queue message${R}`;
1135
- return status;
635
+ if (usageParts.length) {
636
+ lines.push(renderStatusLine(usageParts, width));
637
+ }
638
+ return lines;
1136
639
  }
1137
640
  /**
1138
- * Build status bar showing streaming/ready status and key info.
1139
- * 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.
1140
642
  */
1141
- buildStatusBar(cols) {
1142
- const maxWidth = cols - 2;
1143
- const parts = [];
1144
- // Streaming status with elapsed time (left side)
1145
- if (this.mode === 'streaming') {
1146
- let statusText = ' Streaming';
1147
- if (this.streamingStartTime) {
1148
- const elapsed = Math.floor((Date.now() - this.streamingStartTime) / 1000);
1149
- const mins = Math.floor(elapsed / 60);
1150
- const secs = elapsed % 60;
1151
- statusText += mins > 0 ? ` ${mins}m ${secs}s` : ` ${secs}s`;
1152
- }
1153
- parts.push(`\x1b[32m${statusText}\x1b[0m`); // Green
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));
1154
649
  }
1155
- // Queue indicator during streaming
1156
- if (this.mode === 'streaming' && this.queue.length > 0) {
1157
- parts.push(`\x1b[36mqueued: ${this.queue.length}\x1b[0m`); // Cyan
1158
- }
1159
- // Paste indicator
1160
- if (this.pastePlaceholders.length > 0) {
1161
- const totalLines = this.pastePlaceholders.reduce((sum, p) => sum + p.lineCount, 0);
1162
- 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' });
1163
661
  }
1164
- // Override/warning status
1165
662
  if (this.overrideStatusMessage) {
1166
- parts.push(`\x1b[33m⚠ ${this.overrideStatusMessage}\x1b[0m`); // Yellow
1167
- }
1168
- // If idle with empty buffer, show quick shortcuts
1169
- if (this.mode !== 'streaming' && this.buffer.length === 0 && parts.length === 0) {
1170
- return `${ESC.DIM}Type a message or / for commands${ESC.RESET}`;
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' });
1171
680
  }
1172
- // Multi-line indicator
1173
681
  if (this.buffer.includes('\n')) {
1174
- 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' });
1175
684
  }
1176
- if (parts.length === 0) {
1177
- return ''; // Empty status bar when idle
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
+ });
1178
691
  }
1179
- const joined = parts.join(`${ESC.DIM} · ${ESC.RESET}`);
1180
- return joined.slice(0, maxWidth);
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}`;
771
+ }
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;
1181
783
  }
1182
784
  /**
1183
- * Build mode controls line showing toggles and context info.
1184
- * This is the BOTTOM line below the input area - Claude Code style layout with erosolar features.
1185
- *
1186
- * 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.
1187
787
  */
1188
- buildModeControls(cols) {
1189
- const maxWidth = cols - 2;
1190
- // Use schema-defined colors for consistency
1191
- const { green: GREEN, yellow: YELLOW, cyan: CYAN, magenta: MAGENTA, red: RED, dim: DIM, reset: R } = UI_COLORS;
1192
- // Mode toggles with colors (following ModeControlsSchema)
1193
- const toggles = [];
1194
- // Edit mode (green=auto, yellow=ask) - per schema.editMode
1195
- if (this.editMode === 'display-edits') {
1196
- toggles.push(`${GREEN}⏵⏵ auto-edit${R}`);
1197
- }
1198
- else {
1199
- toggles.push(`${YELLOW}⏸⏸ ask-first${R}`);
1200
- }
1201
- // Thinking mode (cyan when on) - per schema.thinkingMode
1202
- toggles.push(this.thinkingEnabled ? `${CYAN}💭 think${R}` : `${DIM}○ think${R}`);
1203
- // Verification (green when on) - per schema.verificationMode
1204
- toggles.push(this.verificationEnabled ? `${GREEN}✓ verify${R}` : `${DIM}○ verify${R}`);
1205
- // Auto-continue (magenta when on) - per schema.autoContinueMode
1206
- toggles.push(this.autoContinueEnabled ? `${MAGENTA}↻ auto${R}` : `${DIM}○ auto${R}`);
1207
- const leftPart = toggles.join(`${DIM} · ${R}`) + `${DIM} (⇧⇥)${R}`;
1208
- // Context usage with color - per schema.contextUsage thresholds
1209
- let rightPart = '';
1210
- if (this.contextUsage !== null) {
1211
- const rem = Math.max(0, 100 - this.contextUsage);
1212
- // Thresholds: critical < 10%, warning < 25%
1213
- if (rem < 10)
1214
- rightPart = `${RED}⚠ ctx: ${rem}%${R}`;
1215
- else if (rem < 25)
1216
- rightPart = `${YELLOW}! ctx: ${rem}%${R}`;
1217
- else
1218
- rightPart = `${DIM}ctx: ${rem}%${R}`;
1219
- }
1220
- // Calculate visible lengths (strip ANSI)
1221
- const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
1222
- const leftLen = strip(leftPart).length;
1223
- const rightLen = strip(rightPart).length;
1224
- if (leftLen + rightLen < maxWidth - 4) {
1225
- return `${leftPart}${' '.repeat(maxWidth - leftLen - rightLen)}${rightPart}`;
1226
- }
1227
- if (rightLen > 0 && leftLen + 8 < maxWidth) {
1228
- return `${leftPart} ${rightPart}`;
1229
- }
1230
- 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
+ };
1231
794
  }
1232
795
  /**
1233
796
  * Force a re-render
@@ -1250,17 +813,19 @@ export class TerminalInput extends EventEmitter {
1250
813
  handleResize() {
1251
814
  this.lastRenderContent = '';
1252
815
  this.lastRenderCursor = -1;
816
+ this.resetStreamingRenderThrottle();
1253
817
  // Re-clamp pinned header rows to the new terminal height
1254
818
  this.setPinnedHeaderLines(this.pinnedTopRows);
819
+ if (this.scrollRegionActive) {
820
+ this.disableScrollRegion();
821
+ this.enableScrollRegion();
822
+ }
1255
823
  this.scheduleRender();
1256
824
  }
1257
825
  /**
1258
826
  * Register with display's output interceptor to position cursor correctly.
1259
827
  * When scroll region is active, output needs to go to the scroll region,
1260
828
  * not the protected bottom area where the input is rendered.
1261
- *
1262
- * NOTE: With scroll region properly set, content naturally stays within
1263
- * the region boundaries - no cursor manipulation needed per-write.
1264
829
  */
1265
830
  registerOutputInterceptor(display) {
1266
831
  if (this.outputInterceptorCleanup) {
@@ -1268,25 +833,66 @@ export class TerminalInput extends EventEmitter {
1268
833
  }
1269
834
  this.outputInterceptorCleanup = display.registerOutputInterceptor({
1270
835
  beforeWrite: () => {
1271
- // Scroll region handles content containment automatically
1272
- // 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
+ }
1273
845
  },
1274
- afterWrite: () => {
1275
- // No cursor manipulation needed
846
+ afterWrite: (content) => {
847
+ // Advance content row by number of lines written and restore cursor.
848
+ if (this.scrollRegionActive) {
849
+ const { rows } = this.getSize();
850
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
851
+ // Count newlines in content to advance by correct amount
852
+ const lineCount = content ? (content.match(/\n/g) || []).length + 1 : 1;
853
+ this.contentRow = Math.min(this.contentRow + lineCount, scrollBottom);
854
+ this.write(ESC.RESTORE);
855
+ }
1276
856
  },
1277
857
  });
1278
858
  }
859
+ /**
860
+ * Write content directly into the scroll region (for banner, user prompts, etc.).
861
+ * Content starts at top and flows down, then scrolls when bottom is reached.
862
+ */
863
+ writeToScrollRegion(content) {
864
+ if (!content)
865
+ return;
866
+ // Ensure scroll region is active
867
+ if (!this.scrollRegionActive) {
868
+ this.enableScrollRegion();
869
+ }
870
+ const { rows } = this.getSize();
871
+ const scrollBottom = Math.max(this.pinnedTopRows + 1, rows - this.reservedLines);
872
+ const targetRow = Math.min(this.contentRow, scrollBottom);
873
+ // Write at current content position
874
+ this.write(ESC.SAVE);
875
+ this.write(ESC.TO(targetRow, 1));
876
+ this.write(content);
877
+ this.write(ESC.RESTORE);
878
+ // Advance contentRow by number of lines written
879
+ const lineCount = (content.match(/\n/g) || []).length + 1;
880
+ this.contentRow = Math.min(this.contentRow + lineCount, scrollBottom);
881
+ }
882
+ /**
883
+ * Reset content position to start of scroll region.
884
+ * Does NOT clear the terminal - content starts from current position.
885
+ */
886
+ resetContentPosition() {
887
+ const scrollTop = Math.max(1, this.pinnedTopRows + 1);
888
+ this.contentRow = scrollTop;
889
+ }
1279
890
  /**
1280
891
  * Dispose and clean up
1281
892
  */
1282
893
  dispose() {
1283
894
  if (this.disposed)
1284
895
  return;
1285
- // Clean up streaming render timer
1286
- if (this.streamingRenderTimer) {
1287
- clearInterval(this.streamingRenderTimer);
1288
- this.streamingRenderTimer = null;
1289
- }
1290
896
  // Clean up output interceptor
1291
897
  if (this.outputInterceptorCleanup) {
1292
898
  this.outputInterceptorCleanup();
@@ -1294,6 +900,7 @@ export class TerminalInput extends EventEmitter {
1294
900
  }
1295
901
  this.disposed = true;
1296
902
  this.enabled = false;
903
+ this.resetStreamingRenderThrottle();
1297
904
  this.disableScrollRegion();
1298
905
  this.disableBracketedPaste();
1299
906
  this.buffer = '';
@@ -1399,22 +1006,7 @@ export class TerminalInput extends EventEmitter {
1399
1006
  this.toggleEditMode();
1400
1007
  return true;
1401
1008
  }
1402
- // Tab: toggle paste expansion if in placeholder, otherwise toggle thinking
1403
- if (this.findPlaceholderAt(this.cursor)) {
1404
- this.togglePasteExpansion();
1405
- }
1406
- else {
1407
- this.toggleThinking();
1408
- }
1409
- return true;
1410
- case 'escape':
1411
- // Esc: interrupt if streaming, otherwise clear buffer
1412
- if (this.mode === 'streaming') {
1413
- this.emit('interrupt');
1414
- }
1415
- else if (this.buffer.length > 0) {
1416
- this.clear();
1417
- }
1009
+ this.insertText(' ');
1418
1010
  return true;
1419
1011
  }
1420
1012
  return false;
@@ -1432,7 +1024,6 @@ export class TerminalInput extends EventEmitter {
1432
1024
  this.insertPlainText(chunk, insertPos);
1433
1025
  this.cursor = insertPos + chunk.length;
1434
1026
  this.emit('change', this.buffer);
1435
- this.updateSuggestions();
1436
1027
  this.scheduleRender();
1437
1028
  }
1438
1029
  insertNewline() {
@@ -1457,7 +1048,6 @@ export class TerminalInput extends EventEmitter {
1457
1048
  this.cursor = Math.max(0, this.cursor - 1);
1458
1049
  }
1459
1050
  this.emit('change', this.buffer);
1460
- this.updateSuggestions();
1461
1051
  this.scheduleRender();
1462
1052
  }
1463
1053
  deleteForward() {
@@ -1707,7 +1297,9 @@ export class TerminalInput extends EventEmitter {
1707
1297
  if (available <= 0)
1708
1298
  return;
1709
1299
  const chunk = clean.slice(0, available);
1710
- if (isMultilinePaste(chunk)) {
1300
+ const isMultiline = isMultilinePaste(chunk);
1301
+ const isShortMultiline = isMultiline && this.shouldInlineMultiline(chunk);
1302
+ if (isMultiline && !isShortMultiline) {
1711
1303
  this.insertPastePlaceholder(chunk);
1712
1304
  }
1713
1305
  else {
@@ -1727,6 +1319,7 @@ export class TerminalInput extends EventEmitter {
1727
1319
  return;
1728
1320
  this.applyScrollRegion();
1729
1321
  this.scrollRegionActive = true;
1322
+ this.forceRender();
1730
1323
  }
1731
1324
  disableScrollRegion() {
1732
1325
  if (!this.scrollRegionActive)
@@ -1877,17 +1470,19 @@ export class TerminalInput extends EventEmitter {
1877
1470
  this.shiftPlaceholders(position, text.length);
1878
1471
  this.buffer = this.buffer.slice(0, position) + text + this.buffer.slice(position);
1879
1472
  }
1473
+ shouldInlineMultiline(content) {
1474
+ const lines = content.split('\n').length;
1475
+ const maxInlineLines = 4;
1476
+ const maxInlineChars = 240;
1477
+ return lines <= maxInlineLines && content.length <= maxInlineChars;
1478
+ }
1880
1479
  findPlaceholderAt(position) {
1881
1480
  return this.pastePlaceholders.find((ph) => position >= ph.start && position < ph.end) ?? null;
1882
1481
  }
1883
- buildPlaceholder(summary) {
1482
+ buildPlaceholder(lineCount) {
1884
1483
  const id = ++this.pasteCounter;
1885
- const lang = summary.language ? ` ${summary.language.toUpperCase()}` : '';
1886
- // Show first line preview (truncated)
1887
- const preview = summary.preview.length > 30
1888
- ? `${summary.preview.slice(0, 30)}...`
1889
- : summary.preview;
1890
- const placeholder = `[📋 #${id}${lang} ${summary.lineCount}L] "${preview}"`;
1484
+ const plural = lineCount === 1 ? '' : 's';
1485
+ const placeholder = `[Pasted text #${id} +${lineCount} line${plural}]`;
1891
1486
  return { id, placeholder };
1892
1487
  }
1893
1488
  insertPastePlaceholder(content) {
@@ -1895,67 +1490,21 @@ export class TerminalInput extends EventEmitter {
1895
1490
  if (available <= 0)
1896
1491
  return;
1897
1492
  const cleanContent = content.slice(0, available);
1898
- const summary = generatePasteSummary(cleanContent);
1899
- // For short pastes (< 5 lines), show full content instead of placeholder
1900
- if (summary.lineCount < 5) {
1901
- const placeholder = this.findPlaceholderAt(this.cursor);
1902
- const insertPos = placeholder && this.cursor > placeholder.start ? placeholder.end : this.cursor;
1903
- this.insertPlainText(cleanContent, insertPos);
1904
- this.cursor = insertPos + cleanContent.length;
1905
- return;
1906
- }
1907
- const { id, placeholder } = this.buildPlaceholder(summary);
1493
+ const lineCount = cleanContent.split('\n').length;
1494
+ const { id, placeholder } = this.buildPlaceholder(lineCount);
1908
1495
  const insertPos = this.cursor;
1909
1496
  this.shiftPlaceholders(insertPos, placeholder.length);
1910
1497
  this.pastePlaceholders.push({
1911
1498
  id,
1912
1499
  content: cleanContent,
1913
- lineCount: summary.lineCount,
1500
+ lineCount,
1914
1501
  placeholder,
1915
1502
  start: insertPos,
1916
1503
  end: insertPos + placeholder.length,
1917
- summary,
1918
- expanded: false,
1919
1504
  });
1920
1505
  this.buffer = this.buffer.slice(0, insertPos) + placeholder + this.buffer.slice(insertPos);
1921
1506
  this.cursor = insertPos + placeholder.length;
1922
1507
  }
1923
- /**
1924
- * Toggle expansion of a paste placeholder at the current cursor position.
1925
- * When expanded, shows first 3 and last 2 lines of the content.
1926
- */
1927
- togglePasteExpansion() {
1928
- const placeholder = this.findPlaceholderAt(this.cursor);
1929
- if (!placeholder)
1930
- return false;
1931
- placeholder.expanded = !placeholder.expanded;
1932
- // Update the placeholder text in buffer
1933
- const newPlaceholder = placeholder.expanded
1934
- ? this.buildExpandedPlaceholder(placeholder)
1935
- : this.buildPlaceholder(placeholder.summary).placeholder;
1936
- const lengthDiff = newPlaceholder.length - placeholder.placeholder.length;
1937
- // Update buffer
1938
- this.buffer =
1939
- this.buffer.slice(0, placeholder.start) +
1940
- newPlaceholder +
1941
- this.buffer.slice(placeholder.end);
1942
- // Update placeholder tracking
1943
- placeholder.placeholder = newPlaceholder;
1944
- placeholder.end = placeholder.start + newPlaceholder.length;
1945
- // Shift other placeholders
1946
- this.shiftPlaceholders(placeholder.end, lengthDiff, placeholder.id);
1947
- this.scheduleRender();
1948
- return true;
1949
- }
1950
- buildExpandedPlaceholder(ph) {
1951
- const lines = ph.content.split('\n');
1952
- const firstLines = lines.slice(0, 3).map(l => l.slice(0, 60)).join('\n');
1953
- const lastLines = lines.length > 5
1954
- ? '\n...\n' + lines.slice(-2).map(l => l.slice(0, 60)).join('\n')
1955
- : '';
1956
- const lang = ph.summary.language ? ` ${ph.summary.language.toUpperCase()}` : '';
1957
- return `[📋 #${ph.id}${lang} ${ph.lineCount}L ▼]\n${firstLines}${lastLines}\n[/📋 #${ph.id}]`;
1958
- }
1959
1508
  deletePlaceholder(placeholder) {
1960
1509
  const length = placeholder.end - placeholder.start;
1961
1510
  this.buffer = this.buffer.slice(0, placeholder.start) + this.buffer.slice(placeholder.end);
@@ -1963,7 +1512,11 @@ export class TerminalInput extends EventEmitter {
1963
1512
  this.shiftPlaceholders(placeholder.end, -length, placeholder.id);
1964
1513
  this.cursor = placeholder.start;
1965
1514
  }
1966
- updateContextUsage(value) {
1515
+ updateContextUsage(value, autoCompactThreshold) {
1516
+ if (typeof autoCompactThreshold === 'number' && Number.isFinite(autoCompactThreshold)) {
1517
+ const boundedThreshold = Math.max(1, Math.min(100, Math.round(autoCompactThreshold)));
1518
+ this.contextAutoCompactThreshold = boundedThreshold;
1519
+ }
1967
1520
  if (value === null || !Number.isFinite(value)) {
1968
1521
  this.contextUsage = null;
1969
1522
  }
@@ -1990,6 +1543,22 @@ export class TerminalInput extends EventEmitter {
1990
1543
  const next = this.editMode === 'display-edits' ? 'ask-permission' : 'display-edits';
1991
1544
  this.setEditMode(next);
1992
1545
  }
1546
+ scheduleStreamingRender(delayMs) {
1547
+ if (this.streamingRenderTimer)
1548
+ return;
1549
+ const wait = Math.max(16, delayMs);
1550
+ this.streamingRenderTimer = setTimeout(() => {
1551
+ this.streamingRenderTimer = null;
1552
+ this.render();
1553
+ }, wait);
1554
+ }
1555
+ resetStreamingRenderThrottle() {
1556
+ if (this.streamingRenderTimer) {
1557
+ clearTimeout(this.streamingRenderTimer);
1558
+ this.streamingRenderTimer = null;
1559
+ }
1560
+ this.lastStreamingRender = 0;
1561
+ }
1993
1562
  scheduleRender() {
1994
1563
  if (!this.canRender())
1995
1564
  return;