erosolar-cli 1.7.273 → 1.7.274

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